1756 lines
55 KiB
TypeScript
1756 lines
55 KiB
TypeScript
/* ============================================================
|
||
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: [] };
|
||
}
|
||
|
||
/**
|
||
* 后端有时先落库 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 &&
|
||
items[endAsst + 1].kind === "chat" &&
|
||
items[endAsst + 1].role === "assistant"
|
||
) {
|
||
endAsst++;
|
||
}
|
||
|
||
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 [, 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 对比卡片(常与 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;ConnectScreen 会拦截
|
||
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);
|
||
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 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,
|
||
}));
|
||
}
|
||
|
||
const PEQ_CHART_CURVE_OPTS = { yDbMax: 20, paddingY: 6 } as const;
|
||
const PATH_D_TRANSITION = "d 0.45s cubic-bezier(0.22, 1, 0.32, 1)";
|
||
|
||
function fmtHoverFreqHz(f: number): string {
|
||
if (!Number.isFinite(f) || f <= 0) return "—";
|
||
if (f >= 10000) return `${Math.round(f / 1000)}k`;
|
||
if (f >= 1000) return `${(f / 1000).toFixed(1)}k`;
|
||
return `${Math.round(f)}`;
|
||
}
|
||
|
||
/** 沿折线在 x 上插值:频率对数插值,dB 线性插值。 */
|
||
function interpolatePeqAtMx(
|
||
points: Array<{ x: number; y: number; gainDb: number; freq: number }>,
|
||
mx: number,
|
||
): { freq: number; gainDb: number } | null {
|
||
if (points.length === 0) return null;
|
||
if (mx <= points[0].x) return { freq: points[0].freq, gainDb: points[0].gainDb };
|
||
const last = points[points.length - 1];
|
||
if (mx >= last.x) return { freq: last.freq, gainDb: last.gainDb };
|
||
let lo = 0;
|
||
let hi = points.length - 1;
|
||
while (hi - lo > 1) {
|
||
const mid = (lo + hi) >> 1;
|
||
if (points[mid].x <= mx) lo = mid;
|
||
else hi = mid;
|
||
}
|
||
const a = points[lo];
|
||
const b = points[hi];
|
||
const span = b.x - a.x || 1e-9;
|
||
const t = (mx - a.x) / span;
|
||
const logFa = Math.log10(Math.max(a.freq, 1e-6));
|
||
const logFb = Math.log10(Math.max(b.freq, 1e-6));
|
||
const freq = Math.pow(10, logFa + t * (logFb - logFa));
|
||
const gainDb = a.gainDb + t * (b.gainDb - a.gainDb);
|
||
return { freq, gainDb };
|
||
}
|
||
|
||
/**
|
||
* 叠加 PEQ 幅频曲线:鼠标悬停显示频率与 dB;路径 d 带 CSS 过渡(应用/回滚后曲线平滑变化)。
|
||
*/
|
||
function PeqOverlayCompareSvg({
|
||
currentBands,
|
||
targetBands,
|
||
width,
|
||
height,
|
||
currentStroke = "rgba(255,255,255,0.35)",
|
||
targetStroke = "#00FFF6",
|
||
targetFill = "rgba(0,255,246,0.08)",
|
||
}: {
|
||
currentBands: PeqBandForResponse[];
|
||
targetBands: PeqBandForResponse[];
|
||
width: number;
|
||
height: number;
|
||
currentStroke?: string;
|
||
targetStroke?: string;
|
||
targetFill?: string;
|
||
}) {
|
||
const currentData = useMemo(() => {
|
||
if (currentBands.length === 0) return null;
|
||
return buildPeqSvgCurveData({
|
||
bands: currentBands,
|
||
width,
|
||
height,
|
||
...PEQ_CHART_CURVE_OPTS,
|
||
});
|
||
}, [currentBands, width, height]);
|
||
|
||
const targetData = useMemo(
|
||
() =>
|
||
buildPeqSvgCurveData({
|
||
bands: targetBands,
|
||
width,
|
||
height,
|
||
...PEQ_CHART_CURVE_OPTS,
|
||
}),
|
||
[targetBands, width, height],
|
||
);
|
||
|
||
const [hover, setHover] = useState<{
|
||
mx: number;
|
||
freq: number;
|
||
curDb: number | null;
|
||
tgtDb: number;
|
||
} | null>(null);
|
||
|
||
const onMove = useCallback(
|
||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||
const el = e.currentTarget;
|
||
const r = el.getBoundingClientRect();
|
||
if (r.width <= 0) return;
|
||
const mx = ((e.clientX - r.left) / r.width) * width;
|
||
const tgt = interpolatePeqAtMx(targetData.points, mx);
|
||
if (!tgt) return;
|
||
const cur = currentData ? interpolatePeqAtMx(currentData.points, mx) : null;
|
||
setHover({
|
||
mx,
|
||
freq: tgt.freq,
|
||
curDb: cur?.gainDb ?? null,
|
||
tgtDb: tgt.gainDb,
|
||
});
|
||
},
|
||
[currentData, targetData, width],
|
||
);
|
||
|
||
const fmtDb = (v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(1)} dB`;
|
||
|
||
return (
|
||
<div
|
||
className="relative w-full cursor-crosshair"
|
||
style={{ height }}
|
||
onMouseMove={onMove}
|
||
onMouseLeave={() => setHover(null)}
|
||
>
|
||
<svg
|
||
viewBox={`0 0 ${width} ${height}`}
|
||
width="100%"
|
||
height={height}
|
||
className="block"
|
||
preserveAspectRatio="none"
|
||
>
|
||
<line
|
||
x1={0}
|
||
x2={width}
|
||
y1={height / 2}
|
||
y2={height / 2}
|
||
stroke="rgba(255,255,255,0.08)"
|
||
strokeDasharray="2 3"
|
||
/>
|
||
{currentData && (
|
||
<path
|
||
d={currentData.pathD}
|
||
fill="none"
|
||
stroke={currentStroke}
|
||
strokeWidth={1.5}
|
||
vectorEffect="non-scaling-stroke"
|
||
style={{ transition: PATH_D_TRANSITION }}
|
||
/>
|
||
)}
|
||
{targetData.fillD ? (
|
||
<path
|
||
d={targetData.fillD}
|
||
fill={targetFill}
|
||
stroke="none"
|
||
style={{ transition: PATH_D_TRANSITION }}
|
||
/>
|
||
) : null}
|
||
<path
|
||
d={targetData.pathD}
|
||
fill="none"
|
||
stroke={targetStroke}
|
||
strokeWidth={1.5}
|
||
vectorEffect="non-scaling-stroke"
|
||
style={{ transition: PATH_D_TRANSITION }}
|
||
/>
|
||
{hover && (
|
||
<line
|
||
x1={hover.mx}
|
||
x2={hover.mx}
|
||
y1={0}
|
||
y2={height}
|
||
stroke="rgba(255,255,255,0.35)"
|
||
strokeWidth={1}
|
||
strokeDasharray="3 3"
|
||
pointerEvents="none"
|
||
/>
|
||
)}
|
||
</svg>
|
||
{hover && (
|
||
<div
|
||
className="pointer-events-none absolute z-10 min-w-[7.5rem] rounded-lg border border-white/10 bg-[rgba(14,14,16,0.95)] px-2 py-1.5 text-[10px] leading-snug text-white shadow-lg backdrop-blur-sm"
|
||
style={{
|
||
left: `${Math.min(98, Math.max(2, (hover.mx / width) * 100))}%`,
|
||
top: 6,
|
||
transform: "translateX(-50%)",
|
||
}}
|
||
>
|
||
<div className="font-semibold text-[#00FFF6]">{fmtHoverFreqHz(hover.freq)} Hz</div>
|
||
{hover.curDb !== null && (
|
||
<div className="text-white/70">
|
||
当前 <span className="font-mono text-white/90">{fmtDb(hover.curDb)}</span>
|
||
</div>
|
||
)}
|
||
<div className="text-white/70">
|
||
目标 <span className="font-mono text-[#00FFF6]">{fmtDb(hover.tgtDb)}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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)",
|
||
}}
|
||
>
|
||
<PeqOverlayCompareSvg
|
||
currentBands={currentBands}
|
||
targetBands={targetBands}
|
||
width={width}
|
||
height={height}
|
||
currentStroke="rgba(255,255,255,0.35)"
|
||
/>
|
||
</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 }}>
|
||
<PeqOverlayCompareSvg
|
||
currentBands={currentBands}
|
||
targetBands={targetBands}
|
||
width={chartWidth}
|
||
height={chartHeight}
|
||
currentStroke="rgba(255,255,255,0.45)"
|
||
/>
|
||
</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>
|
||
);
|
||
}
|