feat(agent): add task status and client-scoped operations

This commit is contained in:
yu
2026-07-17 18:30:10 +08:00
parent bdca6b0a5c
commit 062e4569aa
13 changed files with 407 additions and 39 deletions
@@ -151,6 +151,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
setAgentState({ connected: true, activity: "已连接", connectError: "", silentConnect: false, messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
if (!headless) message.success("本地 Agent 已连接");
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId);
});
source.addEventListener("tool_call", (event) => {
const data = parseEventData<AgentPendingToolCall>(event);
@@ -200,6 +201,19 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
if (connected) void loadThreads();
}, [connected, loadThreads]);
useEffect(() => {
if (!connected) return;
const activate = () => void activateAgentClient(endpoint, token, clientIdRef.current);
const activateVisible = () => {
if (document.visibilityState === "visible") activate();
};
window.addEventListener("focus", activate);
document.addEventListener("visibilitychange", activateVisible);
return () => {
window.removeEventListener("focus", activate);
document.removeEventListener("visibilitychange", activateVisible);
};
}, [connected, endpoint, token]);
const sendPrompt = async () => {
const text = prompt.trim();
const files = attachments;
@@ -304,7 +318,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
try {
setAgentState({ activity: SITE_TOOL_LABELS[payload.name], waiting: true });
addEventLog(toolName(payload.name), payload, payload);
const result = await runSiteTool(payload.name, payload.input || {}, navigate);
const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result);
@@ -955,6 +969,12 @@ async function postState(endpoint: string, token: string, clientId: string, snap
} catch {}
}
async function activateAgentClient(endpoint: string, token: string, clientId: string) {
try {
await fetch(`${endpoint}/canvas/activate?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST" });
} catch {}
}
async function postToolResult(endpoint: string, token: string, clientId: string, body: { requestId: string; result?: unknown; error?: string }) {
await fetch(`${endpoint}/canvas/result?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
}
@@ -1093,6 +1113,10 @@ function siteToolSummary(name: string, result: unknown) {
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
if (name === "assets_list") return `${numberField(data, "total")} 个资产`;
if (name === "assets_add") return "已加入我的资产";
if (name === "generation_get_status") {
const summary = data.summary && typeof data.summary === "object" ? (data.summary as Record<string, unknown>) : {};
return `${numberField(data, "total")} 个任务,排队 ${numberField(summary, "queued")},运行中 ${numberField(summary, "running")},成功 ${numberField(summary, "succeeded")},失败 ${numberField(summary, "failed")}`;
}
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
return "已完成";
+63 -5
View File
@@ -4,6 +4,7 @@ import { fetchPrompts } from "@/services/api/prompts";
import { uploadImage } from "@/services/image-storage";
import { imageAspectOptions, imageQualityOptions } from "@/components/image-settings-panel";
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useAssetStore } from "@/stores/use-asset-store";
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
@@ -14,6 +15,7 @@ import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
export const SITE_TOOL_NAMES = [
"canvas_list_projects",
"generation_get_status",
"workbench_image_get_config",
"workbench_image_generate",
"workbench_video_get_config",
@@ -31,6 +33,7 @@ export function isSiteTool(name: string): name is SiteToolName {
export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
canvas_list_projects: "画布列表",
generation_get_status: "生成任务状态",
workbench_image_get_config: "生图配置",
workbench_image_generate: "生图工作台生成",
workbench_video_get_config: "视频配置",
@@ -41,11 +44,16 @@ export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
};
type SiteToolInput = Record<string, unknown>;
type SiteToolContext = { canvasSnapshot?: CanvasAgentSnapshot | null };
type GenerationStatus = "idle" | "queued" | "running" | "succeeded" | "failed";
type GenerationStatusItem = { id: string; source: "canvas" | "image" | "video"; status: GenerationStatus; kind?: string; title?: string; prompt?: string; projectId?: string; createdAt?: string; updatedAt?: string; successCount?: number; failCount?: number; error?: string };
export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction): Promise<unknown> {
export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction, context: SiteToolContext = {}): Promise<unknown> {
switch (name) {
case "canvas_list_projects":
return listCanvasProjects(input);
case "generation_get_status":
return getGenerationStatus(input, context.canvasSnapshot);
case "workbench_image_get_config":
return getImageConfig();
case "workbench_image_generate":
@@ -65,6 +73,56 @@ export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navi
}
}
function getGenerationStatus(input: SiteToolInput, canvasSnapshot?: CanvasAgentSnapshot | null) {
const scope = input.scope === "canvas" || input.scope === "image" || input.scope === "video" ? input.scope : "all";
const taskId = typeof input.taskId === "string" ? input.taskId : "";
const nodeIds = new Set(Array.isArray(input.nodeIds) ? input.nodeIds.filter((id): id is string => typeof id === "string") : []);
const limit = Math.max(1, Math.min(100, Math.floor(Number(input.limit)) || 20));
const tasks: GenerationStatusItem[] = [];
const includeCanvas = (scope === "all" || scope === "canvas") && (!taskId || nodeIds.size > 0);
const includeWorkbench = !nodeIds.size || Boolean(taskId);
if (includeCanvas && canvasSnapshot) {
canvasSnapshot.nodes.forEach((node) => {
const status = normalizeCanvasGenerationStatus(node.metadata?.status);
if (!status || (nodeIds.size && !nodeIds.has(node.id))) return;
const metadata = node.metadata || {};
if (!nodeIds.size && node.type !== "config" && status !== "running" && status !== "failed" && !metadata.generationMode && !metadata.generationType && !metadata.model) return;
tasks.push({ id: node.id, source: "canvas", status, kind: metadata.generationMode || node.type, title: node.title, prompt: compactPrompt(metadata.prompt || metadata.composerContent), projectId: canvasSnapshot.projectId, error: metadata.errorDetails });
});
}
if (includeWorkbench) {
useWorkbenchAgentStore.getState().tasks.forEach((task) => {
if ((scope === "image" || scope === "video") && task.kind !== scope) return;
if (scope === "canvas" || (taskId && task.id !== taskId)) return;
tasks.push({ ...task, source: task.kind, prompt: compactPrompt(task.prompt) });
});
}
tasks.sort((a, b) => generationStatusOrder(a.status) - generationStatusOrder(b.status) || (b.updatedAt || "").localeCompare(a.updatedAt || ""));
const summary: Record<GenerationStatus, number> = { idle: 0, queued: 0, running: 0, succeeded: 0, failed: 0 };
tasks.forEach((task) => (summary[task.status] += 1));
return { total: tasks.length, summary, tasks: tasks.slice(0, limit) };
}
function generationStatusOrder(status: GenerationStatus) {
return status === "running" ? 0 : status === "queued" ? 1 : 2;
}
function normalizeCanvasGenerationStatus(status: unknown): GenerationStatus | null {
if (status === "idle") return "idle";
if (status === "loading") return "running";
if (status === "success") return "succeeded";
if (status === "error") return "failed";
return null;
}
function compactPrompt(prompt: unknown) {
const value = typeof prompt === "string" ? prompt.trim() : "";
return value ? `${value.slice(0, 200)}${value.length > 200 ? "..." : ""}` : undefined;
}
function listCanvasProjects(input: SiteToolInput) {
const { projects, hydrated } = useCanvasStore.getState();
if (!hydrated) throw new Error("画布还在加载中,请稍后重试");
@@ -118,8 +176,8 @@ function runImageWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
const run = input.run !== false;
navigate("/image");
useWorkbenchAgentStore.getState().dispatchImage({ prompt, run });
return { ok: true, navigated: "/image", prompt, run, applied, note: run ? "已跳转生图工作台并触发生成,结果请稍后在工作台查看" : "已跳转生图工作台并填入参数,未触发生成" };
const taskId = useWorkbenchAgentStore.getState().dispatchImage({ prompt, run });
return { ok: true, navigated: "/image", prompt, run, taskId, applied, note: run ? "已跳转生图工作台并触发生成,可用 generation_get_status 查询任务" : "已跳转生图工作台并填入参数,未触发生成" };
}
function getVideoConfig() {
@@ -173,8 +231,8 @@ function runVideoWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
const run = input.run !== false;
navigate("/video");
useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run });
return { ok: true, navigated: "/video", prompt, run, applied, note: run ? "已跳转视频创作台并触发生成,结果请稍后在工作台查看" : "已跳转视频创作台并填入参数,未触发生成" };
const taskId = useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run });
return { ok: true, navigated: "/video", prompt, run, taskId, applied, note: run ? "已跳转视频创作台并触发生成,可用 generation_get_status 查询任务" : "已跳转视频创作台并填入参数,未触发生成" };
}
async function searchPrompts(input: SiteToolInput) {
+22 -3
View File
@@ -92,7 +92,9 @@ export default function ImagePage() {
const [autoRunToken, setAutoRunToken] = useState(0);
const imageCommand = useWorkbenchAgentStore((state) => state.imageCommand);
const clearImageCommand = useWorkbenchAgentStore((state) => state.clearImageCommand);
const updateAgentTask = useWorkbenchAgentStore((state) => state.updateTask);
const processedCommandRef = useRef(0);
const agentTaskIdRef = useRef<string | undefined>(undefined);
const model = effectiveConfig.imageModel || effectiveConfig.model;
const canGenerate = Boolean(prompt.trim());
@@ -141,22 +143,30 @@ export default function ImagePage() {
};
const generate = async () => {
const agentTaskId = agentTaskIdRef.current;
agentTaskIdRef.current = undefined;
const text = prompt.trim();
if (!text) {
message.error("请输入生图提示词");
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "请输入生图提示词" });
return;
}
if (!isAiConfigReady(effectiveConfig, model)) {
message.warning("请先完成配置");
openConfigDialog(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图配置不完整" });
return;
}
const snapshot = buildRequestSnapshot();
if (!snapshot) return;
if (!snapshot) {
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图参数无效" });
return;
}
setElapsedMs(0);
setRunning(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "running", error: undefined });
setPreviewLog(null);
setResults(Array.from({ length: generationCount }, () => ({ id: nanoid(), status: "pending" })));
const batchStartedAt = performance.now();
@@ -169,6 +179,8 @@ export default function ImagePage() {
const successCount = successImages.length;
const failCount = generationCount - successCount;
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
const error = failed?.reason instanceof Error ? failed.reason.message : failCount ? "生成失败" : undefined;
if (agentTaskId) updateAgentTask(agentTaskId, { status: successCount ? "succeeded" : "failed", successCount, failCount, error: successCount ? undefined : error });
try {
const logImages = await Promise.all(
@@ -202,8 +214,15 @@ export default function ImagePage() {
processedCommandRef.current = imageCommand.nonce;
clearImageCommand();
if (typeof imageCommand.prompt === "string") setPrompt(imageCommand.prompt);
if (imageCommand.run && !running) setAutoRunToken((value) => value + 1);
}, [imageCommand, clearImageCommand, running]);
if (imageCommand.run && running) {
if (imageCommand.taskId) updateAgentTask(imageCommand.taskId, { status: "failed", error: "生图工作台已有任务正在运行" });
return;
}
if (imageCommand.run) {
agentTaskIdRef.current = imageCommand.taskId;
setAutoRunToken((value) => value + 1);
}
}, [imageCommand, clearImageCommand, running, updateAgentTask]);
useEffect(() => {
if (!autoRunToken) return;
+29 -11
View File
@@ -97,7 +97,9 @@ export default function VideoPage() {
const [autoRunToken, setAutoRunToken] = useState(0);
const videoCommand = useWorkbenchAgentStore((state) => state.videoCommand);
const clearVideoCommand = useWorkbenchAgentStore((state) => state.clearVideoCommand);
const updateAgentTask = useWorkbenchAgentStore((state) => state.updateTask);
const processedCommandRef = useRef(0);
const agentTaskIdRef = useRef<string | undefined>(undefined);
const model = effectiveConfig.videoModel || effectiveConfig.model;
const canGenerate = Boolean(prompt.trim());
@@ -170,10 +172,16 @@ export default function VideoPage() {
}
};
const generate = async () => {
const agentTaskId = agentTaskIdRef.current;
agentTaskIdRef.current = undefined;
const snapshot = buildRequestSnapshot();
if (!snapshot) return;
if (!snapshot) {
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "视频生成参数无效" });
return;
}
setElapsedMs(0);
setRunning(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "running", error: undefined });
setPreviewLog(null);
setResults([{ id: nanoid(), status: "pending" }]);
const batchStartedAt = performance.now();
@@ -181,11 +189,12 @@ export default function VideoPage() {
try {
const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences);
const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "生成中", task });
await saveLog(log);
void pollGenerationLog(log, snapshot.config);
await saveLog(log, false);
void pollGenerationLog(log, snapshot.config, agentTaskId);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
message.error(errorMessage);
setRunning(false);
@@ -198,8 +207,15 @@ export default function VideoPage() {
processedCommandRef.current = videoCommand.nonce;
clearVideoCommand();
if (typeof videoCommand.prompt === "string") setPrompt(videoCommand.prompt);
if (videoCommand.run && !running) setAutoRunToken((value) => value + 1);
}, [videoCommand, clearVideoCommand, running]);
if (videoCommand.run && running) {
if (videoCommand.taskId) updateAgentTask(videoCommand.taskId, { status: "failed", error: "视频工作台已有任务正在运行" });
return;
}
if (videoCommand.run) {
agentTaskIdRef.current = videoCommand.taskId;
setAutoRunToken((value) => value + 1);
}
}, [videoCommand, clearVideoCommand, running, updateAgentTask]);
useEffect(() => {
if (!autoRunToken) return;
@@ -276,7 +292,7 @@ export default function VideoPage() {
.filter((log) => selectedLogIds.includes(log.id))
.map((log) => log.video?.storageKey)
.filter((key): key is string => Boolean(key));
void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(refreshLogs);
void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(() => refreshLogs());
if (previewLog && selectedLogIds.includes(previewLog.id)) {
setPreviewLog(null);
setResults([]);
@@ -285,15 +301,15 @@ export default function VideoPage() {
setDeleteConfirmOpen(false);
};
const saveLog = async (log: GenerationLog) => {
const saveLog = async (log: GenerationLog, resumePending = true) => {
await logStore.setItem(log.id, serializeLog(log));
await refreshLogs();
await refreshLogs(resumePending);
};
const refreshLogs = async () => {
const refreshLogs = async (resumePending = true) => {
const nextLogs = await readStoredLogs();
setLogs(nextLogs);
resumePendingLogs(nextLogs);
if (resumePending) resumePendingLogs(nextLogs);
return nextLogs;
};
@@ -303,7 +319,7 @@ export default function VideoPage() {
}
};
const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig) => {
const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig, agentTaskId?: string) => {
if (!log.task || activeLogIdsRef.current.has(log.id)) return;
activeLogIdsRef.current.add(log.id);
setRunning(true);
@@ -326,6 +342,7 @@ export default function VideoPage() {
mimeType: stored.mimeType,
};
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "succeeded", successCount: 1, failCount: 0, error: undefined });
await saveLog({ ...log, status: "成功", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined });
message.success("视频已生成");
return;
@@ -337,6 +354,7 @@ export default function VideoPage() {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: log.id, status: "failed", error: errorMessage }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
await saveLog({ ...log, status: "失败", durationMs: Date.now() - log.createdAt, error: errorMessage });
message.error(errorMessage);
} finally {
+36 -4
View File
@@ -6,15 +6,30 @@ import { create } from "zustand";
export type WorkbenchCommand = {
nonce: number;
taskId?: string;
prompt?: string;
run: boolean;
};
export type WorkbenchGenerationTask = {
id: string;
kind: "image" | "video";
status: "queued" | "running" | "succeeded" | "failed";
prompt?: string;
createdAt: string;
updatedAt: string;
successCount?: number;
failCount?: number;
error?: string;
};
type WorkbenchAgentStore = {
imageCommand: WorkbenchCommand | null;
videoCommand: WorkbenchCommand | null;
dispatchImage: (command: Omit<WorkbenchCommand, "nonce">) => void;
dispatchVideo: (command: Omit<WorkbenchCommand, "nonce">) => void;
tasks: WorkbenchGenerationTask[];
dispatchImage: (command: Omit<WorkbenchCommand, "nonce" | "taskId">) => string | undefined;
dispatchVideo: (command: Omit<WorkbenchCommand, "nonce" | "taskId">) => string | undefined;
updateTask: (id: string, patch: Partial<Pick<WorkbenchGenerationTask, "status" | "successCount" | "failCount" | "error">>) => void;
clearImageCommand: () => void;
clearVideoCommand: () => void;
};
@@ -25,8 +40,25 @@ const nextNonce = () => (nonce += 1);
export const useWorkbenchAgentStore = create<WorkbenchAgentStore>((set) => ({
imageCommand: null,
videoCommand: null,
dispatchImage: (command) => set({ imageCommand: { ...command, nonce: nextNonce() } }),
dispatchVideo: (command) => set({ videoCommand: { ...command, nonce: nextNonce() } }),
tasks: [],
dispatchImage: (command) => {
const commandNonce = nextNonce();
const task = command.run ? createTask("image", commandNonce, command.prompt) : undefined;
set((state) => ({ imageCommand: { ...command, nonce: commandNonce, taskId: task?.id }, tasks: task ? [task, ...state.tasks].slice(0, 30) : state.tasks }));
return task?.id;
},
dispatchVideo: (command) => {
const commandNonce = nextNonce();
const task = command.run ? createTask("video", commandNonce, command.prompt) : undefined;
set((state) => ({ videoCommand: { ...command, nonce: commandNonce, taskId: task?.id }, tasks: task ? [task, ...state.tasks].slice(0, 30) : state.tasks }));
return task?.id;
},
updateTask: (id, patch) => set((state) => ({ tasks: state.tasks.map((task) => (task.id === id ? { ...task, ...patch, updatedAt: new Date().toISOString() } : task)) })),
clearImageCommand: () => set({ imageCommand: null }),
clearVideoCommand: () => set({ videoCommand: null }),
}));
function createTask(kind: "image" | "video", commandNonce: number, prompt?: string): WorkbenchGenerationTask {
const now = new Date().toISOString();
return { id: `${kind}-${commandNonce}`, kind, status: "queued", prompt, createdAt: now, updatedAt: now };
}