/* ============================================================ AI PAGE — Luxsin X9 AI Assistant - 首次进入: 中央 Logo + 建议气泡,引导用户如何提问 - 对话中: 消息流式展示(用户气泡 / 助手气泡) - 底部: 输入框 + 发送按钮 - 顶部: 标题 + 历史/新建/清除按钮 Backend: /sse/question (SSE), /sse/messages, /sse/chats, /sse/clear ============================================================ */ import BottomNav from "@/components/BottomNav"; import ConnectionPlaceholder from "@/components/ConnectionPlaceholder"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { useDevice } from "@/contexts/DeviceContext"; import { AI_SPLIT_RESTORE_PATH_KEY, readAndClearAiSplitRestorePath, useAIDrawer, } from "@/contexts/AIDrawerContext"; import { useIsLgUp } from "@/hooks/useIsLgUp"; import { ChatRead, MessageRead, OptimizePeqPayload, ToolUseBlock, clearChat, executeFrontendTool, listChats, listMessages, sendToolResult, streamQuestion, updateMessageApplied, } from "@/lib/aiApi"; import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio"; import { cn } from "@/lib/utils"; import { ArrowUp, History, MessageSquarePlus, Sparkles, Sliders, Volume2, Settings2, Wand2, X, Bot, Wrench, AlertCircle, GitCompare, Check, RotateCcw, Loader2, Trash2, Maximize2, ChevronsRight, ChevronsLeft, } from "lucide-react"; import { useLocation } from "wouter"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Streamdown } from "streamdown"; import { toast } from "sonner"; import localeZh from "@/locales/data-zh.json"; import localeEn from "@/locales/data-en.json"; import { resolveDeviceLanguage, resolveLocalePack } from "@/locales/resolveLocale"; type AILocale = NonNullable<(typeof localeZh)["ai"]>; function resolveAILocale(language: number | undefined): AILocale { return resolveLocalePack(language).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; pending?: boolean; error?: boolean; 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; } // 建议气泡图标固定顺序(文案由 i18n 提供) const SUGGESTION_ICONS: React.ReactNode[] = [ , , , , ]; // 将后端 DB 存的 content(可能是 JSON 文本)转为纯文本展示 function extractText(raw: string): { text: string; tools: Array<{ id: string; name: string }> } { const trimmed = raw?.trim?.() ?? ""; if (!trimmed) return { text: "", tools: [] }; if (trimmed.startsWith("[") && trimmed.endsWith("]")) { try { const arr = JSON.parse(trimmed); if (Array.isArray(arr)) { const textParts: string[] = []; const tools: Array<{ id: string; name: string }> = []; for (const block of arr) { if (block?.type === "text" && typeof block.text === "string") { textParts.push(block.text); } else if (block?.type === "tool_use") { tools.push({ id: block.id, name: block.name }); } else if (block?.type === "tool_result") { // 用户侧的工具结果不直接展示 } } return { text: textParts.join("\n").trim(), tools }; } } catch { // fall through } } return { text: raw, tools: [] }; } /** * 后端有时先落库 PEQ 对比行(user + before/after_peq),再落库助手文本, * 导致刷新后对比卡片顶在当轮对话上方。将「最后一条」对比卡片移到 * 其后第一条助手消息(及连续助手气泡)之后,使 set_peq 当轮结尾展示对比。 */ function moveLastOptimizeAfterAssistantReply(items: UIMessage[]): UIMessage[] { let lastOptIdx = -1; for (let i = items.length - 1; i >= 0; i--) { if (items[i].kind === "optimize") { lastOptIdx = i; break; } } if (lastOptIdx === -1) return items; let firstAsst = -1; for (let j = lastOptIdx + 1; j < items.length; j++) { const m = items[j]; if (m.kind === "chat" && m.role === "assistant") { firstAsst = j; break; } } if (firstAsst === -1) { if (lastOptIdx === items.length - 1) return items; const opt = items[lastOptIdx]; const rest = items.filter((_, i) => i !== lastOptIdx); return [...rest, opt]; } let endAsst = firstAsst; while (endAsst + 1 < items.length) { const m = items[endAsst + 1]; if (m.kind === "chat" && m.role === "assistant") endAsst++; else break; } if (lastOptIdx === endAsst + 1) return items; const opt = items[lastOptIdx]; const next: UIMessage[] = []; for (let i = 0; i < items.length; i++) { if (i === lastOptIdx) continue; next.push(items[i]); if (i === endAsst) next.push(opt); } return next; } function makeId() { return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } export type AIPageVariant = "page" | "split"; export interface AIPageProps { variant?: AIPageVariant; /** Hide side panel (desktop split) */ onClose?: () => void; } export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) { const isEmbedded = variant === "split"; const [routePath, setLocation] = useLocation(); const isLgUp = useIsLgUp(); const { setOpen: setAiPanelOpen } = useAIDrawer(); const { isConnected, deviceState, api, updateSetting } = useDevice(); const mac = deviceState?.mac ?? ""; const language = resolveDeviceLanguage(deviceState?.language); const deviceName = deviceState?.device ?? "Luxsin X9"; const aiText = useMemo(() => resolveAILocale(language), [language]); const promptText = useMemo(() => resolveLocalePack(language).prompt, [language]); const eqUi = useMemo(() => { const pack = resolveLocalePack(language); const peq = pack.peq as typeof localeZh.peq; return peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi; }, [language]); const restoreDesktopSplit = useCallback(() => { const path = readAndClearAiSplitRestorePath(); setLocation(path); setAiPanelOpen(true); }, [setLocation, setAiPanelOpen]); const [chatId, setChatId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [pendingApplyMsg, setPendingApplyMsg] = useState(null); const [chats, setChats] = useState([]); // 初次加载历史会话期间,避免先闪一下欢迎页再切到消息 const [initializing, setInitializing] = useState(true); const scrollRef = useRef(null); const abortRef = useRef(null); const textareaRef = useRef(null); const initialLoadRef = useRef(false); const isEmpty = messages.length === 0; // ── 自动滚动到底部 ── useEffect(() => { const el = scrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [messages, sending]); // ── 组件卸载时取消请求 ── useEffect(() => { return () => abortRef.current?.abort(); }, []); // ── 打开历史会话 ── const openHistory = useCallback(async () => { if (!mac) { toast.error(aiText.deviceRequired); return; } setHistoryOpen(true); try { const list = await listChats(mac); setChats(list); } catch (e: any) { toast.error(formatText(aiText.loadHistoryFailed, { message: e?.message ?? e })); } }, [aiText, mac]); // ── 把后端消息行映射为 UI 消息 ── // 用户行且带 before_peq + after_peq → AB 对比卡片(常与 set_peq 相关); // type=0 → 聊天气泡。先按 created_at 排序,再把本轮最后一条对比挪到助手回复之后。 const mapRowsToUI = useCallback((rows: MessageRead[]): UIMessage[] => { const sorted = [...rows].sort((a, b) => { const ta = new Date(a.created_at).getTime(); const tb = new Date(b.created_at).getTime(); if (ta !== tb) return ta - tb; return String(a.id).localeCompare(String(b.id)); }); const result: UIMessage[] = []; for (const r of sorted) { if (r.role === "user" && r.before_peq && r.after_peq) { 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, }); } else 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 })), }); } } return moveLastOptimizeAfterAssistantReply(result); }, []); // ── 加载指定会话的消息 ── 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], ); // ── 进入页面时若有历史会话,自动打开最近一次 ── useEffect(() => { if (initialLoadRef.current) return; if (!mac) return; initialLoadRef.current = true; (async () => { try { const list = await listChats(mac); setChats(list); if (list.length === 0) return; const newest = list.reduce((a, b) => new Date(a.updated_at) > new Date(b.updated_at) ? a : b, ); await loadChat(newest.id, { closePanel: false }); } catch { // 静默失败:保留欢迎页 } finally { setInitializing(false); } })(); }, [mac, loadChat]); // 没有 mac 时(设备未识别完毕)不应该停在 initializing;未连接时 ConnectionPlaceholder 会拦截 useEffect(() => { if (!mac && initializing) { // 给 mac 一段缓冲期,否则即便最终没有 mac 也要释放欢迎页 const t = setTimeout(() => setInitializing(false), 800); return () => clearTimeout(t); } }, [mac, initializing]); // ── 静默刷新当前会话(流式结束后用于拉取新增的 type=2 优化记录) ── const refreshCurrentChat = useCallback( async (id: string) => { try { const rows = await listMessages(id); setMessages(mapRowsToUI(rows)); } catch { // 静默失败,不打扰用户 } }, [mapRowsToUI], ); // ── 新建会话 ── const newChat = useCallback(() => { abortRef.current?.abort(); setMessages([]); setChatId(null); setInput(""); setHistoryOpen(false); }, []); // ── 清空当前会话 ── const clearCurrent = useCallback(async () => { if (!chatId) { newChat(); return; } try { await clearChat(chatId); setChats((prev) => prev.filter((c) => c.id !== chatId)); newChat(); toast.success(aiText.cleared); } catch (e: any) { toast.error(formatText(aiText.clearFailed, { message: e?.message ?? e })); } }, [aiText, chatId, newChat]); // ── 历史面板里删除某条会话(确认框用 AlertDialog,WebView 不支持 window.confirm) ── const confirmDeleteChat = useCallback(async (id: string) => { try { await clearChat(id); setChats((prev) => prev.filter((c) => c.id !== id)); if (chatId === id) { setChatId(null); setMessages([]); } toast.success(aiText.history.deleted); } catch (e: any) { toast.error( formatText(aiText.history.deleteFailed, { message: e?.message ?? e }), ); } }, [aiText, chatId]); // ── 发送提问 ── const send = useCallback( (question: string) => { const trimmed = question.trim(); if (!trimmed || sending) return; if (!mac || !deviceState || !api) { toast.error(aiText.deviceRequired); return; } const userMsg: ChatUIMessage = { kind: "chat", id: makeId(), role: "user", text: trimmed, }; const assistantMsg: ChatUIMessage = { kind: "chat", id: makeId(), role: "assistant", text: "", pending: true, tools: [], }; setMessages((prev) => [...prev, userMsg, assistantMsg]); setInput(""); setSending(true); const assistantId = assistantMsg.id; const updateAssistant = (updater: (m: ChatUIMessage) => ChatUIMessage) => { setMessages((prev) => prev.map((m) => (m.id === assistantId && m.kind === "chat" ? updater(m) : m)), ); }; // 工具执行后续调用时需要保留当前 chat_id,先捕获一下 let currentChatId = chatId; // 组装新版 /sse/question 请求体 const buildAndStream = async () => { let devicePeq: any; try { const peqState = await api.getPeqState(); devicePeq = { peqSelect: peqState.peqSelect ?? deviceState.peqSelect ?? 0, peqEnable: deviceState.peqEnable ?? 1, peq: (peqState.peq ?? []).map((p) => ({ name: p.name, brand: p.brand, model: p.model, filters: p.filters ?? [], preamp: p.preamp, canDel: p.canDel, autoPre: p.autoPre, })), msgCount: deviceState.msgCount ?? 0, }; } catch (e: any) { updateAssistant((m) => ({ ...m, pending: false, error: true, text: aiText.requestFailed, })); setSending(false); toast.error(formatText(aiText.requestFailed, { message: e?.message ?? e })); return; } const controller = streamQuestion( { question: trimmed, device_setting: { ...deviceState }, device_peq: devicePeq, chat_id: currentChatId, }, { onText: (t) => { updateAssistant((m) => ({ ...m, text: (m.text ?? "") + t, pending: false, })); }, onToolUse: async (block: ToolUseBlock) => { updateAssistant((m) => ({ ...m, pending: false, tools: [...(m.tools ?? []), { id: block.id, name: block.name }], })); const result = await executeFrontendTool(block, api); updateAssistant((m) => ({ ...m, tools: (m.tools ?? []).map((t) => t.id === block.id ? { ...t, ok: result.ok } : t, ), })); try { await sendToolResult(block.id, result); } catch (e: any) { toast.error(formatText(aiText.toolResultFailed, { message: e?.message ?? e })); } }, onError: (msg) => { updateAssistant((m) => ({ ...m, pending: false, error: true, text: m.text || msg || aiText.requestFailed, })); setSending(false); }, onDone: () => { setSending(false); updateAssistant((m) => ({ ...m, pending: false })); // 如果是新建会话,后端首次返回时会创建 chat_id;这里拉一次会话列表补上 id const resolveAndRefresh = async () => { let id = currentChatId; if (!id && mac) { try { const list = await listChats(mac); if (list.length > 0) { const newest = list.reduce((a, b) => new Date(a.updated_at) > new Date(b.updated_at) ? a : b, ); id = newest.id; currentChatId = id; setChatId(id); } } catch { return; } } if (id) await refreshCurrentChat(id); }; void resolveAndRefresh(); }, }, ); abortRef.current = controller; }; void buildAndStream(); }, [aiText, api, chatId, deviceState, mac, refreshCurrentChat, sending], ); const stop = useCallback(() => { abortRef.current?.abort(); setSending(false); setMessages((prev) => prev.map((m) => m.kind === "chat" && m.pending ? { ...m, pending: false, text: m.text || aiText.stopped } : m, ), ); }, [aiText]); const onSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); send(input); }, [input, send], ); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(input); } }, [input, send], ); const onInputChange = useCallback((e: React.ChangeEvent) => { setInput(e.target.value); const el = e.target; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 160)}px`; }, []); // ── 优化记录:对比对话框 & 应用/回滚 ── const [compareMsg, setCompareMsg] = useState(null); const [applyingId, setApplyingId] = useState(null); const [currentDevicePeq, setCurrentDevicePeq] = useState(null); // 拉取当前设备 PEQ,作为"当前"参考曲线与"应用/回滚"后的对比基准 const refreshCurrentDevicePeq = useCallback(async () => { if (!api) return; try { const peqState = await api.getPeqState(); const selectedIdx = peqState.peqSelect ?? 0; const selected = peqState.peq?.[selectedIdx]; const selectedFilters = (() => { const f = selected?.filters; if (!f) return undefined; if (typeof f === "string") { try { return JSON.parse(f); } catch { return undefined; } } return f; })(); setCurrentDevicePeq({ name: selected?.name, preamp: selected?.preamp, canDel: selected?.canDel, autoPre: selected?.autoPre, brand: selected?.brand, model: selected?.model, filters: (selectedFilters ?? peqState.filters ?? []) as OptimizePeqPayload["filters"], }); } catch { // 静默失败,保留上一次的值 } }, [api]); useEffect(() => { if (isConnected) void refreshCurrentDevicePeq(); }, [isConnected, refreshCurrentDevicePeq]); const executeApplyToggle = 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); await api.refreshState(); 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, ); void refreshCurrentDevicePeq(); 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, refreshCurrentDevicePeq], ); const handleApplyToggle = useCallback( (msg: OptimizeUIMessage) => { if (!api) { toast.error(aiText.deviceDisconnected); return; } if ((deviceState?.peqEnable ?? 0) !== 1) { setPendingApplyMsg(msg); return; } void executeApplyToggle(msg); }, [aiText, api, deviceState?.peqEnable, executeApplyToggle], ); const confirmEnableAndApply = useCallback( async (msg: OptimizeUIMessage) => { try { const bypassOn = (deviceState?.dsp_enable ?? 0) === 0; await updateSetting(bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }); await executeApplyToggle(msg); } catch (e: any) { toast.error(formatText(aiText.optimize.applyFailed, { message: e?.message ?? e })); } }, [aiText, deviceState?.dsp_enable, executeApplyToggle, updateSetting], ); const openCompare = useCallback( (m: OptimizeUIMessage) => { setCompareMsg(m); void refreshCurrentDevicePeq(); }, [refreshCurrentDevicePeq], ); const headerTitle = aiText.title; if (!isConnected) return ; return (
{/* ── Header ── */}
{!isEmbedded && isLgUp && ( )} {isEmbedded && onClose && ( <> )}
{/* */}
{headerTitle}
{/* ── Messages / Welcome ── */}
{initializing ? (
) : isEmpty ? ( { setInput(p); setTimeout(() => textareaRef.current?.focus(), 0); }} /> ) : (
{messages.map((m) => m.kind === "chat" ? ( ) : ( openCompare(m)} onApplyToggle={() => handleApplyToggle(m)} /> ), )}
)}
{/* ── Input bar ── */}