增加了AI界面的优化
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ build/
|
||||
*.dist
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
+28
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -349,6 +349,9 @@
|
||||
"title": "EQ 對比",
|
||||
"legendBefore": "優化前",
|
||||
"legendAfter": "優化後",
|
||||
"legendCurrent": "當前",
|
||||
"legendAfterApply": "套用後",
|
||||
"legendAfterRollback": "回滾後",
|
||||
"close": "關閉",
|
||||
"apply": "套用此優化",
|
||||
"rollback": "回滾到優化前",
|
||||
|
||||
@@ -350,6 +350,9 @@
|
||||
"title": "EQ 对比",
|
||||
"legendBefore": "优化前",
|
||||
"legendAfter": "优化后",
|
||||
"legendCurrent": "当前",
|
||||
"legendAfterApply": "应用后",
|
||||
"legendAfterRollback": "回滚后",
|
||||
"close": "关闭",
|
||||
"apply": "应用此优化",
|
||||
"rollback": "回滚到优化前",
|
||||
|
||||
+199
-95
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user