Files
sundynix-agentix/sundynix-desktop/frontend/src/shell/JarvisSettings.tsx
T
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

262 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, GATEWAY, getToken } from "../lib/api";
import {
defaultLocalDirs,
localExecEnabled,
localRunnerAvailable,
localRunnerStatus,
setLocalExecEnabled,
startLocalRunner,
stopLocalRunner,
} from "../lib/desktop";
// 每用户 JARVIS 设置:名字 / 人设 / (高级)自带豆包配置。
// 名字与人设归用户自己;豆包配置齐全则语音走用户的账号,否则走系统兜底。
export function JarvisSettings({ open, onClose }: { open: boolean; onClose: () => void }) {
const toast = useToast();
const [cfg, setCfg] = useState<JarvisConfig | null>(null);
const [saving, setSaving] = useState(false);
const [advanced, setAdvanced] = useState(false);
useEffect(() => {
if (!open) return;
setCfg(null);
getMyJarvis()
.then(setCfg)
.catch((e) => toast.push("error", (e as Error).message));
}, [open, toast]);
const set = (k: keyof JarvisConfig, v: string) => setCfg((c) => (c ? { ...c, [k]: v } : c));
const onSave = async () => {
if (!cfg) return;
setSaving(true);
try {
await saveMyJarvis({
name: cfg.name,
persona: cfg.persona,
api_key: cfg.api_key.includes("•") ? "" : cfg.api_key, // 掩码=没改动,留空沿用已存
asr_resource_id: cfg.asr_resource_id,
tts_resource_id: cfg.tts_resource_id,
tts_voice_type: cfg.tts_voice_type,
});
toast.push("success", "已保存,下次说话即生效");
onClose();
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setSaving(false);
}
};
return (
<Dialog
open={open}
onClose={onClose}
title="JARVIS 设置"
footer={
<>
<Button variant="ghost" size="sm" onClick={onClose}>
</Button>
<Button variant="primary" size="sm" onClick={onSave} disabled={saving || !cfg}>
{saving ? "保存中…" : "保存"}
</Button>
</>
}
>
{!cfg ? (
<div className="text-slate-500"></div>
) : (
<div className="space-y-4">
<label className="block">
<span className="text-xs text-slate-400"></span>
<input
className="mt-1 w-full rounded-md border border-line bg-ink-850 px-3 py-2 text-slate-100 focus:border-brand focus:outline-none"
value={cfg.name}
onChange={(e) => set("name", e.target.value)}
placeholder="JARVIS(留空用默认;可改成星期五、小助手…)"
/>
</label>
<label className="block">
<span className="text-xs text-slate-400"> / </span>
<textarea
rows={3}
className="mt-1 w-full resize-none rounded-md border border-line bg-ink-850 px-3 py-2 text-slate-100 focus:border-brand focus:outline-none"
value={cfg.persona}
onChange={(e) => set("persona", e.target.value)}
placeholder="例:简洁专业、平和有礼;或活泼幽默一点。留空用默认平和语气。"
/>
<span className="mt-1 block text-[11px] text-slate-500"></span>
</label>
<button
type="button"
className="text-xs text-slate-400 transition hover:text-slate-200"
onClick={() => setAdvanced((v) => !v)}
>
{advanced ? "▾" : "▸"}
<span className="ml-1 text-slate-500">{cfg.has_own_voice ? "(已启用)" : "(默认走系统)"}</span>
</button>
{advanced && (
<div className="space-y-3 rounded-md border border-line bg-ink-900/50 p-3">
<p className="text-[11px] leading-relaxed text-slate-500">
+
</p>
<Field label="API Key" type="password" value={cfg.api_key} onChange={(v) => set("api_key", v)} placeholder="留空 = 沿用已存 / 系统" />
<Field label="ASR Resource-Id" value={cfg.asr_resource_id} onChange={(v) => set("asr_resource_id", v)} placeholder="volc.bigasr.sauc.duration" />
<Field label="TTS Resource-Id" value={cfg.tts_resource_id} onChange={(v) => set("tts_resource_id", v)} placeholder="seed-tts-2.0" />
<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);
const [canExec, setCanExec] = useState(false);
useEffect(() => {
if (!localRunnerAvailable()) return;
let alive = true;
const poll = () => {
localRunnerStatus().then((s) => alive && setStatus(s)).catch(() => {});
localExecEnabled().then((v) => alive && setCanExec(v)).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
</p>
{online ? (
<div className="space-y-0.5">
{status.slice("online:".length).split("\n").filter(Boolean).map((d) => (
<p key={d} className="break-all font-mono text-[11px] text-slate-400">· {d}</p>
))}
</div>
) : (
<>
<textarea
rows={3}
className="w-full resize-none rounded-md border border-line bg-ink-850 px-3 py-1.5 font-mono text-xs text-slate-100 focus:border-brand focus:outline-none"
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder={"每行一个目录(绝对路径),例:\n/Users/你/Desktop\n/Users/你/Downloads"}
/>
<button
type="button"
className="text-[11px] text-brand-400 transition hover:underline"
onClick={() => void defaultLocalDirs().then((d) => d && setDir(d))}
>
+ / /
</button>
</>
)}
<Button variant={online ? "ghost" : "secondary"} size="sm" onClick={onToggle} disabled={busy}>
{online || status === "connecting" ? "关闭本地访问" : "开启本地访问"}
</Button>
{/* 第二道开关:写文件 + 执行命令。风险等级远高于只读,所以单独开、且每次仍要弹框批准。 */}
{online && (
<label className="mt-1 flex cursor-pointer items-start gap-2 border-t border-line pt-3">
<input
type="checkbox"
className="mt-0.5"
checked={canExec}
onChange={(e) => {
setLocalExecEnabled(e.target.checked);
setCanExec(e.target.checked);
}}
/>
<span className="text-[11px] leading-relaxed text-slate-400">
<b className="text-slate-200"> / </b>
<span className="mt-0.5 block text-slate-500">
JARVIS
</span>
</span>
</label>
)}
</div>
);
}
function Field({
label,
value,
onChange,
placeholder,
type,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<label className="block">
<span className="text-xs text-slate-400">{label}</span>
<input
type={type}
className="mt-1 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={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
</label>
);
}