Files
Blizzard a16229573b feat(jarvis): 本地沙箱改多目录白名单 + 让它知道自己能操作这台电脑
单目录沙箱做不了"操作我电脑"——只能在一个文件夹里打转,跨目录整理直接没戏。
且模型压根不知道 shell 除了跑脚本还能开 App、控 App、触发快捷指令。

沙箱:单根 → 多根白名单
- 用户授权多个目录(设置里每行一个,可一键填入桌面/下载/文档),其余一律拒
- 路径改绝对路径(多根之下相对路径没有唯一含义),支持 ~ 展开,相对路径兜底按首个根解释
- local_list_dir 留空 path = 返回授权目录清单 → 模型据此自己发现"我能访问哪儿"
- local_exec 可指定 cwd(须在授权目录内)
- 防逃逸不变:软链解析后必须落在某个根内,越界即拒(单测覆盖 ../ 与软链逃逸)

告诉模型它能干什么
- local_exec 描述展开:文件整理(mv/cp/find)、mdfind 全盘搜、open 开应用/文件/网址、
  osascript 控制 Mac App、shortcuts run/list 触发快捷指令、系统信息
- 语音系统提示词把 JARVIS 定位成"这台电脑的操作者",并要求先想清用哪个工具再动手

live 验证(两个授权目录):
① "你能访问哪些目录,里面有什么" → 自主先查授权清单、再逐个列举,答全对
② "把下载里的图片挪到桌面" → 自主 mv,文件真的跨目录移动了

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:42:58 +08:00

104 lines
5.1 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sundynix/sundynix-gateway/internal/dsl"
"github.com/sundynix/sundynix-shared/contract"
)
// 语音上行接线:最终转写 → 组 DSL → 复用 preflightCore/launchCore 关卡 → 提交任务 → 回 task_id。
// 语音只是"嘴替键盘",一行编排/工具/计费逻辑都不新造:走的正是 HTTP SubmitTask 那条关卡
// (见记忆 execution-single-entry「提交必走 preflight()+launch() 共用关卡」)。
// defaultJarvisName 是用户没自定义名字时的默认助手名。
const defaultJarvisName = "JARVIS"
// voiceSystemPrompt 组语音 agent 的系统提示:**简短**是硬基线(语音场景要抢首字、别让人干听十几秒),
// **名字与语气/人设由用户决定**——name 用户自定义(你叫 JARVIS、别人叫星期五都行),persona 是用户为
// 这个助手单设的语气人设(与主偏好记忆分开)。persona 为空则默认平和礼貌。
func voiceSystemPrompt(name, persona string) string {
n := strings.TrimSpace(name)
if n == "" {
n = defaultJarvisName
}
s := "你是 " + n + "——用户的私人语音助手,也是他这台电脑的操作者。你能:" +
"在用户授权的目录里看/读/写文件、执行 shell 命令(据此可以整理文件、用 mdfind 全盘找东西、" +
"open 打开应用和文件、osascript 控制 Mac 上的 App、shortcuts run 触发快捷指令)、" +
"查/派平台任务与报告、切换客户端界面、建定时任务(到点自动办事并播报)。" +
"用户让你「做点什么」时,先想清楚用哪个工具、一步步做,别只是嘴上答应。" +
"不知道能访问哪些目录,就用 local_list_dir 留空 path 查一次。" +
"铁律一:凡是用户问任务/报告的状态、进度、结果,**必须先调工具查真实数据再回答**——" +
"哪怕你觉得自己记得,也不许凭记忆或上下文编造任务状态;查不到就如实说查不到。" +
"铁律二:涉及写文件/执行命令,用户会在电脑上收到确认框;被拒绝或被安全策略挡下时如实告诉他," +
"不要换个写法绕过去重试。定时任务要算首次执行还有多少秒,先用时间工具拿当前时间再算。" +
"这是语音对话,务必简短:先直接给结论,一两句话说清,通常不超过三句,别铺垫、别列清单、" +
"别念代码、别复述问题。口语化、自然。派了任务就告诉用户已开跑、稍后可以问进度,不要干等。"
if p := strings.TrimSpace(persona); p != "" {
s += "\n你的语气与人设:" + p
} else {
s += "语气平和、礼貌。"
}
return s
}
// 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), "autonomous": true,
}},
},
"edges": []map[string]any{
{"source": "voice_in", "target": "voice_agent"},
},
}
b, _ := json.Marshal(g)
return b
}
// submitVoiceTask 提交一次语音任务。graphOverride 非空时用客户端画布图(语音触发既有编排),
// 否则用转写现组的单 agent 图。返回 task_id。
func (s *voiceSession) submitVoiceTask(transcript, graphOverride string) (string, error) {
transcript = strings.TrimSpace(transcript)
if transcript == "" && graphOverride == "" {
return "", fmt.Errorf("空转写")
}
ctx := context.Background() // WS 会话长生命周期,不绑单条请求 ctx
var raw json.RawMessage
if strings.TrimSpace(graphOverride) != "" {
raw = json.RawMessage(graphOverride) // 语音触发画布上的既有编排图
} else {
raw = buildVoiceGraph(transcript, s.jarvisName, s.jarvisPersona) // 带上用户的助手名+人设
}
task, err := dsl.ParseAndAssemble(raw)
if err != nil {
return "", err
}
// 共用关卡:预算 / 暂停 / 计费租户 / 积分硬拦截。被拦时把文案上抛(供语音播报/回传)。
billingTenant, block := s.h.preflightCore(ctx, s.uid, s.tenantID)
if block != nil {
return "", fmt.Errorf("%s", block.message())
}
task.Meta[contract.MetaUserID] = s.uid
task.Meta[contract.MetaTenantID] = billingTenant
task.Meta[contract.MetaSessionID] = s.sessionID
task.Meta[contract.MetaModelProfile] = contract.ModelProfileVoice // 语音任务走 JARVIS 快模型(未配则回落工作模型)
if err := s.h.launchCore(ctx, s.uid, task); err != nil {
return "", err
}
return task.ID, nil
}