feat(desktop): JARVIS 设置面板——名字/人设/自带豆包配置(用户级)

- lib/api.ts: getMyJarvis/saveMyJarvis + JarvisConfig 类型
- shell/JarvisSettings.tsx: 弹窗——名字 + 语气人设 + 折叠「高级:我的豆包配置」
  (api_key/asr/tts resource-id/音色;掩码不回传=沿用已存;填齐才覆盖系统)
- VoiceDock: 麦克风上方加设置齿轮,打开面板

真机验证:面板正确加载后端配置(名字=星期五、人设),高级区四项占位清晰,存档下次说话生效。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-22 14:12:03 +08:00
parent 99ce07c1b0
commit 24351014e0
3 changed files with 190 additions and 1 deletions
+35
View File
@@ -789,3 +789,38 @@ export async function runReplay(taskId: string): Promise<{ output: string; exec:
const d = (await res.json()) as { output?: string; exec?: ExecEvent[] };
return { output: d.output ?? "", exec: d.exec ?? [] };
}
// ---- 每用户 JARVIS 设置(名字 / 人设 / 自带豆包配置)----
export interface JarvisConfig {
name: string;
persona: string;
asr_resource_id: string;
tts_resource_id: string;
tts_voice_type: string;
api_key: string; // 读取时为脱敏值;保存留空/掩码=沿用已存
has_own_voice: boolean;
}
export async function getMyJarvis(): Promise<JarvisConfig> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/jarvis`, { headers: bearer() }));
if (!res.ok) throw new Error("读取 JARVIS 设置失败");
return res.json() as Promise<JarvisConfig>;
}
export async function saveMyJarvis(cfg: {
name: string;
persona: string;
api_key?: string;
asr_resource_id?: string;
tts_resource_id?: string;
tts_voice_type?: string;
}): Promise<void> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/me/jarvis`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify(cfg),
}),
);
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error || "保存失败");
}
@@ -0,0 +1,139 @@
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";
// 每用户 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>
)}
</div>
)}
</Dialog>
);
}
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>
);
}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Mic, Square, Loader2, Volume2, X } from "lucide-react";
import { Mic, Square, Loader2, Volume2, X, Settings2 } from "lucide-react";
import { VoiceClient, type VoiceState } from "../lib/voice";
import { JarvisSettings } from "./JarvisSettings";
import { useToast } from "../ui/Toast";
import { cn } from "../ui/cn";
@@ -27,6 +28,7 @@ export function VoiceDock({ onTask }: Props) {
const [transcript, setTranscript] = useState(""); // 我说的(ASR 转写)
const [reply, setReply] = useState(""); // JARVIS 回答(打字机,逐 token 累加)
const [open, setOpen] = useState(false); // 是否展开对话气泡
const [settingsOpen, setSettingsOpen] = useState(false); // JARVIS 设置弹窗
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
const ensureClient = useCallback((): VoiceClient => {
@@ -73,7 +75,18 @@ export function VoiceDock({ onTask }: Props) {
const busy = state === "connecting" || state === "thinking";
return (
<>
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2">
{/* 设置齿轮:名字 / 人设 / 我的豆包配置 */}
<button
onClick={() => setSettingsOpen(true)}
title="JARVIS 设置(名字 / 人设 / 我的豆包)"
aria-label="JARVIS 设置"
className="pointer-events-auto 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-ink-600 hover:text-slate-200"
>
<Settings2 className="h-4 w-4" />
</button>
{/* 对话气泡:我说的(转写)+ 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">
@@ -132,5 +145,7 @@ export function VoiceDock({ onTask }: Props) {
{HINT[state]}
</span>
</div>
<JarvisSettings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</>
);
}