fix(canvas): integrate Agent activity into chat messages without internal branding
This commit is contained in:
parent
0ab68d28ba
commit
c690c0e370
@ -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);
|
||||
|
||||
@ -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}
|
||||
|
||||
47
components/canvas/chat-panel/canvas-agent-progress.tsx
Normal file
47
components/canvas/chat-panel/canvas-agent-progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)} />;
|
||||
}
|
||||
|
||||
64
components/canvas/chat-panel/use-canvas-agent-run.ts
Normal file
64
components/canvas/chat-panel/use-canvas-agent-run.ts
Normal 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 };
|
||||
}
|
||||
29
lib/ai/canvas-agent-runtime/progress.ts
Normal file
29
lib/ai/canvas-agent-runtime/progress.ts
Normal 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 || [] };
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
43
tests/routes/canvas-agent-runtime-progress.test.tsx
Normal file
43
tests/routes/canvas-agent-runtime-progress.test.tsx
Normal 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/);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user