feat(web): 实时对话 - 第一阶段输入行内联语音录制与浏览器 STT 转写 (#165)

Co-authored-by: zhaoyue <zhaoyue@rongshantech.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-07 15:06:56 +08:00
committed by GitHub
parent 6b5f176baa
commit 44c7d48271
13 changed files with 1006 additions and 1 deletions

View File

@@ -17,6 +17,7 @@ import { navigateToSettings } from "@/lib/settings-navigation";
import { cinematicAgentSessionOpsJson, createCinematicAgentSession, isAgentSessionPollingAbort, resumeCinematicAgentSession } from "@/lib/canvas/canvas-agent-session";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { AgentChatEmptyState, AgentPanelChrome } from "./canvas-agent-panel-chrome";
import { CanvasLocalAgentPanel } from "./canvas-local-agent-panel";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
@@ -781,6 +782,10 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
onAddFiles={addImagesToCanvas}
left={
<>
<VoiceRecordingButton
disabled={agentBusy}
onTranscribed={(text) => setPrompt((prev) => (prev.trim() ? `${prev} ${text}` : text))}
/>
<AgentTextModelPicker config={effectiveConfig} value={effectiveConfig.textModel} onChange={(model) => updateConfig("textModel", model)} />
{cinematicEntryActive ? <span className="ml-2 inline-flex h-6 items-center rounded-md px-2 text-[var(--fs-tiny)] font-medium" style={{ background: theme.spatial.surface, color: theme.node.muted }}>影视项目</span> : null}
</>

View File

@@ -13,6 +13,7 @@ import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type Age
import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { AgentChatEmptyState } from "./canvas-agent-panel-chrome";
const PANEL_MOTION_SECONDS = 0.5;
@@ -562,7 +563,15 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
onSubmit={sendPrompt}
onAddFiles={addAttachments}
onRemoveAttachment={removeAttachment}
left={attachments.length ? <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
left={
<>
<VoiceRecordingButton
disabled={!connected || sending || waiting}
onTranscribed={(text) => setAgentState({ prompt: prompt.trim() ? `${prompt} ${text}` : text })}
/>
{attachments.length ? <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
</>
}
/>
</>
)}

View File

@@ -0,0 +1,109 @@
# 实时对话功能实现(第一阶段 MVP)
## 项目概述
实现第一阶段(MVP):**本地语音录制 + 浏览器语音识别转写**。点击"实时对话"按钮 → 输入行内展开波形录制条 → 停止后自动转写为文字 → 文字填入输入框或直接发送。
第一阶段**不接入语音大模型、不依赖后端与 API Key**,转写由浏览器内置 Web Speech API 完成,零配置可用。
---
## 交互流程
1. 点击"实时对话"按钮(`VoiceRecordingButton`)
2. 输入行内展开 `VoiceRecordingInline` 录制条(不弹窗),自动开始录音
3. 显示波形动画(AnalyserNode 实时音量)+ 时长
4. 点击停止 → 停止录音并触发浏览器语音识别
5. 识别成功 → 短暂显示"转写完成" → 回调文本,由调用方填入输入框或直接发送
6. 识别失败(无语音 / 权限拒绝 / 网络异常)→ 录制条内提示并支持重试
---
## 技术架构
```
用户点击"实时对话"按钮
│
▼
VoiceRecordingButton(输入行内展开录制条,不弹窗)
│
▼
VoiceRecordingInline 内联波形录制条
├── useVoiceRecording:MediaRecorder 录制 + AnalyserNode 实时波形
├── AudioWaveform:SVG 波形动画 + 时长显示
└── useSpeechRecognition:Web Speech API 实时转写
│
▼
点击停止 → speech.stop() 返回文本 → onTranscribed(文本) → 填入输入框
```
## 前端实现
### 组件
| 文件 | 说明 |
|------|------|
| `components/conversation/voice-recording-button.tsx` | 语音输入按钮:点击后输入行内展开录制条(局部状态,不弹窗) |
| `components/conversation/voice-recording-inline.tsx` | 内联波形录制条:波形 + 时长 + 停止/取消,停止后自动转写 |
| `components/conversation/audio-waveform.tsx` | SVG 波形可视化组件 |
| `components/conversation/index.ts` | 组件统一导出 |
### Hook
| 文件 | 说明 |
|------|------|
| `hooks/use-voice-recording.ts` | 语音录制逻辑(MediaRecorder + AnalyserNode,波形/时长/停止/取消) |
| `hooks/use-speech-recognition.ts` | 浏览器语音识别(Web Speech API,start/stop/cancel,返回文本与错误) |
## 接入位置
- 创建页主输入行:`web/src/pages/create/index.tsx`(`CreationComposer` 的 `creation-chat-controls`)
- 画布助手面板:`canvas-assistant-panel.tsx`、`canvas-local-agent-panel.tsx`(输入行 left 插槽)
- 测试页:`web/src/pages/test-voice-recording.tsx`(路由 `/test-voice-recording`)
## 使用示例
```tsx
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
<VoiceRecordingButton
disabled={isBusy}
onTranscribed={(text) => setPrompt((prev) => (prev.trim() ? `${prev} ${text}` : text))}
/>
```
## 浏览器支持
- 语音识别使用 Web Speech API(`SpeechRecognition` / `webkitSpeechRecognition`),Chrome / Edge 支持
- 不支持时录制条内提示"当前浏览器不支持语音识别,请使用 Chrome 或 Edge 浏览器",并可取消
- 识别语言默认 `zh-CN`,可通过 `useSpeechRecognition({ lang })` 调整
## 已删除内容
以下第二阶段实验代码已全部删除:
### 前端
- `components/conversation/realtime-conversation.tsx`、`realtime-conversation-button.tsx`、`voice-recording-overlay.tsx`
- `hooks/use-websocket.ts`、`stores/conversation-mode-store.ts`
- `services/api/stt.ts`(后端 Whisper 转写客户端)
### 后端
- `service/stt.go`(OpenAI Whisper 转写)、`model/stt.go`(`stt_results` 表)
- `handler/routes.go` 中 `RegisterSTTRoutes`、`main.go` 中路由注册
- `database/schema.go` 中 `STTResult` 模型注册、`repository/repository.go` 中 `CreateSTTResult`
## 验证情况
- 后端 `go build ./...`:通过
- 前端 `npx tsc --noEmit`:无本次新增类型错误(上游遗留 style profile / image.ts 错误除外)
- 浏览器手动测试(真实麦克风 + 语音识别)未运行,需在 Chrome/Edge 下验证
## 下一步
- [x] 内联波形录制 + 浏览器语音识别自动转写(第一阶段 MVP)
- [x] 移除第二阶段实验代码(WebSocket / WebRTC / Realtime API)
- [ ] 语音播放功能(TTS)
- [ ] 语音消息发送和显示
- [ ] 真正实时对话(WebSocket + WebRTC + Realtime API,后续阶段)

View File

@@ -0,0 +1,100 @@
# 实时对话功能组件(第一阶段 MVP)
本目录实现第一阶段(MVP):**本地语音录制 + 浏览器语音识别转写**。点击"实时对话"按钮 → 输入行内展开波形录制条 → 停止后自动转写为文字 → 文字填入输入框或直接发送。
## 零配置说明
第一阶段**不接入语音大模型、不依赖后端与 API Key**:录音完成后直接使用浏览器内置 Web Speech API(`SpeechRecognition`)识别,转写全程在前端完成。
- 支持的浏览器:Chrome / Edge(Chromium 内核)
- 不支持时录制条内会明确提示,并可取消
- 识别语言默认 `zh-CN`,见 `useSpeechRecognition` 的 `lang` 参数
---
## 组件列表
### 1. `VoiceRecordingButton` - 语音输入按钮
点击后输入行内展开波形录制条(不弹窗),自动开始录音;停止后自动转写并回调文本。使用局部状态,多个输入行可独立使用。
**使用示例:**
```tsx
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
<VoiceRecordingButton
disabled={isBusy}
onTranscribed={(text) => setPrompt((prev) => (prev.trim() ? `${prev} ${text}` : text))}
/>
```
### 2. `VoiceRecordingInline` - 内联波形录制条
输入行内展示的录制控件:波形动画 + 时长 + 停止/取消按钮;停止后自动转写,失败可重试。`VoiceRecordingButton` 默认使用本组件。
**使用示例:**
```tsx
import { VoiceRecordingInline } from "@/components/conversation/voice-recording-inline";
<VoiceRecordingInline
onTranscribed={(text) => setPrompt((prev) => (prev.trim() ? `${prev} ${text}` : text))}
onCancel={() => setOpen(false)}
/>
```
### 3. `AudioWaveform` - 音频波形可视化
SVG 波形可视化组件,支持实时数据更新。
---
## Hook
### `useVoiceRecording` - 语音录制 Hook
封装 MediaRecorder API 和 AnalyserNode,提供录制状态、波形数据、控制方法(供波形动画与时长展示)。
### `useSpeechRecognition` - 浏览器语音识别 Hook
封装 Web Speech API(`SpeechRecognition`/`webkitSpeechRecognition`),提供 `start()` / `stop(): Promise<string>` / `cancel()`,返回是否支持、识别错误。转写不依赖后端。
---
## 完整集成示例
```tsx
import { useState } from "react";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
function ChatInput() {
const [prompt, setPrompt] = useState("");
const handleTranscribed = (text: string) => {
// 转写结果填入输入框,用户确认后发送
setPrompt((current) => (current.trim() ? `${current} ${text}` : text));
};
return (
<div className="flex items-center gap-2">
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="输入消息..." />
{/* 点击后输入行内展开波形录制条,停止后自动转写 */}
<VoiceRecordingButton onTranscribed={handleTranscribed} />
<button>发送</button>
</div>
);
}
```
## 接入位置
- 创建页主输入行:`web/src/pages/create/index.tsx`(`CreationComposer` 的 `creation-chat-controls`)
- 画布助手面板:`canvas-assistant-panel.tsx`、`canvas-local-agent-panel.tsx`(输入行 left 插槽)
- 测试页:`web/src/pages/test-voice-recording.tsx`
## 下一步
- [x] 内联波形录制(不弹窗),停止后自动转写
- [x] 移除第二阶段实验代码(WebSocket / Realtime API / 旧浮层组件)
- [x] 移除后端 STT 接口与数据库表(第一阶段不接入语音大模型)
- [ ] 语音播放功能(TTS)
- [ ] 语音消息发送和显示
- [ ] 多模态对话(图片、视频)
- [ ] 真正实时对话(WebSocket + WebRTC + Realtime API,后续阶段)

View File

@@ -0,0 +1,69 @@
import { useMemo } from "react";
type AudioWaveformProps = {
waveform: number[];
/** 波形颜色,默认使用 CSS 变量 */
color?: string;
/** 波形高度(像素),默认 48 */
height?: number;
/** 波形宽度(像素),默认 200 */
width?: number;
/** 是否动画,默认 true */
animated?: boolean;
};
/**
* 音频波形可视化组件
* 使用 SVG 绘制实时波形
*/
export function AudioWaveform({
waveform,
color = "var(--cn-accent, #3b82f6)",
height = 48,
width = 200,
animated = true,
}: AudioWaveformProps) {
// 如果没有波形数据,显示占位波形
const displayWaveform = useMemo(() => {
if (waveform.length === 0) {
// 生成静态占位波形
return Array.from({ length: 32 }, (_, i) => {
const x = i / 31;
return 0.3 + 0.2 * Math.sin(x * Math.PI * 2);
});
}
return waveform;
}, [waveform]);
const barWidth = width / displayWaveform.length;
const gap = Math.max(1, barWidth * 0.2);
const actualBarWidth = barWidth - gap;
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={animated ? "animate-pulse" : ""}
style={{ overflow: "visible" }}
>
{displayWaveform.map((value, index) => {
const barHeight = Math.max(2, value * height * 0.8);
const x = index * barWidth + gap / 2;
const y = (height - barHeight) / 2;
return (
<rect
key={index}
x={x}
y={y}
width={actualBarWidth}
height={barHeight}
fill={color}
rx={actualBarWidth / 2}
opacity={0.6 + value * 0.4}
/>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,3 @@
export { AudioWaveform } from "./audio-waveform";
export { VoiceRecordingButton } from "./voice-recording-button";
export { VoiceRecordingInline } from "./voice-recording-inline";

View File

@@ -0,0 +1,49 @@
import { useState } from "react";
import { Button, Tooltip } from "antd";
import { Mic } from "lucide-react";
import { VoiceRecordingInline } from "./voice-recording-inline";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
type VoiceRecordingButtonProps = {
/** 转写完成回调,返回转写文本 */
onTranscribed: (text: string) => void;
/** 是否禁用(如发送中或未连接) */
disabled?: boolean;
};
/**
* 语音输入按钮:点击后在输入行内展开波形录制条,录制完成自动 STT 转写
* 使用局部状态,多个输入行可独立使用
*/
export function VoiceRecordingButton({ onTranscribed, disabled }: VoiceRecordingButtonProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [open, setOpen] = useState(false);
return (
<>
<Tooltip title="实时对话">
<Button
type="text"
shape="circle"
className="!h-8 !w-8 !min-w-8"
disabled={disabled}
style={{ color: theme.node.muted }}
icon={<Mic className="size-4" />}
onClick={() => setOpen(true)}
aria-label="实时对话"
/>
</Tooltip>
{open ? (
<VoiceRecordingInline
onTranscribed={(text) => {
setOpen(false);
onTranscribed(text);
}}
onCancel={() => setOpen(false)}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,191 @@
import { useEffect, useRef, useState } from "react";
import { Button, Spin, Tooltip } from "antd";
import { Check, Mic, Square, X } from "lucide-react";
import { AudioWaveform } from "./audio-waveform";
import { useSpeechRecognition } from "@/hooks/use-speech-recognition";
import { useVoiceRecording } from "@/hooks/use-voice-recording";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
type VoiceRecordingInlineProps = {
/** 转写完成回调,返回转写文本 */
onTranscribed: (text: string) => void;
/** 取消录制回调 */
onCancel: () => void;
};
type TranscribeState = "idle" | "transcribing" | "done" | "error";
/**
* 内联语音录制条(输入行内展示)
* 挂载后自动开始录音,显示波形动画;点击停止后自动转写为文字(浏览器 Web Speech API,无需后端与 API Key)
*/
export function VoiceRecordingInline({ onTranscribed, onCancel }: VoiceRecordingInlineProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const {
state,
waveform,
duration,
error: recorderError,
start: startRecording,
stop: stopRecording,
cancel: cancelRecording,
} = useVoiceRecording({
maxDuration: 60,
});
const {
supported: speechSupported,
error: speechError,
start: startSpeech,
stop: stopSpeech,
cancel: cancelSpeech,
} = useSpeechRecognition();
const [transcribeState, setTranscribeState] = useState<TranscribeState>("idle");
const [transcribeError, setTranscribeError] = useState("");
const stopRequestedRef = useRef(false);
const wasRecordingRef = useRef(false);
// 挂载后自动开始录音与语音识别,卸载时清理
useEffect(() => {
if (!speechSupported) {
setTranscribeError("当前浏览器不支持语音识别,请使用 Chrome 或 Edge 浏览器");
setTranscribeState("error");
return;
}
void startRecording();
startSpeech();
return () => {
cancelRecording();
cancelSpeech();
};
}, [startRecording, cancelRecording, startSpeech, cancelSpeech, speechSupported]);
const handleStop = async () => {
if (stopRequestedRef.current || transcribeState !== "idle") return;
stopRequestedRef.current = true;
setTranscribeState("transcribing");
setTranscribeError("");
const text = await stopSpeech();
void stopRecording();
const trimmed = text.trim();
if (!trimmed) {
setTranscribeError("未识别到语音内容,请重试");
setTranscribeState("error");
stopRequestedRef.current = false;
return;
}
setTranscribeState("done");
// 短暂展示成功状态后回调
window.setTimeout(() => onTranscribed(trimmed), 600);
};
// 录音达到最大时长自动停止时,自动触发转写
useEffect(() => {
if (state === "recording") wasRecordingRef.current = true;
if (state === "idle" && wasRecordingRef.current && transcribeState === "idle" && !stopRequestedRef.current) {
wasRecordingRef.current = false;
void handleStop();
}
}, [state, transcribeState]);
const handleRetry = () => {
stopRequestedRef.current = false;
wasRecordingRef.current = false;
setTranscribeError("");
setTranscribeState("idle");
void startRecording();
startSpeech();
};
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const displayError = recorderError || (speechError ? speechError.message : "") || (transcribeState === "error" ? transcribeError : "");
return (
<div
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg border px-2 py-1.5"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
>
{displayError ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-xs" style={{ color: "#dc2626" }}>
{displayError}
</span>
{speechSupported ? (
<Tooltip title="重试">
<Button
type="text"
size="small"
icon={<Mic className="size-3.5" />}
onClick={handleRetry}
style={{ color: theme.node.muted }}
/>
</Tooltip>
) : null}
<Tooltip title="取消">
<Button
type="text"
size="small"
icon={<X className="size-3.5" />}
onClick={onCancel}
style={{ color: theme.node.muted }}
/>
</Tooltip>
</div>
) : transcribeState === "transcribing" ? (
<div className="flex items-center gap-2 px-2" style={{ color: theme.node.muted }}>
<Spin size="small" />
<span className="text-xs">正在转写...</span>
</div>
) : transcribeState === "done" ? (
<div className="flex items-center gap-2 px-2" style={{ color: "#16a34a" }}>
<Check className="size-4" />
<span className="text-xs">转写完成</span>
</div>
) : (
<>
<AudioWaveform
waveform={waveform}
color={theme.accent.primary}
height={32}
width={160}
animated={state === "recording"}
/>
{state === "recording" ? (
<span className="font-mono text-xs tabular-nums" style={{ color: theme.node.text }}>
{formatDuration(duration)}
</span>
) : null}
<Tooltip title="取消">
<Button
type="text"
size="small"
icon={<X className="size-3.5" />}
onClick={() => {
cancelRecording();
cancelSpeech();
onCancel();
}}
style={{ color: theme.node.muted }}
/>
</Tooltip>
<Tooltip title="停止并转写">
<Button
type="text"
size="small"
icon={<Square className="size-3.5" />}
onClick={handleStop}
disabled={state !== "recording" || transcribeState !== "idle"}
style={{ color: theme.accent.primary }}
/>
</Tooltip>
</>
)}
</div>
);
}