增加了AI界面的优化

This commit is contained in:
allen2fuc
2026-04-23 15:17:03 +08:00
parent 4da1fcd53f
commit fdd115ed58
7 changed files with 239 additions and 99 deletions
+2 -1
View File
@@ -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
+1
View File
@@ -8,6 +8,7 @@ build/
*.dist
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
+28 -3
View File
@@ -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;
}
+3
View File
@@ -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",
+3
View File
@@ -349,6 +349,9 @@
"title": "EQ 對比",
"legendBefore": "優化前",
"legendAfter": "優化後",
"legendCurrent": "當前",
"legendAfterApply": "套用後",
"legendAfterRollback": "回滾後",
"close": "關閉",
"apply": "套用此優化",
"rollback": "回滾到優化前",
+3
View File
@@ -350,6 +350,9 @@
"title": "EQ 对比",
"legendBefore": "优化前",
"legendAfter": "优化后",
"legendCurrent": "当前",
"legendAfterApply": "应用后",
"legendAfterRollback": "回滚后",
"close": "关闭",
"apply": "应用此优化",
"rollback": "回滚到优化前",
+199 -95
View File
@@ -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<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) => {
@@ -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 ── */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-4">
<div className="mx-auto w-full max-w-[720px]">
{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}
applying={applyingId === m.id}
onCompare={() => setCompareMsg(m)}
onApplyToggle={() => handleApplyToggle(m)}
/>
),
)}
{sending &&
(() => {
const last = messages[messages.length - 1];
return last && last.kind === "chat" && last.pending ? (
<TypingIndicator />
) : null;
})()}
</div>
)}
</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 ── */}
@@ -558,7 +637,7 @@ export default function AIPage() {
>
<form
onSubmit={onSubmit}
className="flex items-end gap-2 rounded-[22px] px-3 py-2 mx-auto w-full max-w-[720px]"
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)",
@@ -611,7 +690,7 @@ export default function AIPage() {
)}
</form>
{chatId && messages.length > 0 && (
<div className="flex justify-center mt-2 mx-auto w-full max-w-[720px]">
<div className="flex justify-center mt-2">
<button
onClick={clearCurrent}
className="text-[11px] text-white/35 active:text-white/70 transition-colors px-2"
@@ -639,6 +718,7 @@ export default function AIPage() {
<CompareDialog
aiText={aiText}
msg={compareMsg}
currentPeq={currentDevicePeq}
applying={applyingId === compareMsg.id}
onClose={() => setCompareMsg(null)}
onApplyToggle={() => handleApplyToggle(compareMsg)}
@@ -899,18 +979,23 @@ function HistoryPanel({
function OptimizeCard({
aiText,
msg,
currentPeq,
applying,
onCompare,
onApplyToggle,
}: {
aiText: AILocale;
msg: OptimizeUIMessage;
currentPeq: OptimizePeqPayload | null;
applying: boolean;
onCompare: () => void;
onApplyToggle: () => void;
}) {
const filterCount = msg.afterPeq.filters?.length ?? 0;
const preamp = msg.afterPeq.preamp ?? 0;
// 当前未应用 → 目标是 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)}`,
@@ -958,10 +1043,10 @@ function OptimizeCard({
</div>
<div className="text-[12px] text-white/45 mt-0.5">{summary}</div>
{/* 缩略对比曲线 */}
{/* 缩略对比曲线:当前设备 vs 应用/回滚后 */}
<ComparePreview
beforePeq={msg.beforePeq}
afterPeq={msg.afterPeq}
currentPeq={currentPeq}
targetPeq={targetPeq}
width={260}
height={70}
/>
@@ -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 (
<div
className="relative mt-3 rounded-[10px] overflow-hidden"
@@ -1093,11 +1181,13 @@ function ComparePreview({
border: "1px solid rgba(255,255,255,0.05)",
}}
>
<div className="absolute inset-0">
<CurveSvg bands={beforeBands} width={width} height={height} stroke="rgba(255,255,255,0.35)" />
</div>
{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={afterBands} width={width} height={height} stroke="#00FFF6" fill="rgba(0,255,246,0.08)" />
<CurveSvg bands={targetBands} width={width} height={height} stroke="#00FFF6" fill="rgba(0,255,246,0.08)" />
</div>
</div>
);
@@ -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}
</span>
<span className="inline-flex items-center gap-1.5" style={{ color: "#00FFF6" }}>
<span className="block w-3 h-[2px] rounded" style={{ background: "#00FFF6" }} />
{aiText.compareDialog.legendAfter}
{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={beforeBands}
width={chartWidth}
height={chartHeight}
stroke="rgba(255,255,255,0.45)"
/>
</div>
<div className="absolute inset-0">
<CurveSvg
bands={afterBands}
bands={targetBands}
width={chartWidth}
height={chartHeight}
stroke="#00FFF6"
@@ -1209,18 +1313,18 @@ function CompareDialog({
</div>
</div>
{/* 参数对比表 */}
{/* 参数对比表:当前设备 vs 应用/回滚后 */}
<div className="mt-3 grid grid-cols-2 gap-2">
<ParamCard
aiText={aiText}
label={aiText.compareDialog.legendBefore}
peq={msg.beforePeq}
label={currentLabel}
peq={currentPeq}
muted
/>
<ParamCard
aiText={aiText}
label={aiText.compareDialog.legendAfter}
peq={msg.afterPeq}
label={targetLabel}
peq={targetPeq}
highlight
/>
</div>
@@ -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 (
<div