Files
controller-v2/client/src/pages/AIPage.tsx
T
2026-05-08 18:15:52 +08:00

1561 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
AI PAGE — Luxsin X9 AI Assistant
- 首次进入: 中央 Logo + 建议气泡,引导用户如何提问
- 对话中: 消息流式展示(用户气泡 / 助手气泡)
- 底部: 输入框 + 发送按钮
- 顶部: 标题 + 历史/新建/清除按钮
Backend: /sse/question (SSE), /sse/messages, /sse/chats, /sse/clear
============================================================ */
import BottomNav from "@/components/BottomNav";
import ConnectScreen from "@/components/ConnectScreen";
import { useDevice } from "@/contexts/DeviceContext";
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,
} 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 localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
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, string | number>): 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[] = [
<Wand2 key="wand" size={14} />,
<Sliders key="sliders" size={14} />,
<Volume2 key="vol" size={14} />,
<Settings2 key="set" size={14} />,
];
// 将后端 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: [] };
}
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 [, setLocation] = useLocation();
const { isConnected, deviceState, api } = useDevice();
const mac = deviceState?.mac ?? "";
const language = deviceState?.language ?? 2;
const deviceName = deviceState?.device ?? "Luxsin X9";
const aiText = useMemo(() => resolveAILocale(language), [language]);
const [chatId, setChatId] = useState<string | null>(null);
const [messages, setMessages] = useState<UIMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [chats, setChats] = useState<ChatRead[]>([]);
// 初次加载历史会话期间,避免先闪一下欢迎页再切到消息
const [initializing, setInitializing] = useState(true);
const scrollRef = useRef<HTMLDivElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(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 对比卡片;
// 其余按 type=0 聊天消息处理;用户侧的 type=2 仍然只是内部 prompt,跳过。
const mapRowsToUI = useCallback((rows: MessageRead[]): UIMessage[] => {
const result: UIMessage[] = [];
for (const r of rows) {
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 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 时(设备未识别完毕)不应该停在 initializingConnectScreen 会拦截
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]);
// ── 历史面板里删除某条会话 ──
const deleteChatById = useCallback(
async (id: string) => {
const ok = window.confirm(aiText.history.deleteConfirm);
if (!ok) return;
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);
// 如果是新建会话,后端首次返回时会创建 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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send(input);
}
},
[input, send],
);
const onInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
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<OptimizeUIMessage | null>(null);
const [applyingId, setApplyingId] = useState<string | null>(null);
const [currentDevicePeq, setCurrentDevicePeq] = useState<OptimizePeqPayload | null>(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) => {
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,
);
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 openCompare = useCallback(
(m: OptimizeUIMessage) => {
setCompareMsg(m);
void refreshCurrentDevicePeq();
},
[refreshCurrentDevicePeq],
);
const headerTitle = aiText.title;
if (!isConnected) return <ConnectScreen />;
return (
<div
className={cn(
"flex flex-col overflow-hidden bg-black",
/* Same clearance as Layout/main above fixed BottomNav */
"pb-[calc(4rem+env(safe-area-inset-bottom,0px))]",
isEmbedded ? "h-full min-h-0" : "h-[100dvh]",
)}
>
{/* ── Header ── */}
<div className="page-header flex-shrink-0">
<div className="flex items-center gap-2 mr-auto">
{isEmbedded && onClose && (
<>
<button
type="button"
onClick={onClose}
className="mr-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-white/70 transition-colors active:text-white"
style={{ background: "rgba(44,44,46,0.6)" }}
aria-label={aiText.ariaHidePanel}
>
<ChevronsRight size={18} />
</button>
<button
type="button"
onClick={() => {
setLocation("/ai");
onClose();
}}
className="mr-1 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-white/70 transition-colors active:text-white"
style={{ background: "rgba(44,44,46,0.6)" }}
aria-label={aiText.ariaFullPage}
>
<Maximize2 size={16} />
</button>
</>
)}
<div
className="w-7 h-7 rounded-full flex items-center justify-center"
style={{
background: "rgba(0,255,246,0.12)",
border: "1px solid rgba(0,255,246,0.3)",
boxShadow: "0 0 10px rgba(0,255,246,0.25)",
}}
>
{/* <Sparkles size={14} className="text-[#00FFF6]" /> */}
<Bot size={14} className="text-[#00FFF6]" />
</div>
<span className="text-[17px] font-semibold text-white">{headerTitle}</span>
</div>
<button
onClick={openHistory}
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors mr-2"
style={{ background: "rgba(44,44,46,0.6)" }}
aria-label={aiText.ariaHistory}
>
<History size={16} />
</button>
<button
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={aiText.ariaNewChat}
>
<MessageSquarePlus size={16} />
</button>
</div>
{/* ── Messages / Welcome ── */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-4">
{initializing ? (
<div className="h-full flex items-center justify-center">
<Loader2 size={20} className="animate-spin text-white/30" />
</div>
) : isEmpty ? (
<WelcomeView
aiText={aiText}
deviceName={deviceName}
onPick={(p) => {
setInput(p);
setTimeout(() => textareaRef.current?.focus(), 0);
}}
/>
) : (
<div className="pt-3 space-y-3 max-w-full">
{messages.map((m) =>
m.kind === "chat" ? (
<MessageBubble key={m.id} aiText={aiText} msg={m} />
) : (
<OptimizeCard
key={m.id}
aiText={aiText}
msg={m}
currentPeq={currentDevicePeq}
applying={applyingId === m.id}
onCompare={() => openCompare(m)}
onApplyToggle={() => handleApplyToggle(m)}
/>
),
)}
{sending &&
(() => {
const last = messages[messages.length - 1];
return last && last.kind === "chat" && last.pending ? (
<TypingIndicator />
) : null;
})()}
</div>
)}
</div>
{/* ── Input bar ── */}
<div
className="flex-shrink-0 px-3 pt-3 pb-3"
style={{
background: "rgba(10,10,12,0.96)",
borderTop: "1px solid rgba(255,255,255,0.06)",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
}}
>
<form
onSubmit={onSubmit}
className="flex items-end gap-2 rounded-[22px] px-3 py-2 w-full"
style={{
background: "rgba(28,28,30,0.92)",
border: "1px solid rgba(255,255,255,0.08)",
}}
>
<textarea
ref={textareaRef}
value={input}
onChange={onInputChange}
onKeyDown={onKeyDown}
rows={1}
placeholder={aiText.inputPlaceholder}
className="flex-1 bg-transparent outline-none resize-none text-[15px] text-white placeholder:text-white/35 py-1.5 px-1 max-h-40 leading-[1.4]"
/>
{sending ? (
<button
type="button"
onClick={stop}
className="flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{
background: "rgba(239,68,68,0.18)",
border: "1px solid rgba(239,68,68,0.4)",
}}
aria-label={aiText.ariaStop}
>
<span
className="block w-3 h-3 rounded-[2px]"
style={{ background: "#ef4444" }}
/>
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className={cn(
"flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-40",
)}
style={{
background: input.trim() ? "#00FFF6" : "rgba(120,120,128,0.3)",
boxShadow: input.trim() ? "0 0 12px rgba(0,255,246,0.45)" : "none",
}}
aria-label={aiText.ariaSend}
>
<ArrowUp
size={18}
className={input.trim() ? "text-black" : "text-white/50"}
strokeWidth={2.5}
/>
</button>
)}
</form>
{chatId && messages.length > 0 && (
<div className="flex justify-center mt-2">
<button
onClick={clearCurrent}
className="text-[11px] text-white/35 active:text-white/70 transition-colors px-2"
>
{aiText.clearCurrent}
</button>
</div>
)}
</div>
{/* ── History panel ── */}
{historyOpen && (
<HistoryPanel
aiText={aiText}
chats={chats}
currentId={chatId}
onClose={() => setHistoryOpen(false)}
onSelect={(id) => loadChat(id)}
onNew={newChat}
onDelete={deleteChatById}
/>
)}
{/* ── 对比对话框 ── */}
{compareMsg && (
<CompareDialog
aiText={aiText}
msg={compareMsg}
currentPeq={currentDevicePeq}
applying={applyingId === compareMsg.id}
onClose={() => setCompareMsg(null)}
onApplyToggle={() => handleApplyToggle(compareMsg)}
/>
)}
{!isEmbedded && <BottomNav />}
</div>
);
}
// ============================================================
// 子组件
// ============================================================
function WelcomeView({
aiText,
deviceName,
onPick,
}: {
aiText: AILocale;
deviceName: string;
onPick: (prompt: string) => void;
}) {
const suggestions = (aiText.suggestions ?? []).map((s, i) => ({
icon: SUGGESTION_ICONS[i] ?? SUGGESTION_ICONS[0],
title: s.title,
prompt: s.prompt,
}));
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] pt-6">
{/* Logo */}
<div
className="w-16 h-16 rounded-[22px] flex items-center justify-center mb-4"
style={{
background:
"radial-gradient(circle at 30% 30%, rgba(0,255,246,0.3), rgba(0,255,246,0.08) 60%, transparent 80%)",
border: "1px solid rgba(0,255,246,0.4)",
boxShadow:
"0 0 30px rgba(0,255,246,0.25), inset 0 0 20px rgba(0,255,246,0.08)",
}}
>
{/* <Sparkles size={28} className="text-[#00FFF6]" /> */}
<Bot size={28} className="text-[#00FFF6]" />
</div>
<h2 className="text-[22px] font-semibold text-white mb-1.5">{aiText.welcomeTitle}</h2>
<p className="text-[13px] text-white/45 text-center leading-relaxed max-w-[280px] mb-7">
{formatText(aiText.welcomeDescription, { device: deviceName })}
</p>
<div className="w-full max-w-[420px]">
<div className="text-[11px] text-white/35 font-medium tracking-wider uppercase px-1 mb-2">
{aiText.suggestionsLabel}
</div>
<div className="grid grid-cols-2 gap-2">
{suggestions.map((s) => (
<button
key={s.title}
onClick={() => onPick(s.prompt)}
className="text-left rounded-[14px] p-3 transition-all active:scale-[0.98]"
style={{
background: "rgba(28,28,30,0.85)",
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<div
className="w-6 h-6 rounded-[8px] flex items-center justify-center mb-2"
style={{
background: "rgba(0,255,246,0.1)",
border: "1px solid rgba(0,255,246,0.2)",
color: "#00FFF6",
}}
>
{s.icon}
</div>
<div className="text-[13px] font-medium text-white mb-0.5">{s.title}</div>
<div className="text-[11px] text-white/40 leading-snug line-clamp-2">
{s.prompt}
</div>
</button>
))}
</div>
</div>
</div>
);
}
function MessageBubble({ aiText, msg }: { aiText: AILocale; msg: ChatUIMessage }) {
const isUser = msg.role === "user";
return (
<div className={cn("flex w-full", isUser ? "justify-end" : "justify-start")}>
{!isUser && (
<div
className="w-7 h-7 rounded-full flex items-center justify-center mr-2 mt-0.5 flex-shrink-0"
style={{
background: "rgba(0,255,246,0.12)",
border: "1px solid rgba(0,255,246,0.25)",
}}
>
<Bot size={14} className="text-[#00FFF6]" />
</div>
)}
<div
className={cn(
"rounded-[18px] px-3.5 py-2.5 max-w-[82%] text-[14.5px] leading-relaxed break-words",
isUser ? "text-black" : "text-white",
)}
style={
isUser
? {
background: "#00FFF6",
boxShadow: "0 0 14px rgba(0,255,246,0.25)",
borderBottomRightRadius: 6,
}
: {
background: "rgba(28,28,30,0.92)",
border: "1px solid rgba(255,255,255,0.06)",
borderBottomLeftRadius: 6,
}
}
>
{/* 工具调用 chips */}
{msg.tools && msg.tools.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{msg.tools.map((t) => (
<span
key={t.id}
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full"
style={{
background: "rgba(0,255,246,0.08)",
border: "1px solid rgba(0,255,246,0.22)",
color: "#00FFF6",
}}
>
<Wrench size={10} />
{t.name}
{t.ok === false && <AlertCircle size={10} className="text-red-400" />}
</span>
))}
</div>
)}
{isUser ? (
<div className="whitespace-pre-wrap">{msg.text}</div>
) : (
<div className={cn("markdown-body", msg.error && "text-red-400")}>
{msg.text ? (
<Streamdown parseIncompleteMarkdown>{msg.text}</Streamdown>
) : (
<span className="text-white/40 italic">{aiText.thinking}</span>
)}
</div>
)}
</div>
</div>
);
}
function TypingIndicator() {
return (
<div className="flex items-center gap-1.5 ml-9 mt-1">
<span className="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse" style={{ animationDelay: "0ms" }} />
<span className="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse" style={{ animationDelay: "150ms" }} />
<span className="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse" style={{ animationDelay: "300ms" }} />
</div>
);
}
function HistoryPanel({
aiText,
chats,
currentId,
onClose,
onSelect,
onNew,
onDelete,
}: {
aiText: AILocale;
chats: ChatRead[];
currentId: string | null;
onClose: () => void;
onSelect: (id: string) => void;
onNew: () => void;
onDelete: (id: string) => void;
}) {
return (
<div className="fixed inset-0 z-[60] flex flex-col bg-black/70" onClick={onClose}>
<div
className="mt-auto rounded-t-[22px] max-h-[70vh] flex flex-col"
style={{
background: "rgba(18,18,20,0.98)",
border: "1px solid rgba(255,255,255,0.08)",
paddingBottom: "env(safe-area-inset-bottom, 0px)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center px-4 pt-3 pb-2">
<span className="text-[15px] font-semibold text-white flex-1">
{aiText.history.title}
</span>
<button
onClick={onClose}
className="w-7 h-7 rounded-full flex items-center justify-center text-white/60"
style={{ background: "rgba(44,44,46,0.8)" }}
aria-label={aiText.ariaClose}
>
<X size={14} />
</button>
</div>
<div className="overflow-y-auto px-3 pb-3">
<button
onClick={onNew}
className="w-full flex items-center gap-2 px-3 py-3 rounded-[12px] mb-2 text-left"
style={{
background: "rgba(0,255,246,0.08)",
border: "1px dashed rgba(0,255,246,0.3)",
color: "#00FFF6",
}}
>
<MessageSquarePlus size={16} />
<span className="text-[14px] font-medium">{aiText.history.newChat}</span>
</button>
{chats.length === 0 ? (
<div className="text-center text-white/40 text-[13px] py-6">
{aiText.history.empty}
</div>
) : (
<div className="ios-list-group">
{chats.map((c) => (
<div
key={c.id}
className={cn(
"ios-list-row w-full",
currentId === c.id && "bg-white/[0.04]",
)}
>
<button
onClick={() => onSelect(c.id)}
className="flex-1 min-w-0 text-left active:opacity-60"
>
<div className="text-[15px] text-white truncate">{c.title}</div>
<div className="text-[11px] text-white/35 mt-0.5">
{new Date(c.updated_at).toLocaleString()}
</div>
</button>
{currentId === c.id && (
<span className="text-[11px] text-[#00FFF6] ml-2">
{aiText.history.current}
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
onDelete(c.id);
}}
className="ml-2 w-8 h-8 rounded-full flex items-center justify-center text-white/40 hover:text-red-400 active:text-red-500 transition-colors"
style={{ background: "rgba(44,44,46,0.6)" }}
aria-label={aiText.history.delete}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
// ============================================================
// OptimizeCard: type=2 的优化记录气泡
// ============================================================
function OptimizeCard({
aiText,
msg,
currentPeq,
applying,
onCompare,
onApplyToggle,
}: {
aiText: AILocale;
msg: OptimizeUIMessage;
currentPeq: OptimizePeqPayload | null;
applying: boolean;
onCompare: () => void;
onApplyToggle: () => void;
}) {
// 当前未应用 → 目标是 after_peq(点击应用即切到 after
// 当前已应用 → 目标是 before_peq(点击回滚即切回 before
const targetPeq = msg.applied ? msg.beforePeq : msg.afterPeq;
const filterCount = targetPeq.filters?.length ?? 0;
const preamp = targetPeq.preamp ?? 0;
const summary = formatText(aiText.optimize.summary, {
count: filterCount,
preamp: `${preamp > 0 ? "+" : ""}${preamp.toFixed(1)}`,
});
return (
<div className="flex w-full justify-start">
<div
className="w-7 h-7 rounded-full flex items-center justify-center mr-2 mt-0.5 flex-shrink-0"
style={{
background: "rgba(0,255,246,0.12)",
border: "1px solid rgba(0,255,246,0.25)",
}}
>
<Wand2 size={14} className="text-[#00FFF6]" />
</div>
<div
className="rounded-[18px] max-w-[82%] w-full p-3.5"
style={{
background: "rgba(28,28,30,0.92)",
border: "1px solid rgba(0,255,246,0.25)",
borderBottomLeftRadius: 6,
boxShadow: "0 0 14px rgba(0,255,246,0.08)",
}}
>
<div className="flex items-center gap-2 mb-2">
<span
className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full font-medium"
style={{
background: "rgba(0,255,246,0.1)",
border: "1px solid rgba(0,255,246,0.3)",
color: "#00FFF6",
}}
>
<Sparkles size={10} /> {aiText.optimize.badge}
</span>
{msg.applied && (
<span className="text-[10px] text-green-400/90">
{aiText.optimize.applied}
</span>
)}
</div>
<div className="text-[14px] font-semibold text-white truncate">
{msg.name}
</div>
<div className="text-[12px] text-white/45 mt-0.5">{summary}</div>
{/* 缩略对比曲线:当前设备 vs 应用/回滚后 */}
<ComparePreview
currentPeq={currentPeq}
targetPeq={targetPeq}
width={260}
height={70}
/>
<div className="flex gap-2 mt-3">
<button
onClick={onCompare}
className="flex-1 h-9 rounded-[10px] text-[13px] font-medium text-white flex items-center justify-center gap-1.5 active:scale-[0.98] transition-all"
style={{
background: "rgba(44,44,46,0.8)",
border: "1px solid rgba(255,255,255,0.08)",
}}
>
<GitCompare size={14} />
{aiText.optimize.compare}
</button>
<button
onClick={onApplyToggle}
disabled={applying}
className={cn(
"flex-1 h-9 rounded-[10px] text-[13px] font-semibold flex items-center justify-center gap-1.5 active:scale-[0.98] transition-all disabled:opacity-50",
)}
style={
msg.applied
? {
background: "rgba(239,68,68,0.12)",
border: "1px solid rgba(239,68,68,0.3)",
color: "#f87171",
}
: {
background: "#00FFF6",
color: "#000",
boxShadow: "0 0 12px rgba(0,255,246,0.35)",
}
}
>
{applying ? (
<Loader2 size={14} className="animate-spin" />
) : msg.applied ? (
<>
<RotateCcw size={14} /> {aiText.optimize.rollback}
</>
) : (
<>
<Check size={14} /> {aiText.optimize.apply}
</>
)}
</button>
</div>
</div>
</div>
);
}
// ============================================================
// 曲线可视化:把 PEQ 的 filters 转为 buildPeqSvgCurveData 可用的格式
// ============================================================
function peqToBands(peq: OptimizePeqPayload): PeqBandForResponse[] {
const filters = peq.filters ?? [];
return filters.map((f) => ({
enabled: true,
gain: Number(f.gain) || 0,
freq: Number((f.fc ?? f.frequency) ?? 1000),
q: Number(f.q) || 1,
type: f.type,
}));
}
function CurveSvg({
bands,
width,
height,
stroke,
fill,
className,
}: {
bands: PeqBandForResponse[];
width: number;
height: number;
stroke: string;
fill?: string;
className?: string;
}) {
const data = useMemo(
() => buildPeqSvgCurveData({ bands, width, height, yDbMax: 20, paddingY: 6 }),
[bands, width, height],
);
return (
<svg
viewBox={`0 0 ${width} ${height}`}
width="100%"
height={height}
className={className}
preserveAspectRatio="none"
>
{/* 中线 */}
<line
x1={0}
x2={width}
y1={height / 2}
y2={height / 2}
stroke="rgba(255,255,255,0.08)"
strokeDasharray="2 3"
/>
{fill && <path d={data.fillD} fill={fill} />}
<path d={data.pathD} fill="none" stroke={stroke} strokeWidth={1.5} />
</svg>
);
}
function ComparePreview({
currentPeq,
targetPeq,
width,
height,
}: {
currentPeq: OptimizePeqPayload | null;
targetPeq: OptimizePeqPayload;
width: number;
height: number;
}) {
const currentBands = useMemo(
() => (currentPeq ? peqToBands(currentPeq) : []),
[currentPeq],
);
const targetBands = useMemo(() => peqToBands(targetPeq), [targetPeq]);
return (
<div
className="relative mt-3 rounded-[10px] overflow-hidden"
style={{
background: "rgba(10,10,12,0.6)",
border: "1px solid rgba(255,255,255,0.05)",
}}
>
{currentBands.length > 0 && (
<div className="absolute inset-0">
<CurveSvg bands={currentBands} width={width} height={height} stroke="rgba(255,255,255,0.35)" />
</div>
)}
<div className="relative">
<CurveSvg bands={targetBands} width={width} height={height} stroke="#00FFF6" fill="rgba(0,255,246,0.08)" />
</div>
</div>
);
}
// ============================================================
// CompareDialog: 放大版的对比弹框
// ============================================================
function CompareDialog({
aiText,
msg,
currentPeq,
applying,
onClose,
onApplyToggle,
}: {
aiText: AILocale;
msg: OptimizeUIMessage;
currentPeq: OptimizePeqPayload | null;
applying: boolean;
onClose: () => void;
onApplyToggle: () => void;
}) {
// 未应用 → 目标 = 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;
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/75 px-4"
onClick={onClose}
>
<div
className="w-full max-w-[420px] rounded-[22px] overflow-hidden flex flex-col"
style={{
background: "rgba(18,18,20,0.98)",
border: "1px solid rgba(255,255,255,0.08)",
maxHeight: "85vh",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center px-4 pt-4 pb-2">
<div className="flex-1 min-w-0">
<div className="text-[11px] text-[#00FFF6] font-medium tracking-wide">
{aiText.compareDialog.title}
</div>
<div className="text-[15px] font-semibold text-white truncate mt-0.5">
{msg.name}
</div>
</div>
<button
onClick={onClose}
className="w-7 h-7 rounded-full flex items-center justify-center text-white/60"
style={{ background: "rgba(44,44,46,0.8)" }}
aria-label={aiText.ariaClose}
>
<X size={14} />
</button>
</div>
<div className="overflow-y-auto px-4 pb-4 flex-1">
{/* 叠加曲线图 */}
<div
className="rounded-[14px] p-3 mt-1"
style={{
background: "rgba(10,10,12,0.7)",
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<div className="flex items-center gap-3 mb-2 text-[11px]">
<span className="inline-flex items-center gap-1.5 text-white/55">
<span
className="block w-3 h-[2px] rounded"
style={{ background: "rgba(255,255,255,0.45)" }}
/>
{currentLabel}
</span>
<span className="inline-flex items-center gap-1.5" style={{ color: "#00FFF6" }}>
<span className="block w-3 h-[2px] rounded" style={{ background: "#00FFF6" }} />
{targetLabel}
</span>
</div>
<div className="relative" style={{ height: chartHeight }}>
{currentBands.length > 0 && (
<div className="absolute inset-0">
<CurveSvg
bands={currentBands}
width={chartWidth}
height={chartHeight}
stroke="rgba(255,255,255,0.45)"
/>
</div>
)}
<div className="absolute inset-0">
<CurveSvg
bands={targetBands}
width={chartWidth}
height={chartHeight}
stroke="#00FFF6"
fill="rgba(0,255,246,0.08)"
/>
</div>
</div>
{/* 频率轴标签 */}
<div className="flex justify-between text-[10px] text-white/35 mt-1 px-0.5">
<span>20</span>
<span>100</span>
<span>1K</span>
<span>10K</span>
<span>20K</span>
</div>
</div>
{/* 参数对比表:当前设备 vs 应用/回滚后 */}
<div className="mt-3 grid grid-cols-2 gap-2">
<ParamCard
aiText={aiText}
label={currentLabel}
peq={currentPeq}
muted
/>
<ParamCard
aiText={aiText}
label={targetLabel}
peq={targetPeq}
highlight
/>
</div>
</div>
{/* Footer */}
<div
className="px-4 py-3 flex items-center gap-2"
style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}
>
<button
onClick={onClose}
className="flex-1 h-10 rounded-[10px] text-[14px] text-white/70"
style={{ background: "rgba(44,44,46,0.8)" }}
>
{aiText.compareDialog.close}
</button>
<button
onClick={onApplyToggle}
disabled={applying}
className="flex-1 h-10 rounded-[10px] text-[14px] font-semibold flex items-center justify-center gap-1.5 active:scale-[0.98] disabled:opacity-50"
style={
msg.applied
? {
background: "rgba(239,68,68,0.14)",
border: "1px solid rgba(239,68,68,0.35)",
color: "#f87171",
}
: {
background: "#00FFF6",
color: "#000",
boxShadow: "0 0 14px rgba(0,255,246,0.35)",
}
}
>
{applying ? (
<Loader2 size={14} className="animate-spin" />
) : msg.applied ? (
<>
<RotateCcw size={14} /> {aiText.compareDialog.rollback}
</>
) : (
<>
<Check size={14} /> {aiText.compareDialog.apply}
</>
)}
</button>
</div>
</div>
</div>
);
}
function ParamCard({
aiText,
label,
peq,
muted,
highlight,
}: {
aiText: AILocale;
label: string;
peq: OptimizePeqPayload | null;
muted?: boolean;
highlight?: boolean;
}) {
const filters = peq?.filters ?? [];
const summary = formatText(aiText.compareDialog.paramSummary, {
count: filters.length,
preamp: (peq?.preamp ?? 0).toFixed(1),
});
return (
<div
className="rounded-[12px] p-2.5 text-[11px]"
style={{
background: highlight ? "rgba(0,255,246,0.06)" : "rgba(28,28,30,0.9)",
border: highlight
? "1px solid rgba(0,255,246,0.25)"
: "1px solid rgba(255,255,255,0.05)",
}}
>
<div className={cn("font-medium mb-1", highlight ? "text-[#00FFF6]" : "text-white/50")}>
{label}
</div>
<div className="text-white/70 mb-1.5">{summary}</div>
<div className="space-y-0.5 max-h-28 overflow-y-auto pr-1">
{filters.map((f, i) => {
const freq = Number((f.fc ?? f.frequency) ?? 0);
return (
<div
key={i}
className={cn(
"flex justify-between font-mono",
muted ? "text-white/50" : "text-white/85",
)}
>
<span>{freq >= 1000 ? `${(freq / 1000).toFixed(1)}K` : Math.round(freq)}Hz</span>
<span>
{f.gain > 0 ? "+" : ""}
{Number(f.gain).toFixed(1)}dB
</span>
<span className="text-white/40">Q{Number(f.q).toFixed(1)}</span>
</div>
);
})}
</div>
</div>
);
}