feat(web): 实时对话 - 第一阶段输入行内联语音录制与浏览器 STT 转写 (#165)
Co-authored-by: zhaoyue <zhaoyue@rongshantech.com>
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
109
web/src/components/conversation/IMPLEMENTATION.md
Normal file
109
web/src/components/conversation/IMPLEMENTATION.md
Normal 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,后续阶段)
|
||||
100
web/src/components/conversation/README.md
Normal file
100
web/src/components/conversation/README.md
Normal 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,后续阶段)
|
||||
69
web/src/components/conversation/audio-waveform.tsx
Normal file
69
web/src/components/conversation/audio-waveform.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
web/src/components/conversation/index.ts
Normal file
3
web/src/components/conversation/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { AudioWaveform } from "./audio-waveform";
|
||||
export { VoiceRecordingButton } from "./voice-recording-button";
|
||||
export { VoiceRecordingInline } from "./voice-recording-inline";
|
||||
49
web/src/components/conversation/voice-recording-button.tsx
Normal file
49
web/src/components/conversation/voice-recording-button.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
web/src/components/conversation/voice-recording-inline.tsx
Normal file
191
web/src/components/conversation/voice-recording-inline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
web/src/hooks/use-speech-recognition.ts
Normal file
163
web/src/hooks/use-speech-recognition.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type SpeechRecognitionAlternative = { transcript: string; confidence: number };
|
||||
type SpeechRecognitionResult = { isFinal: boolean; length: number; [index: number]: SpeechRecognitionAlternative };
|
||||
type SpeechRecognitionResultList = { length: number; item(index: number): SpeechRecognitionResult; [index: number]: SpeechRecognitionResult };
|
||||
|
||||
type SpeechRecognitionLike = {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
onstart: (() => void) | null;
|
||||
onresult: ((event: { results: SpeechRecognitionResultList }) => void) | null;
|
||||
onerror: ((event: { error: string }) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
};
|
||||
|
||||
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
|
||||
|
||||
function getSpeechRecognition(): SpeechRecognitionConstructor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const globalWithSpeech = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionConstructor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
};
|
||||
return globalWithSpeech.SpeechRecognition || globalWithSpeech.webkitSpeechRecognition || null;
|
||||
}
|
||||
|
||||
export type SpeechRecognitionError = {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type UseSpeechRecognitionOptions = {
|
||||
/** 语言代码,默认 zh-CN */
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
export type UseSpeechRecognitionReturn = {
|
||||
/** 浏览器是否支持 Web Speech API */
|
||||
supported: boolean;
|
||||
/** 识别错误(权限、网络、不支持等) */
|
||||
error: SpeechRecognitionError | null;
|
||||
/** 开始识别,挂载后自动调用 */
|
||||
start: () => void;
|
||||
/** 停止识别并返回已累积的最终文本 */
|
||||
stop: () => Promise<string>;
|
||||
/** 取消识别并清空累积文本 */
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 浏览器内置语音识别 Hook(Web Speech API)
|
||||
* 第一阶段 MVP 使用:不依赖后端、模型渠道或 API Key,零配置可用
|
||||
*/
|
||||
export function useSpeechRecognition(options: UseSpeechRecognitionOptions = {}): UseSpeechRecognitionReturn {
|
||||
const { lang = "zh-CN" } = options;
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const finalTextRef = useRef("");
|
||||
const stopResolveRef = useRef<((text: string) => void) | null>(null);
|
||||
const stopTimerRef = useRef<number | null>(null);
|
||||
const [supported] = useState(() => Boolean(getSpeechRecognition()));
|
||||
const [error, setError] = useState<SpeechRecognitionError | null>(null);
|
||||
|
||||
const start = useCallback(() => {
|
||||
const Constructor = getSpeechRecognition();
|
||||
if (!Constructor) {
|
||||
setError({ code: "unsupported", message: "当前浏览器不支持语音识别,请使用 Chrome 或 Edge" });
|
||||
return;
|
||||
}
|
||||
finalTextRef.current = "";
|
||||
const recognition = new Constructor();
|
||||
recognition.lang = lang;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = false;
|
||||
recognition.maxAlternatives = 1;
|
||||
recognition.onstart = () => setError(null);
|
||||
recognition.onresult = (event) => {
|
||||
let text = finalTextRef.current;
|
||||
for (let i = 0; i < event.results.length; i += 1) {
|
||||
const result = event.results[i];
|
||||
if (result.isFinal && result[0]?.transcript) {
|
||||
text = [text, result[0].transcript.trim()].filter(Boolean).join(" ");
|
||||
}
|
||||
}
|
||||
finalTextRef.current = text;
|
||||
};
|
||||
recognition.onerror = (event) => {
|
||||
if (event.error === "not-allowed" || event.error === "service-not-allowed") {
|
||||
setError({ code: event.error, message: "麦克风权限被拒绝,请在浏览器设置中允许访问麦克风" });
|
||||
} else if (event.error === "no-speech") {
|
||||
setError({ code: event.error, message: "未检测到语音,请重试" });
|
||||
} else if (event.error === "network") {
|
||||
setError({ code: event.error, message: "语音识别服务不可用,请检查网络后重试" });
|
||||
} else {
|
||||
setError({ code: event.error, message: `语音识别失败(${event.error})` });
|
||||
}
|
||||
};
|
||||
recognition.onend = () => {
|
||||
if (stopTimerRef.current !== null) {
|
||||
window.clearTimeout(stopTimerRef.current);
|
||||
stopTimerRef.current = null;
|
||||
}
|
||||
const resolve = stopResolveRef.current;
|
||||
stopResolveRef.current = null;
|
||||
resolve?.(finalTextRef.current);
|
||||
};
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
}, [lang]);
|
||||
|
||||
const stop = useCallback((): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const recognition = recognitionRef.current;
|
||||
if (!recognition) {
|
||||
resolve(finalTextRef.current);
|
||||
return;
|
||||
}
|
||||
stopResolveRef.current = resolve;
|
||||
// 兜底:部分浏览器 stop() 后 onend 可能不触发,超时后返回已识别文本
|
||||
stopTimerRef.current = window.setTimeout(() => {
|
||||
if (stopResolveRef.current) {
|
||||
stopResolveRef.current = null;
|
||||
resolve(finalTextRef.current);
|
||||
}
|
||||
}, 1500);
|
||||
try {
|
||||
recognition.stop();
|
||||
} catch {
|
||||
if (stopTimerRef.current !== null) {
|
||||
window.clearTimeout(stopTimerRef.current);
|
||||
stopTimerRef.current = null;
|
||||
}
|
||||
stopResolveRef.current = null;
|
||||
resolve(finalTextRef.current);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (stopTimerRef.current !== null) {
|
||||
window.clearTimeout(stopTimerRef.current);
|
||||
stopTimerRef.current = null;
|
||||
}
|
||||
stopResolveRef.current = null;
|
||||
const recognition = recognitionRef.current;
|
||||
recognitionRef.current = null;
|
||||
if (recognition) {
|
||||
recognition.onend = null;
|
||||
try {
|
||||
recognition.abort();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
finalTextRef.current = "";
|
||||
}, []);
|
||||
|
||||
useEffect(() => cancel, [cancel]);
|
||||
|
||||
return { supported, error, start, stop, cancel };
|
||||
}
|
||||
193
web/src/hooks/use-voice-recording.ts
Normal file
193
web/src/hooks/use-voice-recording.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type VoiceRecordingState = "idle" | "recording" | "paused";
|
||||
|
||||
type UseVoiceRecordingOptions = {
|
||||
/** 最大录音时长(秒),默认 60 秒 */
|
||||
maxDuration?: number;
|
||||
};
|
||||
|
||||
export type UseVoiceRecordingReturn = {
|
||||
state: VoiceRecordingState;
|
||||
waveform: number[];
|
||||
duration: number;
|
||||
error: string | null;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<Blob | null>;
|
||||
cancel: () => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 语音录制 Hook
|
||||
* 使用 MediaRecorder API 录制音频,AnalyserNode 获取实时波形数据
|
||||
*/
|
||||
export function useVoiceRecording(options: UseVoiceRecordingOptions = {}): UseVoiceRecordingReturn {
|
||||
const { maxDuration = 60 } = options;
|
||||
const [state, setState] = useState<VoiceRecordingState>("idle");
|
||||
const [waveform, setWaveform] = useState<number[]>([]);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
|
||||
// 清理资源
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (timerRef.current) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
mediaRecorderRef.current = null;
|
||||
}, []);
|
||||
|
||||
// 更新波形数据
|
||||
const updateWaveform = useCallback(() => {
|
||||
const analyser = analyserRef.current;
|
||||
if (!analyser) return;
|
||||
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// 取 32 个频段的平均值,用于波形显示
|
||||
const step = Math.floor(bufferLength / 32);
|
||||
const values: number[] = [];
|
||||
for (let i = 0; i < 32; i++) {
|
||||
let sum = 0;
|
||||
for (let j = 0; j < step; j++) {
|
||||
sum += dataArray[i * step + j];
|
||||
}
|
||||
values.push(sum / step / 255);
|
||||
}
|
||||
setWaveform(values);
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(updateWaveform);
|
||||
}, []);
|
||||
|
||||
// 开始录制
|
||||
const start = useCallback(async () => {
|
||||
setError(null);
|
||||
setWaveform([]);
|
||||
setDuration(0);
|
||||
chunksRef.current = [];
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start(100);
|
||||
startTimeRef.current = Date.now();
|
||||
setState("recording");
|
||||
|
||||
// 启动波形更新
|
||||
animationFrameRef.current = requestAnimationFrame(updateWaveform);
|
||||
|
||||
// 启动计时器
|
||||
timerRef.current = window.setInterval(() => {
|
||||
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
||||
setDuration(elapsed);
|
||||
if (elapsed >= maxDuration) {
|
||||
// 达到最大时长,自动停止
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}, 100);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "无法访问麦克风";
|
||||
setError(message);
|
||||
setState("idle");
|
||||
cleanup();
|
||||
}
|
||||
|
||||
}, [cleanup, maxDuration, updateWaveform]);
|
||||
|
||||
// 停止录制
|
||||
const stop = useCallback((): Promise<Blob | null> => {
|
||||
return new Promise((resolve) => {
|
||||
const mediaRecorder = mediaRecorderRef.current;
|
||||
if (!mediaRecorder || mediaRecorder.state === "inactive") {
|
||||
cleanup();
|
||||
setState("idle");
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = chunksRef.current.length > 0 ? new Blob(chunksRef.current, { type: "audio/webm" }) : null;
|
||||
cleanup();
|
||||
setState("idle");
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
mediaRecorder.stop();
|
||||
});
|
||||
}, [cleanup]);
|
||||
|
||||
// 取消录制
|
||||
const cancel = useCallback(() => {
|
||||
chunksRef.current = [];
|
||||
cleanup();
|
||||
setState("idle");
|
||||
setWaveform([]);
|
||||
setDuration(0);
|
||||
}, [cleanup]);
|
||||
|
||||
// 重置
|
||||
const reset = useCallback(() => {
|
||||
cancel();
|
||||
setError(null);
|
||||
}, [cancel]);
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => cleanup();
|
||||
}, [cleanup]);
|
||||
|
||||
return {
|
||||
state,
|
||||
waveform,
|
||||
duration,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
cancel,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Link } from "react-router";
|
||||
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
@@ -746,6 +747,10 @@ function CreationComposer(props: ComposerProps) {
|
||||
</div>
|
||||
<footer className="creation-chat-dock">
|
||||
<div className="creation-chat-controls">
|
||||
<VoiceRecordingButton
|
||||
disabled={props.busy}
|
||||
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
|
||||
/>
|
||||
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
|
||||
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} variant="creation" />
|
||||
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? <GenerationSettingsMenu {...props} /> : null}
|
||||
|
||||
107
web/src/pages/test-voice-recording.tsx
Normal file
107
web/src/pages/test-voice-recording.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, message } from "antd";
|
||||
import { Mic, Send } from "lucide-react";
|
||||
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
/**
|
||||
* 语音录制功能测试页面
|
||||
* 验证输入行内联波形录制和 STT 转写闭环
|
||||
*/
|
||||
export default function TestVoiceRecording() {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const handleTranscribed = (text: string) => {
|
||||
// 转写结果填入输入框,供用户确认或直接发送
|
||||
setPrompt((current) => (current.trim() ? `${current} ${text}` : text));
|
||||
message.success("语音已转写为文字");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
// 原型阶段:发送到对话入口由调用方接入;此处仅输出
|
||||
console.log("发送文本:", prompt);
|
||||
message.success("发送成功(原型阶段,仅控制台输出)");
|
||||
setPrompt("");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8" style={{ background: theme.spatial.surface }}>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="size-5" style={{ color: theme.accent.primary }} />
|
||||
<span>实时对话功能测试(MVP)</span>
|
||||
</div>
|
||||
}
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium" style={{ color: theme.node.text }}>
|
||||
文本输入(语音转写结果会自动填入)
|
||||
</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="输入消息,或点击实时对话按钮用语音输入..."
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm outline-none transition focus:border-current"
|
||||
style={{
|
||||
background: theme.node.fill,
|
||||
borderColor: theme.toolbar.border,
|
||||
color: theme.node.text,
|
||||
minHeight: 80,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音频预览 */}
|
||||
|
||||
{/* 控制栏:实时对话按钮(点击后在输入行展开波形录制条) */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<VoiceRecordingButton onTranscribed={handleTranscribed} />
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Send className="size-4" />}
|
||||
disabled={!prompt.trim()}
|
||||
loading={sending}
|
||||
onClick={handleSubmit}
|
||||
style={{ background: theme.accent.primary, borderColor: theme.accent.primary }}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="rounded-lg border p-3 text-xs" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.muted }}>
|
||||
<div className="font-semibold" style={{ color: theme.node.text }}>
|
||||
使用说明:
|
||||
</div>
|
||||
<ul className="mt-1 list-inside list-disc space-y-1">
|
||||
<li>点击麦克风按钮,输入行内展开波形录制条并自动开始录音</li>
|
||||
<li>波形动画实时显示音量变化</li>
|
||||
<li>点击停止按钮完成录制,自动上传并转写</li>
|
||||
<li>转写结果自动填入输入框,可编辑后发送</li>
|
||||
<li>转写失败时在录制条内提示,可点击麦克风重试</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import WalletPage from "@/pages/wallet";
|
||||
import ProjectsPage from "@/pages/projects";
|
||||
import ProjectDetailPage from "@/pages/projects/detail";
|
||||
import SettingsPage from "@/pages/settings";
|
||||
import TestVoiceRecording from "@/pages/test-voice-recording";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -57,6 +58,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "/skills", element: <RequireAuth><SkillsPage /></RequireAuth> },
|
||||
{ path: "/wallet", element: <RequireAuth><RequireFeature feature="creditsEnabled"><WalletPage /></RequireFeature></RequireAuth> },
|
||||
{ path: "/settings", element: <RequireAuth><SettingsPage /></RequireAuth> },
|
||||
{ path: "/test-voice-recording", element: <RequireAuth><TestVoiceRecording /></RequireAuth> },
|
||||
{ path: "/projects", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectsPage /></RequireFeature></RequireAuth> },
|
||||
{ path: "/projects/:projectId", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectDetailPage /></RequireFeature></RequireAuth> },
|
||||
{ path: "/projects/:projectId/:view", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectDetailPage /></RequireFeature></RequireAuth> },
|
||||
|
||||
Reference in New Issue
Block a user