ai-chatbot/components/canvas/chat-panel/canvas-agent-run-panel.tsx

182 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}