feat(voice): 提升桌面端语音交互与本地任务执行支持

This commit is contained in:
Blizzard
2026-07-24 16:40:25 +08:00
parent e9a8b9b4c6
commit aba88193d8
33 changed files with 1909 additions and 102 deletions
@@ -2,7 +2,8 @@ import { useEffect, useState } from "react";
import { Dialog } from "../ui/Dialog";
import { Button } from "../ui/Button";
import { useToast } from "../ui/Toast";
import { getMyJarvis, saveMyJarvis, type JarvisConfig } from "../lib/api";
import { getMyJarvis, saveMyJarvis, type JarvisConfig, GATEWAY, getToken } from "../lib/api";
import { localRunnerAvailable, localRunnerStatus, startLocalRunner, stopLocalRunner } from "../lib/desktop";
// 每用户 JARVIS 设置:名字 / 人设 / (高级)自带豆包配置。
// 名字与人设归用户自己;豆包配置齐全则语音走用户的账号,否则走系统兜底。
@@ -105,12 +106,85 @@ export function JarvisSettings({ open, onClose }: { open: boolean; onClose: () =
<Field label="音色 voice_type" value={cfg.tts_voice_type} onChange={(v) => set("tts_voice_type", v)} placeholder="zh_male_m191_uranus_bigtts" />
</div>
)}
<LocalAccessSection />
</div>
)}
</Dialog>
);
}
// LocalAccessSection 本地文件访问(JARVIS「本地的手」,只读起步):用户显式选目录 + 开启,
// JARVIS 才能看/读该目录内的文件;随时可关。仅桌面壳可用(浏览器隐藏整个区块)。
function LocalAccessSection() {
const toast = useToast();
const [dir, setDir] = useState("");
const [status, setStatus] = useState("offline");
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!localRunnerAvailable()) return;
let alive = true;
const poll = () => localRunnerStatus().then((s) => alive && setStatus(s)).catch(() => {});
poll();
const iv = window.setInterval(poll, 2000);
return () => {
alive = false;
window.clearInterval(iv);
};
}, []);
if (!localRunnerAvailable()) return null;
const online = status.startsWith("online");
const onToggle = async () => {
setBusy(true);
try {
if (online || status === "connecting") {
stopLocalRunner();
setStatus("offline");
} else {
if (!dir.trim()) throw new Error("先填一个允许 JARVIS 访问的本地目录(绝对路径)");
await startLocalRunner(GATEWAY, getToken(), dir.trim());
setStatus("connecting");
toast.push("success", "本地文件访问已开启(只读,锁定在该目录内)");
}
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="space-y-2 rounded-md border border-line bg-ink-900/50 p-3">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400">访 · </span>
<span className={online ? "text-[11px] text-emerald-400" : "text-[11px] text-slate-500"}>
{online ? "已开启" : status === "connecting" ? "连接中…" : "未开启"}
</span>
</div>
<p className="text-[11px] leading-relaxed text-slate-500">
JARVIS <b>/</b>
</p>
{online ? (
<p className="break-all font-mono text-[11px] text-slate-400">{status.slice("online:".length)}</p>
) : (
<input
className="w-full rounded-md border border-line bg-ink-850 px-3 py-1.5 font-mono text-sm text-slate-100 focus:border-brand focus:outline-none"
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder="/Users/你/Documents/某个目录"
/>
)}
<Button variant={online ? "ghost" : "secondary"} size="sm" onClick={onToggle} disabled={busy}>
{online || status === "connecting" ? "关闭本地访问" : "开启本地访问"}
</Button>
</div>
);
}
function Field({
label,
value,