增加了PEQ对比显示在最下方,图表鼠标移上去有值显示,应用和回滚带过渡动画

This commit is contained in:
allen2fuc
2026-05-13 13:31:48 +08:00
parent c639c77383
commit 651fb0b93e
2 changed files with 256 additions and 61 deletions
+255 -61
View File
@@ -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<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 (
<svg
viewBox={`0 0 ${width} ${height}`}
width="100%"
height={height}
className={className}
preserveAspectRatio="none"
<div
className="relative w-full cursor-crosshair"
style={{ height }}
onMouseMove={onMove}
onMouseLeave={() => setHover(null)}
>
{/* 中线 */}
<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>
<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>
);
}
@@ -1306,14 +1513,13 @@ function ComparePreview({
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>
<PeqOverlayCompareSvg
currentBands={currentBands}
targetBands={targetBands}
width={width}
height={height}
currentStroke="rgba(255,255,255,0.35)"
/>
</div>
);
}
@@ -1408,25 +1614,13 @@ function CompareDialog({
</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>
<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">