Merge pull request 'feat(voice): 按住空格键语音输入(PTT),松开发送' (#14) from feat/site into main
deploy-132 / deploy (push) Successful in 3m1s

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-07-24 13:05:06 +00:00
2 changed files with 70 additions and 3 deletions
@@ -263,6 +263,14 @@ export class VoiceClient {
if (this.state === "listening") this.setState("thinking");
}
// stopPTT 结束本轮说话但保留麦克风上下文:用于按住空格(PTT)松开发送场景。
// 不销毁 micNode/micStream,下次重按 startListening 时 startMic 直接早返回,
// 避免 getUserMedia / AudioContext 重建延迟,实现快速重按。
stopPTT(): void {
this.send({ type: "end" });
if (this.state === "listening") this.setState("thinking");
}
// bargeIn 打断当前朗读(用户又开口)。
bargeIn(): void {
this.send({ type: "barge_in" });
@@ -27,8 +27,10 @@ interface Turn {
const KEEP_TURNS = 3; // 对话流保留最近几轮(含当前轮)
// hintText 状态提示(带人格位:名字来自每用户 JARVIS 设置)。
function hintText(state: VoiceState, name: string, idleLeft: number | null): string {
// pttActive 时显示"松开发送"替代默认聆听提示。
function hintText(state: VoiceState, name: string, idleLeft: number | null, ptt?: boolean): string {
if (idleLeft !== null) return `${idleLeft}s 后自动退出 · 说话取消`;
if (ptt && state === "listening") return "按住说话中 · 松开发送";
switch (state) {
case "connecting":
return "连接中…";
@@ -39,7 +41,7 @@ function hintText(state: VoiceState, name: string, idleLeft: number | null): str
case "speaking":
return "朗读中 · 点击打断";
default:
return "点击开始对话";
return "点击开始对话 · 空格键按住说话";
}
}
@@ -98,6 +100,8 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
const [fullscreen, setFullscreen] = useState(false); // 全屏 JARVIS 模式
const [name, setName] = useState("JARVIS"); // 助手名(提示/对话流/HUD 品牌位)
const [idleLeft, setIdleLeft] = useState<number | null>(null); // 空转退出倒计时(最后 10s 才非空)
const [pttActive, setPttActive] = useState(false); // PTT 按住空格中
const pttRef = useRef(false); // 避免 blur 闭包拿旧值
// onNavigate 经 ref 供长寿命 VoiceClient 回调使用(client 只建一次,闭包别锁死旧 props)。
const onNavigateRef = useRef(onNavigate);
@@ -181,6 +185,61 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
return () => window.removeEventListener("keydown", onKey);
}, [fullscreen]);
// PTT:按住空格语音输入,松开发送(不在对话模式时生效)。
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== " " && e.code !== "Space") return;
// 输入框/文本区里不抢空格
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
e.preventDefault();
const c = clientRef.current;
if (!c || c.inConversation() || c.getState() === "listening") return;
pttRef.current = true;
setPttActive(true);
c.startListening().catch((err: unknown) => {
pttRef.current = false;
setPttActive(false);
toast.push("error", (err as Error).message || "语音启动失败");
});
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key !== " " && e.code !== "Space") return;
e.preventDefault();
pttRef.current = false;
setPttActive(false);
const c = clientRef.current;
if (c && c.getState() === "listening") c.stopPTT();
};
// 窗口失焦时取消 PTT(防止空格一直按着弹不回来)
const onBlur = () => {
if (pttRef.current) {
pttRef.current = false;
setPttActive(false);
const c = clientRef.current;
if (c && c.getState() === "listening") c.stopPTT();
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
// 清理时如有 PTT 未释放也收掉
if (pttRef.current) {
const c = clientRef.current;
if (c && c.getState() === "listening") c.stopPTT();
}
};
}, [toast]);
// 点按语义 = 对话开关:待命点 → 进入连续对话;聆听中点 → 退出;朗读中点 → 打断但留在对话。
const onMic = useCallback(async () => {
const c = ensureClient();
@@ -214,7 +273,7 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
}, [ensureClient]);
const busy = state === "connecting";
const hint = hintText(state, name, idleLeft);
const hint = hintText(state, name, idleLeft, pttActive);
const visibleTurns = turns.filter((t) => t.me || t.ai || t.taskId);
const ringDur = conv ? RING_DUR[state] : undefined;
// 倒计时环:SVG 周长 182.2r=29),剩余秒数映射到 dashoffset(收缩)。