@@ -1,21 +1,28 @@
"use client" ;
import { useEffect , useMemo , useRef , useState , type PointerEvent as ReactPointerEvent } from "react" ;
import { App , Button , Input , Modal , Segmented , Switch , Tooltip } from "antd" ;
import { useCallback , useEffect , useMemo , useRef , useState , type PointerEvent as ReactPointerEvent } from "react" ;
import { App , Button , Input , Segmented , Switch , Tooltip } from "antd" ;
import copyToClipboard from "copy-to-clipboard" ;
import { ArrowUp , Bot , Copy , ImagePlus , LoaderCircle , PlugZap , RotateCcw , Terminal , Trash2 , UserRound , Wrench , X } from "lucide-react" ;
import { ArrowUp , Bot , CheckCircle2 , CircleAlert , Copy , FolderOpen , History , ImagePlus , KeyRound , Link2 , LoaderCircle , PlugZap , Plus , RefreshCw , RotateCcw , Terminal , Trash2 , UserRound , Wrench , X , XCircle } from "lucide-react" ;
import { motion } from "motion/react" ;
import { canvasThemes } from "@/lib/canvas-theme" ;
import type { AuthUser } from "@/services/api/auth" ;
import { useThemeStore } from "@/stores/use-theme-store" ;
import { useUserStore } from "@/stores/use-user-store" ;
import { useCanvasAgentStore , type AgentAttachment , type AgentChatItem , type AgentEventLog , type AgentPendingToolCall } from "../stores/use-canvas-agent-store" ;
import { useCanvasAgentStore , type AgentAttachment , type AgentChatItem , type AgentEventLog , type AgentPanelTab , type AgentPendingToolCall , type AgentThreadSummary } from "../stores/use-canvas-agent-store" ;
import { summarizeCanvasAgentOps , type CanvasAgentOp , type CanvasAgentSnapshot } from "../utils/canvas-agent-ops" ;
const PANEL_MOTION_SECONDS = 0.5 ;
const MAX_ATTACHMENTS = 6 ;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024 ;
const WORKING_TEXT = "working..." ;
const AGENT_CONNECT_STEPS = [
{ title : "1. 本机已安装并登录 Codex" , text : "先确认本机终端里的 Codex 可以正常使用。" , command : "codex --version" } ,
{ title : "2. 安装 Canvas Agent" , text : "推荐全局安装,后续可以直接运行 canvas-agent。" , command : "npm i -g @basketikun/canvas-agent" } ,
{ title : "3. 启动本地 Agent" , text : "启动后终端会输出 Local URL 和 Connect token。" , command : "canvas-agent" } ,
{ title : "4. 回到网页连接" , text : "把终端输出的地址和 token 填到下面,点击连接。" } ,
] ;
type AgentEventPayload = {
agent? : string ;
@@ -29,12 +36,15 @@ type AgentEventPayload = {
type AgentEventItem = { id? : string ; type ? : string ; text? : 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 = { canvasId : string ; workspacePath : string ; activeThreadId? : string } ;
type AgentThreadsResponse = { ok? : boolean ; workspace? : AgentWorkspace ; data? : AgentThreadSummary [ ] } ;
type AgentThreadResponse = { ok? : boolean ; workspace? : AgentWorkspace ; thread? : AgentThreadSummary ; messages? : AgentChatItem [ ] } ;
export function CanvasLocalAgentPanel ( { snapshot , canUndoOps , collapsed , onApplyOps , onUndoOps , onCollapseStart } : { snapshot : CanvasAgentSnapshot ; canUndoOps : boolean ; collapsed : boolean ; onApplyOps : ( ops : CanvasAgentOp [ ] ) = > unknown ; onUndoOps : ( ) = > CanvasAgentSnapshot | null ; onCollapseStart : ( ) = > void } ) {
const theme = canvasThemes [ useThemeStore ( ( state ) = > state . theme ) ] ;
const user = useUserStore ( ( state ) = > state . user ) ;
const { message } = App . useApp ( ) ;
const { width , url , token , connected , enabled , prompt , attachments , sending , waiting , messages , eventLogs , confirmTools , logOpen , activity , pendingTool , setAgentState , addMessage : pushMessage , addEventLog : pushEventLog , clearEventLogs } = useCanvasAgentStore ( ) ;
const { message , modal } = App . useApp ( ) ;
const { width , url , token , connected , enabled , prompt , attachments , sending , waiting , messages , eventLogs , threads , activeThreadId , workspacePath , loadingThreads , activeTab , confirmTools , activity , connectError , pendingTool , setAgentState , addMessage : pushMessage , addEventLog : pushEventLog , clearEventLogs } = useCanvasAgentStore ( ) ;
const [ resizing , setResizing ] = useState ( false ) ;
const listRef = useRef < HTMLDivElement > ( null ) ;
const snapshotRef = useRef ( snapshot ) ;
@@ -45,7 +55,35 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
const errorLoggedRef = useRef ( false ) ;
const attachmentUrlsRef = useRef ( new Set < string > ( ) ) ;
const clientIdRef = useRef ( typeof crypto === "undefined" ? ` ${ Date . now ( ) } ` : crypto . randomUUID ( ) ) ;
const endpoint = useMemo ( ( ) = > url . replace ( /\/$/ , "" ) , [ url ] ) ;
const endpoint = useMemo ( ( ) = > url . trim ( ) . replace ( /\/$/ , "" ) , [ url ] ) ;
const tabStyle = ( tab : AgentPanelTab ) = > ( {
borderColor : activeTab === tab ? theme . node . text : "transparent" ,
color : activeTab === tab ? theme.node.text : theme.node.muted ,
} ) ;
const loadThreads = useCallback ( async ( ) = > {
const projectId = snapshotRef . current . projectId ;
if ( ( ! connectedRef . current && ! useCanvasAgentStore . getState ( ) . connected ) || ! projectId ) return ;
setAgentState ( { loadingThreads : true } ) ;
try {
const data = await fetchAgentJson < AgentThreadsResponse > ( endpoint , token , ` /agent/codex/threads?canvasId= ${ encodeURIComponent ( projectId ) } ` ) ;
const current = useCanvasAgentStore . getState ( ) ;
setAgentState ( {
threads : data.data || [ ] ,
workspacePath : data.workspace?.workspacePath || current . workspacePath ,
activeThreadId : data.workspace?.activeThreadId || current . activeThreadId ,
} ) ;
const nextThreadId = data . workspace ? . activeThreadId || current . activeThreadId ;
if ( nextThreadId && ! current . messages . length ) {
const thread = await fetchAgentJson < AgentThreadResponse > ( endpoint , token , ` /agent/codex/threads/ ${ encodeURIComponent ( nextThreadId ) } ` ) ;
setAgentState ( { messages : normalizeHistoryMessages ( thread . messages || [ ] ) } ) ;
}
} catch ( error ) {
addEventLog ( "读取历史失败" , error ) ;
} finally {
setAgentState ( { loadingThreads : false } ) ;
}
} , [ endpoint , setAgentState , token ] ) ;
useEffect ( ( ) = > {
snapshotRef . current = snapshot ;
@@ -73,7 +111,8 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
source . addEventListener ( "hello" , ( ) = > {
errorLoggedRef . current = false ;
connectedRef . current = true ;
setAgentState ( { connected : true , activity : "已连接" } ) ;
setAgentState ( { connected : true , activity : "已连接" , connectError : "" , messages : useCanvasAgentStore.getState ( ) . messages . filter ( ( item ) = > ! isConnectionErrorMessage ( item ) ) } ) ;
message . success ( "本地 Agent 已连接" ) ;
void postState ( endpoint , token , clientId , snapshotRef . current ) ;
} ) ;
source . addEventListener ( "tool_call" , ( event ) = > {
@@ -96,13 +135,18 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
} ) ;
source . addEventListener ( "agent_done" , ( ) = > {
setAgentState ( { activity : "完成" , waiting : false , sending : false } ) ;
void loadThreads ( ) ;
} ) ;
source . onerror = ( ) = > {
const wasConnected = connectedRef . current ;
if ( ! errorLoggedRef . current || wasConnected ) addMessage ( { role : "error" , text : wasConnected ? "本地 Agent 连接失败或已断开" : "无法连接本地 Agent ,请检查地址和 token" } ) ;
const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败 ,请检查地址和 token" ;
if ( ! errorLoggedRef . current || wasConnected ) {
addEventLog ( wasConnected ? "连接断开" : "连接失败" , { endpoint , error : text } ) ;
message . error ( text ) ;
}
errorLoggedRef . current = true ;
connectedRef . current = false ;
setAgentState ( { waiting : false , activity : "离线 " , connected : false } ) ;
clearAgentSession ( { activity : wasConnected ? "连接断开" : "连接失败 " , connected : false , connectError : text } ) ;
if ( ! wasConnected ) {
source . close ( ) ;
setAgentState ( { enabled : false } ) ;
@@ -113,7 +157,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
connectedRef . current = false ;
setAgentState ( { connected : false } ) ;
} ;
} , [ enabled , endpoint , setAgentState , token ] ) ;
} , [ enabled , endpoint , loadThreads , message , setAgentState , token ] ) ;
useEffect ( ( ) = > {
if ( connected ) void loadThreads ( ) ;
} , [ connected , loadThreads , snapshot . projectId ] ) ;
useEffect ( ( ) = > {
if ( ! connected ) return ;
@@ -134,8 +182,10 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
addMessage ( { role : "user" , text : text || "发送了图片" , attachments : files } ) ;
addEventLog ( "用户发送" , { text , attachments : files.map ( ( { name , type , size } ) = > ( { name , type , size } ) ) } ) ;
try {
const res = await fetch ( ` ${ endpoint } /agent/codex/turn?token= ${ encodeURIComponent ( token ) } ` , { method : "POST" , headers : { "content-type" : "application/json" } , body : JSON.stringify ( { prompt : requestPrompt , attachments : files.map ( ( { name , type , dataUrl } ) = > ( { name , type , dataUrl } ) ) } ) } ) ;
const res = await fetch ( ` ${ endpoint } /agent/codex/turn?token= ${ encodeURIComponent ( token ) } ` , { method : "POST" , headers : { "content-type" : "application/json" } , body : JSON.stringify ( { prompt : requestPrompt , canvasId : snapshotRef.current.projectId , threadId : useCanvasAgentStore.getState ( ) . activeThreadId || undefined , attachments : files.map ( ( { name , type , dataUrl } ) = > ( { name , type , dataUrl } ) ) } ) } ) ;
if ( ! res . ok ) throw new Error ( "本地 Agent 拒绝了请求" ) ;
const data = ( await res . json ( ) ) as { threadId? : string } ;
if ( data . threadId ) setAgentState ( { activeThreadId : data.threadId } ) ;
addEventLog ( "本地 Agent 已接收" , { status : res.status } ) ;
files . forEach ( ( item ) = > {
URL . revokeObjectURL ( item . url ) ;
@@ -203,7 +253,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
const runToolCall = async ( endpoint : string , token : string , payload : AgentPendingToolCall ) = > {
try {
const input : { ops? : CanvasAgentOp [ ] } = payload . input || { } ;
setAgentState ( { activity : payload.name === "canvas_apply_ops" ? "执行画布操作" : "读取画布" } ) ;
setAgentState ( { activity : payload.name === "canvas_apply_ops" ? "执行画布操作" : "读取画布" , waiting : true } ) ;
addEventLog ( toolName ( payload . name ) , payload , payload ) ;
const result = payload . name === "canvas_apply_ops" ? onApplyOpsRef . current ( input . ops || [ ] ) : snapshotRef . current ;
await postToolResult ( endpoint , token , clientIdRef . current , { requestId : payload.requestId , result } ) ;
@@ -214,7 +264,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
} catch ( error ) {
const message = error instanceof Error ? error . message : "画布操作失败" ;
setAgentState ( { activity : "工具失败" , waiting : false } ) ;
addMessage ( { role : "error " , title : "工具失败" , text : message , detail : payload } ) ;
addMessage ( { role : "tool " , title : "工具失败" , text : message , detail : payload } ) ;
await postToolResult ( endpoint , token , clientIdRef . current , { requestId : payload.requestId , error : message } ) ;
}
} ;
@@ -223,7 +273,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
if ( ! pendingTool ) return ;
await postToolResult ( endpoint , token , clientIdRef . current , { requestId : pendingTool.requestId , error : "用户取消了画布工具调用" } ) ;
setAgentState ( { activity : "已取消" , waiting : false } ) ;
addMessage ( { role : "tool" , title : "已取消 " , text : toolName ( pendingTool . name ) , detail : { requestId : pendingTool.requestId , name : pendingTool.name , input : pendingTool.input } } ) ;
addMessage ( { role : "tool" , title : "拒绝执行 " , text : toolName ( pendingTool . name ) , detail : { requestId : pendingTool.requestId , name : pendingTool.name , input : pendingTool.input } } ) ;
pendingToolRef . current = null ;
setAgentState ( { pendingTool : null } ) ;
} ;
@@ -244,6 +294,116 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
if ( connected ) void postState ( endpoint , token , clientIdRef . current , restored ) ;
} ;
const toggleAgentConnection = ( ) = > {
if ( enabled ) {
clearAgentSession ( { enabled : false , connected : false , activity : "离线" , connectError : "" } ) ;
return ;
}
if ( ! endpoint ) {
const text = "请填写本地 Agent 地址" ;
setAgentState ( { connectError : text } ) ;
message . warning ( text ) ;
return ;
}
if ( ! token . trim ( ) ) {
const text = "请填写 Agent token" ;
setAgentState ( { connectError : text } ) ;
message . warning ( text ) ;
return ;
}
try {
const parsed = new URL ( endpoint ) ;
if ( parsed . protocol !== "http:" && parsed . protocol !== "https:" ) throw new Error ( "invalid protocol" ) ;
} catch {
const text = "本地 Agent 地址格式不正确" ;
setAgentState ( { connectError : text } ) ;
message . warning ( text ) ;
return ;
}
errorLoggedRef . current = false ;
setAgentState ( { url : endpoint , token : token.trim ( ) , enabled : true , connected : false , activity : "连接中" , connectError : "" , activeTab : "setup" } ) ;
} ;
function clearAgentSession ( patch : Parameters < typeof setAgentState > [ 0 ] = { } ) {
setAgentState ( {
messages : [ ] ,
threads : [ ] ,
activeThreadId : "" ,
workspacePath : "" ,
loadingThreads : false ,
waiting : false ,
sending : false ,
pendingTool : null ,
. . . patch ,
} ) ;
pendingToolRef . current = null ;
}
const startNewThread = async ( ) = > {
const projectId = snapshotRef . current . projectId ;
if ( ! connected || ! projectId ) return ;
setAgentState ( { loadingThreads : true } ) ;
try {
const data = await fetchAgentJson < AgentThreadResponse > ( endpoint , token , "/agent/codex/threads/new" , { method : "POST" , headers : { "content-type" : "application/json" } , body : JSON.stringify ( { canvasId : projectId } ) } ) ;
setAgentState ( { activeThreadId : data.thread?.id || data . workspace ? . activeThreadId || "" , messages : [ ] , activeTab : "chat" , activity : "新对话" } ) ;
await loadThreads ( ) ;
} catch ( error ) {
addEventLog ( "新建对话失败" , error ) ;
message . error ( error instanceof Error ? error . message : "新建对话失败" ) ;
} finally {
setAgentState ( { loadingThreads : false } ) ;
}
} ;
const resumeThread = async ( threadId : string ) = > {
const projectId = snapshotRef . current . projectId ;
if ( ! connected || ! projectId || ! threadId ) return ;
setAgentState ( { loadingThreads : true } ) ;
try {
const data = await fetchAgentJson < AgentThreadResponse > ( endpoint , token , ` /agent/codex/threads/ ${ encodeURIComponent ( threadId ) } /resume ` , { method : "POST" , headers : { "content-type" : "application/json" } , body : JSON.stringify ( { canvasId : projectId } ) } ) ;
setAgentState ( { activeThreadId : data.thread?.id || threadId , messages : normalizeHistoryMessages ( data . messages || [ ] ) , activeTab : "chat" , activity : "已恢复会话" } ) ;
await loadThreads ( ) ;
} catch ( error ) {
addEventLog ( "恢复对话失败" , error ) ;
message . error ( error instanceof Error ? error . message : "恢复对话失败" ) ;
} finally {
setAgentState ( { loadingThreads : false } ) ;
}
} ;
const deleteThread = async ( threadId : string ) = > {
const projectId = snapshotRef . current . projectId ;
if ( ! connected || ! projectId || ! threadId ) return ;
setAgentState ( { loadingThreads : true } ) ;
try {
await fetchAgentJson ( endpoint , token , ` /agent/codex/threads/ ${ encodeURIComponent ( threadId ) } /delete ` , { method : "POST" , headers : { "content-type" : "application/json" } , body : JSON.stringify ( { canvasId : projectId } ) } ) ;
const current = useCanvasAgentStore . getState ( ) ;
setAgentState ( {
threads : current.threads.filter ( ( thread ) = > thread . id !== threadId ) ,
activeThreadId : current.activeThreadId === threadId ? "" : current . activeThreadId ,
messages : current.activeThreadId === threadId ? [ ] : current . messages ,
} ) ;
message . success ( "记录已删除" ) ;
} catch ( error ) {
addEventLog ( "删除对话失败" , error ) ;
message . error ( error instanceof Error ? error . message : "删除对话失败" ) ;
} finally {
setAgentState ( { loadingThreads : false } ) ;
}
} ;
const confirmDeleteThread = ( thread : AgentThreadSummary ) = > {
const label = thread . name || thread . preview || "未命名对话" ;
modal . confirm ( {
title : "删除对话记录" ,
content : ` 确定删除「 ${ label . length > 48 ? ` ${ label . slice ( 0 , 48 ) } ... ` : label } 」吗? ` ,
okText : "删除" ,
okType : "danger" ,
cancelText : "取消" ,
onOk : ( ) = > deleteThread ( thread . id ) ,
} ) ;
} ;
const startResize = ( event : ReactPointerEvent < HTMLDivElement > ) = > {
event . preventDefault ( ) ;
const startX = event . clientX ;
@@ -292,12 +452,14 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
const handleAgentEvent = ( event : AgentEventPayload ) = > {
if ( shouldLogAgentEvent ( event ) ) addEventLog ( eventTitle ( event ) , event , event ) ;
if ( event . type === "thread.started" && event . thread_id ) setAgentState ( { activeThreadId : event.thread_id } ) ;
const nextActivity = activityText ( event ) ;
if ( nextActivity ) setAgentState ( { activity : nextActivity } ) ;
if ( event . type === "turn.completed" || event . type === "turn.fail ed") setAgentState ( { waiting : false , sending : false } ) ;
if ( event . type === "turn.start ed" ) setAgentState ( { waiting : true } ) ;
if ( event . type === "turn.completed" || event . type === "turn.failed" || event . type === "error" ) setAgentState ( { waiting : false , sending : false } ) ;
const item = formatAgentEvent ( event ) ;
if ( item ) {
if ( item . role !== "tool" && event . type !== "item.updated ") setAgentState ( { waiting : false } ) ;
if ( item . role === "error ") setAgentState ( { waiting : false , sending : false } ) ;
addMessage ( item ) ;
}
} ;
@@ -320,7 +482,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
< div className = "absolute left-0 top-0 h-full w-1 cursor-col-resize transition hover:bg-current/20" onPointerDown = { startResize } / >
< header className = "flex h-14 items-center justify-between border-b px-4" style = { { borderColor : theme.node.stroke } } >
< div className = "flex min-w-0 items-center gap-2" >
< span className = "grid size-8 place-items-center rounded-lg" style = { { background : theme.node.fill } } >
< span className = "grid size-8 place-items-center rounded-lg" >
< Bot className = "size-4" / >
< / span >
< div className = "min-w-0" >
@@ -329,30 +491,40 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
Codex · { connected ? activity : "离线" }
< / div >
< / div >
< span className = "ml-1 rounded-full px-2 py-0.5 text-xs" style = { { background : connected ? "rgba(34,197,94,.14) " : theme . node . fill , color : connected ? "#16a34a" : theme . node . muted } } >
< span className = "ml-1 rounded-full border px-2 py-0.5 text-xs" style = { { borderColor : connected ? "#16a34a " : theme . node . stroke , color : connected ? "#16a34a" : theme . node . muted } } >
{ connected ? "在线" : "离线" }
< / span >
< / div >
< Button type = "text" icon = { < X className = "size-4" / > } onClick = { onCollapseStart } / >
< / header >
< div className = "grid gap-2 border-b p-3" style = { { borderColor : theme.node.stroke } } >
< div className = "grid grid-cols-[minmax(0,1fr)_88px] gap-2 " >
< Input value = { url } onChange = { ( event ) = > setAgentState ( { url : event.target.value } ) } placeholder = "本地 Agent 地址" / >
< Button type = { enabled ? "default" : "primary" } icon = { < PlugZap className = "size-4" / > } onClick = { ( ) = > setAgentState ( { enabled : ! enabled } ) } >
{ enabled ? "断开" : "连接" }
< / Button >
< / div >
< Input.Password value = { token } onChange = { ( event ) = > setAgentState ( { token : event.target.value } ) } placeholder = "Agent token" / >
< div className = "flex flex-wrap items-center justify-between gap-2" >
< label className = "flex items-center gap-2 text-sm" style = { { color : theme.node.muted } } >
< Switch size = "small " checked = { confirmTools } onChange = { ( confirmTools ) = > setAgentState ( { confirmTools } ) } / >
工 具 确 认
< / label >
< div className = "flex items-center gap-1.5" >
< Button size = "small" type = "text" icon = { < Terminal className = "size-3.5" / > } onClick = { ( ) = > setAgentState ( { logOpen : true } ) } >
运 行 日 志 { eventLogs . length ? ` ${ eventLogs . length } ` : "" }
< / Button >
< div className = "border-b px -3" style = { { borderColor : theme.node.stroke } } >
< div className = "flex min-h-11 items-center justify-between gap-3 " >
< nav className = "thin-scrollbar flex min-w-0 flex-1 items-center gap-3 overflow-x-auto text-sm" role = "tablist" aria-label = "Agent 面板" >
< button type = "button" role = "tab" aria-selected = { activeTab === "setup" } className = { ` inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${ activeTab === "setup" ? "font-medium" : "font-normal" } ` } style = { tabStyle ( "setup" ) } onClick = { ( ) = > setAgentState ( { activeTab : "setup" } ) } >
< PlugZap className = "size-3.5" / >
连 接
< / button >
< button type = "button" role = "tab" aria-selected = { activeTab === "chat" } className = { ` h-11 shrink-0 border-b-2 px-0.5 transition ${ activeTab === "chat" ? "font-medium" : "font-normal" } ` } style = { tabStyle ( "chat" ) } onClick = { ( ) = > setAgentState ( { activeTab : "chat" } ) } >
对 话
< / button >
< button type = "button" role = "tab " aria-selected = { activeTab === "history" } className = { ` inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${ activeTab === "history" ? "font-medium" : "font-normal" } ` } style = { tabStyle ( "history" ) } onClick = { ( ) = > {
setAgentState ( { activeTab : "history" } ) ;
void loadThreads ( ) ;
} } >
< History className = "size-3.5" / >
历 史 { threads . length ? ` ${ threads . length } ` : "" }
< / button >
< button type = "button" role = "tab" aria-selected = { activeTab === "log" } className = { ` inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${ activeTab === "log" ? "font-medium" : "font-normal" } ` } style = { tabStyle ( "log" ) } onClick = { ( ) = > setAgentState ( { activeTab : "log" } ) } >
< Terminal className = "size-3.5" / >
日 志 { eventLogs . length ? ` ${ eventLogs . length } ` : "" }
< / button >
< / nav >
< 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 >
< Button size = "small" type = "text" disabled = { ! canUndoOps } icon = { < RotateCcw className = "size-3.5" / > } onClick = { undoLastTool } >
撤 销
< / Button >
@@ -360,26 +532,53 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
< / div >
< / div >
< EventLogModal
logs = { eventLogs }
open = { logOpen }
theme = { theme }
context = { { endpoint , connected , enabled , activity , waiting , sending , messages : messages.length , pendingTool : pendingTool?.name } }
onClose = { ( ) = > setAgentState ( { logOpen : false } ) }
onClear = { clearEventLogs }
onCopied = { ( text ) = > message . success ( text ) }
onCopyBlocked = { ( text ) = > message . warning ( text ) }
/ >
< div ref = { listRef } className = "thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4" >
{ messages . map ( ( item ) = > (
< ChatMessage key = { item . id } item = { item } theme = { theme } user = { user } / >
) ) }
{ pendingTool ? < PendingToolCard tool = { pendingTool } theme = { theme } onReject = { rejectPendingTool } onApprove = { approvePendingTool } / > : null }
{ waiting ? < WorkingMessage theme = { theme } logs = { eventLogs . length } onOpenLog = { ( ) = > setAgentState ( { logOpen : true } ) } / > : null }
< / div >
< AgentComposer prompt = { prompt } attachments = { attachments } connected = { connected } sending = { sending || waiting } theme = { theme } onPromptChange = { ( prompt ) = > setAgentState ( { prompt } ) } onSubmit = { sendPrompt } onAddFiles = { addAttachments } onRemoveAttachment = { removeAttachment } / >
{ activeTab === "setup" ? (
< AgentConnectView
theme = { theme }
url = { url }
token = { token }
enabled = { enabled }
connected = { connected }
activity = { activity }
connectError = { connectError }
onUrlChange = { ( url ) = > setAgentState ( { url , connectError : "" } ) }
onTokenChange = { ( token ) = > setAgentState ( { token , connectError : "" } ) }
onToggleEnabled = { toggleAgentConnection }
/ >
) : activeTab === "history" ? (
< AgentHistoryView
theme = { theme }
threads = { threads }
activeThreadId = { activeThreadId }
workspacePath = { workspacePath }
loading = { loadingThreads }
connected = { connected }
onRefresh = { ( ) = > void loadThreads ( ) }
onNewThread = { ( ) = > void startNewThread ( ) }
onResumeThread = { ( threadId ) = > void resumeThread ( threadId ) }
onDeleteThread = { confirmDeleteThread }
/ >
) : activeTab === "log" ? (
< AgentLogView
logs = { eventLogs }
theme = { theme }
context = { { endpoint , connected , enabled , activity , waiting , sending , messages : messages.length , pendingTool : pendingTool?.name } }
onClear = { clearEventLogs }
onCopied = { ( text ) = > message . success ( text ) }
onCopyBlocked = { ( text ) = > message . warning ( text ) }
/ >
) : (
< >
< div ref = { listRef } className = "thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4" >
{ messages . map ( ( item ) = > (
< ChatMessage key = { item . id } item = { item } theme = { theme } user = { user } / >
) ) }
{ pendingTool ? < PendingToolCard tool = { pendingTool } theme = { theme } onReject = { rejectPendingTool } onApprove = { approvePendingTool } / > : null }
{ waiting && ! pendingTool ? < WorkingMessage theme = { theme } / > : null }
< / div >
< AgentComposer prompt = { prompt } attachments = { attachments } connected = { connected } sending = { sending || waiting } theme = { theme } onPromptChange = { ( prompt ) = > setAgentState ( { prompt } ) } onSubmit = { sendPrompt } onAddFiles = { addAttachments } onRemoveAttachment = { removeAttachment } / >
< / >
) }
< / motion.aside >
< / motion.div >
) ;
@@ -395,7 +594,7 @@ function AgentComposer({ prompt, attachments, connected, sending, theme, onPromp
{ attachments . length ? (
< div className = "thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1" >
{ attachments . map ( ( item ) = > (
< div key = { item . id } className = "group relative size-14 shrink-0 overflow-hidden rounded-xl border" style = { { borderColor : theme.node.stroke , background : theme.node.fill } } title = { item . name } >
< div key = { item . id } className = "group relative size-14 shrink-0 overflow-hidden rounded-xl border" style = { { borderColor : theme.node.stroke } } title = { item . name } >
< img src = { item . url } alt = { item . name } className = "size-full object-cover" / >
< button type = "button" className = "absolute right-1 top-1 grid size-5 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover:opacity-100" style = { { background : theme.toolbar.panel , borderColor : theme.node.stroke , color : theme.node.text } } onClick = { ( ) = > onRemoveAttachment ( item . id ) } aria-label = "移除图片" >
< X className = "size-3" / >
@@ -440,7 +639,7 @@ function AgentComposer({ prompt, attachments, connected, sending, theme, onPromp
) ;
}
function EventLogModal ( { logs , open , theme , context , onClose , onClear , onCopied , onCopyBlocked } : { logs : AgentEventLog [ ] ; open : boolean ; theme : ( typeof canvasThemes ) [ keyof typeof canvasThemes ] ; context : AgentLogContext ; onClose : ( ) = > void ; onClear : ( ) = > void ; onCopied : ( text : string ) = > void ; onCopyBlocked : ( text : string ) = > void } ) {
function AgentLogView ( { logs , theme , context , onClear , onCopied , onCopyBlocked } : { logs : AgentEventLog [ ] ; theme : ( typeof canvasThemes ) [ keyof typeof canvasThemes ] ; context : AgentLogContext ; onClear : ( ) = > void ; onCopied : ( text : string ) = > void ; onCopyBlocked : ( text : string ) = > void } ) {
const [ mode , setMode ] = useState < "text" | "json" > ( "text" ) ;
const textareaRef = useRef < HTMLTextAreaElement > ( null ) ;
const content = mode === "text" ? formatLogText ( logs , context ) : formatLogJson ( logs , context ) ;
@@ -455,40 +654,172 @@ function EventLogModal({ logs, open, theme, context, onClose, onClear, onCopied,
onCopyBlocked ( "已选中日志,请手动复制" ) ;
} ;
return (
< Modal title = "运行日志" open = { open } onCancel = { onClose } footer = { null } width = "min(920px, calc(100vw - 32px))" centered destroyOnHidden >
< div className = "space-y -3" >
< div className = "thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4" >
< div className = "flex min-h-full flex-col gap -3" >
< div >
< div className = "text-base font-semibold leading-6" > 运 行 日 志 < / div >
< / div >
< div className = "flex flex-wrap items-center justify-between gap-2" >
< Segmented size = "small" value = { mode } onChange = { ( value ) = > setMode ( value as "text" | "json" ) } options = { [ { label : "排查日志" , value : "text" } , { label : "原始 JSON" , value : "json" } ] } / >
< div className = "flex items-center gap-2" >
< span className = "text-xs" style = { { color : theme.node.muted } } > { logs . length } 条 < / span >
< Button size = "small" icon = { < Copy className = "size-3.5" / > } onClick = { ( ) = > void copy ( ) } > 复 制 排 查 日 志 < / Button >
< Button size = "small" disabled = { ! lastError } onClick = { ( ) = > lastError && void copy ( formatLogText ( [ lastError ] , context ) , "最近错误已复制" ) } > 复 制 最 近 错 误 < / Button >
< Button size = "small" icon = { < Copy className = "size-3.5" / > } onClick = { ( ) = > void copy ( ) } > 复 制 < / Button >
< Button size = "small" disabled = { ! lastError } onClick = { ( ) = > lastError && void copy ( formatLogText ( [ lastError ] , context ) , "最近错误已复制" ) } > 最 近 错 误 < / Button >
< Button size = "small" danger type = "text" icon = { < Trash2 className = "size-3.5" / > } disabled = { ! logs . length } onClick = { onClear } > 清 空 < / Button >
< / div >
< / div >
< div className = "grid grid-cols-2 gap-2 text-xs md:grid-cols-4" >
{ [
[ "连接" , context . connected ? "在线" : context . enabled ? "连接中" : "未启用" ] ,
[ "状态" , context . activity ] ,
[ "等待" , context . waiting ? "是" : "否" ] ,
[ "工具" , context . pendingTool ? toolName ( context . pendingTool ) : "无" ] ,
] . map ( ( [ label , value ] ) = > (
< div key = { label } className = "rounded-lg border px-3 py-2" style = { { borderColor : theme.node.stroke } } >
< div style = { { color : theme.node.muted } } > { label } < / div >
< div className = "mt-0.5 truncate" style = { { color : theme.node.text } } > { value } < / div >
< / div >
) ) }
< / div >
< textarea
ref = { textareaRef }
readOnly
value = { content }
className = "thin-scrollbar h-[62vh] max-h-[620px] min-h-[360px] w-full resize-none rounded-lg border p-3 font-mono text-xs leading-5 outline-none"
style = { { borderColor : theme.node.stroke , background : theme.node.fill , color : theme.node.text } }
className = "thin-scrollbar min-h-[360px] flex-1 resize-none rounded-lg border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
style = { { borderColor : theme.node.stroke , color : theme.node.text } }
onFocus = { ( event ) = > event . currentTarget . select ( ) }
/ >
< / div >
< / Modal >
< / div >
) ;
}
function AgentConnectView ( { theme , url , token , enabled , connected , activity , connectError , onUrlChange , onTokenChange , onToggleEnabled } : { theme : ( typeof canvasThemes ) [ keyof typeof canvasThemes ] ; url : string ; token : string ; enabled : boolean ; connected : boolean ; activity : string ; connectError : string ; onUrlChange : ( value : string ) = > void ; onTokenChange : ( value : string ) = > void ; onToggleEnabled : ( ) = > void } ) {
const { message } = App . useApp ( ) ;
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接" ;
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme . node . muted ;
const copyCommand = ( command : string ) = > {
copyToClipboard ( command ) ;
message . success ( "命令已复制" ) ;
} ;
return (
< div className = "thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4" >
< div className = "space-y-4" >
< div >
< div className = "text-base font-semibold leading-6" > 连 接 本 地 Agent < / div >
< div className = "mt-1 text-xs leading-5" style = { { color : theme.node.muted } } >
本 地 服 务 只 监 听 127.0 . 0.1 , 画 布 通 过 地 址 和 token 连 接 到 你 电 脑 上 的 Codex 。
< / div >
< / div >
< div className = "space-y-2" >
{ AGENT_CONNECT_STEPS . map ( ( step ) = > {
const command = "command" in step ? step . command : "" ;
return (
< div key = { step . title } className = "rounded-lg px-3 py-2.5" >
< div className = "text-sm font-medium leading-5" > { step . title } < / div >
< div className = "mt-1 text-xs leading-5" style = { { color : theme.node.muted } } > { step . text } < / div >
{ command ? (
< div className = "mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style = { { borderColor : theme.node.stroke , color : theme.node.text } } >
< code className = "min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5" > { command } < / code >
< Tooltip title = "复制命令" >
< Button size = "small" type = "text" className = "!h-6 !w-6 !min-w-6" icon = { < Copy className = "size-3.5" / > } onClick = { ( ) = > copyCommand ( command ) } / >
< / Tooltip >
< / div >
) : null }
< / div >
) ;
} ) }
< / div >
< div className = "rounded-lg border p-3" style = { { borderColor : theme.node.stroke } } >
< div className = "flex flex-wrap items-start justify-between gap-3" >
< div className = "min-w-0 flex-1" >
< div className = "flex min-w-0 items-center gap-2" >
< span className = "shrink-0 text-sm font-medium leading-5" > 网 页 连 接 < / span >
< span className = "inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4" style = { { borderColor : connected || enabled || connectError ? statusColor : theme.node.stroke , color : statusColor } } >
< span className = "size-1.5 shrink-0 rounded-full" style = { { background : statusColor } } / >
< span className = "truncate" > { statusText } < / span >
< / span >
< / div >
< div className = "mt-1 text-xs leading-5" style = { { color : theme.node.muted } } >
填 入 终 端 输 出 的 Local URL 和 Connect token 。
< / div >
< / div >
< Button className = "!h-8 !px-3" type = { enabled ? "default" : "primary" } icon = { < PlugZap className = "size-4" / > } onClick = { onToggleEnabled } >
{ enabled ? "断开" : "连接" }
< / Button >
< / div >
< div className = "mt-3 grid gap-2.5" >
< label className = "grid gap-1.5" >
< span className = "flex items-center gap-1.5 text-xs font-medium" style = { { color : theme.node.muted } } >
< Link2 className = "size-3.5" / >
本 地 地 址
< span className = "font-normal opacity-70" > Local URL < / span >
< / span >
< Input size = "large" prefix = { < Link2 className = "mr-1 size-4" style = { { color : theme.node.faint } } / > } value = { url } onChange = { ( event ) = > onUrlChange ( event . target . value ) } placeholder = "例如 http://127.0.0.1:17371" / >
< / label >
< label className = "grid gap-1.5" >
< span className = "flex items-center gap-1.5 text-xs font-medium" style = { { color : theme.node.muted } } >
< KeyRound className = "size-3.5" / >
连 接 Token
< span className = "font-normal opacity-70" > Connect token < / span >
< / span >
< Input.Password size = "large" prefix = { < KeyRound className = "mr-1 size-4" style = { { color : theme.node.faint } } / > } value = { token } onChange = { ( event ) = > onTokenChange ( event . target . value ) } placeholder = "终端输出的 Connect token" / >
< / label >
{ connectError ? (
< div className = "rounded-md border px-2.5 py-2 text-xs leading-5" style = { { borderColor : "rgba(220,38,38,.35)" , color : "#dc2626" } } >
{ connectError }
< / div >
) : null }
< / div >
< / div >
< / div >
< / div >
) ;
}
function AgentHistoryView ( { theme , threads , activeThreadId , workspacePath , loading , connected , onRefresh , onNewThread , onResumeThread , onDeleteThread } : { theme : ( typeof canvasThemes ) [ keyof typeof canvasThemes ] ; threads : AgentThreadSummary [ ] ; activeThreadId : string ; workspacePath : string ; loading : boolean ; connected : boolean ; onRefresh : ( ) = > void ; onNewThread : ( ) = > void ; onResumeThread : ( threadId : string ) = > void ; onDeleteThread : ( thread : AgentThreadSummary ) = > void } ) {
return (
< div className = "thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3" >
< div className = "space-y-3" >
< div className = "flex min-w-0 items-center gap-2 text-xs" style = { { color : theme.node.muted } } >
< FolderOpen className = "size-3.5 shrink-0" / >
< span className = "shrink-0" > 工 作 空 间 < / span >
< span className = "min-w-0 truncate" title = { workspacePath } > { workspacePath || "默认画布目录" } < / span >
< / div >
< div className = "flex flex-wrap items-center justify-between gap-2" >
< div className = "text-sm" style = { { color : theme.node.muted } } >
{ threads . length ? ` ${ threads . length } 条历史 ` : connected ? "暂无历史" : "未连接" }
< / div >
< div className = "flex items-center gap-2" >
< Button size = "small" icon = { < RefreshCw className = { ` size-3.5 ${ loading ? "animate-spin" : "" } ` } / > } disabled = { ! connected || loading } onClick = { onRefresh } >
刷 新
< / Button >
< Button size = "small" type = "primary" icon = { < Plus className = "size-3.5" / > } disabled = { ! connected || loading } onClick = { onNewThread } >
新 对 话
< / Button >
< / div >
< / div >
< div className = "space-y-2" >
{ threads . map ( ( thread ) = > {
const active = thread . id === activeThreadId ;
return (
< div key = { thread . id } className = "rounded-lg border px-2.5 py-1.5 transition" style = { { borderColor : active ? theme.node.text : theme.node.stroke , background : "transparent" , color : theme.node.text } } >
< div className = "flex items-center gap-2" >
< div className = "min-w-0 flex-1" >
< div className = "flex min-w-0 items-center gap-1.5" >
{ active ? < span className = "shrink-0 text-[10px] font-medium" style = { { color : theme.node.text } } > 当 前 < / span > : null }
< div className = "truncate text-sm font-medium leading-5" > { thread . name || thread . preview || "未命名对话" } < / div >
< / div >
< div className = "truncate text-[11px] leading-4 opacity-65" > { thread . preview || thread . id } < / div >
< / div >
< div className = "flex shrink-0 items-center gap-1" >
< span className = "text-[10px] opacity-55" > { formatThreadTime ( thread . updatedAt || thread . createdAt ) } < / span >
< Button size = "small" className = "!h-6 !px-2" disabled = { loading } onClick = { ( ) = > onResumeThread ( thread . id ) } >
进 入
< / Button >
< Tooltip title = "删除记录" >
< Button size = "small" danger type = "text" className = "!h-6 !w-6 !min-w-6" disabled = { loading } icon = { < Trash2 className = "size-3.5" / > } onClick = { ( ) = > onDeleteThread ( thread ) } / >
< / Tooltip >
< / div >
< / div >
< / div >
) ;
} ) }
{ ! threads . length ? (
< div className = "px-3 py-8 text-center text-sm" style = { { color : theme.node.muted } } >
{ connected ? "当前工作空间还没有对话记录" : "连接本地 Agent 后显示历史记录" }
< / div >
) : null }
< / div >
< / div >
< / div >
) ;
}
@@ -530,35 +861,39 @@ function ChatMessage({ item, theme, user }: { item: AgentChatItem; theme: (typeo
}
function PendingToolCard ( { tool , theme , onReject , onApprove } : { tool : AgentPendingToolCall ; theme : ( typeof canvasThemes ) [ keyof typeof canvasThemes ] ; onReject : ( ) = > void ; onApprove : ( ) = > void } ) {
const summary = summarizeCanvasAgentOps ( tool . input ? . ops || [ ] ) || toolName ( tool . name ) ;
return (
< div className = "flex items-start gap-3" >
< OpenAiAvatar theme = { theme } / >
< div className = "min-w-0 max-w-[82%] rounded-2 xl border p-3 " style = { { borderColor : theme.node.stroke , background : "transparent" , color : theme.node.text } } >
< div className = "min-w-0 flex-1 rounded-xl border p-4 " style = { { borderColor : theme.node.stroke , background : "transparent" , color : theme.node.text } } >
< details >
< summary className = "cursor-pointer list-none" >
< div className = "flex items-start gap-2 " >
< span className = "mt-0.5 grid size-6 shrink-0 place-items-center rounded-ful l border" style = { { borderColor : theme.node.stroke , color : theme.node.muted } } >
< Wrench className = "size-3.5 " / >
< div className = "flex items-start gap-3 " >
< span className = "mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style = { { borderColor : "rgba(217,119,6,.24)" , color : "#d97706" , background : "rgba(217,119,6,.04)" } } >
< CircleAlert className = "size-4 " / >
< / span >
< div className = "min-w-0 flex-1" >
< div className = "flex items-center justify-between gap-3 text-sm font-medium leading-5" >
< div className = "flex flex-wrap items-center gap-2 text-sm font-semibold leading-5" >
< span > 确 认 工 具 调 用 < / span >
< span className = "text-[11px] font-normal " style = { { color : theme.node.muted } } > 详 情 < / span >
< span className = "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium " style = { { borderColor : "rgba(217,119,6,.22)" , color : "#d97706" , background : "rgba(217,119,6,.04)" } } >
等 待 确 认
< / span >
< span className = "ml-auto text-xs font-normal" style = { { color : theme.node.muted } } > 详 情 < / span >
< / div >
< div className = "mt-1 text-x s leading-5 " style = { { color : theme.node.muted } } >
{ summarizeCanvasAgentOps ( tool . input ? . ops || [ ] ) || toolName ( tool . name ) }
< div className = "mt-2 text-sm leading-6 " style = { { color : theme.node.text } } >
{ summary }
< / div >
< / div >
< / div >
< / summary >
< DetailBlock detail = { { requestId : tool.requestId , name : tool.name , input : tool.input } } theme = { theme } / >
< / details >
< div className = "mt-3 flex justify-end gap-2" >
< Button size = "small" onClick = { ( ) = > void onReject ( ) } >
取 消
< div className = "mt-4 grid grid-cols-2 gap-2" >
< Button danger className = "!h-9" icon = { < XCircle className = "size-4" / > } onClick = { ( ) = > void onReject ( ) } >
拒 绝 执 行
< / Button >
< Button size = "small" type = "primary" onClick = { ( ) = > void onApprove ( ) } >
执 行
< Button className = "!h-9" icon = { < CheckCircle2 className = "size-4" / > } style = { { borderColor : "rgba(22,163,74,.42)" , color : "#16a34a" , background : "transparent" } } onClick = { ( ) = > void onApprove ( ) } >
批 准 执 行
< / Button >
< / div >
< / div >
@@ -567,16 +902,26 @@ function PendingToolCard({ tool, theme, onReject, onApprove }: { tool: AgentPend
}
function ToolCard ( { title , text , detail , theme } : { title : string ; text : string ; detail? : unknown ; theme : ( typeof canvasThemes ) [ keyof typeof canvasThemes ] } ) {
const state = toolCardState ( title , text , detail ) ;
return (
< details className = "min-w-0 max-w-[82%] rounded-2 xl border px-3 py-2 .5 text-left" style = { { borderColor : theme.node.stroke , background : "transparent" , color : theme.node.text } } >
< details className = "min-w-0 flex-1 rounded-xl border px-4 py-3 .5 text-left" style = { { borderColor : theme.node.stroke , background : "transparent" , color : theme.node.text } } >
< summary className = "cursor-pointer list-none" >
< div className = "flex items-center gap-2 text-xs font-medium " >
< Wrench className = "size-3.5" / >
< span className = "min-w-0 flex-1 truncate" > { title } < / span >
{ detail ? < span className = "font-normal" style = { { color : theme.node.muted } } > 详 情 < / span > : null }
< / div >
< div className = "mt-1 text-xs leading-5" style = { { color : theme.node.muted } } >
{ text }
< div className = "flex items-start gap-3 " >
< span className = "mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style = { { borderColor : state.softBorder , color : state.color , background : state.softBg } } >
{ state . icon }
< / span >
< div className = "min-w-0 flex-1" >
< div className = "flex flex-wrap items-center gap-2 text-sm font-semibold leading-5" >
< span className = "min-w-0 truncate" > { title } < / span >
< span className = "inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style = { { borderColor : state.softBorder , color : state.color , background : state.softBg } } >
{ state . label }
< / span >
{ detail ? < span className = "ml-auto text-xs font-normal" style = { { color : theme.node.muted } } > 详 情 < / span > : null }
< / div >
< div className = "mt-2 text-sm leading-6" style = { { color : state.isError ? state.color : theme.node.muted } } >
{ text }
< / div >
< / div >
< / div >
< / summary >
{ detail ? < DetailBlock detail = { detail } theme = { theme } / > : null }
@@ -586,23 +931,41 @@ function ToolCard({ title, text, detail, theme }: { title: string; text: string;
function DetailBlock ( { detail , theme } : { detail : unknown ; theme : ( typeof canvasThemes ) [ keyof typeof canvasThemes ] } ) {
return (
< pre className = "thin-scrollbar mt-2 max-h-52 overflow-auto rounded-lg border p-2 text-[11px] leading-4" style = { { borderColor : theme.node.stroke , color : theme.node.muted } } >
< pre className = "thin-scrollbar mt-3 max-h-64 overflow-auto rounded-lg border p-3 text-[11px] leading-4" style = { { borderColor : theme.node.stroke , background : theme.toolbar.panel , color : theme.node.muted } } >
{ JSON . stringify ( detail , null , 2 ) }
< / pre >
) ;
}
function WorkingMessage ( { theme , logs , onOpenLog } : { theme : ( typeof canvasThemes ) [ keyof typeof canvasThemes ] ; logs : number ; onOpenLog : ( ) = > void } ) {
function toolCardState ( title : string , text : string , detail? : unknown ) {
const raw = ` ${ title } ${ text } ${ normalizeText ( objectField ( detail , "error" ) ) } ` ;
const lower = raw . toLowerCase ( ) ;
const tool = String ( objectField ( detail , "name" ) || objectField ( detail , "tool" ) || "" ) ;
if ( /拒绝|取消/ . test ( raw ) || lower . includes ( "rejected" ) ) {
return { label : "拒绝执行" , color : "#dc2626" , softBorder : "rgba(220,38,38,.20)" , softBg : "rgba(220,38,38,.04)" , icon : < XCircle className = "size-4" / > , isError : true } ;
}
if ( /失败|错误/ . test ( raw ) || lower . includes ( "failed" ) || lower . includes ( "error" ) ) {
return { label : "执行失败" , color : "#dc2626" , softBorder : "rgba(220,38,38,.20)" , softBg : "rgba(220,38,38,.04)" , icon : < XCircle className = "size-4" / > , isError : true } ;
}
if ( /完成|成功/ . test ( raw ) || lower . includes ( "completed" ) || lower . includes ( "succeeded" ) ) {
const label = tool === "canvas_apply_ops" || /画布操作/ . test ( title ) ? "已批准执行" : "执行完成" ;
return { label , color : "#16a34a" , softBorder : "rgba(22,163,74,.20)" , softBg : "rgba(22,163,74,.04)" , icon : < CheckCircle2 className = "size-4" / > , isError : false } ;
}
return { label : "工具调用" , color : "#2563eb" , softBorder : "rgba(37,99,235,.20)" , softBg : "rgba(37,99,235,.04)" , icon : < Wrench className = "size-4" / > , isError : false } ;
}
function WorkingMessage ( { 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 ) ;
} , [ ] ) ;
return (
< div className = "flex items-start gap-2.5" >
< OpenAiAvatar theme = { theme } / >
< div className = "min-w-0 max-w-[82%]" >
< div className = "inline-flex items-center gap-2 text-sm" style = { { color : theme.node.muted } } >
< LoaderCircle className = "size-4 animate-spin" / >
< span > working . . . < / span >
< button type = "button" className = "text-xs underline-offset-2 hover:underline" onClick = { onOpenLog } >
运 行 日 志 { logs ? ` ${ logs } ` : "" }
< / button >
< div className = "font-mono text-sm" style = { { color : theme.node.muted } } aria-label = { WORKING_TEXT } >
< span className = "inline-block w-[76px]" > { WORKING_TEXT . slice ( 0 , Math . min ( length , WORKING_TEXT . length ) ) } < / span >
< / div >
< / div >
< / div >
@@ -731,13 +1094,34 @@ function shouldLogAgentEvent(event: AgentEventPayload) {
return ! [ "item.updated" ] . includes ( event . type || "" ) && ! [ "reasoning" ] . includes ( itemType ) && ! ( event . type === "item.started" && itemType === "agent_message" ) ;
}
function isConnectionErrorMessage ( item : AgentChatItem ) {
return item . role === "error" && /连接失败|无法连接本地 Agent|本地 Agent 连接失败/ . test ( item . text ) ;
}
function toolName ( name : string ) {
if ( name === "canvas_apply_ops" ) return "画布操作" ;
if ( name === "canvas_get_state" ) return "读取画布" ;
if ( name === "canvas_get_selection" ) return "读取选区" ;
if ( name === "canvas_export_snapshot" ) return "导出快照" ;
if ( name === "canvas_create_node" ) return "创建节点" ;
if ( name === "canvas_create_text_node" ) return "创建文本" ;
if ( name === "canvas_create_text_nodes" ) return "批量创建文本" ;
if ( name === "canvas_create_config_node" ) return "创建生成配置" ;
if ( name === "canvas_create_image_prompt_flow" ) return "创建生图流程" ;
if ( name === "canvas_create_generation_flow" ) return "创建生成流程" ;
if ( name === "canvas_generate_text" ) return "生成文本" ;
if ( name === "canvas_generate_image" ) return "生成图片" ;
if ( name === "canvas_generate_video" ) return "生成视频" ;
if ( name === "canvas_generate_audio" ) return "生成音频" ;
if ( name === "canvas_update_node" ) return "更新节点" ;
if ( name === "canvas_update_node_text" ) return "更新文本" ;
if ( name === "canvas_move_nodes" ) return "移动节点" ;
if ( name === "canvas_resize_node" ) return "调整节点尺寸" ;
if ( name === "canvas_delete_nodes" ) return "删除节点" ;
if ( name === "canvas_connect_nodes" ) return "连接节点" ;
if ( name === "canvas_select_nodes" ) return "选择节点" ;
if ( name === "canvas_set_viewport" ) return "调整视口" ;
if ( name === "canvas_run_generation" ) return "触发生成" ;
return name ;
}
@@ -818,6 +1202,29 @@ function formatBytes(bytes: number) {
return bytes > 1024 * 1024 ? ` ${ ( bytes / 1024 / 1024 ) . toFixed ( 1 ) } MB ` : ` ${ Math . ceil ( bytes / 1024 ) } KB ` ;
}
async function fetchAgentJson < T > ( endpoint : string , token : string , path : string , init? : RequestInit ) {
const url = ` ${ endpoint } ${ path } ${ path . includes ( "?" ) ? "&" : "?" } token= ${ encodeURIComponent ( token ) } ` ;
const res = await fetch ( url , init ) ;
const data = ( await res . json ( ) . catch ( ( ) = > ( { } ) ) ) as T & { error? : string ; msg? : string } ;
if ( ! res . ok ) throw new Error ( data . error || data . msg || "本地 Agent 请求失败" ) ;
return data ;
}
function normalizeHistoryMessages ( messages : AgentChatItem [ ] ) {
return messages
. map ( ( item , index ) = > ( {
. . . item ,
id : item.id || ` history- ${ index } ` ,
text : normalizeText ( item . text ) ,
} ) )
. filter ( ( item ) = > item . text ) ;
}
function formatThreadTime ( value? : number ) {
if ( ! value ) return "" ;
return new Date ( value * 1000 ) . toLocaleString ( ) ;
}
function createId() {
return typeof crypto === "undefined" ? ` ${ Date . now ( ) } - ${ Math . random ( ) } ` : crypto . randomUUID ( ) ;
}