182 lines
7.1 KiB
TypeScript
182 lines
7.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|