diff --git a/client/src/lib/aiApi.ts b/client/src/lib/aiApi.ts index 4f53489..9f7d77f 100644 --- a/client/src/lib/aiApi.ts +++ b/client/src/lib/aiApi.ts @@ -28,12 +28,33 @@ export interface ChatRead { updated_at: string; } +export interface OptimizePeqPayload { + name?: string; + brand?: string; + model?: string; + preamp?: number; + canDel?: number; + autoPre?: number; + filters?: Array<{ + type: number | string; + fc?: number; + frequency?: number; + gain: number; + q: number; + }>; +} + export interface MessageRead { id: string; role: "user" | "assistant"; content: string; created_at: string; + /** 0: 默认聊天, 1: 摘要, 2: 优化记录, 3: 标题生成 */ type: number; + before_peq: OptimizePeqPayload | null; + after_peq: OptimizePeqPayload | null; + applied: boolean; + applied_at: string | null; } export interface ToolUseBlock { @@ -88,6 +109,14 @@ export async function clearChat(chatId: string): Promise { }); } +export async function updateMessageApplied(messageId: string, applied: boolean): Promise { + const url = `${getAIBaseUrl()}/sse/update_message_applied?message_id=${encodeURIComponent( + messageId, + )}&applied=${applied ? "true" : "false"}`; + const res = await fetch(url, { method: "POST" }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); +} + export async function sendToolResult(toolUseId: string, content: { ok: boolean; content?: any; message?: string }): Promise { await fetch(`${getAIBaseUrl()}/chat/tool_result`, { method: "POST", diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index aa38987..157ae5f 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -485,5 +485,60 @@ "selectedYet": "Target curve not selected yet", "leftChannel": "Left channel", "rightChannel": "Right channel" + }, + "ai": { + "title": "Luxsin AI", + "welcomeTitle": "Hi, I'm Luxsin AI", + "welcomeDescription": "I can help you check and adjust {device} settings, analyze headphone frequency response, optimize EQ, or answer usage questions.", + "suggestionsLabel": "Try asking", + "suggestions": [ + { "title": "Optimize my EQ", "prompt": "Please optimize my current EQ — I want clearer vocals." }, + { "title": "Recommend Pop EQ", "prompt": "Recommend a set of EQ parameters suitable for pop music." }, + { "title": "Check device status", "prompt": "Show me the current volume, input source and output port." }, + { "title": "Switch input source", "prompt": "Switch the input source to USB-C." } + ], + "inputPlaceholder": "Ask about your device, EQ, effects…", + "thinking": "Thinking…", + "stopped": "(Stopped)", + "requestFailed": "Request failed. Please try again later.", + "ariaSend": "Send", + "ariaStop": "Stop", + "ariaNewChat": "New chat", + "ariaHistory": "History", + "ariaClose": "Close", + "clearCurrent": "Clear this chat", + "cleared": "Current chat cleared", + "clearFailed": "Clear failed: {message}", + "deviceRequired": "Please connect a device first", + "deviceDisconnected": "Device disconnected", + "loadHistoryFailed": "Failed to load history: {message}", + "loadChatFailed": "Failed to load chat: {message}", + "toolResultFailed": "Failed to send tool result: {message}", + "history": { + "title": "Chat history", + "newChat": "Start a new chat", + "empty": "No chat history yet", + "current": "Current" + }, + "optimize": { + "badge": "Optimization", + "applied": "Applied", + "summary": "{count} filters · preamp {preamp} dB", + "compare": "Compare", + "apply": "Apply", + "rollback": "Rollback", + "applySuccess": "New EQ applied", + "rollbackSuccess": "Rolled back to original EQ", + "applyFailed": "Operation failed: {message}" + }, + "compareDialog": { + "title": "EQ comparison", + "legendBefore": "Before", + "legendAfter": "After", + "close": "Close", + "apply": "Apply this optimization", + "rollback": "Rollback to original", + "paramSummary": "{count} filters · preamp {preamp} dB" + } } } diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 961b4f0..586416f 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -299,5 +299,60 @@ "selectedYet": "你尚未選擇目標曲線", "leftChannel": "左聲道", "rightChannel": "右聲道" + }, + "ai": { + "title": "Luxsin AI", + "welcomeTitle": "你好,我是 Luxsin AI", + "welcomeDescription": "可以幫你查詢與調整 {device} 的設定,分析耳機頻響、優化 EQ,或者解答使用上的疑問。", + "suggestionsLabel": "試試這樣問", + "suggestions": [ + { "title": "優化我的 EQ", "prompt": "幫我優化目前 EQ,我希望人聲更清晰一些" }, + { "title": "推薦流行樂 EQ", "prompt": "推薦一套適合聽流行音樂的 EQ 參數" }, + { "title": "查看裝置狀態", "prompt": "幫我查看目前的音量、輸入源和輸出埠" }, + { "title": "切換輸入源", "prompt": "把輸入源切換到 USB-C" } + ], + "inputPlaceholder": "問問關於裝置、EQ、音效……", + "thinking": "思考中…", + "stopped": "(已停止)", + "requestFailed": "請求失敗,請稍後重試", + "ariaSend": "發送", + "ariaStop": "停止", + "ariaNewChat": "新增會話", + "ariaHistory": "歷史會話", + "ariaClose": "關閉", + "clearCurrent": "清空目前會話", + "cleared": "已清空目前會話", + "clearFailed": "清空失敗:{message}", + "deviceRequired": "請先連接裝置", + "deviceDisconnected": "裝置未連接", + "loadHistoryFailed": "載入歷史失敗:{message}", + "loadChatFailed": "載入會話失敗:{message}", + "toolResultFailed": "工具結果回傳失敗:{message}", + "history": { + "title": "歷史會話", + "newChat": "開始新會話", + "empty": "暫無歷史會話", + "current": "目前" + }, + "optimize": { + "badge": "優化記錄", + "applied": "已套用", + "summary": "{count} 個濾波器 · 總增益 {preamp} dB", + "compare": "對比", + "apply": "套用", + "rollback": "回滾", + "applySuccess": "已套用新的 EQ", + "rollbackSuccess": "已回滾到原 EQ", + "applyFailed": "操作失敗:{message}" + }, + "compareDialog": { + "title": "EQ 對比", + "legendBefore": "優化前", + "legendAfter": "優化後", + "close": "關閉", + "apply": "套用此優化", + "rollback": "回滾到優化前", + "paramSummary": "{count} 段 · preamp {preamp} dB" + } } } diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index fef4fb9..fbf8ef9 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -300,5 +300,60 @@ "selectedYet": "你还未选择目标曲线", "leftChannel": "左声道", "rightChannel": "右声道" + }, + "ai": { + "title": "Luxsin AI", + "welcomeTitle": "你好,我是 Luxsin AI", + "welcomeDescription": "可以帮你查询与调整 {device} 的设置,分析耳机频响、优化 EQ,或者解答使用上的疑问。", + "suggestionsLabel": "试试这样问", + "suggestions": [ + { "title": "优化我的 EQ", "prompt": "帮我优化当前 EQ,我希望人声更清晰一些" }, + { "title": "推荐流行乐 EQ", "prompt": "推荐一套适合听流行音乐的 EQ 参数" }, + { "title": "查看设备状态", "prompt": "帮我查看当前的音量、输入源和输出端口" }, + { "title": "切换输入源", "prompt": "把输入源切换到 USB-C" } + ], + "inputPlaceholder": "问问关于设备、EQ、音效……", + "thinking": "思考中…", + "stopped": "(已停止)", + "requestFailed": "请求失败,请稍后重试", + "ariaSend": "发送", + "ariaStop": "停止", + "ariaNewChat": "新建会话", + "ariaHistory": "历史会话", + "ariaClose": "关闭", + "clearCurrent": "清空当前会话", + "cleared": "已清空当前会话", + "clearFailed": "清空失败:{message}", + "deviceRequired": "请先连接设备", + "deviceDisconnected": "设备未连接", + "loadHistoryFailed": "加载历史失败:{message}", + "loadChatFailed": "加载会话失败:{message}", + "toolResultFailed": "工具结果回传失败:{message}", + "history": { + "title": "历史会话", + "newChat": "开始新会话", + "empty": "暂无历史会话", + "current": "当前" + }, + "optimize": { + "badge": "优化记录", + "applied": "已应用", + "summary": "{count} 个滤波器 · 总增益 {preamp} dB", + "compare": "对比", + "apply": "应用", + "rollback": "回滚", + "applySuccess": "已应用新的 EQ", + "rollbackSuccess": "已回滚到原 EQ", + "applyFailed": "操作失败:{message}" + }, + "compareDialog": { + "title": "EQ 对比", + "legendBefore": "优化前", + "legendAfter": "优化后", + "close": "关闭", + "apply": "应用此优化", + "rollback": "回滚到优化前", + "paramSummary": "{count} 段 · preamp {preamp} dB" + } } } diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx index c772323..3c380a6 100644 --- a/client/src/pages/AIPage.tsx +++ b/client/src/pages/AIPage.tsx @@ -12,6 +12,7 @@ import { useDevice } from "@/contexts/DeviceContext"; import { ChatRead, MessageRead, + OptimizePeqPayload, ToolUseBlock, clearChat, executeFrontendTool, @@ -19,7 +20,9 @@ import { listMessages, sendToolResult, streamQuestion, + updateMessageApplied, } from "@/lib/aiApi"; +import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio"; import { cn } from "@/lib/utils"; import { ArrowUp, @@ -34,12 +37,35 @@ import { Bot, Wrench, AlertCircle, + GitCompare, + Check, + RotateCcw, + Loader2, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Streamdown } from "streamdown"; import { toast } from "sonner"; +import localeZh from "@/locales/data-zh.json"; +import localeZhHK from "@/locales/data-zh-HK.json"; +import localeEn from "@/locales/data-en.json"; -type UIMessage = { +type AILocale = NonNullable<(typeof localeZh)["ai"]>; + +function resolveAILocale(language: number | undefined): AILocale { + const loc = + language === 0 ? localeEn : language === 1 ? localeZhHK : localeZh; + return loc.ai as AILocale; +} + +function formatText(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (_, k) => { + const v = vars[k]; + return v === undefined || v === null ? "" : String(v); + }); +} + +type ChatUIMessage = { + kind: "chat"; id: string; role: "user" | "assistant"; text: string; @@ -48,33 +74,30 @@ type UIMessage = { tools?: Array<{ id: string; name: string; ok?: boolean }>; }; +type OptimizeUIMessage = { + kind: "optimize"; + id: string; + name: string; + beforePeq: OptimizePeqPayload; + afterPeq: OptimizePeqPayload; + applied: boolean; + appliedAt?: string | null; +}; + +type UIMessage = ChatUIMessage | OptimizeUIMessage; + interface Suggestion { icon: React.ReactNode; title: string; prompt: string; } -const SUGGESTIONS: Suggestion[] = [ - { - icon: , - title: "优化我的 EQ", - prompt: "帮我优化当前 EQ,我希望人声更清晰一些", - }, - { - icon: , - title: "推荐流行乐 EQ", - prompt: "推荐一套适合听流行音乐的 EQ 参数", - }, - { - icon: , - title: "查看设备状态", - prompt: "帮我查看当前的音量、输入源和输出端口", - }, - { - icon: , - title: "切换输入源", - prompt: "把输入源切换到 USB-C", - }, +// 建议气泡图标固定顺序(文案由 i18n 提供) +const SUGGESTION_ICONS: React.ReactNode[] = [ + , + , + , + , ]; // 将后端 DB 存的 content(可能是 JSON 文本)转为纯文本展示 @@ -115,6 +138,8 @@ export default function AIPage() { const language = deviceState?.language ?? 2; const deviceName = deviceState?.device ?? "Luxsin X9"; + const aiText = useMemo(() => resolveAILocale(language), [language]); + const [chatId, setChatId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -143,7 +168,7 @@ export default function AIPage() { // ── 打开历史会话 ── const openHistory = useCallback(async () => { if (!mac) { - toast.error("请先连接设备"); + toast.error(aiText.deviceRequired); return; } setHistoryOpen(true); @@ -151,33 +176,67 @@ export default function AIPage() { const list = await listChats(mac); setChats(list); } catch (e: any) { - toast.error(`加载历史失败: ${e?.message ?? e}`); + toast.error(formatText(aiText.loadHistoryFailed, { message: e?.message ?? e })); } - }, [mac]); + }, [aiText, mac]); + + // ── 把后端消息行映射为 UI 消息(type=0 聊天,type=2 优化卡片) ── + const mapRowsToUI = useCallback((rows: MessageRead[]): UIMessage[] => { + const result: UIMessage[] = []; + for (const r of rows) { + if (r.type === 0) { + const { text, tools } = extractText(r.content); + if (!text && tools.length === 0) continue; + result.push({ + kind: "chat", + id: r.id, + role: r.role, + text, + tools: tools.map((t) => ({ ...t, ok: true })), + }); + } else if (r.type === 2 && r.role === "assistant" && r.before_peq && r.after_peq) { + // 只展示助手侧的优化记录;用户侧(type=2, role=user)只是内部 prompt + result.push({ + kind: "optimize", + id: r.id, + name: r.after_peq.name ?? r.before_peq.name ?? "Optimized EQ", + beforePeq: r.before_peq, + afterPeq: r.after_peq, + applied: !!r.applied, + appliedAt: r.applied_at ?? null, + }); + } + } + return result; + }, []); // ── 加载指定会话的消息 ── - const loadChat = useCallback(async (id: string) => { - try { - const rows: MessageRead[] = await listMessages(id); - const mapped: UIMessage[] = rows - .filter((r) => r.type === 0) - .map((r) => { - const { text, tools } = extractText(r.content); - return { - id: r.id, - role: r.role, - text, - tools: tools.map((t) => ({ ...t, ok: true })), - }; - }) - .filter((m) => m.text || (m.tools && m.tools.length > 0)); - setMessages(mapped); - setChatId(id); - setHistoryOpen(false); - } catch (e: any) { - toast.error(`加载会话失败: ${e?.message ?? e}`); - } - }, []); + const loadChat = useCallback( + async (id: string, opts?: { closePanel?: boolean }) => { + try { + const rows = await listMessages(id); + setMessages(mapRowsToUI(rows)); + setChatId(id); + if (opts?.closePanel !== false) setHistoryOpen(false); + } catch (e: any) { + toast.error(formatText(aiText.loadChatFailed, { message: e?.message ?? e })); + } + }, + [aiText, mapRowsToUI], + ); + + // ── 静默刷新当前会话(流式结束后用于拉取新增的 type=2 优化记录) ── + const refreshCurrentChat = useCallback( + async (id: string) => { + try { + const rows = await listMessages(id); + setMessages(mapRowsToUI(rows)); + } catch { + // 静默失败,不打扰用户 + } + }, + [mapRowsToUI], + ); // ── 新建会话 ── const newChat = useCallback(() => { @@ -197,11 +256,11 @@ export default function AIPage() { try { await clearChat(chatId); newChat(); - toast.success("已清空当前会话"); + toast.success(aiText.cleared); } catch (e: any) { - toast.error(`清空失败: ${e?.message ?? e}`); + toast.error(formatText(aiText.clearFailed, { message: e?.message ?? e })); } - }, [chatId, newChat]); + }, [aiText, chatId, newChat]); // ── 发送提问 ── const send = useCallback( @@ -209,12 +268,18 @@ export default function AIPage() { const trimmed = question.trim(); if (!trimmed || sending) return; if (!mac) { - toast.error("请先连接设备"); + toast.error(aiText.deviceRequired); return; } - const userMsg: UIMessage = { id: makeId(), role: "user", text: trimmed }; - const assistantMsg: UIMessage = { + const userMsg: ChatUIMessage = { + kind: "chat", + id: makeId(), + role: "user", + text: trimmed, + }; + const assistantMsg: ChatUIMessage = { + kind: "chat", id: makeId(), role: "assistant", text: "", @@ -228,9 +293,9 @@ export default function AIPage() { const assistantId = assistantMsg.id; - const updateAssistant = (updater: (m: UIMessage) => UIMessage) => { + const updateAssistant = (updater: (m: ChatUIMessage) => ChatUIMessage) => { setMessages((prev) => - prev.map((m) => (m.id === assistantId ? updater(m) : m)), + prev.map((m) => (m.id === assistantId && m.kind === "chat" ? updater(m) : m)), ); }; @@ -269,7 +334,7 @@ export default function AIPage() { try { await sendToolResult(block.id, result); } catch (e: any) { - toast.error(`工具结果回传失败: ${e?.message ?? e}`); + toast.error(formatText(aiText.toolResultFailed, { message: e?.message ?? e })); } }, onError: (msg) => { @@ -277,42 +342,52 @@ export default function AIPage() { ...m, pending: false, error: true, - text: m.text || msg || "请求失败,请稍后重试", + text: m.text || msg || aiText.requestFailed, })); setSending(false); }, onDone: () => { setSending(false); // 如果是新建会话,后端首次返回时会创建 chat_id;这里拉一次会话列表补上 id - if (!currentChatId && mac) { - listChats(mac) - .then((list) => { + const resolveAndRefresh = async () => { + let id = currentChatId; + if (!id && mac) { + try { + const list = await listChats(mac); if (list.length > 0) { - // 列表按 updated_at 倒序或无序,这里取最新的 const newest = list.reduce((a, b) => new Date(a.updated_at) > new Date(b.updated_at) ? a : b, ); - currentChatId = newest.id; - setChatId(newest.id); + id = newest.id; + currentChatId = id; + setChatId(id); } - }) - .catch(() => void 0); - } + } catch { + return; + } + } + if (id) await refreshCurrentChat(id); + }; + void resolveAndRefresh(); }, }, ); abortRef.current = controller; }, - [api, chatId, deviceName, language, mac, sending], + [aiText, api, chatId, deviceName, language, mac, refreshCurrentChat, sending], ); const stop = useCallback(() => { abortRef.current?.abort(); setSending(false); setMessages((prev) => - prev.map((m) => (m.pending ? { ...m, pending: false, text: m.text || "(已停止)" } : m)), + prev.map((m) => + m.kind === "chat" && m.pending + ? { ...m, pending: false, text: m.text || aiText.stopped } + : m, + ), ); - }, []); + }, [aiText]); const onSubmit = useCallback( (e: React.FormEvent) => { @@ -339,14 +414,67 @@ export default function AIPage() { el.style.height = `${Math.min(el.scrollHeight, 160)}px`; }, []); - const headerTitle = useMemo(() => "Luxsin AI", []); + // ── 优化记录:对比对话框 & 应用/回滚 ── + const [compareMsg, setCompareMsg] = useState(null); + const [applyingId, setApplyingId] = useState(null); + + const handleApplyToggle = useCallback( + async (msg: OptimizeUIMessage) => { + if (!api) { + toast.error(aiText.deviceDisconnected); + return; + } + const nextApplied = !msg.applied; + const targetPeq = nextApplied ? msg.afterPeq : msg.beforePeq; + setApplyingId(msg.id); + try { + await api.upgradePeqChange({ + peqChange: { + name: targetPeq.name ?? msg.name, + filters: (targetPeq.filters ?? []).map((f) => ({ + type: getFilterType(f.type), + fc: (f.fc ?? f.frequency ?? 1000) as number, + gain: f.gain, + q: f.q, + })), + autoPre: targetPeq.autoPre, + preamp: targetPeq.preamp, + canDel: targetPeq.canDel, + }, + }); + await updateMessageApplied(msg.id, nextApplied); + setMessages((prev) => + prev.map((m) => + m.kind === "optimize" && m.id === msg.id + ? { ...m, applied: nextApplied, appliedAt: new Date().toISOString() } + : m, + ), + ); + setCompareMsg((cur) => + cur && cur.id === msg.id ? { ...cur, applied: nextApplied } : cur, + ); + toast.success( + nextApplied ? aiText.optimize.applySuccess : aiText.optimize.rollbackSuccess, + ); + } catch (e: any) { + toast.error(formatText(aiText.optimize.applyFailed, { message: e?.message ?? e })); + } finally { + setApplyingId(null); + } + }, + [aiText, api], + ); + + const headerTitle = aiText.title; if (!isConnected) return ; return ( -
+
{/* ── Header ── */} -
+
@@ -372,49 +500,68 @@ export default function AIPage() { onClick={newChat} className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors" style={{ background: "rgba(44,44,46,0.6)" }} - aria-label="新建会话" + aria-label={aiText.ariaNewChat} >
{/* ── Messages / Welcome ── */} -
- {isEmpty ? ( - { - setInput(p); - setTimeout(() => textareaRef.current?.focus(), 0); - }} - /> - ) : ( -
- {messages.map((m) => ( - - ))} - {sending && messages[messages.length - 1]?.pending && } -
- )} +
+
+ {isEmpty ? ( + { + setInput(p); + setTimeout(() => textareaRef.current?.focus(), 0); + }} + /> + ) : ( +
+ {messages.map((m) => + m.kind === "chat" ? ( + + ) : ( + setCompareMsg(m)} + onApplyToggle={() => handleApplyToggle(m)} + /> + ), + )} + {sending && + (() => { + const last = messages[messages.length - 1]; + return last && last.kind === "chat" && last.pending ? ( + + ) : null; + })()} +
+ )} +
{/* ── Input bar ── */}