增加了PEQ对比显示在最下方,图表鼠标移上去有值显示,应用和回滚带过渡动画
This commit is contained in:
+255
-61
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user