diff --git a/.env b/.env index 7803e95..5171938 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ -VITE_AI_API_URL=https://v2ai.luxsin.com.cn +# VITE_AI_API_URL=https://v2ai.luxsin.com.cn +VITE_AI_API_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore index 387ada4..d21f814 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ *.dist # Environment variables +.env .env.local .env.development.local .env.test.local diff --git a/client/src/lib/aiApi.ts b/client/src/lib/aiApi.ts index 38777b4..1b3e29e 100644 --- a/client/src/lib/aiApi.ts +++ b/client/src/lib/aiApi.ts @@ -80,11 +80,36 @@ export interface SSEEvent { content?: string | ToolUseBlock | null; } +/** 与后端 `DeviceSetting` 对齐,字段来自 `deviceState`。允许其它透传字段。 */ +export interface DeviceSettingPayload { + mac: string; + language: number; + device: string; + volume?: number; + msgCount?: number; + [key: string]: any; +} + +/** 与后端 `DevicePEQ` 对齐,由 `api.getPeqState()` 拼装。 */ +export interface DevicePEQPayload { + peqSelect: number; + peqEnable: number; + peq: Array<{ + name: string; + brand?: string; + model?: string; + filters: Array<{ type: number | string; fc: number; gain: number; q: number }> | string; + preamp?: number; + canDel?: number; + autoPre?: number; + }>; + msgCount: number; +} + export interface QuestionPayload { question: string; - language: number; - mac: string; - device: string; + device_setting: DeviceSettingPayload; + device_peq: DevicePEQPayload; chat_id?: string | null; } diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 157ae5f..68882fd 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -535,6 +535,9 @@ "title": "EQ comparison", "legendBefore": "Before", "legendAfter": "After", + "legendCurrent": "Current", + "legendAfterApply": "After apply", + "legendAfterRollback": "After rollback", "close": "Close", "apply": "Apply this optimization", "rollback": "Rollback to original", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 586416f..b6384e3 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -349,6 +349,9 @@ "title": "EQ 對比", "legendBefore": "優化前", "legendAfter": "優化後", + "legendCurrent": "當前", + "legendAfterApply": "套用後", + "legendAfterRollback": "回滾後", "close": "關閉", "apply": "套用此優化", "rollback": "回滾到優化前", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index fbf8ef9..3b0794c 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -350,6 +350,9 @@ "title": "EQ 对比", "legendBefore": "优化前", "legendAfter": "优化后", + "legendCurrent": "当前", + "legendAfterApply": "应用后", + "legendAfterRollback": "回滚后", "close": "关闭", "apply": "应用此优化", "rollback": "回滚到优化前", diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx index 3c380a6..f16eb30 100644 --- a/client/src/pages/AIPage.tsx +++ b/client/src/pages/AIPage.tsx @@ -180,22 +180,13 @@ export default function AIPage() { } }, [aiText, mac]); - // ── 把后端消息行映射为 UI 消息(type=0 聊天,type=2 优化卡片) ── + // ── 把后端消息行映射为 UI 消息 ── + // 只要助手消息带有 before_peq + after_peq,就展示为 AB 对比卡片; + // 其余按 type=0 聊天消息处理;用户侧的 type=2 仍然只是内部 prompt,跳过。 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 + if (r.role === "user" && r.before_peq && r.after_peq) { result.push({ kind: "optimize", id: r.id, @@ -205,6 +196,16 @@ export default function AIPage() { 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 result; @@ -267,7 +268,7 @@ export default function AIPage() { (question: string) => { const trimmed = question.trim(); if (!trimmed || sending) return; - if (!mac) { + if (!mac || !deviceState || !api) { toast.error(aiText.deviceRequired); return; } @@ -302,12 +303,42 @@ export default function AIPage() { // 工具执行后续调用时需要保留当前 chat_id,先捕获一下 let currentChatId = chatId; - const controller = streamQuestion( + // 组装新版 /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, - language, - mac, - device: deviceName, + device_setting: { ...deviceState }, + device_peq: devicePeq, chat_id: currentChatId, }, { @@ -372,9 +403,11 @@ export default function AIPage() { }, }, ); - abortRef.current = controller; + abortRef.current = controller; + }; + void buildAndStream(); }, - [aiText, api, chatId, deviceName, language, mac, refreshCurrentChat, sending], + [aiText, api, chatId, deviceState, mac, refreshCurrentChat, sending], ); const stop = useCallback(() => { @@ -417,6 +450,44 @@ export default function AIPage() { // ── 优化记录:对比对话框 & 应用/回滚 ── 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 handleApplyToggle = useCallback( async (msg: OptimizeUIMessage) => { @@ -453,6 +524,7 @@ export default function AIPage() { setCompareMsg((cur) => cur && cur.id === msg.id ? { ...cur, applied: nextApplied } : cur, ); + void refreshCurrentDevicePeq(); toast.success( nextApplied ? aiText.optimize.applySuccess : aiText.optimize.rollbackSuccess, ); @@ -462,7 +534,15 @@ export default function AIPage() { setApplyingId(null); } }, - [aiText, api], + [aiText, api, refreshCurrentDevicePeq], + ); + + const openCompare = useCallback( + (m: OptimizeUIMessage) => { + setCompareMsg(m); + void refreshCurrentDevicePeq(); + }, + [refreshCurrentDevicePeq], ); const headerTitle = aiText.title; @@ -508,42 +588,41 @@ export default function AIPage() { {/* ── Messages / Welcome ── */}
-
- {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; - })()} -
- )} -
+ {isEmpty ? ( + { + setInput(p); + setTimeout(() => textareaRef.current?.focus(), 0); + }} + /> + ) : ( +
+ {messages.map((m) => + m.kind === "chat" ? ( + + ) : ( + openCompare(m)} + onApplyToggle={() => handleApplyToggle(m)} + /> + ), + )} + {sending && + (() => { + const last = messages[messages.length - 1]; + return last && last.kind === "chat" && last.pending ? ( + + ) : null; + })()} +
+ )}
{/* ── Input bar ── */} @@ -558,7 +637,7 @@ export default function AIPage() { >
{chatId && messages.length > 0 && ( -
+
{summary}
- {/* 缩略对比曲线 */} + {/* 缩略对比曲线:当前设备 vs 应用/回滚后 */} @@ -1073,18 +1158,21 @@ function CurveSvg({ } function ComparePreview({ - beforePeq, - afterPeq, + currentPeq, + targetPeq, width, height, }: { - beforePeq: OptimizePeqPayload; - afterPeq: OptimizePeqPayload; + currentPeq: OptimizePeqPayload | null; + targetPeq: OptimizePeqPayload; width: number; height: number; }) { - const beforeBands = useMemo(() => peqToBands(beforePeq), [beforePeq]); - const afterBands = useMemo(() => peqToBands(afterPeq), [afterPeq]); + const currentBands = useMemo( + () => (currentPeq ? peqToBands(currentPeq) : []), + [currentPeq], + ); + const targetBands = useMemo(() => peqToBands(targetPeq), [targetPeq]); return (
-
- -
+ {currentBands.length > 0 && ( +
+ +
+ )}
- +
); @@ -1109,18 +1199,30 @@ function ComparePreview({ function CompareDialog({ aiText, msg, + currentPeq, applying, onClose, onApplyToggle, }: { aiText: AILocale; msg: OptimizeUIMessage; + currentPeq: OptimizePeqPayload | null; applying: boolean; onClose: () => void; onApplyToggle: () => void; }) { - const beforeBands = useMemo(() => peqToBands(msg.beforePeq), [msg.beforePeq]); - const afterBands = useMemo(() => peqToBands(msg.afterPeq), [msg.afterPeq]); + // 未应用 → 目标 = after_peq,图例右侧显示“应用后” + // 已应用 → 目标 = before_peq,图例右侧显示“回滚后” + const targetPeq = msg.applied ? msg.beforePeq : msg.afterPeq; + const currentBands = useMemo( + () => (currentPeq ? peqToBands(currentPeq) : []), + [currentPeq], + ); + const targetBands = useMemo(() => peqToBands(targetPeq), [targetPeq]); + const currentLabel = aiText.compareDialog.legendCurrent; + const targetLabel = msg.applied + ? aiText.compareDialog.legendAfterRollback + : aiText.compareDialog.legendAfterApply; const chartWidth = 340; const chartHeight = 160; @@ -1173,25 +1275,27 @@ function CompareDialog({ className="block w-3 h-[2px] rounded" style={{ background: "rgba(255,255,255,0.45)" }} /> - {aiText.compareDialog.legendBefore} + {currentLabel} - {aiText.compareDialog.legendAfter} + {targetLabel}
+ {currentBands.length > 0 && ( +
+ +
+ )}
-
-
-
- {/* 参数对比表 */} + {/* 参数对比表:当前设备 vs 应用/回滚后 */}
@@ -1283,14 +1387,14 @@ function ParamCard({ }: { aiText: AILocale; label: string; - peq: OptimizePeqPayload; + peq: OptimizePeqPayload | null; muted?: boolean; highlight?: boolean; }) { - const filters = peq.filters ?? []; + const filters = peq?.filters ?? []; const summary = formatText(aiText.compareDialog.paramSummary, { count: filters.length, - preamp: (peq.preamp ?? 0).toFixed(1), + preamp: (peq?.preamp ?? 0).toFixed(1), }); return (