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:
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user