diff --git a/app/api/canvas/agent-chat/route.ts b/app/api/canvas/agent-chat/route.ts index 916c2d3bd..06fbca980 100644 --- a/app/api/canvas/agent-chat/route.ts +++ b/app/api/canvas/agent-chat/route.ts @@ -1,3 +1,4 @@ +import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress"; import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options"; import { registerProjectAgentTools } from "@/lib/ai/canvas-agent-runtime/project-tools"; import { isZcodeWorkspaceAuthorized } from "@/lib/ai/canvas-agent-runtime/authorization"; @@ -259,7 +260,7 @@ export async function POST(request: Request) { let archiveVideoResults: (() => Promise) | undefined; const stream = createUIMessageStream({ execute: async ({ writer }) => { - const emit = (type: string, data: unknown) => writer.write({ type, data } as never); + const emit = (type: string, data: unknown) => writer.write({ type, data, ...(type === "data-canvasAgentRun" ? { transient: true } : {}) } as never); let cancelled = false; let leaseLost = false; let activeModelController: AbortController | null = null; @@ -396,6 +397,8 @@ export async function POST(request: Request) { }), }); const finalText = snapshot.message; + const progress = canvasAgentProgress(agentRunView({ ...run, snapshot, status })); + writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress }); if (!isVideo) await persistRuntimeConversationResults({ userId: session.user.id, @@ -415,7 +418,7 @@ export async function POST(request: Request) { id: assistantMessageId, chatId: body.id, role: "assistant", - parts: [{ type: "text", text: finalText }], + parts: [{ type: "text", text: finalText }, { type: "data-canvasAgentProgress", data: progress }], attachments: [], createdAt: new Date(), videoStorageStatus: null, @@ -432,6 +435,12 @@ export async function POST(request: Request) { await checkpointAgentRun(run, { ...snapshot, message: text }, "needs_review").catch( () => undefined, ); + const progress = canvasAgentProgress(agentRunView({ ...run, snapshot: { ...snapshot, message: text }, status: "needs_review" })); + writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress }); + await saveMessages({ messages: [{ id: assistantMessageId, chatId: body.id, role: "assistant", + parts: [{ type: "text", text }, { type: "data-canvasAgentProgress", data: progress }], + attachments: [], createdAt: new Date(), videoStorageStatus: null, temporaryVideoUrl: null, + permanentVideoUrl: null, archivedAt: null, videoMetadata: null }] }).catch(() => undefined); writer.write({ type: "text-delta", id: textId, delta: text }); } finally { clearInterval(heartbeat); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 92d9cee64..5bc6a83e1 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -3,7 +3,9 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing"; -import { CanvasAgentRunPanel } from "@/components/canvas/chat-panel/canvas-agent-run-panel"; +import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress"; +import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run"; +import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress"; import { useEffect, useRef, useState, useCallback, useMemo, lazy, Suspense, type ClipboardEvent as ReactClipboardEvent, type ComponentProps, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react"; import { useSession } from "next-auth/react"; import { useSearchParams } from "next/navigation"; @@ -8813,6 +8815,14 @@ export function CanvasChatPanel({ }, }); + const canvasAgentTask = useCanvasAgentRun({ + enabled: effectiveAgentEnabled && !isVideoWorkspace && !isWorkspacePresentation && activeComposerMode === "agent" && isValidChatUuid(rawChatId) && isValidChatUuid(canvasId), + chatId: rawChatId, projectId: canvasId, busy: status === "submitted" || status === "streaming", + onResults: (projectId, nodeIds) => { void refreshCanvasAfterAgentOperation({ projectId, createdNodeIds: nodeIds }); }, + }); + const activeCanvasAgentRun = canvasAgentTask.run; + const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running"; + const restorePendingClawdRequestToComposer = useCallback( (snapshot: PendingClawdRequestRestoreSnapshot) => { setMessages((previousMessages) => @@ -12388,6 +12398,15 @@ export function CanvasChatPanel({ overrideInput?: string, sensitiveInput?: OpenClawSensitiveInput ) => { + const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim(); + if (isCanvasAgentRunning && !isClawdMode) { + if (supplement && await canvasAgentTask.control("steer", supplement)) { + setInput(""); + const editor = textareaRef.current as HTMLDivElement | null; + if (editor) editor.innerHTML = ""; + } + return; + } const currentSendGuard = getSendGuard(activeChatId); if (currentSendGuard.inFlight || status === "streaming" || status === "submitted") { return; @@ -13222,6 +13241,8 @@ export function CanvasChatPanel({ console.log('[CanvasChat] 已清空输入框和选中状态'); } }, [ + isCanvasAgentRunning, + canvasAgentTask.control, activeChatId, activeComposerMode, agentEntryMode, @@ -14826,6 +14847,8 @@ export function CanvasChatPanel({ : hasAgentUploadSlots ? "可选补充本次生图要求,例如:高级感、暖灰色、近景特写、生成3张。" : "可选补充本次生成要求,例如:更高级、更生活化、暖调灯光。" + : isCanvasAgentRunning + ? canvasAgentTask.stopPending ? "正在停止,完成后可继续编辑" : "补充要求,将从下一步生效" : isClawdMode ? activeClawdSkillTag ? activeClawdSkillTag.placeholder?.trim() || @@ -14888,7 +14911,9 @@ export function CanvasChatPanel({ detailProgress.total > 0 ? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100)) : 0; - const canSend = isClawdMode + const canSend = isCanvasAgentRunning + ? Boolean(input.trim()) && !canvasAgentTask.sending && !canvasAgentTask.stopPending + : isClawdMode ? Boolean( input.trim() || attachments.length > 0 || @@ -15200,6 +15225,7 @@ export function CanvasChatPanel({ const isDetailEmptyState = isDetailGenerationMode && renderableVisibleMessages.length === 0 && + !activeCanvasAgentRun && !isActiveChatPending && !shouldShowImageLoading; const isPlainCanvasEmptyState = @@ -15207,6 +15233,7 @@ export function CanvasChatPanel({ !isDetailGenerationMode && !isVideoWorkspace && renderableVisibleMessages.length === 0 && + !activeCanvasAgentRun && !isActiveChatPending && !shouldShowImageLoading; const latestRenderableMessage = renderableVisibleMessages.at(-1); @@ -15901,7 +15928,7 @@ export function CanvasChatPanel({ "space-y-5 overflow-x-hidden pr-1", isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24" )}> - {!isDetailGenerationMode && renderableVisibleMessages.length === 0 ? ( + {!isDetailGenerationMode && !activeCanvasAgentRun && renderableVisibleMessages.length === 0 ? ( isWorkspacePresentation ? (
@@ -16294,6 +16321,10 @@ export function CanvasChatPanel({ ).values() ) : []; + const messageAgentRun = activeCanvasAgentRun?.assistantMessageId === message.id ? activeCanvasAgentRun : null; + const messageAgentProgress: CanvasAgentProgress | undefined = messageAgentRun + ? canvasAgentProgress(messageAgentRun) + : (message.parts as any[])?.find(part => part.type === "data-canvasAgentProgress")?.data; const assistantTextParts = message.role === "assistant" ? (message.parts || []).filter((part: any) => part.type === "text" && part.text) : []; @@ -16611,6 +16642,12 @@ export function CanvasChatPanel({ : "bg-transparent px-0 py-0" )} > + {messageAgentProgress ? ( + void canvasAgentTask.control("cancel") : undefined} + onResume={messageAgentRun ? () => { canvasAgentResumeRunIdRef.current = messageAgentRun.id; void handleSend(input.trim() || "请继续未完成的步骤,保留已经完成的结果。"); } : undefined} /> + ) : null} {/* 消息内容 */}
{message.parts?.map((part: any, i: number) => { @@ -17024,6 +17061,13 @@ export function CanvasChatPanel({ ); })} + {activeCanvasAgentRun && !renderableVisibleMessages.some(message => message.id === activeCanvasAgentRun.assistantMessageId) ? ( + void canvasAgentTask.control("cancel")} + onResume={() => { canvasAgentResumeRunIdRef.current = activeCanvasAgentRun.id; void handleSend(input.trim() || "请继续未完成的步骤,保留已经完成的结果。"); }} /> + ) : null} + {shouldRenderImageLoading && ( )} - {shouldRenderAssistantTypingPlaceholder ? ( + {shouldRenderAssistantTypingPlaceholder && !isCanvasAgentRunning ? ( ) : null} @@ -17928,18 +17972,6 @@ export function CanvasChatPanel({ )}
)} - { - if (isSubmitting) return; - canvasAgentResumeRunIdRef.current = runId; - void handleSend(input.trim() || "请继续这个任务的未完成步骤,保留已生成的结果,优先处理未通过检查的图片。"); - }} - onResults={(projectId, nodeIds) => { void refreshCanvasAfterAgentOperation({ projectId, createdNodeIds: nodeIds }); }} - /> {/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
)} - {!unifiedImageAgent && !isDetailGenerationMode && activeComposerMode === "agent" && ( -
- {hasSelectedAgentPreset ? ( - <> - - - - ) : ( -
- - Auto Agent -
- )} - {isPresetAgentSelection && selectedAgentPreset?.type === "text" ? ( -
- 可上传图片做分析,也可直接输入文案需求 -
- ) : hasAgentUploadSlots ? ( -
- {isWorkspacePresentation ? "请确认产品图1、产品图2、模特图槽位" : "请上传产品图1、产品图2、模特图"} -
- ) : null} -
- )} {shouldShowAgentProgressCard && activeAgentProgress ? (
{isWorkspacePresentation ? 停止 : null} - ) : isSubmitting ? ( + ) : isSubmitting && !isCanvasAgentRunning ? ( <> {isWorkspacePresentation ? 生成中 : null} diff --git a/components/canvas/chat-panel/canvas-agent-progress.tsx b/components/canvas/chat-panel/canvas-agent-progress.tsx new file mode 100644 index 000000000..59a1873d1 --- /dev/null +++ b/components/canvas/chat-panel/canvas-agent-progress.tsx @@ -0,0 +1,47 @@ +"use client"; +import { CircleAlert, Check, ChevronDown, Loader2, RotateCcw, Square } from "lucide-react"; +import type { AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol"; +import { canvasAgentProgressLabel, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress"; + +export function CanvasAgentProgressMessage(props: { + progress: CanvasAgentProgress; run?: AgentRunView | null; busy?: boolean; + stopping?: boolean; sending?: boolean; onCancel?: () => void; onResume?: () => void; +}) { + const { progress, run } = props; + const running = progress.status === "running"; + const label = progress.status === "completed" ? "处理完成" + : props.stopping && running ? "正在停止后续步骤,已完成的结果会保留" + : canvasAgentProgressLabel(progress.message || "正在处理你的任务"); + return ( +
+
+
+ + {running ? : progress.status === "completed" ? : } + {label} + + + {progress.activities?.length ? ( +
    + {progress.activities.map(activity => ( +
  1. + {activity.status === "started" ? : } + {canvasAgentProgressLabel(activity.title)} + {activity.status === "succeeded" ? "已完成" : activity.status === "failed" ? "待处理" : activity.status === "queued" ? "待执行" : "进行中"} +
  2. + ))} +
+ ) : null} + {run?.inputs.slice(-2).map(input => ( +

{input.applied ? "已应用补充要求" : "已收到补充要求"}:{input.text}

+ ))} +
+ {running && props.onCancel ? ( + + ) : run?.resumable && props.onResume ? ( + + ) : null} +
+
+ ); +} diff --git a/components/canvas/chat-panel/canvas-agent-run-panel.tsx b/components/canvas/chat-panel/canvas-agent-run-panel.tsx index a25046c5b..197bd39e3 100644 --- a/components/canvas/chat-panel/canvas-agent-run-panel.tsx +++ b/components/canvas/chat-panel/canvas-agent-run-panel.tsx @@ -1,181 +1,13 @@ "use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import useSWR from "swr"; -import { Loader2, Square, ArrowUp, RotateCcw } from "lucide-react"; -import { toast } from "sonner"; -import type { AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol"; - -export function CanvasAgentRunPanel(props: { - enabled: boolean; - chatId: string; - projectId: string; - busy: boolean; - onResume: (runId: string) => void; - onResults: (projectId: string, nodeIds: string[]) => void; - onRunChange?: (run: AgentRunView | null) => void; -}) { - const [text, setText] = useState(""); - const [sending, setSending] = useState(false); - const [stopping, setStopping] = useState(false); - const activeScope = useRef(""); - activeScope.current = `${props.chatId}:${props.projectId}`; - const resultSignature = useRef(""); - const key = props.enabled - ? `/api/canvas/agent-runs?${new URLSearchParams({ chatId: props.chatId, projectId: props.projectId })}` - : null; - const { data, mutate } = useSWR<{ run: AgentRunView | null }>( - key, - async (url: string) => { - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) return { run: null }; - return response.json(); - }, - { - refreshInterval: (latest) => (latest?.run?.status === "running" || props.busy ? 2000 : 0), - revalidateOnFocus: true, - shouldRetryOnError: false, - }, - ); - const run = data?.run; - useEffect(() => { - if (data) props.onRunChange?.(data.run); - }, [data, props.onRunChange]); - useEffect(() => { - setText(""); - setStopping(false); - resultSignature.current = ""; - }, [props.chatId, props.projectId]); - useEffect(() => { - if (!props.enabled) return; - const handler = (event: Event) => { - const update = (event as CustomEvent).detail; - if (update?.chatId === props.chatId && update?.projectId === props.projectId) - void mutate({ run: update }, false); - }; - window.addEventListener("canvas-agent-run:update", handler); - return () => window.removeEventListener("canvas-agent-run:update", handler); - }, [props.enabled, props.chatId, props.projectId, mutate]); - useEffect(() => { - if (!run?.resultNodeIds.length) return; - const signature = `${run.id}:${run.resultNodeIds.join(",")}:${run.status}`; - if (signature === resultSignature.current) return; - resultSignature.current = signature; - props.onResults(run.projectId, run.resultNodeIds); - }, [run, props.onResults]); - const control = useCallback( - async (action: "steer" | "cancel") => { - if (!run || sending) return; - const scope = activeScope.current; - setSending(true); - try { - const response = await fetch("/api/canvas/agent-runs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action, - chatId: props.chatId, - projectId: props.projectId, - runId: run.id, - ...(action === "steer" ? { inputId: crypto.randomUUID(), text: text.trim() } : {}), - }), - }); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || "操作失败,请重试"); - if (scope !== activeScope.current) return; - await mutate(result, false); - if (action === "steer") { - setText(""); - toast.message("补充要求已收到,将在下一步应用"); - } else { - setStopping(true); - toast.message("将停止后续步骤,已发出的生成请求会先收好结果"); - } - } catch (error) { - toast.error(error instanceof Error ? error.message : "操作失败"); - } finally { - if (scope === activeScope.current) setSending(false); - } - }, - [run, sending, props.chatId, props.projectId, text, mutate], - ); - if (!props.enabled || !run || run.status === "completed") return null; - const running = run.status === "running"; - const stopPending = stopping || run.cancelRequested; - return ( -
-
- {running ? : null} - - {stopPending && running ? "正在停止后续步骤,等待当前生成结果" : run.message} - - {running ? ( - - ) : run.resumable ? ( - - ) : null} -
-
- {run.runtime?.engine === "zcode" ? ZCode · {!run.runtime.sandbox ? "尚未启动沙箱" : run.runtime.state === "starting" ? "正在启动独立沙箱" : run.runtime.state === "running" ? "独立沙箱运行中" : "沙箱已停止"} : null} - 已处理 {run.steps} 轮 · {run.toolCount} 个工具步骤 -
- {run.inputs.slice(-2).map((input) => ( -
- {input.applied ? "已应用" : "待应用"}:{input.text} -
- ))} - {running && !stopPending ? ( -
{ - event.preventDefault(); - if (text.trim()) void control("steer"); - }} - > - setText(event.target.value)} - maxLength={2000} - disabled={sending} - placeholder="补充要求,将从下一步生效" - aria-label="补充任务要求" - className="min-w-0 flex-1 rounded-xl border border-zinc-200 bg-white px-3 py-2 outline-none focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-950" - /> - -
- ) : null} -
- ); +// Compatibility wrapper for the paused video entry. Image chat observes the run +// once and renders its activity in the corresponding assistant message. +import { CanvasAgentProgressMessage } from "./canvas-agent-progress"; +import { useCanvasAgentRun, type CanvasAgentRunOptions } from "./use-canvas-agent-run"; +import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress"; +export function CanvasAgentRunPanel(props: CanvasAgentRunOptions & { onResume: (runId: string) => void }) { + const task = useCanvasAgentRun(props); + if (!task.run) return null; + return void task.control("cancel")} + onResume={() => props.onResume(task.run!.id)} />; } diff --git a/components/canvas/chat-panel/use-canvas-agent-run.ts b/components/canvas/chat-panel/use-canvas-agent-run.ts new file mode 100644 index 000000000..5afd92e86 --- /dev/null +++ b/components/canvas/chat-panel/use-canvas-agent-run.ts @@ -0,0 +1,64 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import useSWR from "swr"; +import { toast } from "sonner"; +import type { AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol"; +export type CanvasAgentRunOptions = { + enabled: boolean; chatId: string; projectId: string; busy: boolean; + onResults: (projectId: string, nodeIds: string[]) => void; + onRunChange?: (run: AgentRunView | null) => void; +}; +export function useCanvasAgentRun(props: CanvasAgentRunOptions) { + const [sending, setSending] = useState(false); + const [stopping, setStopping] = useState(false); + const sendingRef = useRef(false); + const scope = `${props.chatId}:${props.projectId}`; + const latest = useRef({ props, scope }); latest.current = { props, scope }; + const resultSignature = useRef(""); + const key = props.enabled ? `/api/canvas/agent-runs?${new URLSearchParams({ chatId: props.chatId, projectId: props.projectId })}` : null; + const { data, mutate } = useSWR<{ run: AgentRunView | null }>(key, async (url: string) => { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) return { run: null }; + return response.json(); + }, { refreshInterval: latest => latest?.run?.status === "running" || props.busy ? 2000 : 0, + revalidateOnFocus: true, shouldRetryOnError: false }); + const run = props.enabled && data?.run?.chatId === props.chatId && data.run.projectId === props.projectId ? data.run : null; + useEffect(() => { latest.current.props.onRunChange?.(run); }, [run]); + useEffect(() => { setSending(false); sendingRef.current = false; setStopping(false); resultSignature.current = ""; }, [scope]); + useEffect(() => { + if (!props.enabled) return; + const handler = (event: Event) => { + const update = (event as CustomEvent).detail; + if (update?.chatId === props.chatId && update?.projectId === props.projectId) void mutate({ run: update }, false); + }; + window.addEventListener("canvas-agent-run:update", handler); + return () => window.removeEventListener("canvas-agent-run:update", handler); + }, [props.enabled, props.chatId, props.projectId, mutate]); + useEffect(() => { + if (!run?.resultNodeIds.length) return; + const signature = `${run.id}:${run.resultNodeIds.join(",")}:${run.status}`; + if (signature === resultSignature.current) return; + resultSignature.current = signature; + latest.current.props.onResults(run.projectId, run.resultNodeIds); + }, [run]); + const control = useCallback(async (action: "steer" | "cancel", text?: string) => { + if (!run || sendingRef.current) return false; + sendingRef.current = true; + const startedScope = scope; + setSending(true); + try { + const response = await fetch("/api/canvas/agent-runs", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, chatId: props.chatId, projectId: props.projectId, runId: run.id, + ...(action === "steer" ? { inputId: crypto.randomUUID(), text: text?.trim() } : {}) }) }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "操作失败,请重试"); + if (startedScope !== latest.current.scope) return false; + await mutate(result, false); + if (action === "cancel") setStopping(true); + toast.message(action === "steer" ? "补充要求已收到,将在下一步应用" : "正在停止后续步骤,已完成的结果会保留"); + return true; + } catch (error) { toast.error(error instanceof Error ? error.message : "操作失败"); return false; } + finally { if (startedScope === latest.current.scope) { sendingRef.current = false; setSending(false); } } + }, [run, sending, scope, props.chatId, props.projectId, mutate]); + return { run, sending, stopPending: stopping || Boolean(run?.cancelRequested), control }; +} diff --git a/lib/ai/canvas-agent-runtime/progress.ts b/lib/ai/canvas-agent-runtime/progress.ts new file mode 100644 index 000000000..572bff11e --- /dev/null +++ b/lib/ai/canvas-agent-runtime/progress.ts @@ -0,0 +1,29 @@ +import type { AgentRunSnapshot, AgentRunView } from "./protocol"; + +/** Public task activity only. Native engine diagnostics stay in server snapshots. */ +export function canvasAgentProgressLabel(message: string): string { + if (/zcode|沙箱|sandbox|bwrap|seatbelt|systemd|\/var\/|\/opt\//i.test(message)) { + if (/规划|执行画布|running/i.test(message)) return "正在分析需求并安排处理步骤"; + if (/失败|error|failed|unavailable|denied|拒绝|不足/i.test(message)) return "任务暂时无法继续,已保留完成的结果"; + return "正在准备处理任务"; + } + return message; +} +const oldTitles: Record = { + planCanvasTask: "安排处理步骤", readCanvas: "查看画布", analyzeCanvasImages: "分析图片", + generateCanvasImages: "生成图片", editTargetImage: "编辑图片", readProjectService: "读取所需资料", + getWeather: "查询天气", createDocument: "创建文档", updateDocument: "更新文档", requestSuggestions: "整理修改建议", + runProjectService: "处理创作内容", listProjectServices: "查找所需功能", loadProjectSkill: "读取技能要求", +}; +export function canvasAgentActivities(snapshot: AgentRunSnapshot) { + return snapshot.calls.slice(-12).map(call => ({ + id: call.id, + title: canvasAgentProgressLabel((call.title && call.title !== call.name ? call.title : oldTitles[call.name]) || "处理任务"), + status: call.status, + })); +} +export type CanvasAgentProgress = Pick; +export function canvasAgentProgress(run: AgentRunView): CanvasAgentProgress { + return { id: run.id, status: run.status, message: canvasAgentProgressLabel(run.message), + toolCount: run.toolCount, activities: run.activities || [] }; +} diff --git a/lib/ai/canvas-agent-runtime/protocol.ts b/lib/ai/canvas-agent-runtime/protocol.ts index 6bc5c516e..3616b1549 100644 --- a/lib/ai/canvas-agent-runtime/protocol.ts +++ b/lib/ai/canvas-agent-runtime/protocol.ts @@ -1,3 +1,4 @@ +import { canvasAgentProgressLabel } from "./progress"; import type { ModelMessage } from "ai"; export type CanvasAgentRunStatus = @@ -11,6 +12,7 @@ export class AgentRunControlChangedError extends Error {} export class AgentToolRejectedError extends Error {} export type AgentCall = { id: string; name: string; input: Record }; export type AgentCallRecord = AgentCall & { + title?: string; status: "queued" | "started" | "succeeded" | "failed"; uncertain?: boolean; rejected?: boolean; @@ -53,7 +55,8 @@ export type AgentRunSnapshot = { }; }; export type AgentRunView = { - runtime?: { engine: "zcode"; sandbox?: string; state?: "starting" | "running" | "stopped" }; + assistantMessageId?: string; + activities?: Array<{ id: string; title: string; status: AgentCallRecord["status"] }>; videoSettings?: import("./video-protocol").VideoAgentSettings; id: string; chatId: string; @@ -90,5 +93,5 @@ export function publicAgentError(error: unknown): string { if (/<(?:!doctype|html|head|body)\b/i.test(text) || /cloudflare|ray id/i.test(text)) { return "模型服务暂时不可用,已保留完成的步骤,请稍后继续。"; } - return text.replace(/(?:sk-[\w-]{12,}|Bearer\s+[\w.-]+)/gi, "[已隐藏]").slice(0, 400); + return canvasAgentProgressLabel(text.replace(/(?:sk-[\w-]{12,}|Bearer\s+[\w.-]+)/gi, "[已隐藏]").slice(0, 400)); } diff --git a/lib/ai/canvas-agent-runtime/store.ts b/lib/ai/canvas-agent-runtime/store.ts index e347ef785..5f0200202 100644 --- a/lib/ai/canvas-agent-runtime/store.ts +++ b/lib/ai/canvas-agent-runtime/store.ts @@ -1,3 +1,4 @@ +import { canvasAgentActivities, canvasAgentProgressLabel } from "./progress"; import { and, desc, eq, gt, lt, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { canvasAgentRun, canvasProject, chat } from "@/lib/db/schema"; @@ -35,14 +36,14 @@ export async function assertAgentRunOwnership(userId: string, projectId: string, export function agentRunView(run: Run): AgentRunView { const stale = run.status === "running" && Date.now() - run.updatedAt.getTime() > LEASE_STALE_MS; return { - ...(run.snapshot.nativeRuntime ? { runtime: { engine: "zcode" as const, - sandbox: run.snapshot.nativeRuntime.sandbox, state: run.snapshot.nativeRuntime.state } } : {}), + assistantMessageId: run.assistantMessageId, + activities: canvasAgentActivities(run.snapshot), id: run.id, videoSettings: run.snapshot.videoSettings, chatId: run.chatId, projectId: run.projectId, status: stale ? "interrupted" : (run.status as CanvasAgentRunStatus), - message: stale ? "任务连接已中断,已保留执行记录" : run.snapshot.message, + message: stale ? "任务连接已中断,已保留执行记录" : canvasAgentProgressLabel(run.snapshot.message), steps: run.snapshot.steps, toolCount: run.snapshot.calls.length, resultNodeIds: run.snapshot.resultNodeIds, diff --git a/lib/ai/zcode-agent/task.ts b/lib/ai/zcode-agent/task.ts index b21ce8747..60789b0d6 100644 --- a/lib/ai/zcode-agent/task.ts +++ b/lib/ai/zcode-agent/task.ts @@ -46,13 +46,14 @@ export async function runZcodeCanvasTask(ports: Ports): Promise ports.consumeInputs(snapshot)); if (control.cancelled || ports.signal.aborted) return await save("已停止后续处理,完成的结果保留在画布。", "cancelled"); - await save("正在启动 ZCode 独立沙箱"); + await save("正在准备处理任务"); const startedInputs = new Set(snapshot.inputs.map(input => input.id)); const result = await (ports.native || runNativeZcode)({ runId: ports.runId, attemptId: crypto.randomUUID(), @@ -79,7 +80,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise exclusive(async () => { snapshot.nativeRuntime = { ...snapshot.nativeRuntime!, sandbox: metadata.kind, commit: metadata.commit, state: "starting" }; - await save("正在启动独立沙箱中的 ZCode 核心"); + await save("正在准备处理任务"); }), onEvent: async (event: NativeEvent | NativeResult) => { if (!["turn.started", "result", "session.created", "session.resumed"].includes(event.type)) return; @@ -88,7 +89,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise { @@ -117,7 +118,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise !snapshot.verification!.failedNodeIds!.includes(id))) throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果"); } - snapshot.calls.push({ id: callId, name, input, status: "started" }); + snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" }); await save(`正在执行:${entry.title}`); return undefined; }); if (previous) return previous; diff --git a/lib/types.ts b/lib/types.ts index 7baacf87f..4bf740d44 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -43,6 +43,7 @@ export type CustomUIDataTypes = { clear: null; finish: null; usage: AppUsage; + canvasAgentProgress: import("./ai/canvas-agent-runtime/progress").CanvasAgentProgress; }; export type ChatMessage = UIMessage< diff --git a/playwright.canvas-agent.config.ts b/playwright.canvas-agent.config.ts index 9fc663ea8..607a5bb3e 100644 --- a/playwright.canvas-agent.config.ts +++ b/playwright.canvas-agent.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests/routes", - testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences)?|canvas-agent-project-tools|agent-capability-access)\.test\.ts/, + testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-progress)?|canvas-agent-project-tools|agent-capability-access)\.test\.tsx?/, fullyParallel: true, retries: 0, workers: 2, diff --git a/tests/helpers/canvas-download-server-loader.ts b/tests/helpers/canvas-download-server-loader.ts index 2a82f1142..08e71b369 100644 --- a/tests/helpers/canvas-download-server-loader.ts +++ b/tests/helpers/canvas-download-server-loader.ts @@ -14,7 +14,9 @@ export function serverLoader(mocks: Record, env: Record { + for (const text of ["ZCode 正在规划并执行画布任务", "正在启动 ZCode 独立沙箱", "sandbox bwrap failed at /var/lib/agent"]) + expect(canvasAgentProgressLabel(text)).not.toMatch(/zcode|沙箱|sandbox|bwrap|\/var\//i); + expect(canvasAgentProgressLabel("正在生成图片")).toBe("正在生成图片"); + expect(publicAgentError(new Error("ZCode sandbox failed: /opt/agent/bin/node"))).not.toMatch(/zcode|sandbox|\/opt\//i); +}); +test("public activity exposes real step titles but never tool arguments, output, engine or host paths", () => { + const snapshot = newAgentRunSnapshot("检查图片", []); + snapshot.calls.push({ id: "read", name: "readCanvas", input: { secret: "PRIVATE" }, output: { debug: "/var/lib/agent" }, status: "succeeded" }); + expect(canvasAgentActivities(snapshot)).toEqual([{ id: "read", title: "查看画布", status: "succeeded" }]); + const view = fixture(); const progress = canvasAgentProgress({ ...view, runtime: { engine: "zcode", sandbox: "linux-bwrap" } } as any); + expect(Object.keys(progress).sort()).toEqual(["activities", "id", "message", "status", "toolCount"]); + expect(JSON.stringify(progress)).not.toMatch(/zcode|sandbox|PRIVATE|\/var\//i); +}); +test("progress renders as inline assistant content with one stop action and no second input or mode badge", () => { + const run = fixture();const html = renderToStaticMarkup(createElement(CanvasAgentProgressMessage, { + progress: canvasAgentProgress(run), run, onCancel() {}, + })); + expect(html).toContain('data-agent-message-id="assistant-1"'); + expect(html).toContain("生成图片"); expect(html).toContain("停止后续步骤"); + expect(html).not.toMatch(/ZCode|沙箱|sandbox|Auto Agent| { + const run = { ...fixture(), status: "completed" as const }; + const part = JSON.parse(JSON.stringify({ type: "data-canvasAgentProgress", data: canvasAgentProgress(run) })); + const html = renderToStaticMarkup(createElement(CanvasAgentProgressMessage, { progress: part.data })); + expect(html).toContain("处理完成");expect(html).toContain("生成图片"); + expect(html).not.toContain("停止后续步骤");expect(html).not.toContain("]+open/); +});