diff --git a/JARVIS_BRAIN_DESIGN.md b/JARVIS_BRAIN_DESIGN.md new file mode 100644 index 0000000..ac3696f --- /dev/null +++ b/JARVIS_BRAIN_DESIGN.md @@ -0,0 +1,93 @@ +# sundynix-agentix · JARVIS 大脑中枢设计文档 + +> 版本:2026-07-24 +> 定位:把 JARVIS 从「任务系统的语音皮肤」升级为「平台的常驻大脑中枢」——编排、报告、知识库、本地文件、App 界面都只是它手里的工具(Claw/OpenClaw 式全局控制感)。 +> 配套文档:`LOCAL_AGENT_DESIGN.md`(本地的手,双平面)、`VOICE_DESIGN.md`(耳朵和嘴)、`ARCHITECTURE_DESIGN.md`(总览) + +--- + +## 0. 一句话结论 + +> **角色反转:把编排引擎从"执行主体"降级为 JARVIS 的一个工具(tool),把 JARVIS 从"任务的语音入口"升级为持有全部工具的常驻 ReAct 大脑。** +> +> 这是接线工程,不是重构工程——推理循环(react_agent.go)、语音通道、会话记忆、人格、工具协议、HITL 全部现成,缺的只有三条线:平台工具族、客户端动作通道、主动播报器。 + +## 1. 现状 vs 目标 + +**现状**:用户说话 → 每句话变一个 task → 编排引擎执行 → 念结果。JARVIS 从属于任务系统。 +**目标**:JARVIS = 常驻中枢 agent。你说「看看部署状态」,它自己决定是查任务、跑命令、还是调编排;任务跑完它主动开口告诉你。任务系统从属于它。 + +### 已就绪的器官(盘点) + +| 中枢需要的 | 已有的 | 位置 | +|---|---|---| +| 推理循环 | ReAct 循环 + `list_tools` 动态工具发现(加工具零改循环) | `dispatcher/internal/eino/react_agent.go` | +| 耳朵/嘴 | 语音 WS 全双工 + 连续对话(VAD 断句/自动重听) | `gateway/internal/handler/voice.go` | +| 短期记忆 | sessionID → fetchHistory/appendHistory 闭环 | orchestrator.go | +| 人格 | 每用户 UserJarvis(名字/人设/自带豆包) | `store/user_jarvis.go` | +| 工具协议 | 注册表 + `agent_exposed` + inject 参数 | `mcp-go/internal/mcp/gateway.go` | +| 提交关卡 | `preflightCore`/`launchCore`(无 gin,语音已复用) | `handler/task_handler.go` | +| 安全闸 | HITL 审批 + 预算护栏 + 审计 | 全栈 | +| 本地的手 | 双平面设计(档 A/B) | `LOCAL_AGENT_DESIGN.md` | + +## 2. 三条缺的线 + +### 2.1 平台工具族 `platform_*`(让 JARVIS 操作平台本身) + +新增第三个工具提供方:**gateway 自己**。理由:平台操作的权威(提交关卡、归属校验、计费)都在 gateway,工具就该长在权威所在地——而不是让 mcp-go 绕一圈回来调 gateway。 + +- 协议:`contract` 加 `SubjectToolsPlatform = "sundynix.tools.platform"`(+All/Queue/ToolSubjectPlatform),与 go/py 同族。 +- gateway 用 `bus.ServeTool` 起一个迷你注册表(照抄 mcp-go 的 toolDef 模式:唯一事实源,dispatch 与 list_tools 共用)。 +- dispatcher `agentTools` 多发现一路:`discoverTools(contract.ToolSubjectPlatform, …)`——**一行**。 + +**首批工具**(全部 inject `user_id`,严格归属校验): + +| 工具 | 说明 | +|---|---| +| `platform_recent_tasks` | 最近任务列表(状态/主题/耗时),答"我刚才那些任务怎么样了" | +| `platform_task_status(task_id)` | 单任务状态 + 输出摘要(TaskOwner 校验归属,别人的任务不可见) | +| `platform_gen_report(topic, kb?)` | 提交报告任务——**必须走 preflightCore/launchCore 同一关卡**(预算/积分/落库),返回 task_id | + +后续扩展(同一注册表加条目即可):`platform_run_orchestration(graph_id)`(待编排图有服务端存储后)、`platform_search_kb`、`platform_usage_today`。 + +### 2.2 客户端动作通道(让 JARVIS 控制界面与本机) + +语音 WS 加一类下行消息:`ServerMsg{type:"action", action:"navigate", view:"runs", task_id:…}`。 + +- 客户端 VoiceClient 加 `onAction` 回调 → App 层 dispatcher 执行(切页面/打开面板/聚焦任务)。HUD 从显示器变驾驶舱。 +- **`local.*`(读文件/跑命令)走同一条通道**下发到 Wails Go host 执行——即 `LOCAL_AGENT_DESIGN.md` 档 A 的落点,两个设计在此汇合。 +- 安全:动作白名单(navigate 等 UI 动作低危先行);`local.*` 按该文档 §8 的门禁(cwd 沙箱/HITL/仅桌面端)。 + +### 2.3 主动播报器(中枢与工具的本质区别) + +它会先开口:任务跑完/失败、部署红了 → JARVIS 主动说"先生,报告好了"。 + +- 零件全齐:JetStream 有任务终态事件(status 回写已持久化)、语音 WS 常连、TTS 现成。 +- 新增一个**值班推送器**:订阅当前语音会话用户的任务终态 → 判断值不值得开口(本会话触发的任务优先;免打扰规则)→ 文本 → 现有 TTS 通道播报 + 对话流插一条。 +- 与连续对话状态机合流:播报只在 ready/listening 空闲时插入,说话中排队,别抢麦。 + +## 3. 安全边界(权力越大越要收紧) + +1. 每次工具调用过 ToolPolicy;破坏性操作(exec/写文件/会烧钱的编排提交)过 HITL。 +2. `platform_*` 一律 inject user_id + 服务端归属校验,模型不可指定别人的资源。 +3. `platform_gen_report` 等提交类工具走 preflightCore(预算/积分硬拦截),JARVIS 不能绕过计费花钱。 +4. `local.*` 只对有在线本地 runner 的桌面会话开放(见 LOCAL_AGENT_DESIGN §8)。 +5. 全部动作落审计/轨迹(mcpTool 已自动落 ExecEvent span)。 + +## 4. 落地阶段 + +| 阶段 | 内容 | 改动面 | 状态 | +|---|---|---|---| +| **P1 平台工具族** | contract 加 platform subject;gateway 迷你工具注册表(recent_tasks/task_status/gen_report);dispatcher 多发现一路 | shared 一处 / gateway 新文件 / dispatcher 一行 | **✅ 2026-07-24 live 验收全过**(真实数据/派报告走关卡/越权拒绝/积分硬拦截实测) | +| **P2 动作通道** | ServerMsg action + 客户端 onAction + navigate 白名单(platform_open_view → NATS 语音事件 → WS) | protocol/voice.ts/App | **✅ 2026-07-24 live 验通**(“打开运行页”→界面真切) | +| **P3 主动播报** | 任务终态 → announce 语音事件 → 对话流 + TTS(正朗读则排队不抢麦;prev 比对挡重投重播) | main 状态消费 + voice_event.go | **✅ 2026-07-24 live 验通**(报告跑完 JARVIS 主动开口) | +| **P4 本地的手** | LOCAL_AGENT_DESIGN 档 A:`local_list_dir/read_file`(platform 注册表)→ NATS `local.exec.` → gateway runner WS → 桌面 Go host 沙箱执行;桌面设置「本地文件访问」显式开关;`cmd/localsim` 联调工具 | gateway 2 新文件 / desktop runner+设置 | **✅ 2026-07-24 live 验通**(真读文件+离线降级+沙箱逃逸单测全拦) | +| **P5 常驻会话** | 每句一 task → 长驻 companion session(现靠 history 串联已够用,不急) | dispatcher | 暂缓 | + +每步独立可用:P1 做完立刻能"嘴遥控平台"(问任务、派报告)。 + +## 5. P1 验收 + +- 语音问「我最近的任务都什么状态」→ JARVIS 调 `platform_recent_tasks` 报出真实列表。 +- 语音说「帮我生成一份 XX 报告」→ JARVIS 调 `platform_gen_report` → 回 task_id →「已开跑」;任务在运行页可见、计费正常扣。 +- 越权测试:构造 task_id 查他人任务 → 拒绝。 diff --git a/LOCAL_AGENT_DESIGN.md b/LOCAL_AGENT_DESIGN.md index 706388c..ca29bc5 100644 --- a/LOCAL_AGENT_DESIGN.md +++ b/LOCAL_AGENT_DESIGN.md @@ -243,7 +243,7 @@ LLM 驱动在用户机器上跑命令 = 最高危面。默认全部收紧,宁 | 阶段 | 内容 | 依赖 | 产出 | |---|---|---|---| | **P0** | gateway **LLM 代理端点**:客户端推理经它,挂计量/追踪/记忆注入/护栏 | 复用 `internal/llm` | 中心化咽喉立住 | -| **P1(档 A)** | `local.*` **只读**工具(list/read/grep)+ 客户端 runner 注册 + subject 桥接 + cwd 沙箱 | `ToolCaller` 接口 | "agent 读了我的文件"闭环 + 延迟实测 | +| **P1(档 A)** | `local.*` **只读**工具(list/read)+ 客户端 runner 注册 + subject 桥接 + cwd 沙箱 | `ToolCaller` 接口 | **✅ 2026-07-24 落地并 live 验通**(实现见 JARVIS_BRAIN_DESIGN P4:gateway `local_runner.go` + desktop `localrunner.go`;沙箱逃逸单测全拦;离线优雅降级) | | **P2(档 A)** | write/edit/bash + **本地 HITL** + ToolPolicy 下推 + 审计 | HITL 节点、config 下推 | 可操作本地,治理收紧 | | **P3(档 B)** | 按 §7 抽循环 → 搬 `sundynix-shared` → 客户端跑 react 循环 + 预算令牌桶 + 事件上抛 | 四个解耦点 | Claude Code 手感 | | **P4** | 桥接服务端图编排(Task 式子 agent)、会话 resume、CLAUDE.md 式本地项目记忆 | P3 | 两产品面打通 | diff --git a/sundynix-desktop/app.go b/sundynix-desktop/app.go index c02ffaf..0931f7a 100644 --- a/sundynix-desktop/app.go +++ b/sundynix-desktop/app.go @@ -15,9 +15,11 @@ import ( ) // App 经 Wails v3 的 Service 绑定暴露给前端,承载只有桌面端能做的原生能力: -// 文件读写、系统"另存为"框、用系统默认应用打开、原生通知。 +// 文件读写、系统"另存为"框、用系统默认应用打开、原生通知、本地执行 runner(JARVIS 的手)。 // 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" } diff --git a/sundynix-desktop/frontend/bindings/github.com/sundynix/sundynix-desktop/app.ts b/sundynix-desktop/frontend/bindings/github.com/sundynix/sundynix-desktop/app.ts index 45d8b4b..ff0f147 100644 --- a/sundynix-desktop/frontend/bindings/github.com/sundynix/sundynix-desktop/app.ts +++ b/sundynix-desktop/frontend/bindings/github.com/sundynix/sundynix-desktop/app.ts @@ -3,7 +3,7 @@ /** * App 经 Wails v3 的 Service 绑定暴露给前端,承载只有桌面端能做的原生能力: - * 文件读写、系统"另存为"框、用系统默认应用打开、原生通知。 + * 文件读写、系统"另存为"框、用系统默认应用打开、原生通知、本地执行 runner(JARVIS 的手)。 * 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:"(前端状态显示)。 + */ +export function LocalRunnerStatus(): $CancellablePromise { + return $Call.ByID(1389428436); +} + /** * Notify 弹一条系统通知(best-effort:macOS 用 osascript,其它平台暂静默)。 */ @@ -42,3 +49,18 @@ export function PrintReportPage(filename: string, html: string): $CancellablePro export function SaveReportAs(url: string, filename: string): $CancellablePromise { return $Call.ByID(1437856486, url, filename); } + +/** + * StartLocalRunner 开启本地文件访问:以 workdir 为沙箱根连接 gateway 注册执行器。 + * 幂等:重复调用先停旧连接。断线自动重连(5s 退避)直到 StopLocalRunner。 + */ +export function StartLocalRunner(gatewayURL: string, token: string, workdir: string): $CancellablePromise { + return $Call.ByID(2808780368, gatewayURL, token, workdir); +} + +/** + * StopLocalRunner 关闭本地文件访问(幂等)。 + */ +export function StopLocalRunner(): $CancellablePromise { + return $Call.ByID(476635130); +} diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 824edfb..bf7d477 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -377,7 +377,14 @@ export default function App() { selfUserId={user?.id ?? ""} /> setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} /> - + {/* JARVIS 界面动作(P2):服务端白名单过后,这里再按已知 ViewKey 核一次才执行(双保险)。 */} + { + const allowed: ViewKey[] = ["home", "studio", "kb", "report", "runs", "memory", "usage"]; + if (allowed.includes(view as ViewKey)) goto(view as ViewKey, taskId); + }} + /> ); diff --git a/sundynix-desktop/frontend/src/lib/desktop.ts b/sundynix-desktop/frontend/src/lib/desktop.ts index c246196..bd3928d 100644 --- a/sundynix-desktop/frontend/src/lib/desktop.ts +++ b/sundynix-desktop/frontend/src/lib/desktop.ts @@ -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 { + if (!inWails()) throw new Error("本地文件访问仅桌面端可用"); + await App.StartLocalRunner(gatewayURL, token, workdir); +} + +export function stopLocalRunner(): void { + if (inWails()) void App.StopLocalRunner(); +} + +// localRunnerStatus:"offline" / "connecting" / "online:";浏览器恒 offline。 +export async function localRunnerStatus(): Promise { + if (!inWails()) return "offline"; + return App.LocalRunnerStatus(); +} + // printReportHtml:把已渲染的报告 HTML 在打印视图里出 PDF("打印→存为 PDF")。 // 走前端打印是为了让中文(CJK)零字体依赖即可正确排版——后端 PDF 需内嵌 CJK 字体,较重。 // 桌面壳内 WKWebView 会把 window.open 拦成 null(实机验过),改走原生桥: diff --git a/sundynix-desktop/frontend/src/lib/voice.test.ts b/sundynix-desktop/frontend/src/lib/voice.test.ts new file mode 100644 index 0000000..9e973ce --- /dev/null +++ b/sundynix-desktop/frontend/src/lib/voice.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { micTapAction, nextAfterDrain, shouldSendMic, type VoiceState } from "./voice"; + +// 连续对话的纯决策函数:状态门控 / 播完去向 / 点按语义。 +// VoiceClient 本体依赖 WebSocket/AudioContext(jsdom 难直测),决策逻辑抽纯函数在这测。 + +describe("shouldSendMic 上行音频门控", () => { + it("只在聆听态上行", () => { + expect(shouldSendMic("listening")).toBe(true); + }); + + it.each(["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(["idle", "ready", "connecting", "thinking"])("%s 态点 → 开始对话", (s) => { + expect(micTapAction(s, false)).toBe("start"); + expect(micTapAction(s, true)).toBe("start"); + }); +}); diff --git a/sundynix-desktop/frontend/src/lib/voice.ts b/sundynix-desktop/frontend/src/lib/voice.ts index 37b92a5..ab08c37 100644 --- a/sundynix-desktop/frontend/src/lib/voice.ts +++ b/sundynix-desktop/frontend/src/lib/voice.ts @@ -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"; +} + +// nextAfterDrain:TTS 排队音频播完后的去向。对话模式回聆听继续多轮,否则回 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 { + 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 { if (this.micNode) return; // WKWebView(Wails 原生壳)等非安全上下文里 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(); diff --git a/sundynix-desktop/frontend/src/main.tsx b/sundynix-desktop/frontend/src/main.tsx index 389456e..18206d9 100644 --- a/sundynix-desktop/frontend/src/main.tsx +++ b/sundynix-desktop/frontend/src/main.tsx @@ -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( - + + + , ); diff --git a/sundynix-desktop/frontend/src/shell/JarvisSettings.tsx b/sundynix-desktop/frontend/src/shell/JarvisSettings.tsx index 82fb6a5..a7b5c38 100644 --- a/sundynix-desktop/frontend/src/shell/JarvisSettings.tsx +++ b/sundynix-desktop/frontend/src/shell/JarvisSettings.tsx @@ -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: () = set("tts_voice_type", v)} placeholder="zh_male_m191_uranus_bigtts" /> )} + + )} ); } +// 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 ( +
+
+ 本地文件访问(试验 · 只读) + + {online ? "已开启" : status === "connecting" ? "连接中…" : "未开启"} + +
+

+ 开启后 JARVIS 能查看/读取下面这个目录里的文件(仅此目录,不能写、不能执行命令)。 + 对话里可以说“看看我工作目录里有什么”。 +

+ {online ? ( +

{status.slice("online:".length)}

+ ) : ( + setDir(e.target.value)} + placeholder="/Users/你/Documents/某个目录" + /> + )} + +
+ ); +} + function Field({ label, value, diff --git a/sundynix-desktop/frontend/src/shell/VoiceDock.tsx b/sundynix-desktop/frontend/src/shell/VoiceDock.tsx index 8b89baf..f38a451 100644 --- a/sundynix-desktop/frontend/src/shell/VoiceDock.tsx +++ b/sundynix-desktop/frontend/src/shell/VoiceDock.tsx @@ -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 = { - 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> = { + 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(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 ( +
+ {Array.from({ length: 9 }, (_, i) => ( + + ))} +
+ ); +} + +export function VoiceDock({ onTask, onNavigate }: Props) { const toast = useToast(); const clientRef = useRef(null); const [state, setState] = useState("idle"); - const [transcript, setTranscript] = useState(""); // 我说的(ASR 转写) - const [reply, setReply] = useState(""); // JARVIS 回答(打字机,逐 token 累加) + const [conv, setConv] = useState(false); // 对话模式进行中(呼吸环/静默标识依赖它) + const [turns, setTurns] = useState([]); // 迷你对话流(最近 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(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); // 清屏交给 onTurnStart(startConversation 里触发) + 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.2(r=29),剩余秒数映射到 dashoffset(收缩)。 + const cdOffset = idleLeft !== null ? (182.2 * (10 - idleLeft)) / 10 : 0; return ( <> +
{/* 小工具:全屏 JARVIS 模式 + 设置 */}
@@ -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" > @@ -118,24 +244,44 @@ export function VoiceDock({ onTask }: Props) {
- {/* 对话气泡:我说的(转写)+ JARVIS 回答(打字机) */} - {open && (transcript || reply) && ( -
+ {/* 迷你对话流:最近几轮,旧轮淡化,新轮在底部 */} + {open && visibleTurns.length > 0 && ( +
-
- {transcript && ( -

- - {transcript} -

- )} - {reply && ( -

- JARVIS - {reply} - {state === "thinking" && } -

- )} +
+ {visibleTurns.map((t, i) => { + const current = i === visibleTurns.length - 1; + return ( +
+ {t.me && ( +

+ + {t.me} +

+ )} + {(t.ai || (current && state === "thinking")) && ( +

+ {name} + {t.ai || 正在思考…} + {current && (state === "thinking" || state === "speaking") && t.ai && ( + + )} +

+ )} + {t.taskId && ( + + )} +
+ ); + })}
)} - {/* 麦克风按钮 */} - + )} + > + {/* 呼吸环:对话进行中常亮(三种状态只变节奏)——环在=点击是退出/打断,环灭=点击是开始 */} + {ringDur && ( + + )} + {/* 空转退出倒计时环(最后 10s,琥珀收缩) */} + {idleLeft !== null && ( + + + + + )} + {busy ? ( + + ) : state === "speaking" ? ( + + ) : state === "thinking" && conv ? ( + + ) : ( + + )} + +
{/* 状态提示 */} - - {HINT[state]} + + {hint}
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)} /> diff --git a/sundynix-desktop/frontend/src/ui/ErrorBoundary.tsx b/sundynix-desktop/frontend/src/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..01fc396 --- /dev/null +++ b/sundynix-desktop/frontend/src/ui/ErrorBoundary.tsx @@ -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 { + 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 ( +
+
+
界面出错了
+

+ {this.state.error.message} +

+ +
+
+ ); + } +} diff --git a/sundynix-desktop/frontend/src/views/Home.tsx b/sundynix-desktop/frontend/src/views/Home.tsx index 8f3a400..0437ca0 100644 --- a/sundynix-desktop/frontend/src/views/Home.tsx +++ b/sundynix-desktop/frontend/src/views/Home.tsx @@ -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 ( diff --git a/sundynix-desktop/localrunner.go b/sundynix-desktop/localrunner.go new file mode 100644 index 0000000..1dada89 --- /dev/null +++ b/sundynix-desktop/localrunner.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/coder/websocket" +) + +// 本地执行 runner(JARVIS「本地的手」,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:"(前端状态显示)。 +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)") + } +} diff --git a/sundynix-desktop/localrunner_test.go b/sundynix-desktop/localrunner_test.go new file mode 100644 index 0000000..b8d9efa --- /dev/null +++ b/sundynix-desktop/localrunner_test.go @@ -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) + // 断言基准要用软链解析后的 root:macOS 的 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("未注册操作未被拦截") + } +} diff --git a/sundynix-dispatcher/internal/eino/compose_compiler.go b/sundynix-dispatcher/internal/eino/compose_compiler.go index 3f5bc6c..cc8f295 100644 --- a/sundynix-dispatcher/internal/eino/compose_compiler.go +++ b/sundynix-dispatcher/internal/eino/compose_compiler.go @@ -58,6 +58,7 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t b := &board{ uid: meta(t, contract.MetaUserID), sid: meta(t, contract.MetaSessionID), + tenant: meta(t, contract.MetaTenantID), query: plan.Query, useVoice: meta(t, contract.MetaModelProfile) == contract.ModelProfileVoice, // 语音任务 → 走语音模型池 } diff --git a/sundynix-dispatcher/internal/eino/graph.go b/sundynix-dispatcher/internal/eino/graph.go index 7896782..c3d6177 100644 --- a/sundynix-dispatcher/internal/eino/graph.go +++ b/sundynix-dispatcher/internal/eino/graph.go @@ -20,6 +20,7 @@ const defaultAgentSystem = "你是 sundynix-agentix 平台的 AI 助手。" // board 是图执行的"黑板":节点按拓扑序流转时读写它。 type board struct { uid, sid string + tenant string // 发起任务的租户(平台工具计费口径要跟 HTTP 提交一致,经 inject 传给 platform_*) query string useVoice bool // 该任务用 JARVIS 语音模型池(网关 Meta[model_profile]==voice) profile string diff --git a/sundynix-dispatcher/internal/eino/react_agent.go b/sundynix-dispatcher/internal/eino/react_agent.go index 3e329d5..31380ce 100644 --- a/sundynix-dispatcher/internal/eino/react_agent.go +++ b/sundynix-dispatcher/internal/eino/react_agent.go @@ -114,6 +114,8 @@ func (o *Orchestrator) agentTools(b *board, taskID string, tr *execTracer) []too var out []tool.BaseTool out = append(out, o.discoverTools(contract.ToolSubjectGo, b, taskID, tr)...) out = append(out, o.discoverTools(contract.ToolSubjectPy, b, taskID, tr)...) + // 平台工具族(gateway 提供,JARVIS 大脑中枢):查任务/派报告等平台操作,同协议动态发现。 + out = append(out, o.discoverTools(contract.ToolSubjectPlatform, b, taskID, tr)...) return out } @@ -132,7 +134,7 @@ func (o *Orchestrator) discoverTools(subject func(string) string, b *board, task if json.Unmarshal([]byte(res.Content), &cat) != nil { return nil } - injectVal := map[string]any{"user_id": b.uid, "session_id": b.sid, "task_id": taskID, "kb": b.kb} + injectVal := map[string]any{"user_id": b.uid, "session_id": b.sid, "task_id": taskID, "kb": b.kb, "tenant_id": b.tenant} var out []tool.BaseTool for _, e := range cat.Tools { diff --git a/sundynix-gateway/cmd/localsim/main.go b/sundynix-gateway/cmd/localsim/main.go new file mode 100644 index 0000000..8d36e34 --- /dev/null +++ b/sundynix-gateway/cmd/localsim/main.go @@ -0,0 +1,104 @@ +// Command localsim 模拟桌面端本地执行 runner(联调工具,无需起真 Wails 桌面端): +// 连 gateway 的 /api/v1/local/runner WS,注册为当前用户的本地执行器, +// 在指定工作目录内响应 local_list_dir / local_read_file(只读,与桌面端同协议)。 +// +// 用法:LOCALSIM_TOKEN= go run ./cmd/localsim [-gw ws://localhost:8080] [-dir /path/to/workdir] +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/gorilla/websocket" +) + +type req struct { + ID string `json:"id"` + Tool string `json:"tool"` + Args map[string]any `json:"args,omitempty"` +} + +type resp struct { + ID string `json:"id"` + OK bool `json:"ok"` + Content string `json:"content,omitempty"` + Error string `json:"error,omitempty"` + Workdir string `json:"workdir,omitempty"` +} + +func main() { + gw := flag.String("gw", "ws://localhost:8080", "gateway WS 基址") + dir := flag.String("dir", ".", "沙箱工作目录") + flag.Parse() + token := os.Getenv("LOCALSIM_TOKEN") + if token == "" { + log.Fatal("缺 LOCALSIM_TOKEN(用户 JWT)") + } + root, err := filepath.Abs(*dir) + if err != nil { + log.Fatal(err) + } + + url := strings.TrimRight(*gw, "/") + "/api/v1/local/runner?token=" + token + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + log.Fatalf("连接失败: %v", err) + } + defer conn.Close() + log.Printf("已注册为本地执行器 workdir=%s", root) + + hello, _ := json.Marshal(resp{ID: "hello", OK: true, Workdir: root}) + _ = conn.WriteMessage(websocket.TextMessage, hello) + + for { + _, data, err := conn.ReadMessage() + if err != nil { + log.Fatalf("连接断开: %v", err) + } + var r req + if json.Unmarshal(data, &r) != nil { + continue + } + log.Printf("收到调用 tool=%s args=%v", r.Tool, r.Args) + out, _ := json.Marshal(handle(root, &r)) + _ = conn.WriteMessage(websocket.TextMessage, out) + } +} + +func handle(root string, r *req) *resp { + rel, _ := r.Args["path"].(string) + p := filepath.Join(root, filepath.Clean("/"+rel)) // 简版沙箱(联调工具;正式沙箱在桌面端) + switch r.Tool { + case "local_list_dir": + ents, err := os.ReadDir(p) + if err != nil { + return &resp{ID: r.ID, OK: false, Error: err.Error()} + } + var names []string + for _, e := range ents { + n := e.Name() + if e.IsDir() { + n += "/" + } + names = append(names, n) + } + data, _ := json.Marshal(map[string]any{"dir": rel, "entries": names}) + return &resp{ID: r.ID, OK: true, Content: string(data)} + case "local_read_file": + b, err := os.ReadFile(p) + if err != nil { + return &resp{ID: r.ID, OK: false, Error: err.Error()} + } + if len(b) > 64*1024 { + b = b[:64*1024] + } + return &resp{ID: r.ID, OK: true, Content: string(b)} + default: + return &resp{ID: r.ID, OK: false, Error: fmt.Sprintf("不支持: %s", r.Tool)} + } +} diff --git a/sundynix-gateway/cmd/server/main.go b/sundynix-gateway/cmd/server/main.go index ab048b1..28d14b5 100644 --- a/sundynix-gateway/cmd/server/main.go +++ b/sundynix-gateway/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -94,7 +95,27 @@ func main() { // 任务生命周期:持久消费 dispatcher 回写的状态流转(running/done/failed/timeout),落 PG 供 UI 查询。 // JetStream at-least-once:落库失败 → Nak 重投自愈;UpdateTaskStatus 幂等(按 task_id 覆盖),重投无害。 statusDrain, serr := bus.ConsumeTaskStatus(context.Background(), func(ctx context.Context, ev *contract.TaskStatusEvent) error { - return db.UpdateTaskStatus(ctx, ev.TaskID, ev.Status, ev.Detail) + // 主动播报(JARVIS_BRAIN_DESIGN.md P3):报告类任务首次到达终态 → 给 owner 的语音会话 + // 发 announce(没开语音就静默丢弃)。prev 比对挡 JetStream 重投的重复播报;best-effort, + // 播报失败绝不 Nak——别为一句话把状态落库拖下水。 + prev, _ := db.GetTaskStatus(ctx, ev.TaskID) + if err := db.UpdateTaskStatus(ctx, ev.TaskID, ev.Status, ev.Detail); err != nil { + return err + } + if prev != ev.Status && strings.HasPrefix(ev.TaskID, "report_") && + (ev.Status == "done" || ev.Status == "failed") { + if owner := db.TaskOwner(ctx, ev.TaskID); owner != "" { + topic := db.TaskTopic(ctx, ev.TaskID) + text := "你要的报告《" + topic + "》已经生成好了,可以到报告页查看。" + if ev.Status == "failed" { + text = "抱歉,报告《" + topic + "》生成失败了,可以稍后再试。" + } + if perr := bus.PublishVoiceEvent(owner, &contract.VoiceEvent{Action: "announce", Text: text, TaskID: ev.TaskID}); perr != nil { + log.Printf("[gateway] 播报发布失败 task=%s: %v", ev.TaskID, perr) + } + } + } + return nil }) if serr != nil { log.Printf("[gateway] consume task status: %v", serr) @@ -144,6 +165,13 @@ func main() { log.Printf("[gateway] 入库 worker 池就绪(JetStream 持久队列)") } + // 平台工具族(JARVIS 大脑中枢,见 JARVIS_BRAIN_DESIGN.md):gateway 自己当工具提供方, + // dispatcher 经 list_tools 动态发现。提交类工具复用 preflightCore/launchCore 同一关卡。 + platDrain, perr := handler.New(db, cache, bus, blobStore).ServePlatformTools() + if perr != nil { + log.Printf("[gateway] 平台工具启动失败(JARVIS 平台操作不可用): %v", perr) + } + r := router.New(db, cache, bus, blobStore) addr := envOr("GATEWAY_ADDR", ":8080") // 慢读/Slowloris 防护:限制读头/读体时间与头大小。**不设 WriteTimeout**——会掐断 @@ -183,6 +211,12 @@ func main() { ingestDrain(ictx) icancel() } + // 停平台工具订阅(在途调用回完再退,dispatcher 拿到结果而非干等超时)。 + if platDrain != nil { + pctx, pcancel := context.WithTimeout(context.Background(), to) + platDrain(pctx) + pcancel() + } // 停状态/用量持久消费者(未 ack 的重启后由 JetStream 重投,幂等落库不丢不重)。 if statusDrain != nil { statusDrain(context.Background()) diff --git a/sundynix-gateway/internal/handler/local_runner.go b/sundynix-gateway/internal/handler/local_runner.go new file mode 100644 index 0000000..fcf19b2 --- /dev/null +++ b/sundynix-gateway/internal/handler/local_runner.go @@ -0,0 +1,163 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + + "github.com/sundynix/sundynix-shared/contract" +) + +// 本地执行 runner 接入(JARVIS P4「本地的手」,LOCAL_AGENT_DESIGN 档 A): +// 桌面端 Go host 登录后连本 WS,把自己注册成该用户的本地执行器。local_* 工具调用 +// 经 NATS sundynix.local.exec.(request-reply,本连接队列组订阅)路由到这里, +// 转发给 runner 执行、结果原路回。runner 掉线即退订——调用方拿到"不在线"而非干等。 +// +// 安全:执行发生在**用户自己的机器**、以用户 OS 身份、锁在用户自选的工作目录(沙箱在 +// runner 侧强制);服务端只做路由。归属天然成立:subject 按 uid 分桶,工具 inject user_id。 + +var runnerUpgrader = websocket.Upgrader{ + ReadBufferSize: 4096, WriteBufferSize: 4096, + CheckOrigin: func(*http.Request) bool { return true }, // 鉴权由 token 把关 +} + +const ( + runnerCallTimeout = 12 * time.Second // 等 runner 回执行结果(只读文件操作,够了) + runnerWriteWait = 10 * time.Second +) + +// runnerReq / runnerResp 是 gateway ↔ 桌面 runner 的 WS 消息(JSON 文本帧)。 +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"` // hello 帧带:runner 声明的沙箱根(仅展示/日志) +} + +// LocalRunner: GET /api/v1/local/runner —— 桌面端注册本地执行器(WS,?token= 鉴权)。 +func (h *Handler) LocalRunner(c *gin.Context) { + uid := userID(c) + if uid == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "需要登录"}) + return + } + conn, err := runnerUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + r := &runnerConn{conn: conn, pending: map[string]chan *runnerResp{}} + + // 队列组订阅本用户执行主题:多台桌面在线时 NATS 队列组自动挑一台执行。 + drain, err := h.bus.ServeTool(contract.LocalExecSubject(uid), "local-"+uid, r.forward) + if err != nil { + log.Printf("[local] runner 订阅失败 uid=%s: %v", uid, err) + return + } + log.Printf("[local] 本地执行器上线 uid=%s", uid) + + // 读循环:分发 runner 的执行回执;连接断开即退出。 + for { + _, data, rerr := conn.ReadMessage() + if rerr != nil { + break + } + var resp runnerResp + if json.Unmarshal(data, &resp) != nil { + continue + } + if resp.ID == "hello" { + log.Printf("[local] runner 声明工作目录 uid=%s dir=%s", uid, resp.Workdir) + continue + } + r.deliver(&resp) + } + + dctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + drain(dctx) + cancel() + r.failAll("本地执行器已断开") + log.Printf("[local] 本地执行器下线 uid=%s", uid) +} + +// runnerConn 一条 runner 连接:串行化写 + 按请求 id 关联回执。 +type runnerConn struct { + conn *websocket.Conn + writeMu sync.Mutex + mu sync.Mutex + pending map[string]chan *runnerResp + seq atomic.Int64 +} + +// forward 把一次 local_* 工具调用转发给 runner,等回执(NATS ServeTool 的 handler)。 +func (r *runnerConn) forward(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + id := fmt.Sprintf("r%d", r.seq.Add(1)) + ch := make(chan *runnerResp, 1) + r.mu.Lock() + r.pending[id] = ch + r.mu.Unlock() + defer func() { + r.mu.Lock() + delete(r.pending, id) + r.mu.Unlock() + }() + + data, _ := json.Marshal(runnerReq{ID: id, Tool: call.Tool, Args: call.Args}) + r.writeMu.Lock() + _ = r.conn.SetWriteDeadline(time.Now().Add(runnerWriteWait)) + werr := r.conn.WriteMessage(websocket.TextMessage, data) + r.writeMu.Unlock() + if werr != nil { + return &contract.ToolResult{OK: false, Error: "转发本地执行失败: " + werr.Error()} + } + + select { + case resp := <-ch: + return &contract.ToolResult{OK: resp.OK, Content: resp.Content, Error: resp.Error} + case <-time.After(runnerCallTimeout): + return &contract.ToolResult{OK: false, Error: "本地执行超时(桌面端未响应)"} + case <-ctx.Done(): + return &contract.ToolResult{OK: false, Error: "本地执行取消: " + ctx.Err().Error()} + } +} + +// deliver 把 runner 回执投递给等待中的 forward。 +func (r *runnerConn) deliver(resp *runnerResp) { + r.mu.Lock() + ch := r.pending[resp.ID] + r.mu.Unlock() + if ch != nil { + select { + case ch <- resp: + default: + } + } +} + +// failAll 连接断开时让所有在途调用立即失败(别干等超时)。 +func (r *runnerConn) failAll(msg string) { + r.mu.Lock() + defer r.mu.Unlock() + for id, ch := range r.pending { + select { + case ch <- &runnerResp{ID: id, OK: false, Error: msg}: + default: + } + } +} diff --git a/sundynix-gateway/internal/handler/platform_tools.go b/sundynix-gateway/internal/handler/platform_tools.go new file mode 100644 index 0000000..333d1f8 --- /dev/null +++ b/sundynix-gateway/internal/handler/platform_tools.go @@ -0,0 +1,253 @@ +package handler + +import ( + "context" + "encoding/json" + "log" + "sort" + "strings" + "time" + + "github.com/sundynix/sundynix-shared/contract" +) + +// 平台工具族(JARVIS 大脑中枢,见 JARVIS_BRAIN_DESIGN.md §2.1):gateway 自己作为第三个工具 +// 提供方(与 mcp-go/mcp-py 同协议)。平台操作的权威——提交关卡 preflightCore/launchCore、 +// 归属校验、计费——都在 gateway,工具就长在权威所在地,而不是让 mcp-go 绕一圈回来调。 +// dispatcher 经 list_tools 动态发现,加工具只改这里的注册表。 +// +// 安全铁律:①一律 inject user_id + 服务端归属校验,模型不可指定别人的资源; +// ②会烧钱的提交(gen_report)必须走 preflightCore(预算/暂停/积分硬拦截)同一关卡。 + +// platParam 工具参数声明(与 mcp-go paramSpec 同构,list_tools JSON 契约一致)。 +type platParam struct { + Name string `json:"name"` + Type string `json:"type"` + Desc string `json:"desc"` + Required bool `json:"required"` +} + +// platTool 一个平台工具:元信息 + 处理函数(注册表 = 唯一事实源,dispatch 与 list_tools 共用)。 +type platTool struct { + cn string + desc string + params []platParam + inject []string + handler func(context.Context, *contract.ToolCall) *contract.ToolResult +} + +// platformRegistry 平台工具注册表。想加新工具(run_orchestration/search_kb/usage_today…)只改这里。 +func (h *Handler) platformRegistry() map[string]platTool { + return map[string]platTool{ + "platform_recent_tasks": { + cn: "最近任务", desc: "查询当前用户最近的任务运行列表(状态/主题/时间)。用户问“我最近的任务怎么样了/都有什么任务”时调用。", + inject: []string{"user_id"}, handler: h.platRecentTasks, + }, + "platform_task_status": { + cn: "任务状态", desc: "查询某个任务的当前状态与输出摘要。用户问“那个任务/报告跑完了吗、结果是什么”时调用。", + params: []platParam{{Name: "task_id", Type: "string", Desc: "任务 ID", Required: true}}, + inject: []string{"user_id"}, handler: h.platTaskStatus, + }, + "platform_gen_report": { + cn: "生成报告", desc: "提交一个报告生成任务(异步跑,规划→分章→成稿)。用户要“写/生成一份 XX 报告”时调用。返回 task_id 后告诉用户已开跑、可稍后询问进度,不要原地等待结果。", + params: []platParam{ + {Name: "topic", Type: "string", Desc: "报告主题", Required: true}, + {Name: "kb", Type: "string", Desc: "参考知识库名(可选,用户提到才填)"}, + }, + inject: []string{"user_id", "tenant_id", "session_id"}, handler: h.platGenReport, + }, + "platform_open_view": { + cn: "打开界面", desc: "把用户的客户端界面切到某个页面。用户说“打开/带我去/看看 运行页、报告页、知识库”等时调用。view 取值:home(工作台)/studio(编排)/kb(知识库)/runs(运行)/report(报告)/memory(记忆)/usage(用量)。", + params: []platParam{ + {Name: "view", Type: "string", Desc: "目标页面:home/studio/kb/runs/report/memory/usage", Required: true}, + {Name: "task_id", Type: "string", Desc: "可选:聚焦的任务 ID(配合 runs 页)"}, + }, + inject: []string{"user_id"}, handler: h.platOpenView, + }, + // —— 本地的手(P4,只读起步):执行发生在用户自己的桌面(沙箱工作目录内),服务端只路由。 + // 桌面端不在线/没开本地访问时工具会明确报不可用——如实转告用户即可。 + "local_list_dir": { + cn: "看本地目录", desc: "列出用户本地工作目录(或其子目录)里的文件。用户问“我这个目录/文件夹里有什么”时调用。仅用户桌面端在线且开启了本地访问才可用。", + params: []platParam{{Name: "path", Type: "string", Desc: "相对工作目录的子路径,空=根目录"}}, + inject: []string{"user_id"}, handler: h.platLocalExec, + }, + "local_read_file": { + cn: "读本地文件", desc: "读取用户本地工作目录内某个文件的文本内容(大文件截断)。用户让“看看/读一下 某个本地文件”时调用。仅用户桌面端在线且开启了本地访问才可用。", + params: []platParam{{Name: "path", Type: "string", Desc: "相对工作目录的文件路径", Required: true}}, + inject: []string{"user_id"}, handler: h.platLocalExec, + }, + } +} + +// platLocalExec 把 local_* 调用经 NATS 路由到该用户的桌面 runner(见 local_runner.go)。 +// 无 runner 在线时 NATS 无应答 → 明确报"不在线",绝不挂起任务。 +func (h *Handler) platLocalExec(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + uid, _ := call.Args["user_id"].(string) + if uid == "" { + return &contract.ToolResult{OK: false, Error: "缺少用户身份"} + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + res, err := h.bus.CallTool(cctx, contract.LocalExecSubject(uid), call) + if err != nil { + return &contract.ToolResult{OK: true, Content: "本地执行器不在线:用户桌面端未运行或未开启本地文件访问。请如实告知用户。"} + } + return res +} + +// ServePlatformTools 以队列组订阅 sundynix.tools.platform.>,返回 drain 供优雅停机。 +func (h *Handler) ServePlatformTools() (func(context.Context), error) { + reg := h.platformRegistry() + names := make([]string, 0, len(reg)) + for n := range reg { + names = append(names, n) + } + sort.Strings(names) + drain, err := h.bus.ServeTool(contract.SubjectToolsPlatformAll, contract.QueueToolsPlatform, + func(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + return h.platformDispatch(reg, ctx, call) + }) + if err != nil { + return nil, err + } + log.Printf("[platform] 平台工具就绪 %s (queue=%s): %s", + contract.SubjectToolsPlatformAll, contract.QueueToolsPlatform, strings.Join(names, ", ")) + return drain, nil +} + +func (h *Handler) platformDispatch(reg map[string]platTool, ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + log.Printf("[platform] tool=%s task=%s", call.Tool, call.TaskID) + if call.Tool == "list_tools" { + return platListTools(reg) + } + td, ok := reg[call.Tool] + if !ok { + return &contract.ToolResult{OK: false, Error: "unknown platform tool: " + call.Tool} + } + return td.handler(ctx, call) +} + +// platListTools 自省:JSON 契约与 mcp-go listTools 一致(dispatcher toolCatalogEntry 同一解析)。 +func platListTools(reg map[string]platTool) *contract.ToolResult { + type info struct { + Name string `json:"name"` + CN string `json:"cn"` + Desc string `json:"desc"` + Agent bool `json:"agent_exposed"` + Params []platParam `json:"params,omitempty"` + Inject []string `json:"inject,omitempty"` + } + out := make([]info, 0, len(reg)) + for name, td := range reg { + out = append(out, info{Name: name, CN: td.cn, Desc: td.desc, Agent: true, Params: td.params, Inject: td.inject}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + data, _ := json.Marshal(map[string]any{"service": "gateway-platform", "tools": out}) + return &contract.ToolResult{OK: true, Content: string(data)} +} + +// ---- 工具实现 ---- + +// platRecentTasks 最近任务列表(owner 隔离:只看自己的)。 +func (h *Handler) platRecentTasks(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + uid, _ := call.Args["user_id"].(string) + if uid == "" { + return &contract.ToolResult{OK: false, Error: "缺少用户身份"} + } + rows := h.db.RecentRuns(ctx, uid, 10) + type item struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Topic string `json:"topic,omitempty"` + At string `json:"at"` + } + out := make([]item, 0, len(rows)) + for _, r := range rows { + out = append(out, item{TaskID: r.TaskID, Status: r.Status, Topic: r.Topic, At: r.At.Format("01-02 15:04")}) + } + if len(out) == 0 { + return &contract.ToolResult{OK: true, Content: "没有任何任务运行记录。"} + } + data, _ := json.Marshal(out) + return &contract.ToolResult{OK: true, Content: string(data)} +} + +// platTaskStatus 单任务状态 + 输出摘要。归属校验:不是本人的任务一律说不存在(不泄露存在性)。 +func (h *Handler) platTaskStatus(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + uid, _ := call.Args["user_id"].(string) + taskID, _ := call.Args["task_id"].(string) + if uid == "" || strings.TrimSpace(taskID) == "" { + return &contract.ToolResult{OK: false, Error: "缺少 task_id"} + } + owner := h.db.TaskOwner(ctx, taskID) + if owner == "" || owner != uid { + return &contract.ToolResult{OK: true, Content: "没有找到这个任务(ID 不存在或不属于当前用户)。"} + } + status, detail := h.db.GetTaskStatus(ctx, taskID) + output, _ := h.db.GetRunDetail(ctx, taskID) + if rs := []rune(output); len(rs) > 600 { + output = string(rs[:600]) + "…(已截断)" + } + data, _ := json.Marshal(map[string]string{ + "task_id": taskID, "status": status, "detail": detail, "output_preview": output, + }) + return &contract.ToolResult{OK: true, Content: string(data)} +} + +// platOpenView 界面动作(P2 动作通道):经语音事件通道把 navigate 发到该用户的语音会话, +// 由客户端按白名单执行。视图合法性双重校验(此处 + 会话 onVoiceEvent + 客户端),宁可不动。 +func (h *Handler) platOpenView(_ context.Context, call *contract.ToolCall) *contract.ToolResult { + uid, _ := call.Args["user_id"].(string) + view, _ := call.Args["view"].(string) + taskID, _ := call.Args["task_id"].(string) + if uid == "" || view == "" { + return &contract.ToolResult{OK: false, Error: "缺少 view"} + } + if !navigateViews[view] { + return &contract.ToolResult{OK: false, Error: "未知页面: " + view + "(可用 home/studio/kb/runs/report/memory/usage)"} + } + if err := h.bus.PublishVoiceEvent(uid, &contract.VoiceEvent{Action: "navigate", View: view, TaskID: taskID}); err != nil { + return &contract.ToolResult{OK: false, Error: "下发界面动作失败: " + err.Error()} + } + return &contract.ToolResult{OK: true, Content: "已让客户端切到 " + view + " 页。告诉用户已打开即可。"} +} + +// platGenReport 提交报告任务——与 HTTP GenerateReport 同一条关卡与发射流程 +// (preflightCore:预算/暂停/计费租户/积分硬拦截;launchCore:落库+录像+Publish)。 +func (h *Handler) platGenReport(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + uid, _ := call.Args["user_id"].(string) + topic, _ := call.Args["topic"].(string) + kb, _ := call.Args["kb"].(string) + tid, _ := call.Args["tenant_id"].(string) // 发起任务的活跃租户(计费口径跟 HTTP 提交一致) + sid, _ := call.Args["session_id"].(string) // 挂回发起会话(语音会话可续聊“报告好了吗”) + topic = strings.TrimSpace(topic) + if uid == "" || topic == "" { + return &contract.ToolResult{OK: false, Error: "缺少报告主题"} + } + + billingTenant, block := h.preflightCore(ctx, uid, tid) + if block != nil { + return &contract.ToolResult{OK: true, Content: "无法提交:" + block.message()} + } + id := newReportID() + graph, _ := json.Marshal(map[string]any{"topic": topic}) + task := &contract.Task{ + ID: id, + Graph: graph, + Meta: map[string]any{ + contract.MetaIntent: contract.IntentReport, + contract.MetaTopic: topic, + contract.MetaKB: kb, + contract.MetaUserID: uid, + contract.MetaTenantID: billingTenant, + contract.MetaSessionID: sid, + }, + } + if err := h.launchCore(ctx, uid, task); err != nil { + return &contract.ToolResult{OK: false, Error: "报告任务提交失败: " + err.Error()} + } + data, _ := json.Marshal(map[string]string{ + "task_id": id, "msg": "报告任务已提交开跑,请告知用户任务号并说明完成后可询问进度。", + }) + return &contract.ToolResult{OK: true, Content: string(data)} +} diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 64b8653..13b622c 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -524,6 +524,9 @@ func (h *Handler) StatsOverview(c *gin.Context) { // 此前这里走 RecentTasks(),于是运行历史加了 topic 字段、工作台完全没跟上, // 还在显示 report_;而且 RecentTasks 没有租户过滤,口径也不一致。 recent := h.db.RecentRuns(ctx, uid, 8) + if recent == nil { + recent = []store.RunRow{} // nil slice 会序列化成 null(不是 [])——新账号空数据曾把前端打成白屏 + } // 服务健康(与 Health 同口径:本地可判 + milvus/neo4j 经 mcp-go)。 services := gin.H{"gateway": true, "nats": true, "db": h.db.Enabled(), "redis": h.cache.Enabled(), "milvus": false, "neo4j": false} diff --git a/sundynix-gateway/internal/handler/voice.go b/sundynix-gateway/internal/handler/voice.go index 06535c5..06e97a7 100644 --- a/sundynix-gateway/internal/handler/voice.go +++ b/sundynix-gateway/internal/handler/voice.go @@ -55,8 +55,17 @@ func (h *Handler) VoiceStream(c *gin.Context) { tenantID: tenantID(c), sessionID: sessionID(c), jarvisName: jname, jarvisPersona: jpersona, } + // 语音事件通道(JARVIS 动作/主动播报):会话生命周期内订阅本用户主题。 + // navigate → 转发客户端执行;announce → 对话流显示 + TTS 念出来。 + unsubEv, everr := h.bus.SubscribeVoiceEvent(uid, sess.onVoiceEvent) + if everr != nil { + log.Printf("[voice] 订阅语音事件失败 uid=%s: %v", uid, everr) // 降级:会话照常,仅无动作/播报 + } sess.send(voice.ServerMsg{Type: voice.ServerReady}) sess.run() + if unsubEv != nil { + _ = unsubEv() + } sess.stopASR() // 连接结束,收掉在跑的识别会话 sess.stopTTS() // 连带停掉在朗读的下行 TTS } @@ -86,6 +95,9 @@ type voiceSession struct { turnMu sync.Mutex // 护住一轮的转写累计 + 提交去重(ASR 结果 goroutine 与 ClientEnd 兜底 goroutine 都访问) latestText string // 本轮最近一次转写(部分/最终);ClientEnd 时兜底用它提交 submitted bool // 本轮是否已提交——Final 与 ClientEnd 两条路径只落一次 + + annMu sync.Mutex // 护住主动播报队列(NATS 事件 goroutine 与 TTS 收尾 goroutine 都访问) + pendingAnn []string // 正在朗读时到达的播报先排队,本轮 TTS 播完再念(别抢麦) } // send 下发一条控制/事件消息(文本帧,JSON)。并发安全。 diff --git a/sundynix-gateway/internal/handler/voice_event.go b/sundynix-gateway/internal/handler/voice_event.go new file mode 100644 index 0000000..bcf4ed4 --- /dev/null +++ b/sundynix-gateway/internal/handler/voice_event.go @@ -0,0 +1,119 @@ +package handler + +import ( + "context" + "log" + + "github.com/sundynix/sundynix-gateway/internal/voice" + "github.com/sundynix/sundynix-shared/contract" +) + +// 语音事件下行(JARVIS_BRAIN_DESIGN.md P2 动作通道 + P3 主动播报): +// 服务端任意副本经 NATS sundynix.voice.event. 发事件,本会话订阅并落地—— +// navigate → 转发客户端(界面跳转,客户端按白名单执行) +// announce → 对话流显示 + TTS 念出来;正在朗读则排队,播完再念(别抢麦) + +// navigateViews 是允许下发给客户端的视图白名单(与桌面端 ViewKey 对齐)。 +// 模型/工具给出未知视图时丢弃——动作通道宁可不动,不执行任意指令。 +var navigateViews = map[string]bool{ + "home": true, "studio": true, "kb": true, "runs": true, "report": true, "memory": true, "usage": true, +} + +// onVoiceEvent 处理一条发给本用户的语音事件(NATS 订阅回调,会话生命周期内挂载)。 +func (s *voiceSession) onVoiceEvent(ev *contract.VoiceEvent) { + switch ev.Action { + case "navigate": + if !navigateViews[ev.View] { + log.Printf("[voice] 丢弃未知视图的 navigate uid=%s view=%q", s.uid, ev.View) + return + } + s.send(voice.ServerMsg{Type: voice.ServerAction, Action: "navigate", View: ev.View, TaskID: ev.TaskID}) + case "announce": + if ev.Text == "" { + return + } + s.announce(ev.Text) + } +} + +// announce 主动播报一段话:正在朗读则入队(speak/上一条播报收尾时 flush),空闲则立刻念。 +func (s *voiceSession) announce(text string) { + s.ttsMu.Lock() + busy := s.tts != nil + s.ttsMu.Unlock() + if busy { + s.annMu.Lock() + s.pendingAnn = append(s.pendingAnn, text) + s.annMu.Unlock() + return + } + go s.speakAnnounce(text) +} + +// flushAnnounces 取出并播报排队的第一条(TTS 收尾处调用;逐条播,每条播完又会走到这里)。 +func (s *voiceSession) flushAnnounces() { + s.annMu.Lock() + if len(s.pendingAnn) == 0 { + s.annMu.Unlock() + return + } + text := s.pendingAnn[0] + s.pendingAnn = s.pendingAnn[1:] + s.annMu.Unlock() + go s.speakAnnounce(text) +} + +// speakAnnounce 把一段播报文本念出来:对话流先显示(announce 消息),再走与 speak() 同构的 +// TTS 会话(登记到 s.tts → 客户端可打断;首帧 speaking、收尾 tts_end——客户端状态机零新分支)。 +// TTS 未配置/启动失败则只显示文字(静默降级,播报不该弄挂会话)。 +func (s *voiceSession) speakAnnounce(text string) { + s.send(voice.ServerMsg{Type: voice.ServerAnnounce, Text: text}) + if !s.cfg.TTSEnabled() { + return + } + + ctx, cancel := context.WithCancel(context.Background()) + ts, err := voice.StartTTS(ctx, s.cfg) + if err != nil { + cancel() + log.Printf("[voice] 播报 TTS 启动失败 uid=%s: %v", s.uid, err) + return + } + // 登记为当前 TTS:barge-in/会话收尾都能掐掉它;已有别的在念(竞态)就放弃朗读只留文字。 + s.ttsMu.Lock() + if s.tts != nil { + s.ttsMu.Unlock() + cancel() + ts.Close() + return + } + s.tts = ts + s.ttsCancel = cancel + s.ttsMu.Unlock() + + _ = ts.Speak(text) + _ = ts.Finish() + + first := true + for pcm := range ts.Audio() { + if first { + s.send(voice.ServerMsg{Type: voice.ServerSpeaking}) + first = false + } + s.sendAudio(pcm) + } + if e := ts.Err(); e != nil { + log.Printf("[voice] 播报 TTS 出错 uid=%s: %v", s.uid, e) + } + s.send(voice.ServerMsg{Type: voice.ServerTTSEnd}) + + s.ttsMu.Lock() + if s.tts == ts { + s.tts = nil + s.ttsCancel = nil + } + s.ttsMu.Unlock() + ts.Close() + cancel() + s.flushAnnounces() // 队列里还有播报就接着念 +} diff --git a/sundynix-gateway/internal/handler/voice_task.go b/sundynix-gateway/internal/handler/voice_task.go index c6bcd5e..cf39e7e 100644 --- a/sundynix-gateway/internal/handler/voice_task.go +++ b/sundynix-gateway/internal/handler/voice_task.go @@ -25,8 +25,10 @@ func voiceSystemPrompt(name, persona string) string { if n == "" { n = defaultJarvisName } - s := "你是 " + n + "——用户的私人语音助手。这是语音对话,务必简短:先直接给结论," + - "一两句话说清,通常不超过三句,别铺垫、别列清单、别念代码、别复述问题。口语化、自然。" + s := "你是 " + n + "——用户的私人语音助手,也是这套平台的中枢:你能直接查用户最近的任务、" + + "查某个任务的状态和结果、派发报告生成任务(相应工具会提供给你,需要就调,别凭空编造任务状态)。" + + "这是语音对话,务必简短:先直接给结论,一两句话说清,通常不超过三句,别铺垫、别列清单、" + + "别念代码、别复述问题。口语化、自然。派了任务就告诉用户已开跑、稍后可以问进度,不要干等。" if p := strings.TrimSpace(persona); p != "" { s += "\n你的语气与人设:" + p } else { @@ -37,12 +39,16 @@ func voiceSystemPrompt(name, persona string) string { // buildVoiceGraph 把一句转写组成最简可执行图:input(转写) → agent(用户的 JARVIS,带其名字+人设)。 // 与前端画布 exportDsl 同构(kind=input/agent、config.text/system),dispatcher 直接吃。 +// autonomous=true:走 ReAct 自主工具(含 platform_* 平台工具族)——JARVIS 是大脑中枢不是应声虫, +// 见 JARVIS_BRAIN_DESIGN.md。不需要工具的闲聊模型直接作答,时延不受影响。 func buildVoiceGraph(query, name, persona string) json.RawMessage { g := map[string]any{ "version": "voice-1", "nodes": []map[string]any{ {"id": "voice_in", "kind": "input", "config": map[string]any{"text": query}}, - {"id": "voice_agent", "kind": "agent", "config": map[string]any{"system": voiceSystemPrompt(name, persona)}}, + {"id": "voice_agent", "kind": "agent", "config": map[string]any{ + "system": voiceSystemPrompt(name, persona), "autonomous": true, + }}, }, "edges": []map[string]any{ {"source": "voice_in", "target": "voice_agent"}, diff --git a/sundynix-gateway/internal/handler/voice_tts.go b/sundynix-gateway/internal/handler/voice_tts.go index ebc20fc..78e0231 100644 --- a/sundynix-gateway/internal/handler/voice_tts.go +++ b/sundynix-gateway/internal/handler/voice_tts.go @@ -112,6 +112,7 @@ func (s *voiceSession) speak(taskID string) { s.ttsMu.Unlock() ts.Close() cancel() + s.flushAnnounces() // 朗读期间排队的主动播报,本轮播完接着念(别抢麦) } // stopTTS 掐掉当前下行 TTS(打断 / 会话结束)。幂等。Close 后 Audio 关闭 → 音频泵自然收尾。 diff --git a/sundynix-gateway/internal/nats/publisher.go b/sundynix-gateway/internal/nats/publisher.go index 8a070f1..134d09a 100644 --- a/sundynix-gateway/internal/nats/publisher.go +++ b/sundynix-gateway/internal/nats/publisher.go @@ -71,6 +71,22 @@ func (b *Bus) CallTool(ctx context.Context, subject string, call *contract.ToolC return b.inner.CallTool(ctx, subject, call) } +// ServeTool 以队列组订阅一族工具主题并按调用分发(平台工具族:gateway 自己当工具提供方, +// 见 JARVIS_BRAIN_DESIGN.md §2.1)。返回 drain 供优雅停机。 +func (b *Bus) ServeTool(subject, queue string, h sharedbus.ToolHandler) (func(context.Context), error) { + return b.inner.ServeTool(subject, queue, h) +} + +// PublishVoiceEvent 给某用户的语音会话发事件(JARVIS 动作/主动播报;无会话则静默丢弃)。 +func (b *Bus) PublishVoiceEvent(uid string, ev *contract.VoiceEvent) error { + return b.inner.PublishVoiceEvent(uid, ev) +} + +// SubscribeVoiceEvent 订阅某用户的语音事件(语音 WS 会话生命周期内挂载)。 +func (b *Bus) SubscribeVoiceEvent(uid string, onEvent func(*contract.VoiceEvent)) (func() error, error) { + return b.inner.SubscribeVoiceEvent(uid, onEvent) +} + // Ping 同步探测某节点健康(如 dispatcher 心跳主题)。无人应答 / 超时即返回错误(视为下线)。 func (b *Bus) Ping(ctx context.Context, subject string) ([]byte, error) { return b.inner.Ping(ctx, subject) diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 827d832..1c8dbc9 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -73,6 +73,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. api.GET("/reports/:id/download", middleware.AuthFromHeaderOrQuery(), h.ExportReport) // 兼容旧入口(默认 docx) api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门) api.GET("/voice/stream", middleware.AuthFromHeaderOrQuery(), h.VoiceStream) // 语音会话 WebSocket(?token= 鉴权,WS 带不了 Bearer 头) + api.GET("/local/runner", middleware.AuthFromHeaderOrQuery(), h.LocalRunner) // 桌面本地执行器注册 WebSocket(JARVIS 本地的手) // —— 受保护:owner 作用域业务,必须携带有效 JWT —— p := api.Group("", middleware.RequireAuth()) diff --git a/sundynix-gateway/internal/store/task_query.go b/sundynix-gateway/internal/store/task_query.go index e9262b5..603e74f 100644 --- a/sundynix-gateway/internal/store/task_query.go +++ b/sundynix-gateway/internal/store/task_query.go @@ -100,6 +100,18 @@ type AdminEval struct { // TaskOwner 按 task_id 返回提交者 user.id(供 SSE/导出等公开 by-id 端点做归属校验)。 // 跨租户查(WithoutTenant):这些端点无租户上下文,靠 owner 判权。不存在返回空串。 +// TaskTopic 报告类任务的主题(graph 顶层 topic;普通任务无此字段返回空)。主动播报用。 +func (p *Postgres) TaskTopic(ctx context.Context, taskID string) string { + if p.db == nil || taskID == "" { + return "" + } + var topic string + p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_task"). + Where("task_id = ? and deleted_at is null", taskID). + Select("coalesce(graph->>'topic','')").Scan(&topic) + return topic +} + func (p *Postgres) TaskOwner(ctx context.Context, taskID string) string { if p.db == nil || taskID == "" { return "" diff --git a/sundynix-gateway/internal/voice/protocol.go b/sundynix-gateway/internal/voice/protocol.go index f41bd77..d7c6092 100644 --- a/sundynix-gateway/internal/voice/protocol.go +++ b/sundynix-gateway/internal/voice/protocol.go @@ -35,6 +35,10 @@ type ServerMsg struct { TaskID string `json:"task_id,omitempty"` // error:出错文案 Msg string `json:"msg,omitempty"` + // action:JARVIS 让客户端执行的界面动作(P2 动作通道,见 JARVIS_BRAIN_DESIGN.md §2.2)。 + // 目前仅 navigate:Action="navigate" + View(+可选 TaskID 聚焦某任务)。客户端按白名单执行。 + Action string `json:"action,omitempty"` + View string `json:"view,omitempty"` } // 服务端消息类型。 @@ -46,6 +50,8 @@ const ( ServerSpeaking = "speaking" // Agent 开始出声(首段 TTS 音频将至) ServerTTSEnd = "tts_end" // 本轮 TTS 播放完毕 ServerError = "error" // 出错 + ServerAction = "action" // 界面动作指令(navigate 等,客户端白名单执行) + ServerAnnounce = "announce" // 主动播报(text 显示到对话流;随后照常走 speaking/音频/tts_end) ) // 音频格式(与火山 ASR/TTS 约定,客户端按此采集/播放)。 diff --git a/sundynix-shared/bus/bus.go b/sundynix-shared/bus/bus.go index 9e71ce8..3fbb6a8 100644 --- a/sundynix-shared/bus/bus.go +++ b/sundynix-shared/bus/bus.go @@ -285,6 +285,33 @@ func (b *Bus) SubscribeExec(taskID string, onEvent func([]byte), onDone func()) return sub.Unsubscribe, nil } +// ---- 语音事件(JARVIS 动作/主动播报,core NATS pub-sub)---- + +// PublishVoiceEvent 给某用户的语音会话发一条事件(navigate/announce)。 +// 无人订阅(用户没开语音)就静默丢弃——即时性消息,不补投。 +func (b *Bus) PublishVoiceEvent(uid string, ev *contract.VoiceEvent) error { + data, err := json.Marshal(ev) + if err != nil { + return err + } + return b.nc.Publish(contract.VoiceEventSubject(uid), data) +} + +// SubscribeVoiceEvent 订阅某用户的语音事件(语音 WS 会话建立时挂上,会话结束退订)。 +func (b *Bus) SubscribeVoiceEvent(uid string, onEvent func(*contract.VoiceEvent)) (unsub func() error, err error) { + sub, err := b.nc.Subscribe(contract.VoiceEventSubject(uid), func(m *nats.Msg) { + var ev contract.VoiceEvent + if json.Unmarshal(m.Data, &ev) != nil { + return + } + onEvent(&ev) + }) + if err != nil { + return nil, fmt.Errorf("subscribe voice event: %w", err) + } + return sub.Unsubscribe, nil +} + // ---- MCP 工具调用(core NATS request-reply)---- // CallTool 同步调用一个 MCP 工具:发到 subject,阻塞等待应答。 diff --git a/sundynix-shared/contract/task.go b/sundynix-shared/contract/task.go index 5ecfb67..4956705 100644 --- a/sundynix-shared/contract/task.go +++ b/sundynix-shared/contract/task.go @@ -25,6 +25,21 @@ const ( QueueToolsGo = "mcp-go-workers" // mcp-go 队列组(多副本负载均衡) QueueToolsPy = "mcp-py-workers" // mcp-py 队列组 + // 平台工具(JARVIS 大脑中枢,见 JARVIS_BRAIN_DESIGN.md):由 gateway 自己提供—— + // 平台操作的权威(提交关卡/归属校验/计费)都在 gateway,工具就长在权威所在地。 + SubjectToolsPlatform = "sundynix.tools.platform" // 前缀;实际 sundynix.tools.platform. + SubjectToolsPlatformAll = "sundynix.tools.platform.>" // gateway 通配订阅 + QueueToolsPlatform = "platform-tools-workers" // gateway 多副本队列组 + + // 语音事件通道(JARVIS P2 动作/P3 主动播报):服务端任意副本 → 某用户的语音 WS 会话。 + // core NATS pub-sub(即时性消息:navigate/播报,丢一条无伤;会话在哪个副本谁订阅谁收)。 + SubjectVoiceEvent = "sundynix.voice.event" // 前缀;实际 sundynix.voice.event. + + // 本地执行路由(JARVIS P4「本地的手」,LOCAL_AGENT_DESIGN 档 A):local_* 工具调用 + // 经此主题路由到「持有该用户桌面 runner WS 连接」的 gateway 副本(request-reply, + // 载荷复用 ToolCall/ToolResult)。无人订阅 = 用户桌面端不在线 → 调用方明确报不可用。 + SubjectLocalExec = "sundynix.local.exec" // 前缀;实际 sundynix.local.exec. + // QueueGateway 是网关侧事件订阅/配置应答的队列组:多网关副本下,每条 // eval/usage/status 事件与每个 config 请求只由组内一个副本处理,避免重复落库/重复应答(HA)。 QueueGateway = "gateway-workers" @@ -247,6 +262,25 @@ func StreamSubject(id string) string { return SubjectStream + "." + id } func ToolSubjectGo(tool string) string { return SubjectToolsGo + "." + tool } func ToolSubjectPy(tool string) string { return SubjectToolsPy + "." + tool } +// ToolSubjectPlatform 平台工具主题(gateway 提供,JARVIS 中枢用)。 +func ToolSubjectPlatform(tool string) string { return SubjectToolsPlatform + "." + tool } + +// VoiceEventSubject 某用户的语音事件主题(JARVIS 动作/主动播报)。 +func VoiceEventSubject(uid string) string { return SubjectVoiceEvent + "." + uid } + +// LocalExecSubject 某用户的本地执行路由主题(local_* 工具 → 桌面 runner)。 +func LocalExecSubject(uid string) string { return SubjectLocalExec + "." + uid } + +// VoiceEvent 是发往用户语音会话的一条事件: +// - action=navigate:让客户端界面跳转(view + 可选 task_id) +// - action=announce:主动播报(text 会被 TTS 念出来 + 对话流显示) +type VoiceEvent struct { + Action string `json:"action"` + View string `json:"view,omitempty"` + TaskID string `json:"task_id,omitempty"` + Text string `json:"text,omitempty"` +} + // ToolCall 是 Dispatcher 对一个 MCP 工具的调用请求(NATS request 体)。 type ToolCall struct { Tool string `json:"tool"` // 工具名,如 wiki_search