diff --git a/.gitignore b/.gitignore index d21f814..a52a726 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ temp/ # Webdev artifacts (checkpoint zips, migrations, etc.) .webdev/ +Makefile \ No newline at end of file diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx index eb5ad66..88b7577 100644 --- a/client/src/pages/AIPage.tsx +++ b/client/src/pages/AIPage.tsx @@ -133,6 +133,58 @@ function extractText(raw: string): { text: string; tools: Array<{ id: string; na 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)}`; } @@ -199,11 +251,17 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) }, [aiText, mac]); // ── 把后端消息行映射为 UI 消息 ── - // 只要助手消息带有 before_peq + after_peq,就展示为 AB 对比卡片; - // 其余按 type=0 聊天消息处理;用户侧的 type=2 仍然只是内部 prompt,跳过。 + // 用户行且带 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 rows) { + for (const r of sorted) { if (r.role === "user" && r.before_peq && r.after_peq) { result.push({ kind: "optimize", @@ -226,7 +284,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) }); } } - return result; + return moveLastOptimizeAfterAssistantReply(result); }, []); // ── 加载指定会话的消息 ── @@ -1240,45 +1298,194 @@ function peqToBands(peq: OptimizePeqPayload): PeqBandForResponse[] { })); } -function CurveSvg({ - bands, +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, - stroke, - fill, - className, + currentStroke = "rgba(255,255,255,0.35)", + targetStroke = "#00FFF6", + targetFill = "rgba(0,255,246,0.08)", }: { - bands: PeqBandForResponse[]; + currentBands: PeqBandForResponse[]; + targetBands: PeqBandForResponse[]; width: number; height: number; - stroke: string; - fill?: string; - className?: string; + currentStroke?: string; + targetStroke?: string; + targetFill?: string; }) { - const data = useMemo( - () => buildPeqSvgCurveData({ bands, width, height, yDbMax: 20, paddingY: 6 }), - [bands, width, height], + 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) => { + 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 ( - setHover(null)} > - {/* 中线 */} - - {fill && } - - + + + {currentData && ( + + )} + {targetData.fillD ? ( + + ) : null} + + {hover && ( + + )} + + {hover && ( +
+
{fmtHoverFreqHz(hover.freq)} Hz
+ {hover.curDb !== null && ( +
+ 当前 {fmtDb(hover.curDb)} +
+ )} +
+ 目标 {fmtDb(hover.tgtDb)} +
+
+ )} + ); } @@ -1306,14 +1513,13 @@ function ComparePreview({ border: "1px solid rgba(255,255,255,0.05)", }} > - {currentBands.length > 0 && ( -
- -
- )} -
- -
+ ); } @@ -1408,25 +1614,13 @@ function CompareDialog({
- {currentBands.length > 0 && ( -
- -
- )} -
- -
+
{/* 频率轴标签 */}