diff --git a/README.md b/README.md
index ce4aa26..800863f 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
- [CI 检查](#ci-检查)
- [项目结构](#项目结构)
- [按目标阅读文档](#按目标阅读文档)
+- [性能基线复现](#性能基线复现)
- [许可证](#许可证)
## 展示快照
@@ -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)
diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md
new file mode 100644
index 0000000..5a25b92
--- /dev/null
+++ b/docs/performance-baseline.md
@@ -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`。
+- 当前脚本聚焦前端渲染链路,不覆盖后端网络延迟。
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..bc3cbd8 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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;
diff --git a/src/app/components/MessageList.tsx b/src/app/components/MessageList.tsx
index a7aa519..3b1d575 100644
--- a/src/app/components/MessageList.tsx
+++ b/src/app/components/MessageList.tsx
@@ -13,6 +13,7 @@ type MessageListProps = {
onRegenerate?: (messageId: string) => void;
canRegenerate?: boolean;
isStreaming?: boolean;
+ scrollerRef?: (element: HTMLElement | Window | null) => void;
};
/**
@@ -233,6 +234,7 @@ export default function MessageList({
onRegenerate,
canRegenerate = false,
isStreaming = false,
+ scrollerRef,
}: MessageListProps) {
return (
@@ -240,6 +242,7 @@ export default function MessageList({
data={messages}
followOutput={isStreaming ? "smooth" : false}
increaseViewportBy={480}
+ scrollerRef={scrollerRef}
itemContent={(_, message) => (
{
+ 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((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((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([]);
+ const [running, setRunning] = useState(false);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState("");
+ const scrollerElementRef = useRef(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 (
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {result ? (
+
+
+
消息总数
+
+ {result.datasetMessages}
+
+
+
+
历史加载
+
+ {result.historyLoadMs.toFixed(1)} ms
+
+
+
+
首次渲染
+
+ {result.renderMs.toFixed(1)} ms
+
+
+
+
滚动 FPS
+
+ {result.scrollFps.toFixed(1)}
+
+
+
+
内存增量
+
+ {result.memoryDeltaMb === null ? "N/A" : `${result.memoryDeltaMb.toFixed(2)} MB`}
+
+
+
+ ) : null}
+
+
+
+ {
+ scrollerElementRef.current =
+ element instanceof HTMLElement ? element : null;
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/src/lib/perf/chatPerfDataset.ts b/src/lib/perf/chatPerfDataset.ts
new file mode 100644
index 0000000..9d04f44
--- /dev/null
+++ b/src/lib/perf/chatPerfDataset.ts
@@ -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;
+};