feat(wechat): 关注公众号后自动回复欢迎语(被动回复,可后台配置) #12
@@ -789,3 +789,38 @@ export async function runReplay(taskId: string): Promise<{ output: string; exec:
|
|||||||
const d = (await res.json()) as { output?: string; exec?: ExecEvent[] };
|
const d = (await res.json()) as { output?: string; exec?: ExecEvent[] };
|
||||||
return { output: d.output ?? "", exec: d.exec ?? [] };
|
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 { 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 { VoiceClient, type VoiceState } from "../lib/voice";
|
||||||
|
import { JarvisSettings } from "./JarvisSettings";
|
||||||
import { useToast } from "../ui/Toast";
|
import { useToast } from "../ui/Toast";
|
||||||
import { cn } from "../ui/cn";
|
import { cn } from "../ui/cn";
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function VoiceDock({ onTask }: Props) {
|
|||||||
const [transcript, setTranscript] = useState(""); // 我说的(ASR 转写)
|
const [transcript, setTranscript] = useState(""); // 我说的(ASR 转写)
|
||||||
const [reply, setReply] = useState(""); // JARVIS 回答(打字机,逐 token 累加)
|
const [reply, setReply] = useState(""); // JARVIS 回答(打字机,逐 token 累加)
|
||||||
const [open, setOpen] = useState(false); // 是否展开对话气泡
|
const [open, setOpen] = useState(false); // 是否展开对话气泡
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false); // JARVIS 设置弹窗
|
||||||
|
|
||||||
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
|
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
|
||||||
const ensureClient = useCallback((): VoiceClient => {
|
const ensureClient = useCallback((): VoiceClient => {
|
||||||
@@ -73,7 +75,18 @@ export function VoiceDock({ onTask }: Props) {
|
|||||||
const busy = state === "connecting" || state === "thinking";
|
const busy = state === "connecting" || state === "thinking";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2">
|
<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 回答(打字机) */}
|
{/* 对话气泡:我说的(转写)+ JARVIS 回答(打字机) */}
|
||||||
{open && (transcript || reply) && (
|
{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">
|
<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]}
|
{HINT[state]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<JarvisSettings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user