diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c88da..a13a281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [新增] 新增网页版Agent Loop模式。 + ## v0.3.0 - 2026-06-15 + [新增] 新增canvas-agent通过codex操作画布。 diff --git a/canvas-agent/src/config.ts b/canvas-agent/src/config.ts index e9db514..48d3a77 100644 --- a/canvas-agent/src/config.ts +++ b/canvas-agent/src/config.ts @@ -7,7 +7,7 @@ export const DEFAULT_PORT = 17371; export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas"); export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json"); export const VERSION = readPackageVersion(); -export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。"; +export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。"; export type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] }; export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record }; diff --git a/canvas-agent/src/schemas.ts b/canvas-agent/src/schemas.ts index 339ae46..b60fd1c 100644 --- a/canvas-agent/src/schemas.ts +++ b/canvas-agent/src/schemas.ts @@ -37,6 +37,7 @@ export const canvasOpSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("add_node"), nodeType: nodeTypeSchema.optional(), id: z.string().optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), position: positionSchema.optional(), metadata: recordSchema.optional() }).passthrough(), z.object({ type: z.literal("update_node"), id: z.string(), patch: recordSchema.optional(), metadata: recordSchema.optional() }).passthrough(), z.object({ type: z.literal("delete_node"), id: z.string().optional(), ids: z.array(z.string()).optional() }).passthrough(), + z.object({ type: z.literal("delete_connections"), id: z.string().optional(), ids: z.array(z.string()).optional(), all: z.boolean().optional() }).passthrough(), z.object({ type: z.literal("connect_nodes"), id: z.string().optional(), fromNodeId: z.string(), toNodeId: z.string() }).passthrough(), z.object({ type: z.literal("set_viewport"), viewport: viewportSchema }).passthrough(), z.object({ type: z.literal("select_nodes"), ids: z.array(z.string()) }).passthrough(), @@ -105,7 +106,7 @@ export const toolDescriptions: Record = { canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。", canvas_get_selection: "读取当前网页画布选中的节点。", canvas_export_snapshot: "导出当前画布快照,用于理解布局。", - canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、connect_nodes、set_viewport、select_nodes、run_generation。", + canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。", canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。", canvas_create_text_node: "在当前画布创建单个文本节点。", canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。", diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index fdfa655..931f18e 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,12 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 画布右上角只保留一个 `Agent` 入口,公共顶部固定为 Agent 标题、“网站 / 本机”切换、工具确认和统一收起按钮,切换时只替换面板内部 tab 区且不改变面板宽度:网站 Agent 使用当前文本模型生成画布 ops,不依赖本地 Codex/Claude Code 或 Canvas Agent;本机 Agent 继续连接本地 Codex。两侧对话复用同一套消息、`working...`、工具卡、二级 tab 和底部输入框 UI;网站 Agent 去掉对话/生图/操作模式切换,内部 tab 改为“连接配置 / 对话 / 历史”,对话里去掉重试和插入画布按钮,历史列表改为与本机模式一致的卡片风格,请求时发送压缩后的当前画布节点、连线、选区和视口,模型返回 JSON 后复用现有画布操作协议新增、更新、删除、连接节点、调整视口、选择节点或触发生成,生图需求应通过创建提示词节点、生成配置节点并触发画布生成工具完成;需要验证单入口打开/关闭、网站/本机切换、模型配置缺失提示、网站 Agent 流式回复、工具卡展示、在线操作结果落到画布、图片生成能走画布节点工具、历史记录恢复和错误提示。 +- Agent 执行画布操作时会忽略空 ops、无 type 操作和缺少必要字段的选择/连线/视口/生成操作,避免在线模型返回不完整 JSON 时触发 `filter` 读取错误;需要验证网站 Agent 重新整理画布布局时不会再因异常 ops 崩溃。 +- 网站 Agent 的工具确认开关已接入画布操作执行:开启时模型返回 ops 后先显示确认工具调用卡片,批准后才执行,拒绝会取消;执行后会按画布前后状态判断是否真的生效,未生效时会区分没有连线可删、连线已存在、目标节点不存在、选区/视图已是目标状态等原因并写入日志;删除生成配置节点支持用 `nodeType:"config"` 删除全部配置节点,模型未写对删除目标但用户意图明确时会按当前画布 config 节点补齐删除 ids,需要验证开启确认时删除配置节点必须先确认且实际删除。 +- 网站 Agent 在工具确认前的助手回复会保留模型的计划和过程说明,只把“已完成/已连接/已整理”等完成语气改为“准备执行/准备连接/准备整理”等待确认语气;用户明确要求连线但模型未返回可执行连线时,会按当前画布非配置节点从左到右补齐 `connect_nodes`,需要验证“给他们连线”会先确认再实际连接。 +- 网站 Agent 新增“日志”tab,会记录请求摘要、模型原始回复、解析结果、归一化操作、批准/拒绝和执行前后状态,并支持一键复制日志;画布操作协议新增 `delete_connections`,用户明确要求删除/清空画布连线时会补齐 `delete_connections all:true`,需要验证“把画布里的连线都删除”会先确认再实际删除。 +- 网站 Agent 改为轻量 agent loop:模型返回 ops 后执行工具,执行成功会把最新画布快照和执行结果再次发送给模型,让模型判断任务完成或继续返回下一步 ops;最多连续 4 步,工具确认开启时每一步都需要用户批准,重复返回同一组 ops 时会停止,需要验证多步任务不会停在第一轮。 - 本地 Agent 对话流里的工具调用卡片改为更宽的状态卡:等待确认时显示橙色提示和“批准执行 / 拒绝执行”按钮,执行成功显示绿色“已批准执行/执行完成”,拒绝或失败显示红色状态,需要验证画布操作确认、拒绝、执行失败和读取类工具结果展示。 - 本地 Agent 接入 MVP:新增 `canvas-agent/` 本机 Node 服务,源码使用 TypeScript,HTTP 路由使用 Express,MCP 协议层使用官方 `@modelcontextprotocol/sdk`,工具入参使用 `zod`;支持仓库内 `cd canvas-agent && npm install && npm run build && node dist/index.js` 或发布后 `npx -y @basketikun/canvas-agent` 启动并输出本机地址和 token;新增 GitHub Actions 自动发布 `@basketikun/canvas-agent`,发布判断只读取 `canvas-agent/package.json` 里的包版本,不跟根目录 `VERSION` 绑定;根 README 已增加本地 Canvas Agent 入口;Canvas Agent 默认监听 `127.0.0.1`,使用 token 连接并记录允许的网页 Origin;画布右上角新增 `Agent` 面板,默认只展示 Codex,顶部以连接、对话、历史、日志 tab 切换,进入时默认停在连接 tab,点击后直接高亮对应 tab,连接配置页展示 Codex 前置条件、npm 安装、启动和网页连接步骤,地址或 token 为空、地址格式错误、连接失败时会在连接页保留失败状态和原因,可拖拽调整宽度,并与右侧画布助手一致支持打开/收起宽度动画;Agent 面板首次打开后收起不卸载,网页侧对话、日志、连接配置、等待状态和待确认工具调用会放在 Zustand 内存 store 中保留,断开连接会清空当前对话和历史列表;Canvas Agent 会在本机配置中按画布记录 Codex 工作空间和 active thread,默认工作目录为 `~/.infinite-canvas/codex-workspaces/<画布ID>`,前端历史视图直接放在 Agent 面板内,只展示当前画布默认工作目录,可刷新该目录下 Codex thread 列表、新建对话、恢复旧对话、删除记录,并通过 `thread/read includeTurns` 回填消息,删除记录前会二次确认;以对话气泡展示用户消息、Codex 文本、工具调用和错误,前端按 Codex `type/item/usage` 事件格式读取 `agent_message`、工具结果和 `turn.completed` 状态,收到真实 `item.updated` 时用同一个消息 ID 流式更新 assistant 文本,未收到 `item.updated` 时不做伪流式,原始事件 JSON 收进“运行日志”tab,可切换排查日志和原始 JSON,排查日志会合并本地 Agent 地址、连接状态、等待状态、pending tool、发送/接收关键节点,支持复制排查日志和最近错误,日志只通过顶部日志 tab 查看;连接成功、连接失败和本轮结束不再写入对话区,连接成功时会清理之前残留的连接错误消息,状态留在面板头部和连接 tab,运行日志不再记录 item.updated 和 reasoning 噪声事件;对话区去掉单独背景色并融入面板背景,消息内容裸文本展示,不再显示 `Codex`/`You` 名称,Codex 消息保留透明背景 OpenAI 图标,用户消息保留透明背景用户头像或默认用户图标,回复前显示循环打字机式 `working...` 等待状态;工具调用和授权状态改为对话流里的轻量小卡片,`canvas_apply_ops` 在执行前通过内联确认卡片让用户取消或执行,工具卡片会合并执行结果并可展开查看输入、输出和授权详情;底部输入区改为一体式对话框,图片上传入口和发送按钮都在框内,支持选择或粘贴图片、显示附件体积、发送前限制约 30MB,并在用户消息中显示缩略图,图片会通过本机 Canvas Agent 临时写入文件后作为 app-server `localImage` 输入传给 Codex,Canvas Agent 单次请求体限制为 30MB;Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 事件流,按当前画布 active thread 发送消息,未绑定时新建 thread,恢复旧对话后继续复用该 thread,并在 app-server 配置中注入 `infinite-canvas` MCP 和 `default_tools_approval_mode = "approve"`,侧边栏无需用户预先全局 `codex mcp add` 也可调用画布工具;Claude Code Adapter 代码暂保留但前端入口先隐藏;画布可响应 `canvas_apply_ops` 工具调用来新增/更新/删除/连接节点、选中节点和调整视口,执行后可撤销最近一次工具操作;Canvas Agent 内置 MCP stdio 模式并向 Codex 返回画布工具使用说明,Codex 终端仍可通过 `codex mcp add infinite-canvas -- node /path/to/infinite-canvas/canvas-agent/dist/index.js mcp` 或发布后的 `codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp` 调用 `canvas_get_state`、`canvas_get_selection`、`canvas_export_snapshot`、`canvas_apply_ops`、`canvas_create_node`、`canvas_create_text_node`、`canvas_create_text_nodes`、`canvas_create_config_node`、`canvas_create_image_prompt_flow`、`canvas_create_generation_flow`、`canvas_generate_text`、`canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`、`canvas_run_generation`、节点更新、移动、缩放、删除、连线、选择和视口工具,Claude Code 可通过 `claude mcp add --scope user --transport stdio infinite-canvas -- npx -y @basketikun/canvas-agent mcp` 接入同一组工具;需要验证浏览器连接、侧边栏自动注入 MCP 后的 Codex 工具调用、终端 Codex MCP 工具调用、工具确认/取消/撤销、每画布工作空间隔离、历史列表读取、历史消息回填、历史记录删除确认、Codex thread 复用、运行日志 tab 复制、图片附件预览和断开重连提示。 - 视频创作台创建视频任务成功后会立即写入左侧生成记录,状态显示为“生成中”;刷新页面后会读取本地任务 ID 继续轮询,任务成功或失败后更新同一条记录,需要验证 OpenAI 视频接口和 Seedance 任务接口的刷新恢复。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 4fa3aa1..7226307 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react"; import { useParams, useRouter } from "next/navigation"; -import { BookOpen, Bot, Home, ImageIcon, Images, List, Menu, MessageSquare, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react"; +import { BookOpen, Bot, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react"; import { saveAs } from "file-saver"; import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image"; @@ -27,7 +27,6 @@ import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-conne import { CanvasConfigComposer } from "../components/canvas-config-composer"; import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel"; import { CanvasAssistantPanel } from "../components/canvas-assistant-panel"; -import { CanvasLocalAgentPanel } from "../components/canvas-local-agent-panel"; import { CanvasNodeContextMenu } from "../components/canvas-context-menu"; import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog"; import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog"; @@ -46,6 +45,7 @@ import { CanvasZoomControls } from "../components/canvas-zoom-controls"; import { useCanvasStore } from "../stores/use-canvas-store"; import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops"; import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references"; +import type { CanvasAgentMode } from "../components/canvas-agent-chat-ui"; import { CanvasNodeType, type CanvasAssistantImage, @@ -292,8 +292,7 @@ function InfiniteCanvasPage() { const [previewNodeId, setPreviewNodeId] = useState(null); const [assistantCollapsed, setAssistantCollapsed] = useState(true); const [assistantMounted, setAssistantMounted] = useState(false); - const [localAgentCollapsed, setLocalAgentCollapsed] = useState(true); - const [localAgentMounted, setLocalAgentMounted] = useState(false); + const [agentMode, setAgentMode] = useState("online"); const [agentUndoSnapshot, setAgentUndoSnapshot] = useState(null); const [titleEditing, setTitleEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(""); @@ -659,10 +658,11 @@ function InfiniteCanvasPage() { [connections, currentProject?.title, nodes, projectId, selectedNodeIds, viewport], ); const applyAgentOps = useCallback( - (ops: CanvasAgentOp[]) => { + (ops?: CanvasAgentOp[]) => { + const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : []; const before = { projectId, title: currentProject?.title || "未命名画布", nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current }; - const generationOps = ops.filter((op): op is Extract => op.type === "run_generation"); - const next = applyCanvasAgentOps(before, ops.filter((op) => op.type !== "run_generation")); + const generationOps = safeOps.filter((op): op is Extract => op.type === "run_generation" && Boolean(op.nodeId)); + const next = applyCanvasAgentOps(before, safeOps.filter((op) => op.type !== "run_generation")); nodesRef.current = next.nodes; connectionsRef.current = next.connections; selectedNodeIdsRef.current = new Set(next.selectedNodeIds); @@ -2337,13 +2337,15 @@ function InfiniteCanvasPage() { [insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width], ); - const localAgentOpen = localAgentMounted && !localAgentCollapsed; - const openLocalAgent = () => { - setLocalAgentMounted(true); - setLocalAgentCollapsed(false); + const assistantOpen = assistantMounted && !assistantCollapsed; + const openAgent = (mode: CanvasAgentMode = agentMode) => { + setAgentMode(mode); + setAssistantMounted(true); + setAssistantCollapsed(false); }; - const closeLocalAgent = () => { - setLocalAgentCollapsed(true); + const closeAgent = () => { + setAssistantCollapsed(true); + setAssistantMounted(false); }; if (!projectLoaded) return ; @@ -2368,13 +2370,8 @@ function InfiniteCanvasPage() { onImportImage={() => handleUploadRequest()} onUndo={undoCanvas} onRedo={redoCanvas} - assistantCollapsed={assistantCollapsed} - localAgentOpen={localAgentOpen} - onExpandAssistant={() => { - setAssistantMounted(true); - setAssistantCollapsed(false); - }} - onToggleLocalAgent={() => (localAgentOpen ? closeLocalAgent() : openLocalAgent())} + agentOpen={assistantOpen} + onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())} /> setAssistantCollapsed(true)} onCollapse={() => setAssistantMounted(false)} /> ) : null} - {localAgentMounted ? ( - - ) : null} ); } @@ -2705,10 +2696,8 @@ function CanvasTopBar({ onImportImage, onUndo, onRedo, - assistantCollapsed, - localAgentOpen, - onExpandAssistant, - onToggleLocalAgent, + agentOpen, + onToggleAgent, }: { title: string; titleDraft: string; @@ -2726,10 +2715,8 @@ function CanvasTopBar({ onImportImage: () => void; onUndo: () => void; onRedo: () => void; - assistantCollapsed: boolean; - localAgentOpen: boolean; - onExpandAssistant: () => void; - onToggleLocalAgent: () => void; + agentOpen: boolean; + onToggleAgent: () => void; }) { const colorTheme = useThemeStore((state) => state.theme); const theme = canvasThemes[colorTheme]; @@ -2826,25 +2813,12 @@ function CanvasTopBar({ - {assistantCollapsed ? ( - <> - - - ) : null} setShortcutsOpen(false)} footer={null} centered> @@ -2997,7 +2971,6 @@ async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) { session.messages.map(async (message) => ({ ...message, references: await Promise.all((message.references || []).map(hydrateItem)), - images: await Promise.all((message.images || []).map(hydrateItem)), })), ), })), diff --git a/web/src/app/(user)/canvas/components/canvas-agent-chat-ui.tsx b/web/src/app/(user)/canvas/components/canvas-agent-chat-ui.tsx new file mode 100644 index 0000000..dbe4a68 --- /dev/null +++ b/web/src/app/(user)/canvas/components/canvas-agent-chat-ui.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Button, Tooltip } from "antd"; +import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react"; + +import { canvasThemes } from "@/lib/canvas-theme"; +import type { AuthUser } from "@/services/api/auth"; + +export type CanvasAgentChatAttachment = { id: string; name: string; url: string }; +export type CanvasAgentMode = "online" | "local"; +export type CanvasAgentChatMessage = { + id: string; + role: "user" | "assistant" | "system" | "tool" | "error"; + title?: string; + text: string; + meta?: string; + detail?: unknown; + attachments?: CanvasAgentChatAttachment[]; +}; + +const WORKING_TEXT = "working..."; + +export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: AuthUser | null; 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; + if (isSystem) { + return ( +
+
+ {item.text} + {item.meta ? {item.meta} : null} +
+
+ ); + } + if (item.role === "tool") { + if (objectField(item.detail, "status") === "pending") return onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />; + return ( +
+ + +
+ ); + } + return ( +
+ {!isUser ? : null} +
+
{item.text}
+ {item.attachments?.length ? : null} + {item.meta ?
{item.meta}
: null} +
+ {isUser ? : null} +
+ ); +} + +export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) { + return ( +
+ +
+
+ +
+ + + +
+
+ 确认工具调用 + + 等待确认 + + {detail ? 详情 : null} +
+
+ {summary} +
+
+
+
+ {detail ? : null} +
+ {onReject || onApprove ? ( +
+ + +
+ ) : null} +
+
+ ); +} + +export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { + const state = toolCardState(title, text, detail); + return ( +
+ +
+ + {state.icon} + +
+
+ {title} + + {state.label} + + {detail ? 详情 : null} +
+
+ {text} +
+
+
+
+ {detail ? : null} +
+ ); +} + +export function AgentWorkingMessage({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { + const [length, setLength] = useState(1); + useEffect(() => { + const timer = window.setInterval(() => setLength((value) => (value >= WORKING_TEXT.length + 4 ? 1 : value + 1)), 120); + return () => window.clearInterval(timer); + }, [setLength]); + return ( +
+ +
+
+ {WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))} +
+
+
+ ); +} + +export function AgentChatComposer({ + prompt, + attachments = [], + disabled, + sending, + placeholder, + theme, + onPromptChange, + onSubmit, + onAddFiles, + onRemoveAttachment, + left, +}: { + prompt: string; + attachments?: CanvasAgentChatAttachment[]; + disabled?: boolean; + sending?: boolean; + placeholder: string; + theme: (typeof canvasThemes)[keyof typeof canvasThemes]; + onPromptChange: (value: string) => void; + onSubmit: () => void; + onAddFiles?: (files: FileList | File[] | null) => void | Promise; + onRemoveAttachment?: (id: string) => void; + left?: ReactNode; +}) { + const fileInputRef = useRef(null); + const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length); + return ( +
event.stopPropagation()}> +
+ {attachments.length ? ( +
+ {attachments.map((item) => ( +
+ {item.name} + {onRemoveAttachment ? ( + + ) : null} +
+ ))} +
+ ) : null} +