feat(agent): implement tool confirmation mode in chat input with automatic confirmation by default

This commit is contained in:
HouYunFei
2026-07-29 15:44:56 +08:00
parent fa58ec4715
commit d76594a7e7
9 changed files with 107 additions and 102 deletions
+2 -1
View File
@@ -2,6 +2,7 @@
## Unreleased
+ [调整] Agent 工具确认模式移至对话输入框,并默认使用自动确认。
+ [优化] Agent 用户图片消息改为紧凑缩略图展示,并支持单击放大预览。
+ [修复] 修复 Agent 图片预览打开后在对话区域产生额外空行的问题。
+ [优化] Canvas Agent 内置 Codex CLI 升级至 0.145.0。
@@ -9,7 +10,7 @@
+ [新增] Canvas Agent 支持通过 `--debug` 输出详细日志并按日期保存到本地日志文件。
+ [优化] Canvas Agent Debug 日志改为简洁的纯文本单行格式。
+ [优化] 精简网页与本地 Agent 的 HTTP 诊断日志,过滤轮询和流式重复事件并改用任务摘要展示。
+ [优化] Agent 对话采用透明无气泡的简洁用户消息样式,输入框上方居中实时展示带平滑数字动画的最新一次模型调用 Token 用量。
+ [优化] Agent 对话移除双方头像,用户消息改为右侧透明排版,工具活动改为图标与状态同行的紧凑卡片;输入框上方居中实时展示带平滑数字动画的最新一次模型调用 Token 用量。
+ [优化] Canvas Agent 流式回复改为增量合并传输并隔离消息列表渲染,减少长回复和长会话中的重复更新与界面卡顿。
+ [优化] Agent 对话新增思考摘要、计划、命令、搜索和文件变更时间线,工具卡片改为中文摘要与用户可读详情,不再展示原始 JSON。
+ [新增] Agent 对话支持 Codex 结构化任务进度,在输入区上方独立折叠展示并逐项实时更新待处理、进行中和已完成状态。
@@ -137,12 +137,10 @@ Codex 调 canvas_create_text_node
## 工具确认和撤销
为了避免 Codex 直接改动画布,浏览器侧对 `canvas_apply_ops` 默认有二次确认机制。收到写操作后,右侧面板会先展示待执行工具调用,用户确认后才真正应用到画布。
浏览器侧默认自动执行 `canvas_apply_ops` 写操作。需要逐次审核时,可在右侧对话输入框左下方把「自动确认」切换为「手动确认」;收到写操作后,对话中会展示待执行工具调用,用户确认后才真正应用到画布。
执行后,前端会保存最近一次操作前的画布快照,用于撤销上一轮 Agent 工具操作。
如果关闭工具确认,本地 Codex 可以直接执行画布工具调用,但仍然是通过同一套 `Canvas Agent -> SSE -> 浏览器执行` 的链路。
## 安全边界
- `Canvas Agent` 默认只监听 `127.0.0.1`。
+5 -4
View File
@@ -5,13 +5,14 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。
- Canvas Agent Codex 升级:启动 Canvas Agent 后应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,实际运行的 Codex CLI 版本应为 0.145.0;运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。
- Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应按“时间 级别 消息 详情”的纯文本单行格式输出日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文。
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。
- Agent 对话统计:用户消息应右对齐并使用透明无边框、无气泡背景的简洁样式,用户消息和 Codex 回复下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败。
- Agent 对话统计:用户消息应右对齐并使用透明无气泡的简洁排版,用户和 Codex 两侧均不显示人物头像,消息下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败。
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。
- Agent 过程时间线:Codex 运行时应在对话中依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;命令详情只展示工作目录、耗时、退出状态和运行输出,文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON,刷新历史会话后仍保持相同展示。
- Agent 过程时间线:Codex 运行时应在对话中依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;工具活动应使用紧凑卡片排版,状态图标、标题和状态文字保持在同一行,不应由单个图标占据一行;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;命令详情只展示工作目录、耗时、退出状态和运行输出,文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON,刷新历史会话后仍保持相同展示。
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话;第一次发送消息时才创建新线程,不应产生未发送消息的空历史记录;需要继续旧对话时可在「历史」中主动选择恢复。
- Agent 当前画布优先:在已打开某个画布时要求 Agent 创建、修改、整理或生成内容,Agent 应直接读取并操作当前画布,不应先调用 `canvas_list_projects` 或使用 `site_navigate` 重复进入画布;只有明确要求查看或切换其他画布时才允许查询画布列表并导航。
@@ -40,4 +41,4 @@ description: 当前版本已实现但仍需人工验证的变更项
- 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示。
- 本地 Agent 图片附件落画布:在右侧 Agent 上传参考图并要求基于商品信息创建生图流程,附件应创建为保持原比例的真实图片节点,分析提示词应创建为文本节点,二者都应连接到生成配置节点;刷新页面后参考图仍可显示并参与生成。任务中途切换到其他标签页时,附件只能写入发起任务的标签页;若发起标签页关闭,附件读取应失败且不能落入其他画布。
- Agent 对话滚动:从历史或日志切回对话、恢复其他会话时应自动定位到最新消息;手动向上浏览后应显示向下箭头且新消息不强制打断阅读,点击箭头后平滑回到底部并继续跟随新消息。
- Agent 消息区分:用户消息应在右侧以主题浅底气泡显示AI 回复应在左侧保留 Agent 图标并使用开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。
- Agent 消息区分:用户消息应在右侧使用透明无气泡排版AI 回复应在左侧使用无头像的开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。
@@ -1,6 +1,6 @@
import { useRef, type ReactNode } from "react";
import { Button, Tooltip } from "antd";
import { ArrowUp, ImagePlus, LoaderCircle, Square, X } from "lucide-react";
import { Button, Dropdown, Tooltip } from "antd";
import { ArrowUp, Check, ChevronUp, Hand, ImagePlus, LoaderCircle, RefreshCw, Square, X } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { isPlainEnterKey } from "@/lib/keyboard-event";
@@ -18,6 +18,8 @@ export function AgentChatComposer({
onStop,
onAddFiles,
onRemoveAttachment,
confirmTools,
onConfirmToolsChange,
left,
}: {
prompt: string;
@@ -31,6 +33,8 @@ export function AgentChatComposer({
onStop?: () => void;
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
onRemoveAttachment?: (id: string) => void;
confirmTools?: boolean;
onConfirmToolsChange?: (confirmTools: boolean) => void;
left?: ReactNode;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -84,6 +88,7 @@ export function AgentChatComposer({
</Tooltip>
</>
) : null}
{onConfirmToolsChange ? <ToolConfirmationMenu confirmTools={Boolean(confirmTools)} theme={theme} onChange={onConfirmToolsChange} /> : null}
{left}
</div>
<div className="flex shrink-0 items-center gap-1.5">
@@ -98,3 +103,45 @@ export function AgentChatComposer({
</div>
);
}
function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (confirmTools: boolean) => void }) {
return (
<Dropdown
trigger={["click"]}
placement="topLeft"
menu={{
items: [
{
key: "manual",
label: <ConfirmationOption icon={<Hand className="size-4" />} title="手动确认" description="Agent 执行画布写入前会请求确认" selected={confirmTools} />,
onClick: () => onChange(true),
},
{
key: "automatic",
label: <ConfirmationOption icon={<RefreshCw className="size-4" />} title="自动确认" description="Agent 会自动执行画布写入操作" selected={!confirmTools} />,
onClick: () => onChange(false),
},
],
}}
>
<button type="button" className="flex h-9 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition" style={{ background: theme.toolbar.activeBg, color: theme.node.text }} aria-label="选择工具确认模式">
{confirmTools ? <Hand className="size-3.5" /> : <RefreshCw className="size-3.5" />}
<span>{confirmTools ? "手动确认" : "自动确认"}</span>
<ChevronUp className="size-3 opacity-50" />
</button>
</Dropdown>
);
}
function ConfirmationOption({ icon, title, description, selected }: { icon: ReactNode; title: string; description: string; selected: boolean }) {
return (
<div className="flex min-w-64 items-start gap-3 py-1">
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{title}</span>
<span className="mt-0.5 block text-xs leading-5 opacity-60">{description}</span>
</span>
{selected ? <Check className="mt-0.5 size-4 shrink-0" /> : null}
</div>
);
}
+43 -80
View File
@@ -1,10 +1,9 @@
import { useEffect, useState, type ReactNode } from "react";
import { Button, Image } from "antd";
import { Brain, CheckCircle2, ChevronDown, Circle, CircleAlert, FilePenLine, FileText, ListChecks, LoaderCircle, Search, TerminalSquare, UserRound, Wrench, XCircle } from "lucide-react";
import { Brain, CheckCircle2, ChevronDown, Circle, CircleAlert, FilePenLine, FileText, ListChecks, LoaderCircle, Search, TerminalSquare, Wrench, XCircle } from "lucide-react";
import { Streamdown } from "streamdown";
import { canvasThemes } from "@/lib/canvas-theme";
import type { LocalUser } from "@/stores/use-user-store";
export type AgentChatAttachment = { id: string; name: string; url: string };
export type AgentChatMessageItem = {
@@ -19,7 +18,7 @@ export type AgentChatMessageItem = {
streamId?: string;
};
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: AgentChatMessageItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: { item: AgentChatMessageItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
const isUser = item.role === "user";
const isSystem = item.role === "system";
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
@@ -35,18 +34,12 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
}
if (item.role === "tool") {
if (objectField(item.detail, "status") === "pending") return <AgentPendingToolCard summary={item.text} detail={item.detail} theme={theme} onReject={() => onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />;
return (
<div className="flex items-start gap-3">
<AgentAvatar theme={theme} />
<AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />
</div>
);
return <AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />;
}
return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentAvatar theme={theme} /> : null}
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
<div
className={isUser ? "min-w-0 max-w-[82%] py-1 text-right text-sm leading-6" : "min-w-0 flex-1 text-left text-sm leading-6"}
className={isUser ? "min-w-0 max-w-[82%] py-1 text-right text-sm leading-6" : "min-w-0 w-full text-left text-sm leading-6"}
style={{ color }}
>
{isUser ? (
@@ -57,7 +50,6 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} alignRight={isUser} /> : null}
{item.meta ? <div className={`mt-1 text-[11px] tabular-nums opacity-55 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null}
</div>
{isUser ? <AgentUserAvatar user={user} theme={theme} /> : null}
</div>
);
}
@@ -65,33 +57,28 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) {
const view = userDetail(detail);
return (
<div className="flex items-start gap-3">
<AgentAvatar theme={theme} />
<div className="min-w-0 flex-1 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(217,119,6,.28)", background: "rgba(217,119,6,.025)", color: theme.node.text }}>
<details className="group">
<summary className={`flex list-none items-start gap-2.5 ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
<CircleAlert className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium leading-5">
<span></span>
{view ? <ChevronDown className="ml-auto size-3.5 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
</div>
<div className="mt-1 text-sm leading-5" style={{ color: theme.node.muted }}>{summary}</div>
</div>
</summary>
{view ? <AgentDetailBlock detail={view} theme={theme} /> : null}
</details>
{onReject || onApprove ? (
<div className="mt-3 flex justify-end gap-2 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<Button danger type="text" className="!h-8" icon={<XCircle className="size-3.5" />} onClick={() => onReject?.()}>
</Button>
<Button type="text" className="!h-8" icon={<CheckCircle2 className="size-3.5" />} style={{ color: "#16a34a" }} onClick={() => onApprove?.()}>
</Button>
<div className="min-w-0 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(217,119,6,.28)", background: "rgba(217,119,6,.025)", color: theme.node.text }}>
<details className="group">
<summary className={`list-none ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
<div className="flex min-w-0 items-center gap-2 text-sm font-medium leading-5">
<CircleAlert className="size-4 shrink-0 text-amber-600" />
<span className="min-w-0 flex-1"></span>
{view ? <ChevronDown className="size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
</div>
) : null}
</div>
<div className="mt-1 pl-6 text-sm leading-5" style={{ color: theme.node.muted }}>{summary}</div>
</summary>
{view ? <div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
</details>
{onReject || onApprove ? (
<div className="mt-3 flex justify-end gap-2 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<Button danger type="text" className="!h-8" icon={<XCircle className="size-3.5" />} onClick={() => onReject?.()}>
</Button>
<Button type="text" className="!h-8" icon={<CheckCircle2 className="size-3.5" />} style={{ color: "#16a34a" }} onClick={() => onApprove?.()}>
</Button>
</div>
) : null}
</div>
);
}
@@ -103,23 +90,19 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
const view = userDetail(detail);
const kind = String(objectField(detail, "kind") || "");
return (
<details className="group min-w-0 flex-1 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
<summary className={`flex list-none items-start gap-2.5 ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
<span className="mt-0.5 shrink-0" style={{ color: state.color }}>{toolIcon(kind, state.icon)}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium leading-5">
<span className="min-w-0 truncate">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-normal" style={{ color: state.color }}>
{state.label}
</span>
{view ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
</div>
<div className={`mt-1 whitespace-pre-wrap break-words text-sm leading-5 ${kind === "command" ? "font-mono text-[12px]" : ""}`} style={{ color: state.isError ? state.color : theme.node.muted }}>
{text}
</div>
<details className="group min-w-0 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
<summary className={`list-none ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
<div className="flex min-w-0 items-center gap-2 text-sm leading-5">
<span className="shrink-0" style={{ color: state.color }}>{toolIcon(kind, state.icon)}</span>
<span className="min-w-0 truncate font-medium">{title}</span>
<span className="shrink-0 text-[11px]" style={{ color: state.color }}>{state.label}</span>
{view ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
</div>
<div className={`mt-1 whitespace-pre-wrap break-words pl-6 text-sm leading-5 ${kind === "command" ? "font-mono text-[12px]" : ""}`} style={{ color: state.isError ? state.color : theme.node.muted }}>
{text}
</div>
</summary>
{view ? <AgentDetailBlock detail={view} theme={theme} /> : null}
{view ? <div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
</details>
);
}
@@ -163,16 +146,13 @@ export function AgentWorkingMessage({ text, activityKey, theme }: { text: string
return () => window.clearInterval(timer);
}, [activityKey]);
return (
<div className="flex items-start gap-3">
<AgentAvatar theme={theme} />
<div className="min-w-0 flex-1 py-1" aria-live="polite">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm" style={{ color: theme.node.muted }}>
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
<span className="min-w-0">{text}</span>
{elapsed >= 5 ? <span className="shrink-0 text-[11px] tabular-nums opacity-60">{waitingTime(elapsed)}</span> : null}
</div>
{elapsed >= 30 ? <div className="mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}></div> : null}
<div className="min-w-0 py-1" aria-live="polite">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm" style={{ color: theme.node.muted }}>
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
<span className="min-w-0">{text}</span>
{elapsed >= 5 ? <span className="shrink-0 text-[11px] tabular-nums opacity-60">{waitingTime(elapsed)}</span> : null}
</div>
{elapsed >= 30 ? <div className="mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}></div> : null}
</div>
);
}
@@ -222,23 +202,6 @@ function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeo
);
}
function AgentAvatar({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<span className="grid size-8 shrink-0 place-items-center" role="img" aria-label="OpenAI">
<span className="size-5 opacity-80" style={{ background: theme.node.text, WebkitMask: "url(/icons/openai.svg) center / contain no-repeat", mask: "url(/icons/openai.svg) center / contain no-repeat" }} />
</span>
);
}
function AgentUserAvatar({ user, theme }: { user: LocalUser | null; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const avatarUrl = user?.avatarUrl?.trim();
return (
<span className="grid size-8 shrink-0 place-items-center overflow-hidden rounded-full" style={{ color: theme.node.text }}>
{avatarUrl ? <img src={avatarUrl} alt="" className="size-full object-cover" referrerPolicy="no-referrer" /> : <UserRound className="size-4" />}
</span>
);
}
function AgentMessageAttachments({ attachments, alignRight }: { attachments: AgentChatAttachment[]; alignRight?: boolean }) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
return (
+3 -5
View File
@@ -6,7 +6,6 @@ import { motion, useSpring, useTransform } from "motion/react";
import { canvasThemes } from "@/lib/canvas-theme";
import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops";
import { useAgentStore, type AgentChatItem, type AgentPendingToolCall, type AgentTokenUsage } from "@/stores/use-agent-store";
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
import { AgentChatMessage, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message";
import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters";
@@ -28,7 +27,6 @@ export function AgentChatTimeline({
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);
@@ -56,7 +54,7 @@ export function AgentChatTimeline({
<div className="relative min-h-0 flex-1">
<div ref={listRef} className="thin-scrollbar h-full select-text space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
{messages.map((item) => (
isPlanMessage(item) ? null : <AgentChatMessageRow key={item.id} item={item} theme={theme} user={user} />
isPlanMessage(item) ? null : <AgentChatMessageRow key={item.id} item={item} theme={theme} />
))}
{pendingTool ? (
<AgentPendingToolCard
@@ -96,10 +94,10 @@ export function AgentTaskProgress({ theme, busy }: { theme: (typeof canvasThemes
);
}
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme, user }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null }) {
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<div style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}>
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} user={user} />
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} />
</div>
);
});
+1 -6
View File
@@ -1,6 +1,6 @@
import { useState, type PointerEvent as ReactPointerEvent } from "react";
import { Bot, PanelRightClose } from "lucide-react";
import { Button, Switch, Tooltip } from "antd";
import { Button, Tooltip } from "antd";
import { motion } from "motion/react";
import { LocalAgentPanel } from "./local-agent-panel";
@@ -17,7 +17,6 @@ export function AgentPanel() {
const panelMounted = useAgentStore((state) => state.panelMounted);
const panelOpen = useAgentStore((state) => state.panelOpen);
const panelClosing = useAgentStore((state) => state.panelClosing);
const confirmTools = useAgentStore((state) => state.confirmTools);
const setAgentState = useAgentStore((state) => state.setAgentState);
const closePanel = useAgentStore((state) => state.closePanel);
@@ -72,10 +71,6 @@ export function AgentPanel() {
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<label className="flex items-center gap-1.5 text-xs" style={{ color: theme.node.muted }}>
<Switch size="small" checked={confirmTools} onChange={(confirmTools) => setAgentState({ confirmTools })} />
</label>
<Tooltip title="收起对话">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} />
</Tooltip>
@@ -806,6 +806,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
onStop={stopTurn}
onAddFiles={addAttachments}
onRemoveAttachment={removeAttachment}
confirmTools={confirmTools}
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
left={
attachments.length ? (
<span className="text-[11px]" style={{ color: theme.node.muted }}>
+1 -1
View File
@@ -80,7 +80,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
workspacePath: "",
loadingThreads: false,
activeTab: "setup",
confirmTools: true,
confirmTools: false,
activity: "就绪",
connectError: "",
pendingTool: null,