增加了PEQ对比显示在最下方,图表鼠标移上去有值显示,应用和回滚带过渡动画
This commit is contained in:
@@ -107,3 +107,4 @@ temp/
|
|||||||
|
|
||||||
# Webdev artifacts (checkpoint zips, migrations, etc.)
|
# Webdev artifacts (checkpoint zips, migrations, etc.)
|
||||||
.webdev/
|
.webdev/
|
||||||
|
Makefile
|
||||||
+255
-61
@@ -133,6 +133,58 @@ function extractText(raw: string): { text: string; tools: Array<{ id: string; na
|
|||||||
return { text: raw, tools: [] };
|
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() {
|
function makeId() {
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
@@ -199,11 +251,17 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
}, [aiText, mac]);
|
}, [aiText, mac]);
|
||||||
|
|
||||||
// ── 把后端消息行映射为 UI 消息 ──
|
// ── 把后端消息行映射为 UI 消息 ──
|
||||||
// 只要助手消息带有 before_peq + after_peq,就展示为 AB 对比卡片;
|
// 用户行且带 before_peq + after_peq → AB 对比卡片(常与 set_peq 相关);
|
||||||
// 其余按 type=0 聊天消息处理;用户侧的 type=2 仍然只是内部 prompt,跳过。
|
// type=0 → 聊天气泡。先按 created_at 排序,再把本轮最后一条对比挪到助手回复之后。
|
||||||
const mapRowsToUI = useCallback((rows: MessageRead[]): UIMessage[] => {
|
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[] = [];
|
const result: UIMessage[] = [];
|
||||||
for (const r of rows) {
|
for (const r of sorted) {
|
||||||
if (r.role === "user" && r.before_peq && r.after_peq) {
|
if (r.role === "user" && r.before_peq && r.after_peq) {
|
||||||
result.push({
|
result.push({
|
||||||
kind: "optimize",
|
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({
|
const PEQ_CHART_CURVE_OPTS = { yDbMax: 20, paddingY: 6 } as const;
|
||||||
bands,
|
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,
|
width,
|
||||||
height,
|
height,
|
||||||
stroke,
|
currentStroke = "rgba(255,255,255,0.35)",
|
||||||
fill,
|
targetStroke = "#00FFF6",
|
||||||
className,
|
targetFill = "rgba(0,255,246,0.08)",
|
||||||
}: {
|
}: {
|
||||||
bands: PeqBandForResponse[];
|
currentBands: PeqBandForResponse[];
|
||||||
|
targetBands: PeqBandForResponse[];
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
stroke: string;
|
currentStroke?: string;
|
||||||
fill?: string;
|
targetStroke?: string;
|
||||||
className?: string;
|
targetFill?: string;
|
||||||
}) {
|
}) {
|
||||||
const data = useMemo(
|
const currentData = useMemo(() => {
|
||||||
() => buildPeqSvgCurveData({ bands, width, height, yDbMax: 20, paddingY: 6 }),
|
if (currentBands.length === 0) return null;
|
||||||
[bands, width, height],
|
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 (
|
return (
|
||||||
<svg
|
<div
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
className="relative w-full cursor-crosshair"
|
||||||
width="100%"
|
style={{ height }}
|
||||||
height={height}
|
onMouseMove={onMove}
|
||||||
className={className}
|
onMouseLeave={() => setHover(null)}
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
>
|
||||||
{/* 中线 */}
|
<svg
|
||||||
<line
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
x1={0}
|
width="100%"
|
||||||
x2={width}
|
height={height}
|
||||||
y1={height / 2}
|
className="block"
|
||||||
y2={height / 2}
|
preserveAspectRatio="none"
|
||||||
stroke="rgba(255,255,255,0.08)"
|
>
|
||||||
strokeDasharray="2 3"
|
<line
|
||||||
/>
|
x1={0}
|
||||||
{fill && <path d={data.fillD} fill={fill} />}
|
x2={width}
|
||||||
<path d={data.pathD} fill="none" stroke={stroke} strokeWidth={1.5} />
|
y1={height / 2}
|
||||||
</svg>
|
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)",
|
border: "1px solid rgba(255,255,255,0.05)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentBands.length > 0 && (
|
<PeqOverlayCompareSvg
|
||||||
<div className="absolute inset-0">
|
currentBands={currentBands}
|
||||||
<CurveSvg bands={currentBands} width={width} height={height} stroke="rgba(255,255,255,0.35)" />
|
targetBands={targetBands}
|
||||||
</div>
|
width={width}
|
||||||
)}
|
height={height}
|
||||||
<div className="relative">
|
currentStroke="rgba(255,255,255,0.35)"
|
||||||
<CurveSvg bands={targetBands} width={width} height={height} stroke="#00FFF6" fill="rgba(0,255,246,0.08)" />
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1408,25 +1614,13 @@ function CompareDialog({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative" style={{ height: chartHeight }}>
|
<div className="relative" style={{ height: chartHeight }}>
|
||||||
{currentBands.length > 0 && (
|
<PeqOverlayCompareSvg
|
||||||
<div className="absolute inset-0">
|
currentBands={currentBands}
|
||||||
<CurveSvg
|
targetBands={targetBands}
|
||||||
bands={currentBands}
|
width={chartWidth}
|
||||||
width={chartWidth}
|
height={chartHeight}
|
||||||
height={chartHeight}
|
currentStroke="rgba(255,255,255,0.45)"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{/* 频率轴标签 */}
|
{/* 频率轴标签 */}
|
||||||
<div className="flex justify-between text-[10px] text-white/35 mt-1 px-0.5">
|
<div className="flex justify-between text-[10px] text-white/35 mt-1 px-0.5">
|
||||||
|
|||||||
Reference in New Issue
Block a user