feat(voice): 下行打字机文本流 + 首块快出,即时反馈
- protocol.go: 新增 ServerReply("reply") 消息——Agent 回答增量文本
- voice_tts.go: token 一到即转发客户端(打字机),同时攒句喂 TTS(音频随后)
- sentence_buffer.go: 本轮首句用低阈值(5 rune)抢首字延迟,之后回常规 12
- 桌面端 voice.ts onReply + VoiceDock 对话气泡(我说的 + JARVIS 打字机回答,思考态光标)
管线已最优:文字在 LLM 首 token 即刻上屏、音频紧随。剩余时延=大模型 TTFT(deepseek-v4-pro
4-7s 且波动大,疑似推理模型),这是模型的账、非管线——真要"马上响应"需换快模型。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ export interface VoiceCallbacks {
|
||||
onState?: (s: VoiceState) => void;
|
||||
onTranscript?: (text: string, final: boolean) => void;
|
||||
onTask?: (taskId: string) => void;
|
||||
onReply?: (deltaText: string) => void; // Agent 回答增量文本(打字机,逐 token)
|
||||
onError?: (msg: string) => void;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,9 @@ export class VoiceClient {
|
||||
case "task":
|
||||
if (m.task_id) this.cb.onTask?.(m.task_id);
|
||||
break;
|
||||
case "reply":
|
||||
if (m.text) this.cb.onReply?.(m.text); // 打字机:回答增量文本
|
||||
break;
|
||||
case "speaking":
|
||||
this.setState("speaking");
|
||||
break;
|
||||
|
||||
@@ -24,8 +24,9 @@ export function VoiceDock({ onTask }: Props) {
|
||||
const toast = useToast();
|
||||
const clientRef = useRef<VoiceClient | null>(null);
|
||||
const [state, setState] = useState<VoiceState>("idle");
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [open, setOpen] = useState(false); // 是否展开转写气泡
|
||||
const [transcript, setTranscript] = useState(""); // 我说的(ASR 转写)
|
||||
const [reply, setReply] = useState(""); // JARVIS 回答(打字机,逐 token 累加)
|
||||
const [open, setOpen] = useState(false); // 是否展开对话气泡
|
||||
|
||||
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
|
||||
const ensureClient = useCallback((): VoiceClient => {
|
||||
@@ -38,7 +39,11 @@ export function VoiceDock({ onTask }: Props) {
|
||||
},
|
||||
onTask: (taskId) => {
|
||||
onTask(taskId);
|
||||
setOpen(false);
|
||||
setOpen(true); // 留着气泡显示打字机回答
|
||||
},
|
||||
onReply: (delta) => {
|
||||
setReply((r) => r + delta); // 打字机:增量拼接,LLM 首 token 即刻可见
|
||||
setOpen(true);
|
||||
},
|
||||
onError: (msg) => toast.push("error", msg),
|
||||
});
|
||||
@@ -55,6 +60,7 @@ export function VoiceDock({ onTask }: Props) {
|
||||
c.stopListening();
|
||||
} else {
|
||||
setTranscript("");
|
||||
setReply(""); // 新一轮:清上一轮的回答
|
||||
setOpen(true);
|
||||
await c.startListening(); // speaking 中会先打断再开新一轮
|
||||
}
|
||||
@@ -68,11 +74,25 @@ export function VoiceDock({ onTask }: Props) {
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2">
|
||||
{/* 转写气泡 */}
|
||||
{open && transcript && (
|
||||
<div className="pointer-events-auto max-w-xs rounded-2xl border border-line bg-ink-850/95 px-4 py-2.5 text-sm text-slate-200 shadow-xl backdrop-blur">
|
||||
{/* 对话气泡:我说的(转写)+ JARVIS 回答(打字机) */}
|
||||
{open && (transcript || reply) && (
|
||||
<div className="pointer-events-auto max-w-xs rounded-2xl border border-line bg-ink-850/95 px-4 py-3 text-sm shadow-xl backdrop-blur">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-1 leading-relaxed">{transcript}</span>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
{transcript && (
|
||||
<p className="leading-relaxed text-slate-400">
|
||||
<span className="mr-1 text-[11px] text-slate-500">我</span>
|
||||
{transcript}
|
||||
</p>
|
||||
)}
|
||||
{reply && (
|
||||
<p className="leading-relaxed text-slate-100">
|
||||
<span className="mr-1 text-[11px] text-brand-300">JARVIS</span>
|
||||
{reply}
|
||||
{state === "thinking" && <span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] animate-pulse bg-brand-300 align-middle" />}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button className="mt-0.5 text-slate-500 hover:text-slate-300" onClick={() => setOpen(false)} aria-label="收起">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user