fix(canvas): integrate Agent activity into chat messages without internal branding

This commit is contained in:
134 2026-10-21 00:01:43 +08:00
parent 0ab68d28ba
commit c690c0e370
13 changed files with 277 additions and 260 deletions

View File

@ -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<void>) | 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);

View File

@ -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 ? (
<div className="flex h-full min-h-[360px] items-center justify-center">
<div className="w-full max-w-3xl rounded-[18px] border border-dashed border-[#d7dce5] bg-white/78 p-7 text-center shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-zinc-900/60">
@ -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 ? (
<CanvasAgentProgressMessage progress={messageAgentProgress} run={messageAgentRun} busy={isSubmitting}
stopping={canvasAgentTask.stopPending} sending={canvasAgentTask.sending}
onCancel={messageAgentRun ? () => void canvasAgentTask.control("cancel") : undefined}
onResume={messageAgentRun ? () => { canvasAgentResumeRunIdRef.current = messageAgentRun.id; void handleSend(input.trim() || "请继续未完成的步骤,保留已经完成的结果。"); } : undefined} />
) : null}
{/* 消息内容 */}
<div className="w-full min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] leading-relaxed">
{message.parts?.map((part: any, i: number) => {
@ -17024,6 +17061,13 @@ export function CanvasChatPanel({
);
})}
{activeCanvasAgentRun && !renderableVisibleMessages.some(message => message.id === activeCanvasAgentRun.assistantMessageId) ? (
<CanvasAgentProgressMessage progress={canvasAgentProgress(activeCanvasAgentRun)} run={activeCanvasAgentRun} busy={isSubmitting}
stopping={canvasAgentTask.stopPending} sending={canvasAgentTask.sending}
onCancel={() => void canvasAgentTask.control("cancel")}
onResume={() => { canvasAgentResumeRunIdRef.current = activeCanvasAgentRun.id; void handleSend(input.trim() || "请继续未完成的步骤,保留已经完成的结果。"); }} />
) : null}
{shouldRenderImageLoading && (
<ImageGenerationLoading
imageCount={loadingImageCount}
@ -17038,7 +17082,7 @@ export function CanvasChatPanel({
/>
)}
{shouldRenderAssistantTypingPlaceholder ? (
{shouldRenderAssistantTypingPlaceholder && !isCanvasAgentRunning ? (
<CanvasAssistantTypingPlaceholder label="分析中" />
) : null}
@ -17928,18 +17972,6 @@ export function CanvasChatPanel({
)}
</div>
)}
<CanvasAgentRunPanel
enabled={effectiveAgentEnabled && !isVideoWorkspace && activeComposerMode === "agent" && isValidChatUuid(rawChatId) && isValidChatUuid(canvasId)}
chatId={rawChatId}
projectId={canvasId}
busy={isSubmitting}
onResume={(runId) => {
if (isSubmitting) return;
canvasAgentResumeRunIdRef.current = runId;
void handleSend(input.trim() || "请继续这个任务的未完成步骤,保留已生成的结果,优先处理未通过检查的图片。");
}}
onResults={(projectId, nodeIds) => { void refreshCanvasAfterAgentOperation({ projectId, createdNodeIds: nodeIds }); }}
/>
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div
className={cn(
@ -18098,54 +18130,6 @@ export function CanvasChatPanel({
</div>
</div>
)}
{!unifiedImageAgent && !isDetailGenerationMode && activeComposerMode === "agent" && (
<div className="mb-2 flex flex-wrap items-center gap-2">
{hasSelectedAgentPreset ? (
<>
<button
type="button"
onClick={() => setAgentEntryMode("auto")}
className={cn(
"inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
!isPresetAgentSelection
? "bg-[#eef2ff] text-[#4338ca] dark:bg-blue-500/15 dark:text-blue-200"
: "bg-[#f4f5f7] text-[#5b6472] hover:bg-[#eceef2] dark:bg-white/8 dark:text-zinc-300 dark:hover:bg-white/12"
)}
>
<Sparkles className="h-4 w-4" />
<span>Auto Agent</span>
</button>
<button
type="button"
onClick={() => setAgentEntryMode("preset")}
className={cn(
"inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
isPresetAgentSelection
? "bg-[#eef2ff] text-[#4338ca] dark:bg-blue-500/15 dark:text-blue-200"
: "bg-[#f4f5f7] text-[#5b6472] hover:bg-[#eceef2] dark:bg-white/8 dark:text-zinc-300 dark:hover:bg-white/12"
)}
>
<Sparkles className="h-4 w-4" />
<span>{selectedAgentPreset?.name || "预设Agent"}</span>
</button>
</>
) : (
<div className="inline-flex items-center gap-2 rounded-full bg-[#eef2ff] px-3 py-1.5 text-sm font-medium text-[#4338ca] dark:bg-blue-500/15 dark:text-blue-200">
<Sparkles className="h-4 w-4" />
<span>Auto Agent</span>
</div>
)}
{isPresetAgentSelection && selectedAgentPreset?.type === "text" ? (
<div className="inline-flex items-center rounded-full bg-[#f4f5f7] px-3 py-1.5 text-xs text-[#5b6472] dark:bg-white/8 dark:text-zinc-300">
可上传图片做分析,也可直接输入文案需求
</div>
) : hasAgentUploadSlots ? (
<div className="inline-flex items-center rounded-full bg-[#f4f5f7] px-3 py-1.5 text-xs text-[#5b6472] dark:bg-white/8 dark:text-zinc-300">
{isWorkspacePresentation ? "请确认产品图1、产品图2、模特图槽位" : "请上传产品图1、产品图2、模特图"}
</div>
) : null}
</div>
)}
{shouldShowAgentProgressCard && activeAgentProgress ? (
<div
className={cn(
@ -18405,10 +18389,10 @@ export function CanvasChatPanel({
>
<div
ref={textareaRef as any}
contentEditable={!isSubmitting}
contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)}
role="textbox"
tabIndex={isSubmitting ? -1 : 0}
aria-disabled={isSubmitting}
aria-disabled={isSubmitting && (!isCanvasAgentRunning || canvasAgentTask.stopPending)}
aria-label="描述你的设计需求"
aria-multiline="true"
suppressContentEditableWarning
@ -19091,10 +19075,11 @@ export function CanvasChatPanel({
}
void handleSend();
}}
disabled={isClawdStopAvailable ? false : isSubmitting || !canSend}
disabled={isClawdStopAvailable ? false : isCanvasAgentRunning ? canvasAgentTask.sending || canvasAgentTask.stopPending || !input.trim() : isSubmitting || !canSend}
title={
isClawdStopAvailable
? "停止处理并重新编辑"
: isCanvasAgentRunning ? "发送补充要求"
: isWorkspacePresentation || isDetailGenerationMode
? getToolbarItem("chatStartGeneration").label
: !isVideoWorkspace && (activeComposerMode === "image" || isImageAgentComposer) && estimatedPointsCost
@ -19107,7 +19092,7 @@ export function CanvasChatPanel({
<Square className="h-3.5 w-3.5 fill-current" />
{isWorkspacePresentation ? <span className="ml-2">停止</span> : null}
</>
) : isSubmitting ? (
) : isSubmitting && !isCanvasAgentRunning ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isWorkspacePresentation ? <span className="ml-2">生成中</span> : null}

View File

@ -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 (
<div className="my-2 w-full min-w-0 text-xs text-zinc-500 dark:text-zinc-400" data-testid="canvas-agent-progress" data-agent-message-id={run?.assistantMessageId}>
<div className="flex items-start gap-2">
<details className="min-w-0 flex-1" open={running}>
<summary className="flex cursor-pointer list-none items-center gap-2 py-1 [&::-webkit-details-marker]:hidden">
{running ? <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" /> : progress.status === "completed" ? <Check className="h-3.5 w-3.5 shrink-0" /> : <CircleAlert className="h-3.5 w-3.5 shrink-0" />}
<span className="min-w-0 flex-1" role="status" aria-live="polite">{label}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</summary>
{progress.activities?.length ? (
<ol className="ml-1.5 mt-1 space-y-1.5 border-l border-zinc-200 pl-4 dark:border-zinc-800" aria-label="任务执行步骤">
{progress.activities.map(activity => (
<li key={activity.id} className="flex items-center gap-2">
{activity.status === "started" ? <Loader2 className="h-3 w-3 shrink-0 animate-spin" /> : <span className="h-1 w-1 shrink-0 rounded-full bg-current" />}
<span>{canvasAgentProgressLabel(activity.title)}</span>
<span className="text-[11px] opacity-75">{activity.status === "succeeded" ? "已完成" : activity.status === "failed" ? "待处理" : activity.status === "queued" ? "待执行" : "进行中"}</span>
</li>
))}
</ol>
) : null}
{run?.inputs.slice(-2).map(input => (
<p key={input.id} className="mt-2 whitespace-pre-wrap break-words">{input.applied ? "已应用补充要求" : "已收到补充要求"}:{input.text}</p>
))}
</details>
{running && props.onCancel ? (
<button type="button" onClick={props.onCancel} disabled={props.sending || props.stopping} className="rounded-md p-1.5 hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-white/10" title="停止后续步骤" aria-label="停止后续步骤"><Square className="h-3.5 w-3.5" /></button>
) : run?.resumable && props.onResume ? (
<button type="button" onClick={props.onResume} disabled={props.busy} className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-white/10"><RotateCcw className="h-3.5 w-3.5" />继续任务</button>
) : null}
</div>
</div>
);
}

View File

@ -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<AgentRunView>).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 (
<div
className="mb-2 rounded-2xl border border-zinc-200 bg-zinc-50 px-3 py-2.5 text-xs dark:border-zinc-800 dark:bg-zinc-900"
data-testid="canvas-agent-run"
>
<div
className="flex items-center gap-2 text-zinc-700 dark:text-zinc-200"
role="status"
aria-live="polite"
>
{running ? <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" /> : null}
<span className="min-w-0 flex-1">
{stopPending && running ? "正在停止后续步骤,等待当前生成结果" : run.message}
</span>
{running ? (
<button
type="button"
onClick={() => void control("cancel")}
disabled={sending || stopPending}
className="rounded-lg p-1.5 hover:bg-zinc-200 disabled:opacity-40 dark:hover:bg-zinc-800"
title="停止后续步骤"
aria-label="停止后续步骤"
>
<Square className="h-3.5 w-3.5" />
</button>
) : run.resumable ? (
<button
type="button"
onClick={() => props.onResume(run.id)}
disabled={props.busy}
className="flex shrink-0 items-center gap-1 rounded-lg px-2 py-1.5 hover:bg-zinc-200 disabled:opacity-40 dark:hover:bg-zinc-800"
>
<RotateCcw className="h-3.5 w-3.5" />
继续任务
</button>
) : null}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
{run.runtime?.engine === "zcode" ? <span className="mr-2">ZCode · {!run.runtime.sandbox ? "尚未启动沙箱" : run.runtime.state === "starting" ? "正在启动独立沙箱" : run.runtime.state === "running" ? "独立沙箱运行中" : "沙箱已停止"}</span> : null}
已处理 {run.steps} 轮 · {run.toolCount} 个工具步骤
</div>
{run.inputs.slice(-2).map((input) => (
<div key={input.id} className="mt-1 truncate text-zinc-500" title={input.text}>
{input.applied ? "已应用" : "待应用"}:{input.text}
</div>
))}
{running && !stopPending ? (
<form
className="mt-2 flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
if (text.trim()) void control("steer");
}}
>
<input
value={text}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={sending || !text.trim()}
title="发送补充要求"
aria-label="发送补充要求"
className="rounded-full bg-zinc-200 p-2 text-zinc-700 disabled:opacity-40 dark:bg-zinc-800 dark:text-zinc-100"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
</form>
) : null}
</div>
);
// 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 <CanvasAgentProgressMessage progress={canvasAgentProgress(task.run)} run={task.run} busy={props.busy}
stopping={task.stopPending} sending={task.sending} onCancel={() => void task.control("cancel")}
onResume={() => props.onResume(task.run!.id)} />;
}

View File

@ -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<AgentRunView>).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 };
}

View File

@ -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<string, string> = {
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<AgentRunView, "id" | "status" | "message" | "toolCount" | "activities">;
export function canvasAgentProgress(run: AgentRunView): CanvasAgentProgress {
return { id: run.id, status: run.status, message: canvasAgentProgressLabel(run.message),
toolCount: run.toolCount, activities: run.activities || [] };
}

View File

@ -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<string, unknown> };
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));
}

View File

@ -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,

View File

@ -46,13 +46,14 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
let question = "";
const tools = Object.keys(ports.tools);
const instructions = [ports.instructions,
"你正在独立沙箱内运行原生 ZCode 核心。本站已授权的图片、节点、界面及项目业务能力通过 canvas MCP 提供;视频画布暂未授权,不得绕过限制;先读实际画布,不猜测节点 ID。",
"你正在处理本站 Agent 任务。本站已授权的图片、节点、界面及项目业务能力通过 canvas MCP 提供;视频画布暂未授权,不得绕过限制;先读实际画布,不猜测节点 ID。",
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
"查询项目级能力先用 listProjectServices;技能先 loadProjectSkill。项目工具的任务受理、文件选择与后台缺失不等于处理完成。",
"需要澄清时调用 requestCanvasUserInput;不要使用终端交互。工具返回的 userUpdates 是用户的新补充,必须从下一步纳入。",
"工具错误涉及结果未确认时停止收费动作,不通过更换调用 ID 或另一个工具重复生成。用户明确要求修改的目标才能删除或覆盖。",
"面向用户统一称为 Agent。回复只包含用户需要的处理进度和结果,不展示内部引擎品牌、沙箱、执行进程、系统路径、内部配置或原始调试信息。",
"只有真实工具返回成功才可声称完成;打开面板、等待用户选文件、后台缺少处理服务都不能声称媒体已处理。",
].join("\n");
let prompt = snapshot.nativeRuntime.sessionId
@ -61,7 +62,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
while (true) {
const control = await exclusive(() => 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<CanvasAgentRunSt
},
onSandbox: async metadata => 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<CanvasAgentRunSt
if (event.traceId) snapshot.nativeRuntime!.traceId = event.traceId;
snapshot.nativeRuntime!.state = "running";
if (event.type === "turn.started") snapshot.steps++;
await save("ZCode 正在规划并执行画布任务");
await save("正在分析需求并安排处理步骤");
});
},
execute: async (name, input, callId) => {
@ -117,7 +118,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (!targets.length || targets.some(id => !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;

View File

@ -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<

View File

@ -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,

View File

@ -14,7 +14,9 @@ export function serverLoader(mocks: Record<string, unknown>, env: Record<string,
const module = { exports: {} };
modules.set(filename, module);
const source = ts.transpileModule(readFileSync(filename, "utf8"), {
fileName: filename,
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
esModuleInterop: true,

View File

@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { canvasAgentProgress, canvasAgentProgressLabel, canvasAgentActivities } from "@/lib/ai/canvas-agent-runtime/progress";
import { newAgentRunSnapshot, publicAgentError, type AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol";
import { serverLoader } from "../helpers/canvas-download-server-loader";
const { CanvasAgentProgressMessage } = serverLoader({})("components/canvas/chat-panel/canvas-agent-progress.tsx");
function fixture(): AgentRunView {
return { id: "r", assistantMessageId: "assistant-1", chatId: "c", projectId: "p", status: "running",
message: "ZCode 正在规划并执行画布任务", steps: 3, toolCount: 1, resultNodeIds: [], inputs: [],
updatedAt: new Date().toISOString(), cancelRequested: false, resumable: false,
activities: [{ id: "step-1", title: "生成图片", status: "started" }] };
}
test("public status converts legacy engine and isolation messages without exposing diagnostics", () => {
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|<input|<form|已处理\s*3\s*轮/i);
});
test("completed execution details remain expandable in saved assistant history without active controls", () => {
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("<button");
expect(html).not.toMatch(/<details[^>]+open/);
});