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
+4 -2
View File
@@ -15,9 +15,11 @@ import (
)
// App 经 Wails v3 的 Service 绑定暴露给前端,承载只有桌面端能做的原生能力:
// 文件读写、系统"另存为"框、用系统默认应用打开、原生通知。
// 文件读写、系统"另存为"框、用系统默认应用打开、原生通知、本地执行 runnerJARVIS 的手)
// v3 中不再需要注入 ctx——对话框经 application.Get().Dialog 获取。
type App struct{}
type App struct {
runner LocalRunner // 本地执行器(localrunner.go):用户显式开启才连接
}
// Ping 供前端探活 Go 桥是否就绪。
func (a *App) Ping() string { return "sundynix-desktop ok" }
@@ -3,7 +3,7 @@
/**
* App 经 Wails v3 的 Service 绑定暴露给前端,承载只有桌面端能做的原生能力:
* 文件读写、系统"另存为"框、用系统默认应用打开、原生通知。
* 文件读写、系统"另存为"框、用系统默认应用打开、原生通知、本地执行 runnerJARVIS 的手)
* v3 中不再需要注入 ctx——对话框经 application.Get().Dialog 获取。
* @module
*/
@@ -12,6 +12,13 @@
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
/**
* LocalRunnerStatus 返回 "offline" / "connecting" / "online:<workdir>"(前端状态显示)。
*/
export function LocalRunnerStatus(): $CancellablePromise<string> {
return $Call.ByID(1389428436);
}
/**
* Notify 弹一条系统通知(best-effortmacOS 用 osascript,其它平台暂静默)。
*/
@@ -42,3 +49,18 @@ export function PrintReportPage(filename: string, html: string): $CancellablePro
export function SaveReportAs(url: string, filename: string): $CancellablePromise<string> {
return $Call.ByID(1437856486, url, filename);
}
/**
* StartLocalRunner 开启本地文件访问:以 workdir 为沙箱根连接 gateway 注册执行器。
* 幂等:重复调用先停旧连接。断线自动重连(5s 退避)直到 StopLocalRunner。
*/
export function StartLocalRunner(gatewayURL: string, token: string, workdir: string): $CancellablePromise<void> {
return $Call.ByID(2808780368, gatewayURL, token, workdir);
}
/**
* StopLocalRunner 关闭本地文件访问(幂等)。
*/
export function StopLocalRunner(): $CancellablePromise<void> {
return $Call.ByID(476635130);
}
+8 -1
View File
@@ -377,7 +377,14 @@ export default function App() {
selfUserId={user?.id ?? ""}
/>
<InviteMembers open={inviteOpen} onClose={() => setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} />
<VoiceDock onTask={onVoiceTask} />
{/* JARVIS 界面动作(P2):服务端白名单过后,这里再按已知 ViewKey 核一次才执行(双保险)。 */}
<VoiceDock
onTask={onVoiceTask}
onNavigate={(view, taskId) => {
const allowed: ViewKey[] = ["home", "studio", "kb", "report", "runs", "memory", "usage"];
if (allowed.includes(view as ViewKey)) goto(view as ViewKey, taskId);
}}
/>
</div>
</ToastProvider>
);
@@ -27,6 +27,28 @@ export function notify(title: string, body: string): void {
if (inWails()) void App.Notify(title, body);
}
// ---- 本地执行 runner(JARVIS「本地的手」,仅桌面壳可用;浏览器一律不可用)----
export function localRunnerAvailable(): boolean {
return inWails();
}
// startLocalRunner:以 workdir 为沙箱根开启本地文件访问(Go host 连 gateway 注册执行器)。
export async function startLocalRunner(gatewayURL: string, token: string, workdir: string): Promise<void> {
if (!inWails()) throw new Error("本地文件访问仅桌面端可用");
await App.StartLocalRunner(gatewayURL, token, workdir);
}
export function stopLocalRunner(): void {
if (inWails()) void App.StopLocalRunner();
}
// localRunnerStatus"offline" / "connecting" / "online:<workdir>";浏览器恒 offline。
export async function localRunnerStatus(): Promise<string> {
if (!inWails()) return "offline";
return App.LocalRunnerStatus();
}
// printReportHtml:把已渲染的报告 HTML 在打印视图里出 PDF"打印→存为 PDF")。
// 走前端打印是为了让中文(CJK)零字体依赖即可正确排版——后端 PDF 需内嵌 CJK 字体,较重。
// 桌面壳内 WKWebView 会把 window.open 拦成 null(实机验过),改走原生桥:
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { micTapAction, nextAfterDrain, shouldSendMic, type VoiceState } from "./voice";
// 连续对话的纯决策函数:状态门控 / 播完去向 / 点按语义。
// VoiceClient 本体依赖 WebSocket/AudioContextjsdom 难直测),决策逻辑抽纯函数在这测。
describe("shouldSendMic 上行音频门控", () => {
it("只在聆听态上行", () => {
expect(shouldSendMic("listening")).toBe(true);
});
it.each<VoiceState>(["idle", "connecting", "ready", "thinking", "speaking"])(
"%s 态不上行(防回声喂 ASR + 省计费)",
(s) => {
expect(shouldSendMic(s)).toBe(false);
},
);
});
describe("nextAfterDrain 播完去向", () => {
it("对话模式回聆听(自动重听)", () => {
expect(nextAfterDrain(true)).toBe("listening");
});
it("非对话模式回 ready 待命", () => {
expect(nextAfterDrain(false)).toBe("ready");
});
});
describe("micTapAction 点按语义(对话开关)", () => {
it("聆听中点 → 退出对话", () => {
expect(micTapAction("listening", true)).toBe("stop");
expect(micTapAction("listening", false)).toBe("stop");
});
it("朗读中点(对话里)→ 打断但留在对话", () => {
expect(micTapAction("speaking", true)).toBe("interrupt");
});
it("朗读中点(非对话)→ 开始对话(startConversation 内部先打断)", () => {
expect(micTapAction("speaking", false)).toBe("start");
});
it.each<VoiceState>(["idle", "ready", "connecting", "thinking"])("%s 态点 → 开始对话", (s) => {
expect(micTapAction(s, false)).toBe("start");
expect(micTapAction(s, true)).toBe("start");
});
});
+182 -6
View File
@@ -15,9 +15,38 @@ export interface VoiceCallbacks {
onTranscript?: (text: string, final: boolean) => void;
onTask?: (taskId: string) => void;
onReply?: (deltaText: string) => void; // Agent 回答增量文本(打字机,逐 token)
onTurnStart?: () => void; // 新一轮开始(含自动重听)——UI 清上一轮转写/回答
onAction?: (action: string, view: string, taskId: string) => void; // JARVIS 界面动作(navigate 等,白名单执行)
onAnnounce?: (text: string) => void; // 主动播报文本(随后照常来 speaking/音频/tts_end
onError?: (msg: string) => void;
}
// ---- 连续对话的纯决策函数(抽出便于单测;VoiceClient/VoiceDock 共用)----
// shouldSendMic:当前状态是否应上行麦克风音频。对话模式麦克风常开,
// 但只在聆听时发——thinking/speaking 不发(防扬声器回声喂给 ASR + 省 ASR 时长计费)。
export function shouldSendMic(state: VoiceState): boolean {
return state === "listening";
}
// nextAfterDrainTTS 排队音频播完后的去向。对话模式回聆听继续多轮,否则回 ready 待命。
export function nextAfterDrain(conversation: boolean): VoiceState {
return conversation ? "listening" : "ready";
}
// micTapAction:点按麦克风的语义(对话开关)。
// 聆听中点 → 退出对话;朗读中点(在对话里)→ 打断但留在对话;其余 → 开始对话。
export type MicTapAction = "start" | "stop" | "interrupt";
export function micTapAction(state: VoiceState, inConversation: boolean): MicTapAction {
if (state === "listening") return "stop";
if (state === "speaking" && inConversation) return "interrupt";
return "start";
}
// 健壮性定时器时长(仅对话模式生效)
const IDLE_EXIT_MS = 30_000; // 聆听空转:30s 无任何转写 → 自动退出对话(防 ASR 长连接白烧计费)
const THINK_WATCHDOG_MS = 90_000; // 思考看门狗:final 后 90s 没等到朗读 → 回聆听继续对话
const UP_SAMPLE_RATE = 16000; // 上行 ASR 采样率(与网关 voice.AudioSampleRate 一致)
const DOWN_SAMPLE_RATE = 24000; // 下行 TTS 采样率(与网关 voice.TTSSampleRate 一致)
@@ -67,6 +96,12 @@ export class VoiceClient {
private lvlBuf = new Uint8Array(512); // 复用的时域采样缓冲(level() 每帧读,别每帧新建)
private drainTimer: number | null = null; // tts_end 后等排队音频放完的定时器
// 对话模式(免按键连续多轮):麦克风常开、VAD 自动断句提交、答完自动重听
private conversation = false;
private idleTimer: number | null = null; // 聆听空转定时器
private idleDeadline = 0; // 空转退出时刻(epoch ms),UI 读它画最后 10s 倒计时环
private thinkTimer: number | null = null; // 思考看门狗定时器
constructor(cb: VoiceCallbacks) {
this.cb = cb;
}
@@ -94,6 +129,9 @@ export class VoiceClient {
reject(new Error("ws error"));
};
ws.onclose = () => {
this.conversation = false; // 连接断了对话就断,别留定时器空转
this.clearIdleTimer();
this.clearThinkTimer();
this.stopMic();
if (this.state !== "idle") this.setState("idle");
};
@@ -103,7 +141,7 @@ export class VoiceClient {
private onMessage(e: MessageEvent) {
if (typeof e.data === "string") {
let m: { type: string; text?: string; final?: boolean; task_id?: string; msg?: string };
let m: { type: string; text?: string; final?: boolean; task_id?: string; msg?: string; action?: string; view?: string };
try {
m = JSON.parse(e.data);
} catch {
@@ -115,7 +153,13 @@ export class VoiceClient {
break;
case "transcript":
this.cb.onTranscript?.(m.text ?? "", !!m.final);
if (m.final) this.setState("thinking");
if (m.final) {
this.clearIdleTimer();
this.setState("thinking");
if (this.conversation) this.armThinkTimer(); // 看门狗:任务失败/无 TTS 也能回到聆听
} else if (this.conversation && this.state === "listening") {
this.armIdleTimer(); // 有声音活动 → 空转计时重来
}
break;
case "task":
if (m.task_id) this.cb.onTask?.(m.task_id);
@@ -124,13 +168,41 @@ export class VoiceClient {
if (m.text) this.cb.onReply?.(m.text); // 打字机:回答增量文本
break;
case "speaking":
this.clearThinkTimer();
this.setState("speaking");
break;
case "tts_end":
// 别 resetPlayback!服务端"音频发完" ≠ 客户端"播完":音频按 nextStart 预约到未来时刻播,
// 而火山合成远快于真实语速,收到 tts_end 时大半音频还排在队列里没播。stop 掉就只剩前几个字。
// 让排队音频自然放完,最后一段结束再回 ready。
this.drainThenReady();
// 让排队音频自然放完,最后一段结束再回 ready/聆听
this.clearThinkTimer();
if (this.state === "speaking") {
// 对话模式:此刻就补发 start——服务端重建 ASR 的握手(几百毫秒)与排队音频播放重叠,
// 音频一播完立刻能听;上行音频门控在 shouldSendMic,回聆听态前不会发。
if (this.conversation) this.send({ type: "start" });
this.drainThenReady();
} else if (this.state === "thinking") {
// 没进朗读就结束(TTS 启动失败/无音频):别干等看门狗,立刻进下一轮/回待命。
if (this.conversation) {
this.send({ type: "start" });
this.cb.onTurnStart?.();
this.setState("listening");
this.armIdleTimer();
} else {
this.setState("ready");
}
}
// 其余态(已打断回聆听等):忽略——这是上一轮 speak 收尾的迟到 tts_end
// 再发 start 会把用户已开口的新一轮重置掉。
break;
case "action":
// JARVIS 界面动作(P2 动作通道):交给 UI 层白名单执行,客户端不盲信。
this.cb.onAction?.(m.action ?? "", m.view ?? "", m.task_id ?? "");
break;
case "announce":
// 主动播报(P3):文本进对话流;音频随后照常走 speaking/二进制帧/tts_end,
// 现有状态机零新分支(speaking 会关上行门控,播完 drain 回聆听)。
if (m.text) this.cb.onAnnounce?.(m.text);
break;
case "error":
this.cb.onError?.(m.msg ?? "语音出错");
@@ -197,6 +269,96 @@ export class VoiceClient {
this.resetPlayback();
}
// ---- 对话模式:免按键连续多轮 ----
inConversation(): boolean {
return this.conversation;
}
// startConversation 进入连续对话:麦克风常开,之后 VAD 自动断句提交、答完自动重听,
// 直到 stopConversation(点按退出 / 空转超时)。
async startConversation(graph?: string): Promise<void> {
await this.connect();
this.ensurePlayCtx(); // 用户手势内建好播放上下文(自动播放策略)
if (this.state === "speaking") this.bargeIn();
this.send({ type: "start", graph });
await this.startMic(); // 失败(无麦/无权限)直接抛给 UI,不进对话模式
this.conversation = true;
this.cb.onTurnStart?.();
this.setState("listening");
this.armIdleTimer();
}
// stopConversation 退出连续对话:关麦、清定时器、回待命。幂等。
stopConversation(): void {
this.conversation = false;
this.clearIdleTimer();
this.clearThinkTimer();
this.stopMic();
if (this.state !== "idle") this.setState("ready");
}
// interruptAndListen 对话模式里打断朗读并立即回聆听(点按打断,留在对话)。
interruptAndListen(): void {
this.bargeIn(); // 服务端掐 TTS + 本地清排队音频/drain 定时器
this.clearThinkTimer();
this.send({ type: "start" }); // 服务端重置轮 + 重建 ASR
this.cb.onTurnStart?.();
this.setState("listening");
this.armIdleTimer();
}
// ---- 健壮性定时器(仅对话模式)----
private armIdleTimer(): void {
this.clearIdleTimer();
this.idleDeadline = Date.now() + IDLE_EXIT_MS;
this.idleTimer = window.setTimeout(() => {
this.idleTimer = null;
this.idleDeadline = 0;
if (this.conversation && this.state === "listening") {
this.stopConversation();
this.cb.onError?.("长时间没听到声音,已退出连续对话");
}
}, IDLE_EXIT_MS);
}
private clearIdleTimer(): void {
this.idleDeadline = 0;
if (this.idleTimer !== null) {
window.clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
// idleRemainingMs 距空转自动退出还剩多少毫秒;不在「对话中聆听」则返回 null。
// UI 用它画最后 10s 的倒计时环(开口即 armIdleTimer 重置,环自然消失)。
idleRemainingMs(): number | null {
if (!this.conversation || this.state !== "listening" || this.idleDeadline === 0) return null;
return Math.max(0, this.idleDeadline - Date.now());
}
private armThinkTimer(): void {
this.clearThinkTimer();
this.thinkTimer = window.setTimeout(() => {
this.thinkTimer = null;
if (this.conversation && this.state === "thinking") {
this.cb.onError?.("等回答超时,继续聆听");
this.send({ type: "start" });
this.cb.onTurnStart?.();
this.setState("listening");
this.armIdleTimer();
}
}, THINK_WATCHDOG_MS);
}
private clearThinkTimer(): void {
if (this.thinkTimer !== null) {
window.clearTimeout(this.thinkTimer);
this.thinkTimer = null;
}
}
private async startMic(): Promise<void> {
if (this.micNode) return;
// WKWebViewWails 原生壳)等非安全上下文里 navigator.mediaDevices 可能未暴露——
@@ -253,6 +415,8 @@ export class VoiceClient {
const node = ctx.createScriptProcessor(4096, 1, 1);
node.onaudioprocess = (ev) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
// 对话模式麦克风常开,但只在聆听态上行——thinking/speaking 不发(防回声 + 省 ASR 计费)。
if (!shouldSendMic(this.state)) return;
const f32 = ev.inputBuffer.getChannelData(0);
const ds = downsampleTo16k(f32, ctx.sampleRate);
this.ws.send(floatToPCM16(ds));
@@ -313,7 +477,8 @@ export class VoiceClient {
this.nextStart = 0;
}
// drainThenReady 处理 tts_end:不打断,等排队音频按调度自然放完,最后回 ready。
// drainThenReady 处理 tts_end:不打断,等排队音频按调度自然放完,再去下一站——
// 对话模式回聆听(自动重听,新一轮),否则回 ready 待命。
// speaking 态要维持到真正播完(HUD 频谱靠它读 playAnalyser);播完把队列清干净。
private drainThenReady(): void {
if (this.drainTimer !== null) window.clearTimeout(this.drainTimer);
@@ -323,7 +488,15 @@ export class VoiceClient {
this.drainTimer = null;
this.sources = [];
this.nextStart = 0;
if (this.state === "speaking") this.setState("ready"); // 期间被打断/新一轮改了态就不覆盖
if (this.state !== "speaking") return; // 期间被打断/新一轮改了态就不覆盖
const next = nextAfterDrain(this.conversation);
if (next === "listening") {
this.cb.onTurnStart?.(); // 新一轮:UI 清上一轮转写/回答
this.setState("listening"); // start 已在 tts_end 时提前发,ASR 握手与播放重叠
this.armIdleTimer();
} else {
this.setState("ready");
}
}, remainMs + 120); // +120ms 余量,等末尾 source 真正 onended
}
@@ -333,6 +506,9 @@ export class VoiceClient {
// close 彻底关闭会话(发 bye、停采集/播放、断 WS)。
close(): void {
this.conversation = false;
this.clearIdleTimer();
this.clearThinkTimer();
this.send({ type: "bye" });
this.stopMic();
this.resetPlayback();
+4 -1
View File
@@ -3,11 +3,14 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { applyInitialTheme } from "./lib/theme";
import { ErrorBoundary } from "./ui/ErrorBoundary";
applyInitialTheme(); // 先于渲染设好主题类,避免首屏闪烁
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);
@@ -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,
+257 -81
View File
@@ -1,64 +1,178 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Mic, Square, Loader2, Volume2, X, Settings2, Maximize2 } from "lucide-react";
import { VoiceClient, type VoiceState } from "../lib/voice";
import { ExternalLink, Loader2, Mic, MicOff, Settings2, Maximize2, Volume2, X } from "lucide-react";
import { VoiceClient, micTapAction, type VoiceState } from "../lib/voice";
import { JarvisSettings } from "./JarvisSettings";
import { JarvisHud } from "./JarvisHud";
import { getMyJarvis } from "../lib/api";
import { useToast } from "../ui/Toast";
import { cn } from "../ui/cn";
// JARVIS 语音坞:右下角悬浮麦克风。点按说话 → 转写 → 提交任务 → 跳运行页 → 朗读回答
// onTask 把语音触发的 task_id 交回 App,接入既有运行·观测流(与键盘提交同一条路)。
// JARVIS 语音坞:右下角悬浮麦克风,连续对话模式(点一次进对话,VAD 自动断句、答完自动重听)
// 气泡是「迷你对话流」——保留最近几轮,旧轮淡化,新轮追加(不清屏丢上下文)。
// onTask 不再每轮强制跳运行页(连续对话会被拽走):任务收进对话流里的芯片,点击才跳。
interface Props {
onTask: (taskId: string) => void;
// JARVIS 界面动作(P2 动作通道):view 已过服务端白名单,App 层再按已知视图执行一次(双保险)。
onNavigate?: (view: string, taskId?: string) => void;
}
const HINT: Record<VoiceState, string> = {
idle: "点击说话",
connecting: "连接中…",
ready: "点击说话",
listening: "聆听中,再点结束",
thinking: "思考中…",
speaking: "朗读中,点击打断",
// 一轮对话:我说的 + JARVIS 答的 + 本轮触发的任务(芯片入口)。
interface Turn {
me: string;
ai: string;
taskId?: string;
}
const KEEP_TURNS = 3; // 对话流保留最近几轮(含当前轮)
// hintText 状态提示(带人格位:名字来自每用户 JARVIS 设置)。
function hintText(state: VoiceState, name: string, idleLeft: number | null): string {
if (idleLeft !== null) return `${idleLeft}s 后自动退出 · 说话取消`;
switch (state) {
case "connecting":
return "连接中…";
case "listening":
return "聆听中 · 说完自动发送 · 点击退出";
case "thinking":
return `${name} 正在思考`;
case "speaking":
return "朗读中 · 点击打断";
default:
return "点击开始对话";
}
}
// 呼吸环节奏:同一元素三种状态只变速度——聆听慢呼吸 / 思考更缓 / 朗读加快。
const RING_DUR: Partial<Record<VoiceState, string>> = {
listening: "2.2s",
thinking: "3.2s",
speaking: "1.4s",
};
export function VoiceDock({ onTask }: Props) {
const DOCK_CSS = `
@keyframes jdock-breathe {
0%, 100% { transform: scale(1); opacity: .85; }
50% { transform: scale(1.12); opacity: .3; }
}
`;
// VuBars 实时电平条:聆听读麦克风、朗读读放音(client.level() 已按状态切)。
// 「麦是活的/在出声」的信任信号——常开麦不给可见反馈,用户不敢用。
function VuBars({ getLevel, active }: { getLevel: () => number; active: boolean }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!active) return;
let raf = 0;
const tick = () => {
const el = ref.current;
if (el) {
const lvl = getLevel();
for (let i = 0; i < el.children.length; i++) {
const jitter = 0.55 + 0.45 * Math.sin(Date.now() / 90 + i * 1.7);
(el.children[i] as HTMLElement).style.height = `${3 + lvl * 14 * jitter}px`;
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [active, getLevel]);
return (
<div ref={ref} className="flex h-4 items-end gap-[2px]" aria-hidden>
{Array.from({ length: 9 }, (_, i) => (
<span key={i} className="w-[3px] rounded-[1px] bg-accent-400" style={{ height: 3 }} />
))}
</div>
);
}
export function VoiceDock({ onTask, onNavigate }: Props) {
const toast = useToast();
const clientRef = useRef<VoiceClient | null>(null);
const [state, setState] = useState<VoiceState>("idle");
const [transcript, setTranscript] = useState(""); // 我说的(ASR 转写
const [reply, setReply] = useState(""); // JARVIS 回答(打字机,逐 token 累加
const [conv, setConv] = useState(false); // 对话模式进行中(呼吸环/静默标识依赖它
const [turns, setTurns] = useState<Turn[]>([]); // 迷你对话流(最近 KEEP_TURNS 轮
const [open, setOpen] = useState(false); // 是否展开对话气泡
const [settingsOpen, setSettingsOpen] = useState(false); // JARVIS 设置弹窗
const [fullscreen, setFullscreen] = useState(false); // 全屏 JARVIS 模式
const [name, setName] = useState("JARVIS"); // 助手名(HUD 品牌位)
const [name, setName] = useState("JARVIS"); // 助手名(提示/对话流/HUD 品牌位)
const [idleLeft, setIdleLeft] = useState<number | null>(null); // 空转退出倒计时(最后 10s 才非空)
// onNavigate 经 ref 供长寿命 VoiceClient 回调使用(client 只建一次,闭包别锁死旧 props)。
const onNavigateRef = useRef(onNavigate);
onNavigateRef.current = onNavigate;
// 助手名:挂载即拉一次(未登录/失败保持默认)。
useEffect(() => {
getMyJarvis()
.then((j) => setName(j.name || "JARVIS"))
.catch(() => {});
}, []);
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
const ensureClient = useCallback((): VoiceClient => {
if (!clientRef.current) {
clientRef.current = new VoiceClient({
onState: setState,
onTranscript: (text, final) => {
setTranscript(text);
if (final) setOpen(true);
onState: (s) => {
setState(s);
setConv(clientRef.current?.inConversation() ?? false);
},
onTranscript: (text) => {
setTurns((ts) => {
const last = ts[ts.length - 1] ?? { me: "", ai: "" };
return [...ts.slice(0, -1), { ...last, me: text }];
});
setOpen(true);
},
// 任务触发:收进当前轮的芯片,点击才跳运行页(连续对话不被拽走)。
onTask: (taskId) => {
onTask(taskId);
setOpen(true); // 留着气泡显示打字机回答
setTurns((ts) => {
const last = ts[ts.length - 1] ?? { me: "", ai: "" };
return [...ts.slice(0, -1), { ...last, taskId }];
});
},
onReply: (delta) => {
setReply((r) => r + delta); // 打字机:增量拼接,LLM 首 token 即刻可见
setTurns((ts) => {
const last = ts[ts.length - 1] ?? { me: "", ai: "" };
return [...ts.slice(0, -1), { ...last, ai: last.ai + delta }];
});
setOpen(true);
},
// 新一轮开始(含答完自动重听):对话流追加空轮,旧轮保留淡化——不清屏丢上下文。
onTurnStart: () => {
setTurns((ts) => [...ts.filter((t) => t.me || t.ai), { me: "", ai: "" }].slice(-KEEP_TURNS));
},
// JARVIS 界面动作:目前只认 navigate,交 App 层执行(那里再按已知视图核一次)。
onAction: (action, view, taskId) => {
if (action === "navigate") onNavigateRef.current?.(view, taskId || undefined);
},
// 主动播报:作为一条 JARVIS 独立轮插进对话流(音频随后照常来)。
onAnnounce: (text) => {
setTurns((ts) => [...ts.filter((t) => t.me || t.ai), { me: "", ai: text }].slice(-KEEP_TURNS));
setOpen(true);
},
onError: (msg) => toast.push("error", msg),
});
}
return clientRef.current;
}, [onTask, toast]);
}, [toast]);
useEffect(() => () => clientRef.current?.close(), []);
// 空转倒计时轮询:只在「对话中聆听」时跑;最后 10s 显示倒计时环,开口即消。
useEffect(() => {
if (!(conv && state === "listening")) {
setIdleLeft(null);
return;
}
const iv = window.setInterval(() => {
const ms = clientRef.current?.idleRemainingMs() ?? null;
setIdleLeft(ms !== null && ms <= 10_000 ? Math.ceil(ms / 1000) : null);
}, 250);
return () => window.clearInterval(iv);
}, [conv, state]);
// Esc 退出全屏 JARVIS 模式。
useEffect(() => {
if (!fullscreen) return;
@@ -67,23 +181,30 @@ export function VoiceDock({ onTask }: Props) {
return () => window.removeEventListener("keydown", onKey);
}, [fullscreen]);
// 点按语义 = 对话开关:待命点 → 进入连续对话;聆听中点 → 退出;朗读中点 → 打断但留在对话。
const onMic = useCallback(async () => {
const c = ensureClient();
try {
if (state === "listening") {
c.stopListening();
} else {
setTranscript("");
setReply(""); // 新一轮:清上一轮的回答
setOpen(true);
await c.startListening(); // speaking 中会先打断再开新一轮
switch (micTapAction(state, c.inConversation())) {
case "stop":
c.stopConversation();
break;
case "interrupt":
c.interruptAndListen();
break;
case "start":
setOpen(true); // 清屏交给 onTurnStartstartConversation 里触发)
await c.startConversation();
break;
}
} catch (e) {
toast.push("error", (e as Error).message || "麦克风启动失败(检查权限)");
} finally {
setConv(clientRef.current?.inConversation() ?? false);
}
}, [ensureClient, state, toast]);
// 进全屏 JARVIS 模式:建好客户端(这样 HUD 读实时电平),一次助手名做品牌位
// 进全屏 JARVIS 模式:建好客户端(HUD 读实时电平),一次助手名。
const openFullscreen = useCallback(() => {
ensureClient();
getMyJarvis()
@@ -92,11 +213,16 @@ export function VoiceDock({ onTask }: Props) {
setFullscreen(true);
}, [ensureClient]);
const active = state === "listening";
const busy = state === "connecting" || state === "thinking";
const busy = state === "connecting";
const hint = hintText(state, name, idleLeft);
const visibleTurns = turns.filter((t) => t.me || t.ai || t.taskId);
const ringDur = conv ? RING_DUR[state] : undefined;
// 倒计时环:SVG 周长 182.2r=29),剩余秒数映射到 dashoffset(收缩)。
const cdOffset = idleLeft !== null ? (182.2 * (10 - idleLeft)) / 10 : 0;
return (
<>
<style>{DOCK_CSS}</style>
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2">
{/* 小工具:全屏 JARVIS 模式 + 设置 */}
<div className="pointer-events-auto flex items-center gap-2">
@@ -104,7 +230,7 @@ export function VoiceDock({ onTask }: Props) {
onClick={openFullscreen}
title="全屏 JARVIS 模式"
aria-label="全屏 JARVIS 模式"
className="flex h-8 w-8 items-center justify-center rounded-full border border-line bg-ink-850/90 text-slate-400 shadow-md backdrop-blur transition hover:border-brand hover:text-brand-300"
className="flex h-8 w-8 items-center justify-center rounded-full border border-line bg-ink-850/90 text-slate-400 shadow-md backdrop-blur transition hover:border-brand hover:text-accent-400"
>
<Maximize2 className="h-4 w-4" />
</button>
@@ -118,24 +244,44 @@ export function VoiceDock({ onTask }: Props) {
</button>
</div>
{/* 对话气泡:我说的(转写)+ 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">
{/* 迷你对话流:最近几轮,旧轮淡化,新轮在底部 */}
{open && visibleTurns.length > 0 && (
<div className="pointer-events-auto w-64 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">
<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 className="flex-1 space-y-2">
{visibleTurns.map((t, i) => {
const current = i === visibleTurns.length - 1;
return (
<div key={i} className={cn("space-y-1", !current && "opacity-40")}>
{t.me && (
<p className="leading-relaxed text-slate-400">
<span className="mr-1 text-[11px] text-slate-500"></span>
{t.me}
</p>
)}
{(t.ai || (current && state === "thinking")) && (
<p className="leading-relaxed text-slate-100">
<span className="mr-1 text-[11px] text-accent-400">{name}</span>
{t.ai || <span className="text-slate-400"></span>}
{current && (state === "thinking" || state === "speaking") && t.ai && (
<span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] animate-pulse bg-accent-400 align-middle" />
)}
</p>
)}
{t.taskId && (
<button
onClick={() => onTask(t.taskId!)}
title="查看运行轨迹"
className="flex w-full items-center gap-1.5 rounded-lg border border-ink-600/60 bg-ink-800/80 px-2 py-1 text-[11px] text-slate-300 transition hover:border-brand hover:text-accent-400"
>
<span className="h-1.5 w-1.5 rounded-full bg-brand" />
· {t.taskId.slice(-8)}
<ExternalLink className="ml-auto h-3 w-3" />
</button>
)}
</div>
);
})}
</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" />
@@ -144,36 +290,66 @@ export function VoiceDock({ onTask }: Props) {
</div>
)}
{/* 麦克风按钮 */}
<button
onClick={onMic}
title={HINT[state]}
aria-label={HINT[state]}
className={cn(
"pointer-events-auto relative flex h-14 w-14 items-center justify-center rounded-full shadow-xl transition",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-brand/60",
active
? "bg-danger text-white"
: state === "speaking"
? "bg-brand text-white"
{/* 电平/静默指示 + 麦克风按钮 */}
<div className="pointer-events-auto flex items-center gap-2.5">
{conv && (state === "listening" || state === "speaking") && (
<VuBars getLevel={() => clientRef.current?.level() ?? 0} active />
)}
{conv && state === "thinking" && (
<span className="flex items-center gap-1 rounded-full bg-ink-900/80 px-2.5 py-1 text-[10px] text-slate-400">
<MicOff className="h-3 w-3" />
</span>
)}
<button
onClick={onMic}
title={hint}
aria-label={hint}
className={cn(
"relative flex h-14 w-14 items-center justify-center rounded-full shadow-xl transition",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-brand/60",
conv && state === "thinking"
? "bg-ink-800 text-accent-400"
: "bg-brand text-white hover:bg-brand-500 active:scale-95",
)}
>
{active && <span className="absolute inset-0 animate-ping rounded-full bg-danger/40" />}
{busy ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : state === "speaking" ? (
<Volume2 className="h-6 w-6" />
) : active ? (
<Square className="h-5 w-5" />
) : (
<Mic className="h-6 w-6" />
)}
</button>
)}
>
{/* 呼吸环:对话进行中常亮(三种状态只变节奏)——环在=点击是退出/打断,环灭=点击是开始 */}
{ringDur && (
<span
className="pointer-events-none absolute -inset-1.5 rounded-full border-2 border-accent-400"
style={{ animation: `jdock-breathe ${ringDur} ease-in-out infinite` }}
/>
)}
{/* 空转退出倒计时环(最后 10s,琥珀收缩) */}
{idleLeft !== null && (
<svg viewBox="0 0 64 64" className="pointer-events-none absolute -inset-1.5 -rotate-90" aria-hidden>
<circle cx="32" cy="32" r="29" fill="none" stroke="rgba(148,163,184,.25)" strokeWidth="2.5" />
<circle
cx="32" cy="32" r="29" fill="none" stroke="#e0a94f" strokeWidth="2.5" strokeLinecap="round"
strokeDasharray="182.2" strokeDashoffset={cdOffset}
/>
</svg>
)}
{busy ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : state === "speaking" ? (
<Volume2 className="h-6 w-6" />
) : state === "thinking" && conv ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<Mic className="h-6 w-6" />
)}
</button>
</div>
{/* 状态提示 */}
<span className="pointer-events-none rounded-full bg-ink-900/80 px-2.5 py-0.5 text-[11px] text-slate-400">
{HINT[state]}
<span
className={cn(
"pointer-events-none rounded-full bg-ink-900/80 px-2.5 py-0.5 text-[11px]",
idleLeft !== null ? "text-amber-400" : "text-slate-400",
)}
>
{hint}
</span>
</div>
<JarvisSettings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
@@ -182,9 +358,9 @@ export function VoiceDock({ onTask }: Props) {
name={name}
state={state}
getLevel={() => clientRef.current?.level() ?? 0}
transcript={transcript}
reply={reply}
hint={HINT[state]}
transcript={visibleTurns[visibleTurns.length - 1]?.me ?? ""}
reply={visibleTurns[visibleTurns.length - 1]?.ai ?? ""}
hint={hint}
onMic={onMic}
onClose={() => setFullscreen(false)}
/>
@@ -0,0 +1,44 @@
import { Component, type ReactNode } from "react";
// 全局错误边界:任何组件渲染崩溃时显示错误卡片,而不是 React 整树卸载变白屏。
// (曾经:stats 空数据 → Home 读 null.length 崩 → 用户只看到"闪一下变白",毫无线索。)
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error): void {
console.error("[ErrorBoundary] 界面渲染崩溃:", error);
}
render() {
if (!this.state.error) return this.props.children;
return (
<div className="flex h-screen items-center justify-center bg-ink-950 p-8">
<div className="max-w-md rounded-xl border border-line bg-ink-900 p-6 text-center">
<div className="text-base font-medium text-slate-100"></div>
<p className="mt-2 break-all text-xs leading-relaxed text-slate-500">
{this.state.error.message}
</p>
<button
onClick={() => window.location.reload()}
className="mt-4 rounded-lg bg-brand px-4 py-1.5 text-sm text-white transition hover:bg-brand-500"
>
</button>
</div>
</div>
);
}
}
+5 -3
View File
@@ -93,11 +93,13 @@ export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey,
return () => { alive = false; clearInterval(id); };
}, []);
const taskTrend = ov?.task_trend.map((d) => d.count) ?? [];
const tokenTrend = ov?.token_trend.map((d) => d.count) ?? [];
// 注意:后端空数据时 nil slice 序列化成 null(不是 [])——可选链只保护 ov 不保护字段,
// `ov?.task_trend.map` 在 task_trend=null 时照样崩(曾导致新账号进来整页白屏)。一律 ?? [] 兜底。
const taskTrend = (ov?.task_trend ?? []).map((d) => d.count);
const tokenTrend = (ov?.token_trend ?? []).map((d) => d.count);
const maxTask = Math.max(...taskTrend, 1);
const fmtTokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${n}`);
const hasRuns = !!ov && ov.recent_runs.length > 0;
const hasRuns = (ov?.recent_runs ?? []).length > 0;
const quality = ov?.eval_count ? `${Math.round((ov.eval_avg ?? 0) * 100)}%` : "—";
return (
+244
View File
@@ -0,0 +1,244 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/coder/websocket"
)
// 本地执行 runnerJARVIS「本地的手」,LOCAL_AGENT_DESIGN 档 A / JARVIS_BRAIN_DESIGN P4):
// 桌面端 Go host 连 gateway 的 /api/v1/local/runner WS,把自己注册成本用户的本地执行器;
// 服务端把 local_* 工具调用转发过来,这里在**用户自选工作目录的沙箱内**执行并回结果。
//
// 安全铁律(P1 只读起步):
// - 只实现 list_dir / read_file,无写无 exec
// - 一切路径锁死在用户显式选择的 workdir 根下(清洗 + 软链解析后前缀校验,越界即拒);
// - 用户不点"开启",runner 永不连接——本地访问是显式授权,不是默认能力。
type runnerReq struct {
ID string `json:"id"`
Tool string `json:"tool"`
Args map[string]any `json:"args,omitempty"`
}
type runnerResp struct {
ID string `json:"id"`
OK bool `json:"ok"`
Content string `json:"content,omitempty"`
Error string `json:"error,omitempty"`
Workdir string `json:"workdir,omitempty"`
}
// LocalRunner 管一条 runner 连接的生命周期(App 持有单例)。
type LocalRunner struct {
mu sync.Mutex
cancel context.CancelFunc
workdir string
status string // offline / connecting / online
}
// StartLocalRunner 开启本地文件访问:以 workdir 为沙箱根连接 gateway 注册执行器。
// 幂等:重复调用先停旧连接。断线自动重连(5s 退避)直到 StopLocalRunner。
func (a *App) StartLocalRunner(gatewayURL, token, workdir string) error {
abs, err := filepath.Abs(strings.TrimSpace(workdir))
if err != nil {
return fmt.Errorf("工作目录无效: %w", err)
}
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
return fmt.Errorf("工作目录不存在或不是目录: %s", abs)
}
a.runner.stop()
ctx, cancel := context.WithCancel(context.Background())
a.runner.mu.Lock()
a.runner.cancel = cancel
a.runner.workdir = abs
a.runner.status = "connecting"
a.runner.mu.Unlock()
wsURL := strings.Replace(strings.TrimRight(gatewayURL, "/"), "http", "ws", 1) +
"/api/v1/local/runner?token=" + token
go a.runner.loop(ctx, wsURL, abs)
return nil
}
// StopLocalRunner 关闭本地文件访问(幂等)。
func (a *App) StopLocalRunner() { a.runner.stop() }
// LocalRunnerStatus 返回 "offline" / "connecting" / "online:<workdir>"(前端状态显示)。
func (a *App) LocalRunnerStatus() string {
a.runner.mu.Lock()
defer a.runner.mu.Unlock()
if a.runner.status == "online" {
return "online:" + a.runner.workdir
}
return a.runner.status
}
func (r *LocalRunner) stop() {
r.mu.Lock()
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
r.status = "offline"
r.mu.Unlock()
}
func (r *LocalRunner) setStatus(s string) {
r.mu.Lock()
r.status = s
r.mu.Unlock()
}
// loop 连接→服务→断线重连(5s 退避),直到 ctx 取消。
func (r *LocalRunner) loop(ctx context.Context, wsURL, root string) {
for {
if err := r.serve(ctx, wsURL, root); err != nil && ctx.Err() == nil {
r.setStatus("connecting")
}
select {
case <-ctx.Done():
r.setStatus("offline")
return
case <-time.After(5 * time.Second):
}
}
}
// serve 一条连接的会话:发 hello → 循环收请求、沙箱内执行、回结果。
func (r *LocalRunner) serve(ctx context.Context, wsURL, root string) error {
dctx, dcancel := context.WithTimeout(ctx, 10*time.Second)
conn, _, err := websocket.Dial(dctx, wsURL, nil)
dcancel()
if err != nil {
return err
}
defer conn.Close(websocket.StatusNormalClosure, "bye")
conn.SetReadLimit(1 << 20)
r.setStatus("online")
hello, _ := json.Marshal(runnerResp{ID: "hello", OK: true, Workdir: root})
if err := conn.Write(ctx, websocket.MessageText, hello); err != nil {
return err
}
for {
_, data, err := conn.Read(ctx)
if err != nil {
return err
}
var req runnerReq
if json.Unmarshal(data, &req) != nil {
continue
}
resp := execLocal(root, &req)
out, _ := json.Marshal(resp)
if err := conn.Write(ctx, websocket.MessageText, out); err != nil {
return err
}
}
}
// ---- 沙箱执行(只读)----
const (
maxReadBytes = 64 * 1024 // read_file 上限:64KB,超出截断(语音/对话场景足够)
maxDirEntries = 200 // list_dir 上限条数
)
// resolveInRoot 把相对路径解析进沙箱根:清洗 + 软链解析后必须仍在 root 下,越界即错。
func resolveInRoot(root, rel string) (string, error) {
p := filepath.Join(root, filepath.Clean("/"+rel)) // 前置 "/" 再 Clean:吃掉 ../ 逃逸
// 软链解析(目标可能不存在:解析其父目录)
resolved, err := filepath.EvalSymlinks(p)
if err != nil {
if !os.IsNotExist(err) {
return "", err
}
resolved = p
}
rootR, err := filepath.EvalSymlinks(root)
if err != nil {
rootR = root
}
if resolved != rootR && !strings.HasPrefix(resolved, rootR+string(filepath.Separator)) {
return "", fmt.Errorf("路径越出工作目录沙箱")
}
return resolved, nil
}
func execLocal(root string, req *runnerReq) *runnerResp {
fail := func(msg string) *runnerResp { return &runnerResp{ID: req.ID, OK: false, Error: msg} }
rel, _ := req.Args["path"].(string)
switch req.Tool {
case "local_list_dir":
p, err := resolveInRoot(root, rel)
if err != nil {
return fail(err.Error())
}
ents, err := os.ReadDir(p)
if err != nil {
return fail("读目录失败: " + err.Error())
}
sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() })
type item struct {
Name string `json:"name"`
Dir bool `json:"dir"`
Size int64 `json:"size,omitempty"`
}
out := make([]item, 0, len(ents))
for i, e := range ents {
if i >= maxDirEntries {
break
}
it := item{Name: e.Name(), Dir: e.IsDir()}
if fi, err := e.Info(); err == nil && !e.IsDir() {
it.Size = fi.Size()
}
out = append(out, it)
}
data, _ := json.Marshal(map[string]any{"dir": rel, "entries": out, "truncated": len(ents) > maxDirEntries})
return &runnerResp{ID: req.ID, OK: true, Content: string(data)}
case "local_read_file":
if strings.TrimSpace(rel) == "" {
return fail("缺少文件路径")
}
p, err := resolveInRoot(root, rel)
if err != nil {
return fail(err.Error())
}
fi, err := os.Stat(p)
if err != nil {
return fail("文件不存在: " + rel)
}
if fi.IsDir() {
return fail("这是目录不是文件: " + rel)
}
f, err := os.Open(p)
if err != nil {
return fail("打开失败: " + err.Error())
}
defer f.Close()
buf := make([]byte, maxReadBytes+1)
n, _ := f.Read(buf)
content := string(buf[:min(n, maxReadBytes)])
if n > maxReadBytes {
content += "\n…(文件过大,已截断到 64KB"
}
return &runnerResp{ID: req.ID, OK: true, Content: content}
default:
return fail("本地执行器不支持该操作: " + req.Tool + "(只读版仅 list_dir/read_file")
}
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// 沙箱是本地执行的安全命门:路径清洗/软链逃逸/越界读全都要拒——这里逐项钉死。
func newSandbox(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "sub", "b.txt"), []byte("world"), 0o644); err != nil {
t.Fatal(err)
}
return root
}
func TestResolveInRootBlocksEscape(t *testing.T) {
root := newSandbox(t)
// 断言基准要用软链解析后的 rootmacOS 的 TempDir 在 /var(→/private/var 软链)下,
// resolveInRoot 返回的是解析后的绝对路径,拿未解析 root 做前缀比较会误报。
rootR, err := filepath.EvalSymlinks(root)
if err != nil {
rootR = root
}
for _, rel := range []string{"..", "../..", "../../etc/passwd", "sub/../../outside", "/etc/passwd"} {
p, err := resolveInRoot(root, rel)
// 前置 "/"+Clean 把绝对路径/.. 都钉回 root 下(如 root/etc/passwd,不算逃逸);
// 无论哪种形式,成功解析的结果都必须仍在 root(解析后)之内。
if err == nil && p != rootR && !strings.HasPrefix(p, rootR+string(filepath.Separator)) {
t.Fatalf("路径 %q 逃出了沙箱: %s", rel, p)
}
}
}
func TestResolveInRootBlocksSymlinkEscape(t *testing.T) {
root := newSandbox(t)
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "evil")
if err := os.Symlink(outside, link); err != nil {
t.Skip("无法创建软链,跳过")
}
if _, err := resolveInRoot(root, "evil/secret.txt"); err == nil {
t.Fatal("软链逃逸未被拦截")
}
}
func TestExecLocalListAndRead(t *testing.T) {
root := newSandbox(t)
// list_dir 根目录
resp := execLocal(root, &runnerReq{ID: "1", Tool: "local_list_dir", Args: map[string]any{"path": ""}})
if !resp.OK {
t.Fatalf("list_dir 失败: %s", resp.Error)
}
var listing struct {
Entries []struct {
Name string `json:"name"`
Dir bool `json:"dir"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(resp.Content), &listing); err != nil {
t.Fatal(err)
}
if len(listing.Entries) != 2 {
t.Fatalf("期望 2 项,得 %d", len(listing.Entries))
}
// read_file 子目录文件
resp = execLocal(root, &runnerReq{ID: "2", Tool: "local_read_file", Args: map[string]any{"path": "sub/b.txt"}})
if !resp.OK || resp.Content != "world" {
t.Fatalf("read_file 失败: ok=%v content=%q err=%s", resp.OK, resp.Content, resp.Error)
}
// read_file 越界必须拒
resp = execLocal(root, &runnerReq{ID: "3", Tool: "local_read_file", Args: map[string]any{"path": "../outside.txt"}})
if resp.OK {
t.Fatal("越界读未被拦截")
}
// 未知工具(写/exec 都不在只读版里)必须拒
resp = execLocal(root, &runnerReq{ID: "4", Tool: "local_write_file", Args: map[string]any{"path": "a.txt"}})
if resp.OK {
t.Fatal("未注册操作未被拦截")
}
}