feat(codex): optimize streaming response handling by merging deltas and improving message rendering performance

This commit is contained in:
HouYunFei
2026-07-29 11:32:03 +08:00
parent 1f6a652799
commit c6f8b1e394
6 changed files with 145 additions and 100 deletions
+1
View File
@@ -8,6 +8,7 @@
+ [优化] Canvas Agent Debug 日志改为简洁的纯文本单行格式。
+ [优化] 精简网页与本地 Agent 的 HTTP 诊断日志,过滤轮询和流式重复事件并改用任务摘要展示。
+ [优化] Agent 对话采用透明无气泡的简洁用户消息样式,输入框上方居中实时展示带平滑数字动画的最新一次模型调用 Token 用量。
+ [优化] Canvas Agent 流式回复改为增量合并传输并隔离消息列表渲染,减少长回复和长会话中的重复更新与界面卡顿。
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
+1 -1
View File
@@ -134,7 +134,7 @@ default_tools_approval_mode = "approve"
本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。
侧边栏会展示 Codex 返回的 `thread.started``turn.started``item.*``turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会转成 `item.updated`,网页会用同一条消息做真实流式更新,并把工具细节收进运行日志。
侧边栏会展示 Codex 返回的 `thread.started``turn.started``item.*``turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会合并短时间内的文本增量并转成 `item.updated`,网页会用同一条消息做真实流式更新,并把工具细节收进运行日志。
侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。
+28 -3
View File
@@ -11,9 +11,11 @@ import type { AgentEmit } from "./types.js";
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
type PendingDelta = { delta: string; params: CodexNotificationParams<"item/agentMessage/delta">; timer: ReturnType<typeof setTimeout> };
const canvasAgentMcp = canvasAgentMcpCommand();
const require = createRequire(import.meta.url);
const STREAM_UPDATE_INTERVAL_MS = 40;
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
export class CodexAppClient {
@@ -26,6 +28,7 @@ export class CodexAppClient {
private pending = new Map<number, PendingRequest>();
private activeTurns = new Map<string, PendingRequest>();
private completedTurns = new Map<string, Error | null>();
private pendingDeltas = new Map<string, PendingDelta>();
/** 保存 app-server 子进程和事件出口。 */
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
@@ -180,6 +183,7 @@ export class CodexAppClient {
if (event.type === "item.completed") {
const item = field(event, "item") as JsonRecord | undefined;
const id = String(field(item, "id") || "");
this.flushDelta(id);
const streamedText = this.textByItem.get(id);
if (item?.type === "agent_message" && streamedText && !item.text) item.text = streamedText;
if (id) this.textByItem.delete(id);
@@ -208,9 +212,27 @@ export class CodexAppClient {
/** 合并并广播 Agent 文本增量。 */
private emitDelta(params: CodexNotificationParams<"item/agentMessage/delta">) {
const id = params.itemId;
const text = `${this.textByItem.get(id) || ""}${params.delta}`;
this.textByItem.set(id, text);
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text }, ...codexEventScope(params) });
this.textByItem.set(id, `${this.textByItem.get(id) || ""}${params.delta}`);
const pending = this.pendingDeltas.get(id);
if (pending) {
pending.delta += params.delta;
pending.params = params;
return;
}
this.pendingDeltas.set(id, {
delta: params.delta,
params,
timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS),
});
}
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
private flushDelta(id: string) {
const pending = this.pendingDeltas.get(id);
if (!pending) return;
clearTimeout(pending.timer);
this.pendingDeltas.delete(id);
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", delta: pending.delta }, ...codexEventScope(pending.params) });
}
/** 自动回复 app-server 发起的授权或交互请求。 */
@@ -236,8 +258,11 @@ export class CodexAppClient {
/** 拒绝进程退出时仍未完成的请求与 turn。 */
private failAll(message: string) {
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
this.pending.clear();
this.activeTurns.clear();
this.pendingDeltas.clear();
this.textByItem.clear();
this.currentThreadId = "";
this.currentTurnId = "";
}
+3 -1
View File
@@ -59,7 +59,9 @@ export class CanvasSession {
/** 更新并广播 Codex 运行状态。 */
setCodexState(patch: Partial<CodexState>) {
this.codexState = { ...this.codexState, ...patch };
const next = { ...this.codexState, ...patch };
if (next.busy === this.codexState.busy && next.threadId === this.codexState.threadId && next.turnId === this.codexState.turnId) return;
this.codexState = next;
logger.debug("Codex state changed", this.codexState);
this.emitAll("codex_state", this.codexState);
}
@@ -10,6 +10,7 @@ description: 当前版本已实现但仍需人工验证的变更项
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。
- Agent 对话统计:用户消息应右对齐并使用透明无边框、无气泡背景的简洁样式,用户消息和 Codex 回复下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和 `working...`,任务运行期间不应闪退;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
- Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用。
- 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`OpenAI 模板应按自动或指定档位正确组装请求。
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { App, Button, Checkbox, Input, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
@@ -12,7 +12,7 @@ import { readImageMeta } from "@/lib/image-utils";
import { randomId } from "@/lib/utils";
import { uploadImage } from "@/services/image-storage";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
import { useShallow } from "zustand/react/shallow";
import { useAgentStore, type AgentAttachment, type AgentCanvasContext, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary, type AgentTokenUsage } from "@/stores/use-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
@@ -42,7 +42,7 @@ type AgentEventPayload = {
usage?: Record<string, unknown>;
duration_ms?: number;
};
type AgentEventItem = { id?: string; type?: string; text?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } };
type AgentEventItem = { id?: string; type?: string; text?: unknown; delta?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } };
type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
@@ -51,13 +51,11 @@ type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?:
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string };
type AgentHelloEvent = { ok?: boolean; clientId?: string; codex?: AgentCodexState };
type AgentHealth = { codexBusy?: boolean };
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; emptyThread?: boolean };
type AgentChatEvent = { threadId?: string; sourceClientId?: string; message?: AgentChatItem };
export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
const { message, modal } = App.useApp();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -65,7 +63,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool } = useAgentStore(
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool } = useAgentStore(
useShallow((state) => ({
width: state.width,
url: state.url,
@@ -76,7 +74,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
attachments: state.attachments,
sending: state.sending,
waiting: state.waiting,
messages: state.messages,
tokenUsage: state.tokenUsage,
eventLogs: state.eventLogs,
threads: state.threads,
@@ -94,9 +91,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const pushMessage = useAgentStore((state) => state.addMessage);
const pushEventLog = useAgentStore((state) => state.addEventLog);
const clearEventLogs = useAgentStore((state) => state.clearEventLogs);
const listRef = useRef<HTMLDivElement>(null);
const followMessagesRef = useRef(true);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const messageCount = useAgentStore((state) => state.messages.length);
const canvasContextRef = useRef<AgentCanvasContext | null>(useAgentStore.getState().canvasContext);
const confirmToolsRef = useRef(confirmTools);
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
@@ -106,7 +101,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(randomId());
const loadThreadsSequenceRef = useRef(0);
const turnSyncSequenceRef = useRef(0);
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
const loadThreads = useCallback(async (skipHistory = false) => {
@@ -114,37 +108,24 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const sequence = ++loadThreadsSequenceRef.current;
setAgentState({ loadingThreads: true });
try {
const currentThreadId = useAgentStore.getState().activeThreadId;
const currentThreadRequest = currentThreadId && !skipHistory ? fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`).catch(() => null) : null;
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
const nextThreadId = data.workspace?.activeThreadId || "";
let nextMessages: AgentChatItem[] = [];
if (nextThreadId && !skipHistory) {
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`);
let thread = nextThreadId === currentThreadId ? await currentThreadRequest : null;
thread ||= await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`);
nextMessages = normalizeHistoryMessages(thread.messages || []);
}
if (sequence !== loadThreadsSequenceRef.current) return;
setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "", activeThreadId: nextThreadId, messages: nextMessages });
setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "", activeThreadId: nextThreadId, ...(skipHistory ? {} : { messages: nextMessages }) });
} catch (error) {
addEventLog("读取历史失败", error);
} finally {
if (sequence === loadThreadsSequenceRef.current) setAgentState({ loadingThreads: false });
}
}, [endpoint, setAgentState, token]);
const syncTurnMessages = async (sequence: number) => {
while (sequence === turnSyncSequenceRef.current && useAgentStore.getState().connected) {
try {
const health = await fetchAgentJson<AgentHealth>(endpoint, token, "/health");
if (!health.codexBusy) {
await wait(300);
if (sequence !== turnSyncSequenceRef.current) return;
await loadThreads();
if (sequence === turnSyncSequenceRef.current) setAgentState({ waiting: false, sending: false, activity: "完成" });
return;
}
} catch {}
await wait(500);
}
};
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -166,30 +147,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
useEffect(() => {
pendingToolRef.current = pendingTool;
}, [pendingTool]);
const updateScrollState = useCallback(() => {
const list = listRef.current;
if (!list) return;
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD;
followMessagesRef.current = atBottom;
setShowScrollToBottom(!atBottom);
}, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const list = listRef.current;
if (!list) return;
followMessagesRef.current = true;
list.scrollTo({ top: list.scrollHeight, behavior });
setShowScrollToBottom(false);
}, []);
useEffect(() => {
if (activeTab !== "chat") return;
const frame = requestAnimationFrame(() => scrollToBottom("auto"));
return () => cancelAnimationFrame(frame);
}, [activeTab, activeThreadId, scrollToBottom]);
useEffect(() => {
if (activeTab !== "chat") return;
const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
return () => cancelAnimationFrame(frame);
}, [activeTab, messages, pendingTool, scrollToBottom, updateScrollState, waiting]);
useEffect(() => () => attachmentUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []);
useEffect(() => {
@@ -233,11 +190,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
source.addEventListener("workspace_changed", (event) => {
const data = parseEventData<AgentWorkspaceEvent>(event);
if (!data) return;
enqueueEvent(async () => {
enqueueEvent(() => {
const nextThreadId = data.activeThreadId ?? data.threadId ?? "";
pendingToolRef.current = null;
setAgentState({ activeThreadId: nextThreadId, messages: [], tokenUsage: null, pendingTool: null });
await loadThreads(data.emptyThread);
void loadThreads(Boolean(data.emptyThread));
});
});
source.addEventListener("chat_message", (event) => {
@@ -333,8 +290,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
attachmentUrlsRef.current.delete(item.url);
});
setAgentState({ prompt: "", attachments: [], sending: false, waiting: true, activity: "Codex 正在运行" });
const sequence = ++turnSyncSequenceRef.current;
void syncTurnMessages(sequence);
} catch (error) {
const text = error instanceof Error ? error.message : "发送失败";
const busy = text.includes("Codex 正在运行");
@@ -533,7 +488,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
loadThreadsSequenceRef.current += 1;
turnSyncSequenceRef.current += 1;
setAgentState({
messages: [],
tokenUsage: null,
@@ -645,6 +599,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const log = formatAgentEventLog(event);
if (log) addEventLog(log.title, log.text);
if (event.type === "thread.started" && event.thread_id) setAgentState({ activeThreadId: event.thread_id });
if (event.type === "item.updated" && event.item?.type === "agent_message" && event.item.id) {
appendStreamDelta(event.item.id, stringText(event.item.delta));
return;
}
if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.id) {
const currentMessages = useAgentStore.getState().messages;
const index = currentMessages.findIndex((message) => message.streamId === event.item?.id);
@@ -659,7 +617,17 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
if (item) addMessage(item);
};
const streaming = messages.some((message) => message.streamId);
const appendStreamDelta = (streamId: string, delta: string) => {
if (!delta) return;
const currentMessages = useAgentStore.getState().messages;
const index = currentMessages.findIndex((message) => message.streamId === streamId);
if (index < 0) {
pushMessage({ id: `stream-${streamId}`, role: "assistant", title: "Codex", text: delta, streamId });
return;
}
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text: `${message.text}${delta}` } : message)) });
};
const content = (
<>
<AgentPanelTabs
@@ -715,43 +683,14 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
<AgentLogView
logs={eventLogs}
theme={theme}
context={{ endpoint, connected, enabled, activity, waiting, sending, messages: messages.length, pendingTool: pendingTool?.name }}
context={{ endpoint, connected, enabled, activity, waiting, sending, messages: messageCount, pendingTool: pendingTool?.name }}
onClear={clearEventLogs}
onCopied={(text) => message.success(text)}
onCopyBlocked={(text) => message.warning(text)}
/>
) : (
<>
<div className="relative min-h-0 flex-1">
<div ref={listRef} className="thin-scrollbar h-full space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
{messages.map((item) => (
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
))}
{pendingTool ? (
<AgentPendingToolCard
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }}
theme={theme}
onReject={rejectPendingTool}
onApprove={approvePendingTool}
/>
) : null}
{(sending || waiting) && !streaming && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
</div>
{showScrollToBottom ? (
<Tooltip title="滚动到底部" placement="left">
<Button
type="text"
shape="circle"
aria-label="滚动到底部"
className="!absolute bottom-3 left-1/2 z-10 !h-8 !w-8 !min-w-8 -translate-x-1/2 backdrop-blur transition hover:-translate-y-0.5"
style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
icon={<ChevronDown className="size-4" />}
onClick={() => scrollToBottom()}
/>
</Tooltip>
) : null}
</div>
<AgentChatTimeline theme={theme} pendingTool={pendingTool} sending={sending} waiting={waiting} onRejectTool={rejectPendingTool} onApproveTool={approvePendingTool} />
{tokenUsage ? <AgentUsageBar usage={tokenUsage} theme={theme} /> : null}
<AgentChatComposer
prompt={prompt}
@@ -782,6 +721,87 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
return embedded ? content : null;
}
function AgentChatTimeline({
theme,
pendingTool,
sending,
waiting,
onRejectTool,
onApproveTool,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
pendingTool: AgentPendingToolCall | null;
sending: boolean;
waiting: boolean;
onRejectTool: () => void;
onApproveTool: () => void;
}) {
const messages = useAgentStore((state) => state.messages);
const user = useUserStore((state) => state.user);
const listRef = useRef<HTMLDivElement>(null);
const followMessagesRef = useRef(true);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const streaming = messages.some((message) => message.streamId);
const updateScrollState = useCallback(() => {
const list = listRef.current;
if (!list) return;
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD;
followMessagesRef.current = atBottom;
setShowScrollToBottom(!atBottom);
}, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const list = listRef.current;
if (!list) return;
followMessagesRef.current = true;
list.scrollTo({ top: list.scrollHeight, behavior });
setShowScrollToBottom(false);
}, []);
useEffect(() => {
const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
return () => cancelAnimationFrame(frame);
}, [messages, pendingTool, scrollToBottom, updateScrollState, waiting]);
return (
<div className="relative min-h-0 flex-1">
<div ref={listRef} className="thin-scrollbar h-full space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
{messages.map((item) => (
<AgentChatMessageRow key={item.id} item={item} theme={theme} user={user} />
))}
{pendingTool ? (
<AgentPendingToolCard
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }}
theme={theme}
onReject={onRejectTool}
onApprove={onApproveTool}
/>
) : null}
{(sending || waiting) && !streaming && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
</div>
{showScrollToBottom ? (
<Tooltip title="滚动到底部" placement="left">
<Button
type="text"
shape="circle"
aria-label="滚动到底部"
className="!absolute bottom-3 left-1/2 z-10 !h-8 !w-8 !min-w-8 -translate-x-1/2 backdrop-blur transition hover:-translate-y-0.5"
style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
icon={<ChevronDown className="size-4" />}
onClick={() => scrollToBottom()}
/>
</Tooltip>
) : null}
</div>
);
}
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme, user }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null }) {
return (
<div style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}>
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} user={user} />
</div>
);
});
function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<div className="flex items-center justify-center gap-4 px-4 pt-1 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>
@@ -1170,7 +1190,7 @@ function agentAttachmentToChatAttachment(item: AgentAttachment): CanvasAgentChat
function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
const item = event.item;
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
if ((event.type === "item.updated" || event.type === "item.completed") && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text), streamId: event.type === "item.updated" ? item.id : undefined };
if (event.type === "item.completed" && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text) };
if (event.type === "item.completed" && isMcpToolItem(item) && isReadTool(String(item?.tool || ""))) return { role: "tool", title: `${toolName(String(item?.tool || ""))}完成`, text: item?.error?.message || toolSummary(item), detail: toolDetail(item) };
return null;
}
@@ -1439,10 +1459,6 @@ function createId() {
return randomId();
}
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}