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
+93
View File
@@ -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 subjectgateway 迷你工具注册表(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.<uid>` → 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 查他人任务 → 拒绝。
+1 -1
View File
@@ -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 P4gateway `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 | 两产品面打通 |
+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");
});
});
+181 -5
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。
// 让排队音频自然放完,最后一段结束再回 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();
+3
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>
<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,
+236 -60
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 && (
<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>
{transcript}
{t.me}
</p>
)}
{reply && (
{(t.ai || (current && state === "thinking")) && (
<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" />}
<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>
)}
{/* 麦克风按钮 */}
{/* 电平/静默指示 + 麦克风按钮 */}
<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[state]}
aria-label={HINT[state]}
title={hint}
aria-label={hint}
className={cn(
"pointer-events-auto relative flex h-14 w-14 items-center justify-center rounded-full shadow-xl transition",
"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"
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" />}
{/* 呼吸环:对话进行中常亮(三种状态只变节奏)——环在=点击是退出/打断,环灭=点击是开始 */}
{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" />
) : active ? (
<Square className="h-5 w-5" />
) : 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("未注册操作未被拦截")
}
}
@@ -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, // 语音任务 → 走语音模型池
}
@@ -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
@@ -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 {
+104
View File
@@ -0,0 +1,104 @@
// Command localsim 模拟桌面端本地执行 runner(联调工具,无需起真 Wails 桌面端):
// 连 gateway 的 /api/v1/local/runner WS,注册为当前用户的本地执行器,
// 在指定工作目录内响应 local_list_dir / local_read_file(只读,与桌面端同协议)。
//
// 用法:LOCALSIM_TOKEN=<jwt> 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)}
}
}
+35 -1
View File
@@ -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())
@@ -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.<uid>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:
}
}
}
@@ -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)}
}
@@ -524,6 +524,9 @@ func (h *Handler) StatsOverview(c *gin.Context) {
// 此前这里走 RecentTasks(),于是运行历史加了 topic 字段、工作台完全没跟上,
// 还在显示 report_<hex>;而且 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}
@@ -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)。并发安全。
@@ -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.<uid> 发事件,本会话订阅并落地——
// 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() // 队列里还有播报就接着念
}
@@ -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"},
@@ -112,6 +112,7 @@ func (s *voiceSession) speak(taskID string) {
s.ttsMu.Unlock()
ts.Close()
cancel()
s.flushAnnounces() // 朗读期间排队的主动播报,本轮播完接着念(别抢麦)
}
// stopTTS 掐掉当前下行 TTS(打断 / 会话结束)。幂等。Close 后 Audio 关闭 → 音频泵自然收尾。
@@ -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)
@@ -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())
@@ -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 ""
@@ -35,6 +35,10 @@ type ServerMsg struct {
TaskID string `json:"task_id,omitempty"`
// error:出错文案
Msg string `json:"msg,omitempty"`
// actionJARVIS 让客户端执行的界面动作(P2 动作通道,见 JARVIS_BRAIN_DESIGN.md §2.2)。
// 目前仅 navigateAction="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 约定,客户端按此采集/播放)。
+27
View File
@@ -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,阻塞等待应答。
+34
View File
@@ -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.<tool>
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.<user_id>
// 本地执行路由(JARVIS P4「本地的手」,LOCAL_AGENT_DESIGN 档 A):local_* 工具调用
// 经此主题路由到「持有该用户桌面 runner WS 连接」的 gateway 副本(request-reply
// 载荷复用 ToolCall/ToolResult)。无人订阅 = 用户桌面端不在线 → 调用方明确报不可用。
SubjectLocalExec = "sundynix.local.exec" // 前缀;实际 sundynix.local.exec.<user_id>
// 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