Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [CI 检查](#ci-检查)
- [项目结构](#项目结构)
- [按目标阅读文档](#按目标阅读文档)
- [性能基线复现](#性能基线复现)
- [许可证](#许可证)

## 展示快照
Expand All @@ -34,6 +35,15 @@ pnpm lint
pnpm type-check
```

## 性能基线复现

```bash
pnpm dev
```

打开 `http://localhost:3000/perf`,使用固定轮次执行基线并记录结果。
详细说明见 [docs/performance-baseline.md](docs/performance-baseline.md)。

## CI 检查

- Workflow 文件:[`.github/workflows/ci.yml`](.github/workflows/ci.yml)
Expand Down
42 changes: 42 additions & 0 deletions docs/performance-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 性能基线复现说明

本文提供可复现的前端基线测量流程,用于比较优化前后差异。

## 1. 目标指标

- 历史会话加载耗时(`historyLoadMs`)
- 长会话首次渲染耗时(`renderMs`)
- 滚动帧率(`scrollFps`)
- 内存增量(`memoryDeltaMb`,浏览器支持时)

## 2. 运行前准备

1. 启动开发环境:

```bash
pnpm dev
```

2. 打开性能面板:

- `http://localhost:3000/perf`

## 3. 复现步骤

1. 在“轮次”输入固定值(建议 `300`,对应约 `600` 条消息)。
2. 点击“运行基线”。
3. 记录输出的 4 项核心指标。
4. 每个版本重复 3 次,取平均值。

## 4. 对比建议

- 同一台机器、同一浏览器、同一轮次数进行对比。
- 若要做 PR 评估,建议附上:
- 基线版本 commit
- 优化版本 commit
- 三次平均结果

## 5. 限制说明

- `memoryDeltaMb` 依赖浏览器的 `performance.memory`,部分浏览器会显示 `N/A`。
- 当前脚本聚焦前端渲染链路,不覆盖后端网络延迟。
15 changes: 14 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import type { NextConfig } from "next";

const parseAllowedDevOrigins = () => {
const raw = process.env.NEXT_ALLOWED_DEV_ORIGINS?.trim();
if (!raw) {
// 默认放行本地局域网调试来源,避免 Next 未来版本升级后被跨域策略拦截。
return ["192.168.96.167"];
}

return raw
.split(",")
.map((item) => item.trim())
.filter(Boolean);
};

const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: parseAllowedDevOrigins(),
};

export default nextConfig;
3 changes: 3 additions & 0 deletions src/app/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type MessageListProps = {
onRegenerate?: (messageId: string) => void;
canRegenerate?: boolean;
isStreaming?: boolean;
scrollerRef?: (element: HTMLElement | Window | null) => void;
};

/**
Expand Down Expand Up @@ -233,13 +234,15 @@ export default function MessageList({
onRegenerate,
canRegenerate = false,
isStreaming = false,
scrollerRef,
}: MessageListProps) {
return (
<div className="h-full">
<Virtuoso
data={messages}
followOutput={isStreaming ? "smooth" : false}
increaseViewportBy={480}
scrollerRef={scrollerRef}
itemContent={(_, message) => (
<div className="py-2.5">
<MessageCard
Expand Down
220 changes: 220 additions & 0 deletions src/app/perf/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"use client";

import type { UIMessage } from "ai";
import { useMemo, useRef, useState } from "react";
import MessageList from "@/app/components/MessageList";
import { readStoredMessages, writeStoredMessages } from "@/lib/chatMessageStorage";
import { buildSyntheticMessages } from "@/lib/perf/chatPerfDataset";

type PerfResult = {
datasetMessages: number;
historyLoadMs: number;
renderMs: number;
scrollFps: number;
memoryDeltaMb: number | null;
};

const PERF_SESSION_ID = "perf:baseline";

const getUsedHeapSize = () => {
if (typeof performance === "undefined") return null;
const withMemory = performance as Performance & {
memory?: {
usedJSHeapSize?: number;
};
};

return typeof withMemory.memory?.usedJSHeapSize === "number"
? withMemory.memory.usedJSHeapSize
: null;
};

/**
* 用两帧作为“渲染稳定”采样点,减少单帧偶然抖动影响。
*/
const waitForStableFrame = async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve();
});
});
});
};

/**
* 通过固定时长自动滚动来估算滚动帧率。
*/
const measureScrollFps = async (element: HTMLElement) => {
const durationMs = 1_500;
const start = performance.now();
let frameCount = 0;
const maxScrollTop = Math.max(1, element.scrollHeight - element.clientHeight);

await new Promise<void>((resolve) => {
const tick = (now: number) => {
frameCount += 1;
const progress = Math.min(1, (now - start) / durationMs);
element.scrollTop = maxScrollTop * progress;
if (progress >= 1) {
resolve();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});

return (frameCount / durationMs) * 1_000;
};

export default function PerfPage() {
const [turnCount, setTurnCount] = useState(300);
const [messages, setMessages] = useState<UIMessage[]>([]);
const [running, setRunning] = useState(false);
const [result, setResult] = useState<PerfResult | null>(null);
const [error, setError] = useState("");
const scrollerElementRef = useRef<HTMLElement | null>(null);
const datasetPreviewCount = useMemo(() => Math.max(1, Math.floor(turnCount)) * 2, [turnCount]);

const handleRunBaseline = async () => {
setRunning(true);
setError("");
setResult(null);

try {
const dataset = buildSyntheticMessages(turnCount);
const heapBefore = getUsedHeapSize();

writeStoredMessages(PERF_SESSION_ID, dataset);
const loadStartedAt = performance.now();
const loaded = readStoredMessages(PERF_SESSION_ID);
const historyLoadMs = performance.now() - loadStartedAt;

const renderStartedAt = performance.now();
setMessages(loaded);
await waitForStableFrame();
const renderMs = performance.now() - renderStartedAt;

const scrollerElement = scrollerElementRef.current;
if (!scrollerElement) {
throw new Error("未找到消息滚动容器,无法测量滚动 FPS");
}
const scrollFps = await measureScrollFps(scrollerElement);
const heapAfter = getUsedHeapSize();
const memoryDeltaMb =
heapBefore !== null && heapAfter !== null
? (heapAfter - heapBefore) / (1024 * 1024)
: null;

setResult({
datasetMessages: loaded.length,
historyLoadMs,
renderMs,
scrollFps,
memoryDeltaMb,
});
} catch (runError) {
setError(runError instanceof Error ? runError.message : "性能基线执行失败");
} finally {
setRunning(false);
}
};

return (
<div className="flex h-full min-h-screen flex-col bg-[#f4f2ec] px-4 py-6 dark:bg-slate-900 md:px-8">
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4">
<header className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
性能基线面板
</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
固定数据集 + 固定流程,复现历史加载、长会话渲染与滚动 FPS。
</p>

<div className="mt-4 flex flex-wrap items-center gap-3">
<label className="text-sm text-slate-600 dark:text-slate-300">
轮次
<input
type="number"
min={50}
step={50}
value={turnCount}
onChange={(event) => setTurnCount(Number(event.target.value))}
className="ml-2 h-9 w-28 rounded-lg border border-slate-300 bg-white px-2 text-sm text-slate-700 outline-none focus:border-slate-500 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-200"
/>
</label>
<button
type="button"
disabled={running}
onClick={() => {
void handleRunBaseline();
}}
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-400 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200 dark:disabled:bg-slate-700 dark:disabled:text-slate-300"
>
{running ? "执行中..." : "运行基线"}
</button>
<span className="text-xs text-slate-500 dark:text-slate-400">
预计消息数:{datasetPreviewCount}
</span>
</div>
</header>

{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-300">
{error}
</div>
) : null}

{result ? (
<section className="grid gap-3 md:grid-cols-5">
<div className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
<p className="text-xs text-slate-500 dark:text-slate-400">消息总数</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{result.datasetMessages}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
<p className="text-xs text-slate-500 dark:text-slate-400">历史加载</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{result.historyLoadMs.toFixed(1)} ms
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
<p className="text-xs text-slate-500 dark:text-slate-400">首次渲染</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{result.renderMs.toFixed(1)} ms
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
<p className="text-xs text-slate-500 dark:text-slate-400">滚动 FPS</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{result.scrollFps.toFixed(1)}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
<p className="text-xs text-slate-500 dark:text-slate-400">内存增量</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{result.memoryDeltaMb === null ? "N/A" : `${result.memoryDeltaMb.toFixed(2)} MB`}
</p>
</div>
</section>
) : null}

<section className="min-h-0 flex-1 rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
<div className="h-[62vh]">
<MessageList
messages={messages}
canRegenerate={false}
isStreaming={false}
scrollerRef={(element) => {
scrollerElementRef.current =
element instanceof HTMLElement ? element : null;
}}
/>
</div>
</section>
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions src/lib/perf/chatPerfDataset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { UIMessage } from "ai";

const SAMPLE_CODE = [
"```ts",
"export const sum = (a: number, b: number) => a + b;",
"```",
].join("\n");

/**
* 生成可复现的长会话数据集,便于在不同版本间做稳定对比。
*/
export const buildSyntheticMessages = (turnCount: number): UIMessage[] => {
const normalizedTurns = Math.max(1, Math.floor(turnCount));
const messages: UIMessage[] = [];

for (let i = 1; i <= normalizedTurns; i += 1) {
messages.push({
id: `u_${i}`,
role: "user",
parts: [
{
type: "text",
text: `请解释第 ${i} 轮问题,并给出可执行建议。`,
},
],
} as UIMessage);

messages.push({
id: `a_${i}`,
role: "assistant",
parts: [
{
type: "text",
text: `第 ${i} 轮回答:\n- 结论\n- 方案\n- 风险\n\n代码示例:\n${SAMPLE_CODE}`,
},
],
} as UIMessage);
}

return messages;
};