chore: commit workspace updates
This commit is contained in:
51
web/src/lib/canvas/agent-approval-presentation.ts
Normal file
51
web/src/lib/canvas/agent-approval-presentation.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
type RecordValue = Record<string, unknown>;
|
||||
|
||||
function record(value: unknown): RecordValue | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function approvalArguments(value: unknown): RecordValue | null {
|
||||
if (typeof value !== "string") return record(value);
|
||||
try {
|
||||
return record(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function agentApprovalPresentation(detail: unknown) {
|
||||
const payload = record(detail);
|
||||
const call = record(payload?.call);
|
||||
const fn = record(call?.function);
|
||||
const name = text(fn?.name) || text(payload?.toolName);
|
||||
const args = approvalArguments(fn?.arguments ?? payload?.arguments);
|
||||
|
||||
if (name === "canvas_apply_ops") {
|
||||
const ops = Array.isArray(args?.ops) ? args.ops.map(record).filter((op): op is RecordValue => Boolean(op)) : [];
|
||||
const added = ops.filter((op) => op.type === "add_node").length;
|
||||
const updated = ops.filter((op) => op.type === "update_node").length;
|
||||
const connected = ops.filter((op) => op.type === "connect_nodes").length;
|
||||
const action = [added ? `新增 ${added} 个节点` : "", updated ? `修改 ${updated} 个节点` : "", connected ? `建立 ${connected} 条引用连线` : ""].filter(Boolean).join(",");
|
||||
const titles = ops.map((op) => text(op.title)).filter(Boolean).slice(0, 3).map((title) => title.slice(0, 64));
|
||||
return { title: "确认画布修改", description: action ? `Agent 准备${action},确认后才会写入画布。` : "Agent 准备修改当前画布,确认后才会写入。", items: titles };
|
||||
}
|
||||
if (name === "generate_media") {
|
||||
const type = args?.mode === "video" ? "视频" : args?.mode === "image" ? "图片" : "媒体";
|
||||
const references = Array.isArray(args?.referenceNodeIds) ? args.referenceNodeIds.filter((id) => typeof id === "string") : [];
|
||||
const items = [
|
||||
text(payload?.modelName) ? `模型:${text(payload?.modelName)}` : (text(args?.channelModelKey) || text(args?.logicalModelId)) ? `模型:${text(args?.channelModelKey) || text(args?.logicalModelId)}` : "",
|
||||
text(args?.title) ? `节点:${text(args?.title).slice(0, 64)}` : "",
|
||||
references.length ? `引用 ${references.length} 个画布资产,并建立连线` : "不引用画布媒体资产",
|
||||
typeof args?.durationSeconds === "number" && args.durationSeconds > 0 ? `时长:${args.durationSeconds} 秒` : "",
|
||||
text(args?.size) ? `画幅:${text(args?.size)}` : "",
|
||||
text(args?.quality) ? `质量:${text(args?.quality)}` : "",
|
||||
typeof args?.videoGenerateAudio === "boolean" ? `音频:${args.videoGenerateAudio ? "开启" : "关闭"}` : "",
|
||||
].filter(Boolean);
|
||||
return { title: `确认生成${type}`, description: `${type}草稿节点和引用连线已创建,尚未提交生成。确认模型与画幅后批准才会提交收费任务;拒绝则保留草稿,结果自动回写画布。`, items };
|
||||
}
|
||||
return { title: "确认执行操作", description: "Agent 请求执行一项操作,确认后才会继续。", items: [] as string[] };
|
||||
}
|
||||
59
web/src/lib/canvas/agent-canvas-actions.ts
Normal file
59
web/src/lib/canvas/agent-canvas-actions.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
|
||||
export type AgentCanvasAction = { nodeId: string; title: string; nodeType: string; action: string };
|
||||
|
||||
export function agentRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
export function agentToolArguments(detail: unknown) {
|
||||
const value = agentRecord(detail).arguments;
|
||||
if (typeof value !== "string") return agentRecord(value);
|
||||
try { return agentRecord(JSON.parse(value)); } catch { return {}; }
|
||||
}
|
||||
|
||||
export function agentCanvasActions(toolName: string, detail: unknown, references: CanvasResourceReference[] = []): AgentCanvasAction[] {
|
||||
const payload = agentRecord(detail);
|
||||
const result = agentRecord(payload.result);
|
||||
const args = agentToolArguments(detail);
|
||||
const actions: AgentCanvasAction[] = [];
|
||||
const add = (id: unknown, title: unknown, kind: unknown, action: string) => {
|
||||
if (typeof id !== "string" || !id || actions.some((item) => item.nodeId === id && item.action === action)) return;
|
||||
const reference = references.find((item) => item.nodeId === id);
|
||||
actions.push({ nodeId: id, title: typeof title === "string" && title ? title : reference?.title || id, nodeType: typeof kind === "string" ? kind : reference?.kind || "", action });
|
||||
};
|
||||
if (Array.isArray(payload.actions)) {
|
||||
for (const value of payload.actions) {
|
||||
const action = agentRecord(value);
|
||||
add(action.nodeId, action.title, action.nodeType, String(action.action || "updated"));
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
const failed = payload.eventType === "tool_failed";
|
||||
if (toolName === "generate_media") {
|
||||
const submitted = payload.eventType === "generation_task_created";
|
||||
const action = failed ? "failed" : submitted ? "generating" : "generated";
|
||||
add(result.nodeId || payload.nodeId || args.nodeId, payload.title || args.title, payload.mode || args.mode, action);
|
||||
if (!failed || result.taskSubmitted === true) {
|
||||
const ids = payload.referenceNodeIds || args.referenceNodeIds;
|
||||
if (Array.isArray(ids)) for (const id of ids) add(id, undefined, undefined, "referenced");
|
||||
}
|
||||
} else if (toolName === "canvas_get_state" && !failed && Array.isArray(result.nodes)) {
|
||||
for (const value of result.nodes) { const node = agentRecord(value); add(node.id, node.title, node.type, "read"); }
|
||||
} else if (toolName === "canvas_apply_ops" && !failed && Array.isArray(args.ops)) {
|
||||
for (const value of args.ops) {
|
||||
const op = agentRecord(value);
|
||||
if (op.type === "connect_nodes") {
|
||||
add(op.fromNodeId, undefined, undefined, "referenced");
|
||||
add(op.toNodeId, undefined, undefined, "updated");
|
||||
} else add(op.id, op.title, op.nodeType, op.type === "add_node" ? "created" : "updated");
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function agentCanvasActionLabel(action: AgentCanvasAction) {
|
||||
const kind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", script: "分镜" } as Record<string, string>)[action.nodeType] || "";
|
||||
const verb = ({ created: "创建了", updated: "更新了", referenced: "引用了", read: "读取了", generating: "已提交生成:", generated: "已生成", failed: "生成未完成:" } as Record<string, string>)[action.action] || "定位";
|
||||
return `${verb}${kind}节点《${action.title}》`;
|
||||
}
|
||||
93
web/src/lib/canvas/agent-canvas-patch.ts
Normal file
93
web/src/lib/canvas/agent-canvas-patch.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CanvasConnection, CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type Change<T> = { before: T | null; after: T };
|
||||
export type AgentCanvasPatch = {
|
||||
canvasId: string;
|
||||
updatedAt: string;
|
||||
nodes: Change<CanvasNodeData>[];
|
||||
connections: Change<CanvasConnection>[];
|
||||
};
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function equal(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (record(a) && record(b)) {
|
||||
const keys = Object.keys(a);
|
||||
return keys.length === Object.keys(b).length && keys.every((key) => equal(a[key], b[key]));
|
||||
}
|
||||
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => equal(item, b[index]));
|
||||
}
|
||||
|
||||
// Three-way merge: untouched local fields (e.g. a drag or prompt edit) survive.
|
||||
// A concurrently changed field or a deleted node is a conflict, never an overwrite.
|
||||
function mergeValue(current: unknown, before: unknown, after: unknown): unknown {
|
||||
if (equal(before, after) || equal(current, after)) return current;
|
||||
if (equal(current, before)) return after;
|
||||
if (record(current) && record(before) && record(after)) {
|
||||
const next = { ...current };
|
||||
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("无效的画布增量字段");
|
||||
const value = mergeValue(current[key], before[key], after[key]);
|
||||
if (value === undefined) delete next[key];
|
||||
else next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
throw new Error("Agent 画布增量与本地内容冲突,需要校准;已保留本地编辑");
|
||||
}
|
||||
|
||||
function mergeItems<T extends { id: string }>(items: T[], changes: Change<T>[]): T[] {
|
||||
if (!changes.length) return items;
|
||||
const next = new Map(items.map((item) => [item.id, item]));
|
||||
for (const { before, after } of changes) {
|
||||
if (!after?.id || (before && before.id !== after.id)) throw new Error("无效的画布增量节点");
|
||||
next.set(after.id, mergeValue(next.get(after.id) ?? null, before, after) as T);
|
||||
}
|
||||
const result = [...next.values()];
|
||||
return result.length === items.length && result.every((item, index) => item === items[index]) ? items : result;
|
||||
}
|
||||
|
||||
export function applyAgentCanvasPatch(project: CanvasProject, patch: AgentCanvasPatch): CanvasProject {
|
||||
if (patch.canvasId !== project.id || !Array.isArray(patch.nodes) || !Array.isArray(patch.connections)) throw new Error("画布增量不属于当前画布或格式无效");
|
||||
const byId = new Map(project.nodes.map((node) => [node.id, node]));
|
||||
const nodeChanges = patch.nodes.map((change) => {
|
||||
const current = byId.get(change.after.id);
|
||||
const beforeTask = change.before?.metadata?.taskId;
|
||||
const afterTask = change.after.metadata?.taskId;
|
||||
if (beforeTask && current?.metadata?.taskId !== beforeTask && current?.metadata?.taskId !== afterTask) throw new Error("画布增量冲突:生成节点已删除或绑定另一任务,不能覆盖");
|
||||
if (!current || !beforeTask || beforeTask !== afterTask) return change;
|
||||
const terminal = (status: string | undefined) => ["succeeded", "failed", "cancelled"].includes(status || "");
|
||||
// A local task subscriber may already know a newer state than the SSE
|
||||
// checkpoint. Never turn its terminal result back into a pending task.
|
||||
if (terminal(current.metadata?.taskStatus) && !terminal(change.after.metadata?.taskStatus)) return { before: current, after: current };
|
||||
if (terminal(current.metadata?.taskStatus)) return change;
|
||||
const metadata = { ...change.before!.metadata };
|
||||
for (const key of ["status", "taskStatus", "taskProgress", "taskStage"] as const) {
|
||||
if (!equal(metadata[key], change.after.metadata?.[key])) Object.assign(metadata, { [key]: current.metadata?.[key] });
|
||||
}
|
||||
return { ...change, before: { ...change.before!, metadata } };
|
||||
});
|
||||
const nodes = mergeItems(project.nodes, nodeChanges);
|
||||
const connections = mergeItems(project.connections, patch.connections);
|
||||
if (nodes === project.nodes && connections === project.connections) return project;
|
||||
return { ...project, nodes, connections, updatedAt: patch.updatedAt || project.updatedAt };
|
||||
}
|
||||
|
||||
export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: CanvasProject, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const changes = <T extends { id: string }>(before: T[], after: T[]): Change<T>[] => {
|
||||
const byId = new Map(before.map((item) => [item.id, item]));
|
||||
const afterIds = new Set(after.map((item) => item.id));
|
||||
if (before.some((item) => !afterIds.has(item.id))) throw new Error("画布节点已删除,请先同步本地编辑");
|
||||
return after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item }));
|
||||
};
|
||||
return applyAgentCanvasPatch({ ...previous, nodes, connections }, {
|
||||
canvasId: previous.id,
|
||||
updatedAt: incoming.updatedAt,
|
||||
nodes: changes(previous.nodes, incoming.nodes),
|
||||
connections: changes(previous.connections, incoming.connections),
|
||||
});
|
||||
}
|
||||
9
web/src/lib/canvas/agent-canvas-snapshot.ts
Normal file
9
web/src/lib/canvas/agent-canvas-snapshot.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { canonicalize } from "json-canonicalize";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
|
||||
// Panning/focusing is local UI state, not an edit to Agent-owned canvas content.
|
||||
export function sameAgentCanvasContent(previous: CanvasProject | undefined, current: CanvasProject) {
|
||||
if (!previous) return false;
|
||||
const content = ({ viewport: _viewport, updatedAt: _updatedAt, ...rest }: CanvasProject) => rest;
|
||||
return canonicalize(content(previous)) === canonicalize(content(current));
|
||||
}
|
||||
@@ -31,7 +31,7 @@ function withinBudget(cost: { bytes: number; tokens: number }) {
|
||||
}
|
||||
|
||||
/** Applies only to persisted conversational history, never a live tool-call/result sequence. */
|
||||
export function budgetCanvasAgentHistory(system: AiTextMessage, history: AiTextMessage[], current: AiTextMessage): AiTextMessage[] {
|
||||
export function budgetAgentHistory(system: AiTextMessage, history: AiTextMessage[], current: AiTextMessage): AiTextMessage[] {
|
||||
const protectedMessages = [system, ...history.filter((message) => message.role === "system")];
|
||||
const groups: AiTextMessage[][] = [];
|
||||
for (const message of history) {
|
||||
21
web/src/lib/canvas/agent-debug-export.ts
Normal file
21
web/src/lib/canvas/agent-debug-export.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Export only diagnostics explicitly supplied by the chat, never app config or storage.
|
||||
export function redactAgentDiagnostics(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redactAgentDiagnostics);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, /^(authorization|cookie|set-cookie|api[-_]?key|access[-_]?token|refresh[-_]?token|token|password|secret|client[-_]?secret|headers)$/i.test(key) ? "[REDACTED]" : redactAgentDiagnostics(item)]));
|
||||
if (typeof value !== "string") return value;
|
||||
if (/^\s*[\[{]/.test(value)) {
|
||||
try { return JSON.stringify(redactAgentDiagnostics(JSON.parse(value))); } catch { /* Not serialized tool arguments. */ }
|
||||
}
|
||||
return value
|
||||
.replace(/\bBearer\s+[^\s"'<>]+/gi, "Bearer [REDACTED]")
|
||||
.replace(/\bsk-[\w-]{12,}/g, "[REDACTED]")
|
||||
.replace(/data:[^\s"']+/gi, "[MEDIA OMITTED]")
|
||||
.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => {
|
||||
try { const parsed = new URL(url); parsed.username = ""; parsed.password = ""; parsed.search = ""; parsed.hash = ""; return parsed.toString(); } catch { return "[URL OMITTED]"; }
|
||||
})
|
||||
.replace(/((?:api[-_]?key|token|password|secret)\s*[=:]\s*)[^\s,;"']+/gi, "$1[REDACTED]");
|
||||
}
|
||||
|
||||
export function buildAgentDebugExport(diagnostics: Record<string, unknown>) {
|
||||
return JSON.stringify(redactAgentDiagnostics({ format: "canvas-agent-debug", version: 1, exportedAt: new Date().toISOString(), notice: "包含对话和提示词;已尽力过滤凭证与签名参数,分享前仍请检查隐私内容。", ...diagnostics }), null, 2);
|
||||
}
|
||||
21
web/src/lib/canvas/agent-error-presentation.ts
Normal file
21
web/src/lib/canvas/agent-error-presentation.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ApiError } from "@/services/api/request";
|
||||
import { AgentStreamError } from "@/services/api/agent";
|
||||
|
||||
export function agentErrorPresentation(cause: unknown, fallback = "Agent 执行失败") {
|
||||
const text = typeof cause === "string" ? cause.trim() : cause instanceof Error ? cause.message : fallback;
|
||||
if ((cause instanceof ApiError || cause instanceof AgentStreamError) && cause.status === 404) {
|
||||
return {
|
||||
title: "Agent 接口或资源不存在",
|
||||
text: text || "当前后端未找到请求的 Agent 接口或会话资源。",
|
||||
meta: "请确认后端已加载 Agent 路由,以及画布、会话和模型仍可访问;旧进程需要更新后重启。",
|
||||
};
|
||||
}
|
||||
if (cause instanceof ApiError && cause.status === 501) {
|
||||
return {
|
||||
title: "当前 Agent 能力尚未开放",
|
||||
text: text || "当前请求的 Agent 能力尚未开放,请使用 capabilities 返回的权限和工具范围。",
|
||||
};
|
||||
}
|
||||
// 渠道、鉴权、配额等业务错误保留真实语义,不能按 message 猜测为服务未部署。
|
||||
return { title: fallback, text: text || fallback };
|
||||
}
|
||||
43
web/src/lib/canvas/agent-panel-layout.ts
Normal file
43
web/src/lib/canvas/agent-panel-layout.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type AgentPanelLayout = { left: number; top: number; width: number; height: number };
|
||||
export type AgentPanelViewport = { width: number; height: number };
|
||||
export type AgentPanelGesture = "move" | "north" | "west" | "northwest";
|
||||
|
||||
export const AGENT_PANEL_LAYOUT_KEY = "canvas:agent-panel-layout:v1";
|
||||
const MARGIN = 12;
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
|
||||
export function clampAgentPanelLayout(layout: AgentPanelLayout, viewport: AgentPanelViewport): AgentPanelLayout {
|
||||
const maxWidth = Math.max(1, viewport.width - MARGIN * 2);
|
||||
const maxHeight = Math.max(1, viewport.height - MARGIN * 2);
|
||||
const width = clamp(layout.width, Math.min(360, maxWidth), maxWidth);
|
||||
const height = clamp(layout.height, Math.min(420, maxHeight), maxHeight);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: clamp(layout.left, MARGIN, Math.max(MARGIN, viewport.width - width - MARGIN)),
|
||||
top: clamp(layout.top, MARGIN, Math.max(MARGIN, viewport.height - height - MARGIN)),
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreAgentPanelLayout(raw: string | null, viewport: AgentPanelViewport): AgentPanelLayout {
|
||||
const fallback = { width: 448, height: 720, left: viewport.width - 464, top: viewport.height - 732 };
|
||||
if (!raw) return clampAgentPanelLayout(fallback, viewport);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && ["left", "top", "width", "height"].every((key) => typeof (parsed as Record<string, unknown>)[key] === "number" && Number.isFinite((parsed as Record<string, unknown>)[key]))) {
|
||||
return clampAgentPanelLayout(parsed as AgentPanelLayout, viewport);
|
||||
}
|
||||
} catch {
|
||||
// UI 偏好损坏不影响对话或服务端数据,恢复可见的默认窗口。
|
||||
}
|
||||
return clampAgentPanelLayout(fallback, viewport);
|
||||
}
|
||||
|
||||
export function changeAgentPanelLayout(start: AgentPanelLayout, gesture: AgentPanelGesture, dx: number, dy: number, viewport: AgentPanelViewport): AgentPanelLayout {
|
||||
if (gesture === "move") return clampAgentPanelLayout({ ...start, left: start.left + dx, top: start.top + dy }, viewport);
|
||||
const right = start.left + start.width;
|
||||
const bottom = start.top + start.height;
|
||||
const left = gesture === "north" ? start.left : clamp(start.left + dx, MARGIN, right - Math.min(360, start.width));
|
||||
const top = gesture === "west" ? start.top : clamp(start.top + dy, MARGIN, bottom - Math.min(420, start.height));
|
||||
return { left, top, width: right - left, height: bottom - top };
|
||||
}
|
||||
51
web/src/lib/canvas/agent-tool-presentation.ts
Normal file
51
web/src/lib/canvas/agent-tool-presentation.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export const AGENT_TOOL_METADATA: Record<string, { summary: string | ((context: { pending: boolean; detail?: unknown }) => string); failureMessage: string }> = {
|
||||
canvas_list_node_types: { summary: "已读取可用节点类型", failureMessage: "获取可用节点类型失败" },
|
||||
canvas_get_state: { summary: "已读取当前画布", failureMessage: "获取画布内容失败" },
|
||||
task_get: { summary: "已查询任务状态", failureMessage: "查询任务状态失败" },
|
||||
canvas_apply_ops: { summary: ({ pending }) => pending ? "准备更新画布内容" : "画布内容已保存至服务端", failureMessage: "更新画布内容失败" },
|
||||
model_list: { summary: "已获取可用模型", failureMessage: "获取可用模型失败" },
|
||||
generate_media: { summary: ({ pending, detail }) => pending ? "准备创建媒体节点并生成" : field(detail, "eventType") === "tool_completed" ? "生成结果已回写画布节点" : "媒体节点已创建,生成任务已提交", failureMessage: "媒体生成未完成" },
|
||||
};
|
||||
|
||||
type ToolStatus = "completed" | "failed" | "noop" | "rejected" | "pending";
|
||||
|
||||
function field(value: unknown, key: string): unknown {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
export function agentToolStatus(title: string, text: string, detail?: unknown): ToolStatus {
|
||||
const event = field(detail, "eventType");
|
||||
if (event === "tool_completed" || event === "generation_task_created" || event === "canvas_updated") return "completed";
|
||||
if (event === "tool_failed") return "failed";
|
||||
const raw = `${title} ${text} ${field(detail, "error") || ""}`;
|
||||
if (field(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return "noop";
|
||||
if (/拒绝|取消|rejected/i.test(raw)) return "rejected";
|
||||
if (/失败|错误|failed|error/i.test(raw)) return "failed";
|
||||
if (/完成|成功|completed|succeeded/i.test(raw)) return "completed";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function friendlyAgentToolSummary(toolName: string, text: string, detail?: unknown, pending = false) {
|
||||
const status = agentToolStatus(toolName, text, detail);
|
||||
const failed = status === "failed" || status === "rejected";
|
||||
if (toolName === "generate_media" && failed && field(field(detail, "result"), "phase") === "admission") return "生成请求未提交";
|
||||
if (toolName === "generate_media" && failed && field(field(detail, "result"), "status") === "succeeded") return "生成成功,画布回写未完成";
|
||||
if (toolName === "skills_load") {
|
||||
const count = /(?:加载|启用)\s*(\d+)\s*个/u.exec(text)?.[1];
|
||||
return failed ? "Skills 技能加载失败" : count ? `已加载 ${count} 个 Skills 技能` : "已加载 Skills 技能";
|
||||
}
|
||||
if (toolName === "skill_read_file") {
|
||||
const name = String(field(detail, "skillName") || field(detail, "skillId") || "Skills");
|
||||
const path = field(detail, "path");
|
||||
const files = field(field(detail, "result"), "files");
|
||||
const listing = path === "" || (path === undefined && Array.isArray(files));
|
||||
const target = typeof path === "string" && path ? `${name} · ${path}` : name;
|
||||
if (failed) return `${listing ? "列出参考文件失败" : "读取参考资料失败"} · ${target}`;
|
||||
if (listing && Array.isArray(files)) return files.length ? `${name} · 可读参考文件 ${files.length} 个` : `${name} · 无可读参考文件,使用已加载正文`;
|
||||
return `${pending ? "准备读取参考资料" : "已读取参考资料"} · ${target}`;
|
||||
}
|
||||
const metadata = AGENT_TOOL_METADATA[toolName];
|
||||
const summary = typeof metadata?.summary === "function" ? metadata.summary({ pending, detail }) : metadata?.summary;
|
||||
const failure = metadata?.failureMessage;
|
||||
return failed ? failure || "操作未完成" : summary || (pending ? "准备执行操作" : "操作已完成");
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { SKILL_RUNTIME_AGENT_GUIDANCE, skillRuntime } from "@/services/skill-runtime";
|
||||
import type { ResponseToolCall } from "@/services/api/image";
|
||||
|
||||
export const CANVAS_ONLINE_AGENT_PROMPT =
|
||||
`你是当前创作工作台内置的在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。${SKILL_RUNTIME_AGENT_GUIDANCE}`;
|
||||
|
||||
export const CANVAS_READ_TOOL_NAMES = [
|
||||
"canvas_get_state",
|
||||
"canvas_get_context",
|
||||
"canvas_find_nodes",
|
||||
"canvas_get_node",
|
||||
"canvas_get_connection",
|
||||
"canvas_get_generation_tasks",
|
||||
"canvas_get_resources",
|
||||
"canvas_validate_ops",
|
||||
"canvas_get_selection",
|
||||
"canvas_export_snapshot",
|
||||
] as const;
|
||||
|
||||
const CANVAS_READ_TOOL_NAME_SET = new Set<string>(CANVAS_READ_TOOL_NAMES);
|
||||
|
||||
export function isCanvasReadTool(name: string) {
|
||||
return CANVAS_READ_TOOL_NAME_SET.has(name) || skillRuntime.agentToolNames("onlineAgent").has(name);
|
||||
}
|
||||
|
||||
export function isWritableToolCall(call: ResponseToolCall) {
|
||||
return !isCanvasReadTool(call.function.name);
|
||||
}
|
||||
|
||||
export function buildCanvasOnlineAgentSystemContent(skillCatalog: string) {
|
||||
return [CANVAS_ONLINE_AGENT_PROMPT, skillCatalog ? `当前可按需加载的技能(仅元数据):\n${skillCatalog}` : ""].filter(Boolean).join("\n\n");
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
agentSessionFailureMessage,
|
||||
createAgentSession,
|
||||
queryAgentSession,
|
||||
type AgentSessionDetail,
|
||||
type CreateSessionInput,
|
||||
} from "@/services/api/task-center";
|
||||
|
||||
const CINEMATIC_SESSION_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
type CinematicSessionWaitOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type CreateCinematicSessionOptions = CinematicSessionWaitOptions & {
|
||||
onCreated?: (detail: AgentSessionDetail) => void;
|
||||
};
|
||||
|
||||
export async function createCinematicAgentSession(input: CreateSessionInput, options: CreateCinematicSessionOptions = {}) {
|
||||
const created = await createAgentSession(input);
|
||||
throwIfAborted(options.signal);
|
||||
options.onCreated?.(created);
|
||||
return waitForCinematicAgentSession(created, options);
|
||||
}
|
||||
|
||||
export async function resumeCinematicAgentSession(id: string, options: CinematicSessionWaitOptions = {}) {
|
||||
throwIfAborted(options.signal);
|
||||
const detail = await queryAgentSession(id);
|
||||
return waitForCinematicAgentSession(detail, options);
|
||||
}
|
||||
|
||||
export function cinematicAgentSessionOpsJson(detail: AgentSessionDetail) {
|
||||
if (detail.session.status !== "completed") throw new Error("后端影视 Agent 会话尚未完成");
|
||||
if (!detail.session.canvasOpsJson) throw new Error("后端影视 Agent 没有返回画布操作");
|
||||
return detail.session.canvasOpsJson;
|
||||
}
|
||||
|
||||
export function isAgentSessionPollingAbort(error: unknown) {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
async function waitForCinematicAgentSession(initialDetail: AgentSessionDetail, options: CinematicSessionWaitOptions) {
|
||||
let detail = initialDetail;
|
||||
// 后端任务策略负责生成超时和终态收敛;前端只在调用方 Abort 时停止监听,
|
||||
// 避免用独立的固定轮询次数把仍在服务端执行的分镜误判为失败。
|
||||
for (;;) {
|
||||
throwIfAborted(options.signal);
|
||||
if (detail.session.status === "completed") return detail;
|
||||
if (detail.session.status === "failed") throw new Error(agentSessionFailureMessage(detail));
|
||||
await abortableDelay(CINEMATIC_SESSION_POLL_INTERVAL_MS, options.signal);
|
||||
detail = await queryAgentSession(initialDetail.session.id);
|
||||
}
|
||||
}
|
||||
|
||||
function abortableDelay(ms: number, signal?: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
throwIfAborted(signal);
|
||||
const finish = () => {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
};
|
||||
const abort = () => {
|
||||
window.clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const timer = window.setTimeout(finish, ms);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import { type ResponseFunctionTool } from "@/services/api/image";
|
||||
import { skillRuntime } from "@/services/skill-runtime";
|
||||
import { CREATIVE_AGENT_TOOLS } from "@/lib/creation/creative-agent-tools";
|
||||
|
||||
const JSON_RECORD_SCHEMA = { type: "object", additionalProperties: true };
|
||||
const POSITION_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], additionalProperties: false };
|
||||
const VIEWPORT_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, k: { type: "number" } }, required: ["x", "y", "k"], additionalProperties: false };
|
||||
const NODE_TYPE_SCHEMA = { type: "string", enum: ["image", "text", "skill", "video", "audio"] };
|
||||
const WORKFLOW_NODE_KIND_SCHEMA = { type: "string", enum: ["text", "script", "image", "video", "audio", "character_cards", "character_three_view", "storyboard_video"] };
|
||||
const GENERATION_MODE_SCHEMA = { type: "string", enum: ["text", "image", "video", "audio"] };
|
||||
const GENERATION_OPTION_PROPERTIES = {
|
||||
model: { type: "string" },
|
||||
size: { type: "string" },
|
||||
quality: { type: "string" },
|
||||
transparentBackground: { type: "string", enum: ["true", "false"] },
|
||||
count: { type: "number" },
|
||||
seconds: { type: "string" },
|
||||
vquality: { type: "string" },
|
||||
generateAudio: { type: "string" },
|
||||
watermark: { type: "string" },
|
||||
audioVoice: { type: "string" },
|
||||
audioFormat: { type: "string" },
|
||||
audioSpeed: { type: "string" },
|
||||
audioInstructions: { type: "string" },
|
||||
};
|
||||
const CANVAS_OP_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", enum: ["add_node", "update_node", "delete_node", "delete_connections", "connect_nodes", "set_viewport", "select_nodes", "run_generation"] },
|
||||
id: { type: "string" },
|
||||
ids: { type: "array", items: { type: "string" } },
|
||||
nodeType: NODE_TYPE_SCHEMA,
|
||||
title: { type: "string" },
|
||||
x: { type: "number" },
|
||||
y: { type: "number" },
|
||||
width: { type: "number" },
|
||||
height: { type: "number" },
|
||||
position: POSITION_SCHEMA,
|
||||
metadata: JSON_RECORD_SCHEMA,
|
||||
patch: JSON_RECORD_SCHEMA,
|
||||
all: { type: "boolean" },
|
||||
fromNodeId: { type: "string" },
|
||||
toNodeId: { type: "string" },
|
||||
viewport: VIEWPORT_SCHEMA,
|
||||
nodeId: { type: "string" },
|
||||
mode: GENERATION_MODE_SCHEMA,
|
||||
prompt: { type: "string" },
|
||||
retry: { type: "boolean" },
|
||||
},
|
||||
required: ["type"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
function toolDefinition(name: string, description: string, properties: Record<string, unknown>, required: string[] = [], strict = false): ResponseFunctionTool {
|
||||
return { type: "function", function: { name, description, parameters: { type: "object", properties, required, additionalProperties: false }, strict } };
|
||||
}
|
||||
|
||||
function generationToolDefinition(name: string, description: string, mode?: "text" | "image" | "video" | "audio") {
|
||||
return toolDefinition(
|
||||
name,
|
||||
description,
|
||||
{
|
||||
prompt: { type: "string" },
|
||||
title: { type: "string" },
|
||||
x: { type: "number" },
|
||||
y: { type: "number" },
|
||||
referenceNodeIds: { type: "array", items: { type: "string" } },
|
||||
...(mode ? {} : { mode: GENERATION_MODE_SCHEMA }),
|
||||
autoRun: { type: "boolean" },
|
||||
...GENERATION_OPTION_PROPERTIES,
|
||||
},
|
||||
["prompt"],
|
||||
);
|
||||
}
|
||||
|
||||
export const CANVAS_ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [
|
||||
...CREATIVE_AGENT_TOOLS,
|
||||
...skillRuntime.agentTools("onlineAgent"),
|
||||
toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}),
|
||||
toolDefinition("canvas_get_context", "读取语义化画布上下文、真实节点 id、连接关系、资源就绪状态和状态哈希。", {}),
|
||||
toolDefinition("canvas_find_nodes", "按标题、内容、提示词、类型、状态或资产检索真实节点。", { query: { type: "string" }, ids: { type: "array", items: { type: "string" } }, types: { type: "array", items: { type: "string" } }, statuses: { type: "array", items: { type: "string" } }, resourceOnly: { type: "boolean" }, limit: { type: "number" } }),
|
||||
toolDefinition("canvas_get_node", "按真实节点 id 精确读取单个节点、资源状态和关联连线。", { id: { type: "string" } }, ["id"]),
|
||||
toolDefinition("canvas_get_connection", "按真实连线 id 精确读取端点节点和 handle 信息。", { id: { type: "string" } }, ["id"]),
|
||||
toolDefinition("canvas_get_generation_tasks", "读取当前画布绑定的生成任务观察状态,不主动轮询上游。", { status: { type: "string" }, nodeIds: { type: "array", items: { type: "string" } }, limit: { type: "number" } }),
|
||||
toolDefinition("canvas_get_resources", "读取画布媒体资源引用、类型、尺寸、大小、时长和就绪状态,不返回媒体 URL。", { nodeIds: { type: "array", items: { type: "string" } }, status: { type: "string" }, limit: { type: "number" } }),
|
||||
toolDefinition("canvas_validate_ops", "在写入前校验节点 id、连接关系和批量操作参数。", { ops: { type: "array", items: CANVAS_OP_SCHEMA } }, ["ops"]),
|
||||
toolDefinition("canvas_get_selection", "读取当前网页画布选中的节点。", {}),
|
||||
toolDefinition("canvas_export_snapshot", "导出当前画布快照,用于理解布局。", {}),
|
||||
toolDefinition(
|
||||
"canvas_apply_ops",
|
||||
"批量操作当前网页画布。复杂写操作应先 canvas_validate_ops;可传 canvas_get_context 返回的 expectedStateHash 防止基于过期状态写入。",
|
||||
{ ops: { type: "array", items: CANVAS_OP_SCHEMA }, expectedRevision: { type: "number" }, expectedStateHash: { type: "string" } },
|
||||
["ops"],
|
||||
false,
|
||||
),
|
||||
toolDefinition(
|
||||
"canvas_create_workflow",
|
||||
"创建语义化工作流/流水线:节点使用真实的文本、脚本、图片、视频或音频类型;character_cards=角色拆分图片卡片,character_three_view=角色三视图,storyboard_video=分镜剧情视频。工具会自动生成唯一 id、按节点实际尺寸布局、创建 edges/referenceRefs/referenceNodeIds 连线、选择新节点并复核重叠。媒体节点必须提供有意义的 prompt 或 content;已有素材先 canvas_find_nodes/canvas_get_resources,再把真实 node id 放入 referenceNodeIds。不要用 canvas_create_text_nodes 代替工作流。",
|
||||
{
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
nodes: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: { type: "string" },
|
||||
kind: WORKFLOW_NODE_KIND_SCHEMA,
|
||||
title: { type: "string" },
|
||||
content: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
description: { type: "string" },
|
||||
referenceRefs: { type: "array", items: { type: "string" } },
|
||||
referenceNodeIds: { type: "array", items: { type: "string" } },
|
||||
runGeneration: { type: "boolean" },
|
||||
width: { type: "number" },
|
||||
height: { type: "number" },
|
||||
},
|
||||
required: ["ref", "kind", "title"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
edges: { type: "array", items: { type: "object", properties: { from: { type: "string" }, to: { type: "string" } }, required: ["from", "to"], additionalProperties: false } },
|
||||
direction: { type: "string", enum: ["horizontal", "vertical"] },
|
||||
start: POSITION_SCHEMA,
|
||||
gap: { type: "number" },
|
||||
autoRun: { type: "boolean" },
|
||||
},
|
||||
["nodes"],
|
||||
),
|
||||
toolDefinition(
|
||||
"canvas_create_node",
|
||||
"创建任意类型节点:text、image、video、audio。适合创建文本、媒体占位或自定义 metadata 节点。",
|
||||
{ nodeType: NODE_TYPE_SCHEMA, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" }, metadata: JSON_RECORD_SCHEMA },
|
||||
["nodeType"],
|
||||
),
|
||||
toolDefinition("canvas_create_text_node", "在当前画布创建单个文本节点。", { text: { type: "string" }, x: { type: "number" }, y: { type: "number" }, title: { type: "string" }, width: { type: "number" }, height: { type: "number" } }),
|
||||
toolDefinition(
|
||||
"canvas_create_text_nodes",
|
||||
"批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
||||
{
|
||||
items: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" }, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" } },
|
||||
required: ["text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
x: { type: "number" },
|
||||
y: { type: "number" },
|
||||
gap: { type: "number" },
|
||||
direction: { type: "string", enum: ["row", "column"] },
|
||||
},
|
||||
["items"],
|
||||
),
|
||||
toolDefinition("canvas_create_cinematic_session", "把自然语言创作指令提交给后端影视 Agent 会话,后端拆解为剧本、场景、分镜、镜头和成片节点,并返回可写回画布的操作。", { prompt: { type: "string" } }, ["prompt"]),
|
||||
toolDefinition(
|
||||
"canvas_create_image_prompt_flow",
|
||||
"创建提示词文本节点和图片目标节点并自动连线,可选择立即触发生图。",
|
||||
{ prompt: { type: "string" }, x: { type: "number" }, y: { type: "number" }, autoRun: { type: "boolean" }, ...GENERATION_OPTION_PROPERTIES },
|
||||
["prompt"],
|
||||
),
|
||||
generationToolDefinition("canvas_create_generation_flow", "创建通用生成流程:提示词文本节点、对应类型的生成目标节点和参考节点连线,可用于文案、生图、视频或音频。"),
|
||||
generationToolDefinition("canvas_generate_text", "创建通用文本生成流程并立即触发生成。", "text"),
|
||||
generationToolDefinition("canvas_generate_image", "创建通用图片生成流程并立即触发生成。", "image"),
|
||||
generationToolDefinition("canvas_generate_video", "创建通用视频生成流程并立即触发生成。", "video"),
|
||||
generationToolDefinition("canvas_generate_audio", "创建通用音频生成流程并立即触发生成。", "audio"),
|
||||
toolDefinition("canvas_update_node", "更新节点基础字段或 metadata。", { id: { type: "string" }, patch: JSON_RECORD_SCHEMA, metadata: JSON_RECORD_SCHEMA }, ["id"]),
|
||||
toolDefinition("canvas_update_node_text", "更新文本节点内容和标题。", { id: { type: "string" }, text: { type: "string" }, title: { type: "string" } }, ["id", "text"]),
|
||||
toolDefinition(
|
||||
"canvas_move_nodes",
|
||||
"移动一个或多个节点,支持绝对坐标或 dx/dy 偏移。",
|
||||
{
|
||||
items: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
items: { type: "object", properties: { id: { type: "string" }, x: { type: "number" }, y: { type: "number" }, dx: { type: "number" }, dy: { type: "number" } }, required: ["id"], additionalProperties: false },
|
||||
},
|
||||
},
|
||||
["items"],
|
||||
),
|
||||
toolDefinition("canvas_resize_node", "调整节点尺寸。", { id: { type: "string" }, width: { type: "number" }, height: { type: "number" }, freeResize: { type: "boolean" } }, ["id", "width", "height"]),
|
||||
toolDefinition("canvas_delete_nodes", "删除指定节点及相关连线。", { ids: { type: "array", items: { type: "string" }, minItems: 1 } }, ["ids"]),
|
||||
toolDefinition(
|
||||
"canvas_connect_nodes",
|
||||
"批量连接节点。",
|
||||
{ connections: { type: "array", minItems: 1, items: { type: "object", properties: { fromNodeId: { type: "string" }, toNodeId: { type: "string" } }, required: ["fromNodeId", "toNodeId"], additionalProperties: false } } },
|
||||
["connections"],
|
||||
),
|
||||
toolDefinition("canvas_select_nodes", "设置当前选中节点。", { ids: { type: "array", items: { type: "string" } } }, ["ids"]),
|
||||
toolDefinition("canvas_set_viewport", "调整画布视口。", { viewport: VIEWPORT_SCHEMA }, ["viewport"]),
|
||||
toolDefinition("canvas_run_generation", "触发指定节点生成;对已有生成任务明确重试时传 retry=true。", { nodeId: { type: "string" }, mode: GENERATION_MODE_SCHEMA, prompt: { type: "string" }, retry: { type: "boolean" } }, ["nodeId"]),
|
||||
];
|
||||
@@ -16,13 +16,10 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
|
||||
if (acceptedInputKinds.length) {
|
||||
const source = nodes.find((node) => node.id === candidate.fromNodeId);
|
||||
const sourceKind = source ? getNodeInputKind(source.type) : undefined;
|
||||
const isMediaConversion = target.type === CanvasNodeType.MediaConversion;
|
||||
const hasAcceptedSource = isMediaConversion
|
||||
? source?.type === CanvasNodeType.Image || source?.type === CanvasNodeType.Video
|
||||
: Boolean(sourceKind && acceptedInputKinds.includes(sourceKind));
|
||||
const hasAcceptedSource = Boolean(sourceKind && acceptedInputKinds.includes(sourceKind));
|
||||
if (!sourceKind || !hasAcceptedSource) {
|
||||
const labels = acceptedInputKinds.map(acceptedInputKindLabel).join("或");
|
||||
return `${isMediaConversion ? "转换" : labels}节点只接受${labels}输入`;
|
||||
return `${labels}节点只接受${labels}输入`;
|
||||
}
|
||||
const maxInputCount = getNodeMaxInputCount(target.type);
|
||||
if (maxInputCount) {
|
||||
@@ -31,7 +28,7 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
|
||||
.filter((connection) => connection.toNodeId === target.id)
|
||||
.map((connection) => connection.fromNodeId),
|
||||
).size;
|
||||
if (inputCount > maxInputCount) return `${isMediaConversion ? "转换" : "当前"}节点最多连接 ${maxInputCount} 个输入`;
|
||||
if (inputCount > maxInputCount) return `当前节点最多连接 ${maxInputCount} 个输入`;
|
||||
}
|
||||
}
|
||||
const mode = getNodeGenerationMode(target);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { hashCanvasAgentSnapshot, type CanvasAgentOp, type CanvasAgentSnapshot } from "./canvas-agent-ops";
|
||||
import { hashCanvasSnapshot, type CanvasOperation, type CanvasSnapshot } from "./canvas-operation-contract";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { getNodeDefinition } from "./node-registry";
|
||||
import { isPluginEffectivelyEnabled } from "@/stores/use-plugin-store";
|
||||
import { inspectStoryboardReadiness } from "./canvas-storyboard-context";
|
||||
|
||||
export type CanvasAgentResource = {
|
||||
export type CanvasResourceInsight = {
|
||||
nodeId: string;
|
||||
nodeTitle: string;
|
||||
nodeType: CanvasNodeData["type"];
|
||||
@@ -21,7 +21,7 @@ export type CanvasAgentResource = {
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
export function buildCanvasAgentContext(snapshot: CanvasAgentSnapshot, options: { stateHash?: string; hashSource?: "browser-local" | "canvas-agent-server" } = {}) {
|
||||
export function buildCanvasContext(snapshot: CanvasSnapshot, options: { stateHash?: string; hashSource?: "browser-local" | "canvas-server" } = {}) {
|
||||
const nodes = snapshot.nodes || [];
|
||||
const selectedIds = new Set(snapshot.selectedNodeIds || []);
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
@@ -37,11 +37,10 @@ export function buildCanvasAgentContext(snapshot: CanvasAgentSnapshot, options:
|
||||
schemaVersion: 1,
|
||||
revision: snapshot.revision ?? 0,
|
||||
// The browser can build a fast local fingerprint synchronously, but the
|
||||
// canonical hash used by the Canvas Agent Runtime is calculated on the
|
||||
// server. Local Runtime callers may override this value after a state
|
||||
// sync; otherwise make the source explicit so it is never mistaken for
|
||||
// the server hash in a write precondition.
|
||||
stateHash: options.stateHash || hashCanvasAgentSnapshot(snapshot),
|
||||
// The cloud control plane calculates the canonical hash on the server.
|
||||
// The browser fingerprint is marked explicitly so it cannot be mistaken
|
||||
// for the server hash in a write precondition.
|
||||
stateHash: options.stateHash || hashCanvasSnapshot(snapshot),
|
||||
hashSource: options.hashSource || "browser-local",
|
||||
canvas: { projectId: snapshot.projectId, domainProjectId: snapshot.domainProjectId, title: snapshot.title, viewport: snapshot.viewport, nodeCount: nodes.length, connectionCount: snapshot.connections.length, selectedNodeCount: selectedIds.size, nodeTypeCounts },
|
||||
selection: nodes.filter((node) => selectedIds.has(node.id)).map(compactNode),
|
||||
@@ -56,7 +55,7 @@ export function buildCanvasAgentContext(snapshot: CanvasAgentSnapshot, options:
|
||||
};
|
||||
}
|
||||
|
||||
export function findCanvasAgentNodes(snapshot: CanvasAgentSnapshot, input: { query?: string; ids?: string[]; types?: string[]; statuses?: string[]; resourceOnly?: boolean; limit?: number }) {
|
||||
export function findCanvasNodes(snapshot: CanvasSnapshot, input: { query?: string; ids?: string[]; types?: string[]; statuses?: string[]; resourceOnly?: boolean; limit?: number }) {
|
||||
const query = input.query?.trim().toLocaleLowerCase();
|
||||
const ids = input.ids?.length ? new Set(input.ids) : null;
|
||||
const types = input.types?.length ? new Set(input.types) : null;
|
||||
@@ -75,7 +74,7 @@ export function findCanvasAgentNodes(snapshot: CanvasAgentSnapshot, input: { que
|
||||
return { query: input.query || "", total: nodes.length, truncated: nodes.length > limit, nodes: nodes.slice(0, limit).map(compactNode) };
|
||||
}
|
||||
|
||||
export function getCanvasAgentNode(snapshot: CanvasAgentSnapshot, input: { id: string }) {
|
||||
export function getCanvasNode(snapshot: CanvasSnapshot, input: { id: string }) {
|
||||
const node = snapshot.nodes.find((candidate) => candidate.id === input.id);
|
||||
if (!node) return { found: false, id: input.id, node: null, connections: [] };
|
||||
const nodeById = new Map(snapshot.nodes.map((candidate) => [candidate.id, candidate]));
|
||||
@@ -85,7 +84,7 @@ export function getCanvasAgentNode(snapshot: CanvasAgentSnapshot, input: { id: s
|
||||
return { found: true, id: node.id, node: compactNode(node), resource: resourceFromNode(node), connections };
|
||||
}
|
||||
|
||||
export function getCanvasAgentConnection(snapshot: CanvasAgentSnapshot, input: { id: string }) {
|
||||
export function getCanvasConnection(snapshot: CanvasSnapshot, input: { id: string }) {
|
||||
const connection = snapshot.connections.find((candidate) => candidate.id === input.id);
|
||||
if (!connection) return { found: false, id: input.id, connection: null };
|
||||
const nodeById = new Map(snapshot.nodes.map((candidate) => [candidate.id, candidate]));
|
||||
@@ -98,7 +97,7 @@ export function getCanvasAgentConnection(snapshot: CanvasAgentSnapshot, input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function getCanvasAgentGenerationTasks(snapshot: CanvasAgentSnapshot, input: { status?: string; nodeIds?: string[]; limit?: number }) {
|
||||
export function getCanvasGenerationTasks(snapshot: CanvasSnapshot, input: { status?: string; nodeIds?: string[]; limit?: number }) {
|
||||
const nodeIds = input.nodeIds?.length ? new Set(input.nodeIds) : null;
|
||||
const tasks = snapshot.nodes.flatMap((node) => {
|
||||
const metadata = (node.metadata || {}) as Record<string, unknown>;
|
||||
@@ -126,7 +125,7 @@ export function getCanvasAgentGenerationTasks(snapshot: CanvasAgentSnapshot, inp
|
||||
return { total: tasks.length, truncated: tasks.length > limit, tasks: tasks.slice(0, limit) };
|
||||
}
|
||||
|
||||
export function getCanvasAgentResources(snapshot: CanvasAgentSnapshot, input: { nodeIds?: string[]; status?: string; limit?: number }) {
|
||||
export function getCanvasResources(snapshot: CanvasSnapshot, input: { nodeIds?: string[]; status?: string; limit?: number }) {
|
||||
const nodeIds = input.nodeIds?.length ? new Set(input.nodeIds) : null;
|
||||
const limit = Math.min(Math.max(input.limit || 100, 1), 300);
|
||||
const resources = snapshot.nodes.flatMap((node) => {
|
||||
@@ -137,7 +136,7 @@ export function getCanvasAgentResources(snapshot: CanvasAgentSnapshot, input: {
|
||||
return { total: resources.length, truncated: resources.length > limit, resources: resources.slice(0, limit) };
|
||||
}
|
||||
|
||||
export function validateCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops: CanvasAgentOp[]) {
|
||||
export function validateCanvasOperations(snapshot: CanvasSnapshot, ops: CanvasOperation[]) {
|
||||
const liveNodeIds = new Set(snapshot.nodes.map((node) => node.id));
|
||||
const connectionKeys = new Set(snapshot.connections.map(connectionKey));
|
||||
const liveConnections = new Map<string, { key: string; fromNodeId: string; toNodeId: string }>();
|
||||
@@ -202,7 +201,7 @@ export function validateCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops: Canva
|
||||
else ids.forEach(removeConnection);
|
||||
} else if (op.type === "set_viewport") validateViewport(issues, index, op.viewport);
|
||||
});
|
||||
return { ok: !issues.some((issue) => issue.severity === "error"), issues, operationCount: ops.length, currentStateHash: hashCanvasAgentSnapshot(snapshot) };
|
||||
return { ok: !issues.some((issue) => issue.severity === "error"), issues, operationCount: ops.length, currentStateHash: hashCanvasSnapshot(snapshot) };
|
||||
}
|
||||
|
||||
function validateNodeNumbers(issues: Array<{ index: number; severity: "error" | "warning"; message: string }>, index: number, value: Record<string, unknown>) {
|
||||
@@ -257,7 +256,7 @@ function compactNode(node: CanvasNodeData) {
|
||||
};
|
||||
}
|
||||
|
||||
function resourceFromNode(node: CanvasNodeData): CanvasAgentResource | null {
|
||||
function resourceFromNode(node: CanvasNodeData): CanvasResourceInsight | null {
|
||||
const metadata = (node.metadata || {}) as Record<string, unknown>;
|
||||
const storageKey = typeof metadata.storageKey === "string" && metadata.storageKey.trim() ? metadata.storageKey.trim() : undefined;
|
||||
const resourceId = storageKey?.startsWith("resource:") ? storageKey.slice("resource:".length) : stringValue(metadata.resourceId);
|
||||
@@ -102,7 +102,6 @@ function workflowMetadataForResultNode(): Partial<CanvasNodeMetadata> {
|
||||
workflowProvider: undefined,
|
||||
runningHubWorkflowId: undefined,
|
||||
runningHubWorkflowKind: undefined,
|
||||
comfyBridgeWorkflowId: undefined,
|
||||
workflowParameters: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ export function subscribeCanvasViewportPreview(container: HTMLDivElement, listen
|
||||
*/
|
||||
export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, preview: CanvasNodeDragPreview | null) {
|
||||
if (!container) return;
|
||||
if (preview) container.dataset.canvasNodeDragging = "true";
|
||||
else delete container.dataset.canvasNodeDragging;
|
||||
|
||||
let state = nodeDragPreviewDomStates.get(container);
|
||||
if (!state) {
|
||||
|
||||
@@ -96,14 +96,5 @@ export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyM
|
||||
visibleColumns: [...node.metadata.storyboard.visibleColumns],
|
||||
referenceNodeIds: remapReferenceIds(node.metadata.storyboard.referenceNodeIds, idMap) || [],
|
||||
} : undefined;
|
||||
if (node.type === "portrait-clearance" && metadata.portraitClearance) {
|
||||
metadata.portraitClearance = {
|
||||
...metadata.portraitClearance,
|
||||
inputBindings: metadata.portraitClearance.inputBindings.map((binding) => ({ ...binding, nodeId: idMap.get(binding.nodeId) || binding.nodeId })),
|
||||
activeTaskId: undefined,
|
||||
task: undefined,
|
||||
lastResult: undefined,
|
||||
};
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
11
web/src/lib/canvas/canvas-node-task-state.ts
Normal file
11
web/src/lib/canvas/canvas-node-task-state.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
/** A historical taskId is not a lock. Only live task states / submission are. */
|
||||
export function isCanvasNodeGenerating(node: CanvasNodeData | undefined, runningNodeId?: string | null) {
|
||||
if (!node) return false;
|
||||
if (runningNodeId === node.id) return true;
|
||||
const meta = node.metadata;
|
||||
if (meta?.taskId && (meta.taskStatus === "queued" || meta.taskStatus === "running")) return true;
|
||||
if (["succeeded", "failed", "cancelled"].includes(meta?.taskStatus || "")) return false;
|
||||
return meta?.status === "loading" || Boolean(meta?.taskId && meta.status !== "success" && meta.status !== "error");
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { getNodeDefinition } from "./node-registry";
|
||||
import { isPluginEffectivelyEnabled } from "@/stores/use-plugin-store";
|
||||
import { CanvasNodeType, type CanvasNodeTypeId, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
export type CanvasAgentOp =
|
||||
export type CanvasOperation =
|
||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeTypeId; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeTypeId }
|
||||
@@ -15,7 +15,7 @@ export type CanvasAgentOp =
|
||||
| { type: "select_nodes"; ids: string[] }
|
||||
| { type: "run_generation"; nodeId: string; mode?: "text" | "image" | "video" | "audio"; prompt?: string; retry?: boolean };
|
||||
|
||||
export type CanvasAgentSnapshot = {
|
||||
export type CanvasSnapshot = {
|
||||
projectId: string;
|
||||
domainProjectId?: string;
|
||||
title: string;
|
||||
@@ -27,7 +27,7 @@ export type CanvasAgentSnapshot = {
|
||||
stateHash?: string;
|
||||
};
|
||||
|
||||
export type CanvasAgentOperationImpact = {
|
||||
export type CanvasOperationImpact = {
|
||||
operationCount: number;
|
||||
affectedNodeCount: number;
|
||||
destructiveCount: number;
|
||||
@@ -36,7 +36,7 @@ export type CanvasAgentOperationImpact = {
|
||||
warning: string;
|
||||
};
|
||||
|
||||
export type CanvasAgentGenerationVerification = {
|
||||
export type CanvasGenerationVerification = {
|
||||
nodeId: string;
|
||||
taskId?: string;
|
||||
taskStatus?: string;
|
||||
@@ -46,7 +46,7 @@ export type CanvasAgentGenerationVerification = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type CanvasAgentPostcondition = {
|
||||
export type CanvasOperationPostcondition = {
|
||||
ok: boolean;
|
||||
changed: boolean;
|
||||
ranGeneration: boolean;
|
||||
@@ -58,7 +58,7 @@ export type CanvasAgentPostcondition = {
|
||||
affectedNodeIds: string[];
|
||||
missingNodeIds: string[];
|
||||
missingConnectionIds: string[];
|
||||
generation: CanvasAgentGenerationVerification[];
|
||||
generation: CanvasGenerationVerification[];
|
||||
warnings: string[];
|
||||
overlapWarnings: string[];
|
||||
beforeStateHash: string;
|
||||
@@ -75,7 +75,7 @@ export type CanvasAgentPostcondition = {
|
||||
* reported as submitted/running/succeeded rather than being collapsed into
|
||||
* a misleading boolean "completed".
|
||||
*/
|
||||
export function verifyCanvasAgentOps(before: CanvasAgentSnapshot, after: CanvasAgentSnapshot, ops: CanvasAgentOp[]): CanvasAgentPostcondition {
|
||||
export function verifyCanvasOperations(before: CanvasSnapshot, after: CanvasSnapshot, ops: CanvasOperation[]): CanvasOperationPostcondition {
|
||||
const beforeNodeIds = new Set(before.nodes.map((node) => node.id));
|
||||
const afterNodeIds = new Set(after.nodes.map((node) => node.id));
|
||||
const beforeConnectionIds = new Set(before.connections.map((connection) => connection.id));
|
||||
@@ -91,7 +91,7 @@ export function verifyCanvasAgentOps(before: CanvasAgentSnapshot, after: CanvasA
|
||||
const warnings: string[] = [];
|
||||
const overlapWarnings = findCanvasNodeOverlaps(after.nodes, createdNodeIds);
|
||||
if (overlapWarnings.length) warnings.push(...overlapWarnings);
|
||||
const generation: CanvasAgentGenerationVerification[] = [];
|
||||
const generation: CanvasGenerationVerification[] = [];
|
||||
const addNodeCount = ops.filter((op) => op.type === "add_node").length;
|
||||
if (createdNodeIds.length < addNodeCount) {
|
||||
failedPostconditions.add("add_node:count");
|
||||
@@ -102,7 +102,7 @@ export function verifyCanvasAgentOps(before: CanvasAgentSnapshot, after: CanvasA
|
||||
affectedNodeIds.add(nodeId);
|
||||
if (!afterNodeIds.has(nodeId)) missingNodeIds.add(nodeId);
|
||||
};
|
||||
const connectionExists = (op: Extract<CanvasAgentOp, { type: "connect_nodes" }>) => after.connections.some((connection) => connection.fromNodeId === op.fromNodeId
|
||||
const connectionExists = (op: Extract<CanvasOperation, { type: "connect_nodes" }>) => after.connections.some((connection) => connection.fromNodeId === op.fromNodeId
|
||||
&& connection.toNodeId === op.toNodeId
|
||||
&& connection.fromHandleId === op.fromHandleId
|
||||
&& connection.toHandleId === op.toHandleId);
|
||||
@@ -180,7 +180,7 @@ export function verifyCanvasAgentOps(before: CanvasAgentSnapshot, after: CanvasA
|
||||
}
|
||||
|
||||
const ranGeneration = generation.length > 0;
|
||||
const changed = hashCanvasAgentSnapshot(before) !== hashCanvasAgentSnapshot(after) || ranGeneration;
|
||||
const changed = hashCanvasSnapshot(before) !== hashCanvasSnapshot(after) || ranGeneration;
|
||||
const generationFailed = generation.some((item) => item.outcome === "not_started" || item.outcome === "failed" || item.outcome === "cancelled");
|
||||
return {
|
||||
ok: missingNodeIds.size === 0 && missingConnectionIds.size === 0 && failedPostconditions.size === 0 && !generationFailed && overlapWarnings.length === 0,
|
||||
@@ -197,8 +197,8 @@ export function verifyCanvasAgentOps(before: CanvasAgentSnapshot, after: CanvasA
|
||||
generation,
|
||||
warnings: [...new Set(warnings)],
|
||||
overlapWarnings,
|
||||
beforeStateHash: hashCanvasAgentSnapshot(before),
|
||||
afterStateHash: hashCanvasAgentSnapshot(after),
|
||||
beforeStateHash: hashCanvasSnapshot(before),
|
||||
afterStateHash: hashCanvasSnapshot(after),
|
||||
hashSource: "browser-local",
|
||||
};
|
||||
}
|
||||
@@ -218,7 +218,7 @@ export function findCanvasNodeOverlaps(nodes: CanvasNodeData[], onlyIds?: string
|
||||
return overlaps;
|
||||
}
|
||||
|
||||
export function canvasAgentPostconditionMessage(result: CanvasAgentPostcondition) {
|
||||
export function canvasOperationPostconditionMessage(result: CanvasOperationPostcondition) {
|
||||
const nodeSummary = result.createdNodeIds.length ? `已创建 ${result.createdNodeIds.length} 个节点` : result.changed ? "画布已更新" : "画布状态未变化";
|
||||
const connectionSummary = result.expectedConnectionCount
|
||||
? `预期 ${result.expectedConnectionCount} 条连线,实际新增 ${result.createdConnectionIds.length} 条(当前共 ${result.connectionCount} 条)`
|
||||
@@ -235,7 +235,7 @@ export function canvasAgentPostconditionMessage(result: CanvasAgentPostcondition
|
||||
return `${nodeSummary},${connectionSummary},布局无重叠,已复核最终状态。`;
|
||||
}
|
||||
|
||||
export function summarizeCanvasAgentOps(ops?: CanvasAgentOp[]) {
|
||||
export function summarizeCanvasOperations(ops?: CanvasOperation[]) {
|
||||
const counts = (Array.isArray(ops) ? ops : []).reduce<Record<string, number>>((acc, op) => {
|
||||
if (!op?.type) return acc;
|
||||
acc[op.type] = (acc[op.type] || 0) + 1;
|
||||
@@ -246,7 +246,7 @@ export function summarizeCanvasAgentOps(ops?: CanvasAgentOp[]) {
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export function previewCanvasAgentOps(ops?: CanvasAgentOp[], snapshot?: CanvasAgentSnapshot): CanvasAgentOperationImpact {
|
||||
export function previewCanvasOperations(ops?: CanvasOperation[], snapshot?: CanvasSnapshot): CanvasOperationImpact {
|
||||
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
|
||||
const nodeById = new Map((snapshot?.nodes || []).map((node) => [node.id, node]));
|
||||
const affectedNodeIds = new Set<string>();
|
||||
@@ -313,7 +313,7 @@ export function previewCanvasAgentOps(ops?: CanvasAgentOp[], snapshot?: CanvasAg
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasAgentOp[]) {
|
||||
export function applyCanvasOperations(snapshot: CanvasSnapshot, ops?: CanvasOperation[]) {
|
||||
let nodes = snapshot.nodes;
|
||||
let connections = snapshot.connections;
|
||||
let selectedNodeIds = snapshot.selectedNodeIds;
|
||||
@@ -375,7 +375,7 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasA
|
||||
return { ...snapshot, nodes, connections, selectedNodeIds, viewport };
|
||||
}
|
||||
|
||||
export function hashCanvasAgentSnapshot(snapshot: CanvasAgentSnapshot) {
|
||||
export function hashCanvasSnapshot(snapshot: CanvasSnapshot) {
|
||||
let hash = 2166136261;
|
||||
const text = JSON.stringify({ projectId: snapshot.projectId, domainProjectId: snapshot.domainProjectId, title: snapshot.title, nodes: snapshot.nodes, connections: snapshot.connections, selectedNodeIds: snapshot.selectedNodeIds, viewport: snapshot.viewport });
|
||||
for (let index = 0; index < text.length; index += 1) hash = Math.imul(hash ^ text.charCodeAt(index), 16777619);
|
||||
@@ -413,7 +413,7 @@ function generationModeLabel(mode?: "text" | "image" | "video" | "audio") {
|
||||
return "图片";
|
||||
}
|
||||
|
||||
function generationOutcome(taskStatus?: string, officialStatus?: string, taskId?: string): CanvasAgentGenerationVerification["outcome"] {
|
||||
function generationOutcome(taskStatus?: string, officialStatus?: string, taskId?: string): CanvasGenerationVerification["outcome"] {
|
||||
if (!taskId) return "not_started";
|
||||
const status = taskStatus || officialStatus;
|
||||
if (status === "queued" || status === "pending" || officialStatus === "pending") return "queued";
|
||||
@@ -424,7 +424,7 @@ function generationOutcome(taskStatus?: string, officialStatus?: string, taskId?
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function generationVerificationMessage(outcome: CanvasAgentGenerationVerification["outcome"], resourceReady: boolean) {
|
||||
function generationVerificationMessage(outcome: CanvasGenerationVerification["outcome"], resourceReady: boolean) {
|
||||
if (outcome === "not_started") return "没有成功提交任务";
|
||||
if (outcome === "queued") return "排队中";
|
||||
if (outcome === "running") return "生成中";
|
||||
@@ -232,19 +232,6 @@ export function buildImageGenerationMetadata(type: CanvasImageGenerationType, co
|
||||
}
|
||||
|
||||
export function nodeReferenceImage(node: CanvasNodeData): ReferenceImage | null {
|
||||
if (node.type === CanvasNodeType.MediaConversion) {
|
||||
// 转换节点完成后才算图片素材:结果写在 storageKey / resultStorageKey,content 会被清空。
|
||||
const conversion = node.metadata?.mediaConversion;
|
||||
const storageKey = conversion?.resultStorageKey || node.metadata?.storageKey;
|
||||
if (conversion?.status !== "completed" || !storageKey) return null;
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.title || `conversion-${node.id}.png`,
|
||||
type: node.metadata?.mimeType || "image/png",
|
||||
dataUrl: node.metadata?.content || "",
|
||||
storageKey,
|
||||
};
|
||||
}
|
||||
if (node.type !== CanvasNodeType.Image || (!node.metadata?.content && !node.metadata?.storageKey)) return null;
|
||||
return {
|
||||
id: node.id,
|
||||
@@ -338,8 +325,8 @@ export function buildVideoGenerationMetadata(
|
||||
const metadata = node?.metadata;
|
||||
const referenceImageIds = new Set((context?.referenceImages || []).map((image) => image.id));
|
||||
// 工作流视频把已连接媒体交给字段映射处理,不再把历史首尾帧选择当成硬约束。
|
||||
// 这样旧节点切换到 RunningHub/ComfyUI 后,不会因为残留的首尾帧 ID 阻断生成。
|
||||
const workflowVideo = node?.type === CanvasNodeType.Config && ((config?.taskWorkflowProvider === "runninghub" || config?.taskWorkflowProvider === "comfyui") || isCanvasWorkflowProvider(metadata));
|
||||
// 工作流视频把已连接媒体交给字段映射处理,不再把历史首尾帧选择当成硬约束。
|
||||
const workflowVideo = node?.type === CanvasNodeType.Config && (config?.taskWorkflowProvider === "runninghub" || isCanvasWorkflowProvider(metadata));
|
||||
const startFrame = workflowVideo ? undefined : requireConnectedVideoFrame(metadata?.videoStartFrameNodeId, "首帧", referenceImageIds);
|
||||
const endFrame = workflowVideo ? undefined : requireConnectedVideoFrame(metadata?.videoEndFrameNodeId, "尾帧", referenceImageIds);
|
||||
return {
|
||||
@@ -407,21 +394,18 @@ export function getGenerationCount(count: string) {
|
||||
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
|
||||
}
|
||||
|
||||
export function generationWorkflowMetadata(config: AiConfig): Pick<CanvasNodeMetadata, "workflowProvider" | "runningHubWorkflowId" | "runningHubWorkflowKind" | "comfyBridgeWorkflowId"> {
|
||||
export function generationWorkflowMetadata(config: AiConfig): Pick<CanvasNodeMetadata, "workflowProvider" | "runningHubWorkflowId" | "runningHubWorkflowKind"> {
|
||||
const provider = config.taskWorkflowProvider || "model";
|
||||
return {
|
||||
workflowProvider: provider,
|
||||
runningHubWorkflowId: provider === "runninghub" ? config.runningHub.workflowId : undefined,
|
||||
runningHubWorkflowKind: provider === "runninghub" ? config.runningHub.selectedKind : undefined,
|
||||
comfyBridgeWorkflowId: provider === "comfyui" ? config.comfyBridge.workflowId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements): AiConfig {
|
||||
// 只有独立 Config 节点读取工作流元数据;普通图片/视频/音频节点始终按基础模型生成。
|
||||
const workflowProvider = mode !== "text" && node?.type === CanvasNodeType.Config
|
||||
? resolveCanvasWorkflowProvider(node.metadata) === "comfyui" ? "comfyui" : "runninghub"
|
||||
: "model";
|
||||
const workflowProvider = mode !== "text" && node?.type === CanvasNodeType.Config && resolveCanvasWorkflowProvider(node.metadata) === "runninghub" ? "runninghub" : "model";
|
||||
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode);
|
||||
@@ -432,15 +416,9 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
const selectedRunningHubWorkflow = workflowProvider === "runninghub"
|
||||
? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind))
|
||||
: undefined;
|
||||
const comfyBridgeWorkflowId = node?.metadata?.comfyBridgeWorkflowId?.trim() || config.comfyBridge.workflowId.trim();
|
||||
const selectedComfyBridgeWorkflow = workflowProvider === "comfyui"
|
||||
? config.comfyBridge.workflows.find((item) => item.workflowId.trim() === comfyBridgeWorkflowId)
|
||||
: undefined;
|
||||
const selectedWorkflowFields = workflowProvider === "runninghub"
|
||||
? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson)
|
||||
: workflowProvider === "comfyui"
|
||||
? selectedComfyBridgeWorkflow?.fields?.length ? selectedComfyBridgeWorkflow.fields : workflowVideoFieldsFromJson(selectedComfyBridgeWorkflow?.workflowJson)
|
||||
: [];
|
||||
: [];
|
||||
const workflowOutputSize = workflowProvider === "model" ? "" : workflowOutputSizeValue(selectedWorkflowFields, workflowParameters);
|
||||
const workflowParameterValue = (source: string) => {
|
||||
const value = workflowParameters[`source:${source}`];
|
||||
@@ -508,12 +486,10 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
const modeCapability = mode === "video" || mode === "audio" ? mode : "image";
|
||||
const runningHubCapability = normalizeRunningHubCapability(selectedRunningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability));
|
||||
const runningHub = { ...config.runningHub, enabled: workflowProvider === "runninghub" && config.runningHub.enabled, selectedKind: selectedRunningHubWorkflow?.kind === "app" ? "app" as const : "workflow" as const, workflowId: runningHubWorkflowId, capability: runningHubCapability, workflows: workflowProvider === "runninghub" ? config.runningHub.workflows.map((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind) ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[], workflowParameters) } : item) : config.runningHub.workflows };
|
||||
const comfyBridge = { ...config.comfyBridge, enabled: workflowProvider === "comfyui" && config.comfyBridge.enabled, workflowId: comfyBridgeWorkflowId, capability: selectedComfyBridgeWorkflow?.capability || modeCapability, workflows: workflowProvider === "comfyui" ? config.comfyBridge.workflows.map((item) => item.workflowId.trim() === comfyBridgeWorkflowId ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[], workflowParameters) } : item) : config.comfyBridge.workflows };
|
||||
return {
|
||||
...requestedConfig,
|
||||
taskWorkflowProvider: workflowProvider,
|
||||
runningHub,
|
||||
comfyBridge,
|
||||
model,
|
||||
quality: generationDefaults.quality || requestedConfig.quality,
|
||||
size: generationDefaults.size ?? requestedConfig.size,
|
||||
|
||||
@@ -310,6 +310,36 @@ export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connectio
|
||||
return globalReferences.map((reference) => activeByNodeId.get(reference.nodeId) || reference);
|
||||
}
|
||||
|
||||
/** Agent 的 @ 菜单覆盖整个画布,而不是只覆盖可作为生成输入的资源节点。 */
|
||||
export function buildCanvasAgentMentionReferences(nodes: CanvasNodeData[]): CanvasResourceReference[] {
|
||||
return nodes.map((node, index) => {
|
||||
const kind = resourceKind(node) || "text";
|
||||
const fallbackTitle = `节点 ${index + 1}`;
|
||||
return {
|
||||
id: node.id,
|
||||
nodeId: node.id,
|
||||
kind,
|
||||
label: node.title?.trim() || fallbackTitle,
|
||||
title: node.title?.trim() || fallbackTitle,
|
||||
previewUrl: node.metadata?.workflowKind === "character"
|
||||
? node.metadata.characterCoverUrl
|
||||
: node.type === CanvasNodeType.Drawing
|
||||
? node.metadata?.drawingPreviewUrl
|
||||
: node.type === CanvasNodeType.Video
|
||||
? canvasNodeVideoPreviewUrl(node)
|
||||
: node.metadata?.previewContent || node.metadata?.content,
|
||||
storageKey: node.metadata?.storageKey,
|
||||
previewStorageKey: node.type === CanvasNodeType.Video ? node.metadata?.videoPreview?.storageKey : undefined,
|
||||
drawingId: node.type === CanvasNodeType.Drawing ? node.metadata?.drawingId : undefined,
|
||||
drawingRevision: node.type === CanvasNodeType.Drawing ? node.metadata?.drawingRevision : undefined,
|
||||
text: node.metadata?.content || node.metadata?.composerContent || node.metadata?.prompt || node.title,
|
||||
active: true,
|
||||
sourceType: node.type,
|
||||
mentionToken: canvasNodeMentionToken(node.id),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueCanvasNodes(nodes: CanvasNodeData[]) {
|
||||
const seen = new Set<string>();
|
||||
return nodes.filter((node) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { CanvasAgentOp, CanvasAgentSnapshot } from "./canvas-agent-ops";
|
||||
import type { CanvasOperation, CanvasSnapshot } from "./canvas-operation-contract";
|
||||
import { createStoryboardRow } from "./canvas-project-domain";
|
||||
import type { StoryboardRow } from "@/types/canvas";
|
||||
import { inspectStoryboardReadiness } from "./canvas-storyboard-context";
|
||||
import { createShortDramaPipeline } from "./canvas-short-drama";
|
||||
|
||||
export function buildAgentStoryboardDraft(input: { title: string; prompt: string; x: number; y: number }): CanvasAgentOp[] {
|
||||
export function buildAgentStoryboardDraft(input: { title: string; prompt: string; x: number; y: number }): CanvasOperation[] {
|
||||
if (!input.title.trim() || !input.prompt.trim()) throw new Error("待拆镜节点需要标题和原始剧本");
|
||||
const node = createShortDramaPipeline({ x: input.x - 180, y: input.y }).nodes[2];
|
||||
return [{ type: "add_node", id: node.id, nodeType: node.type, title: input.title, position: node.position, width: node.width, height: node.height,
|
||||
@@ -13,7 +13,7 @@ export function buildAgentStoryboardDraft(input: { title: string; prompt: string
|
||||
|
||||
const textFields = ["plotDescription", "dialogue", "videoMotionPrompt", "imageGenerationPrompt", "camera", "motion", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "narrativeIntent", "viewerPOV", "performanceBlocking", "timeBeats", "continuityOut", "negativePrompt"] as const;
|
||||
|
||||
export function validateAgentStoryboardCreation(ops: CanvasAgentOp[]) {
|
||||
export function validateAgentStoryboardCreation(ops: CanvasOperation[]) {
|
||||
for (const op of ops) {
|
||||
if (op.type !== "add_node" || op.nodeType !== "script") continue;
|
||||
const rows = op.metadata?.storyboard?.rows;
|
||||
@@ -22,13 +22,13 @@ export function validateAgentStoryboardCreation(ops: CanvasAgentOp[]) {
|
||||
return ops;
|
||||
}
|
||||
|
||||
export function readAgentStoryboard(snapshot: CanvasAgentSnapshot, nodeId: string) {
|
||||
export function readAgentStoryboard(snapshot: CanvasSnapshot, nodeId: string) {
|
||||
const node = snapshot.nodes.find((item) => item.id === nodeId && item.type === "script");
|
||||
if (!node) throw new Error("未找到当前画布的分镜脚本节点,请先查找真实节点");
|
||||
return { node, rows: node.metadata?.storyboard?.rows || [], readiness: inspectStoryboardReadiness(snapshot.nodes) };
|
||||
}
|
||||
|
||||
export function buildAgentStoryboardOperations(snapshot: CanvasAgentSnapshot, input: Record<string, unknown>): CanvasAgentOp[] {
|
||||
export function buildAgentStoryboardOperations(snapshot: CanvasSnapshot, input: Record<string, unknown>): CanvasOperation[] {
|
||||
if (typeof input.nodeId !== "string") throw new Error("需要分镜节点 nodeId");
|
||||
const { node, rows } = readAgentStoryboard(snapshot, input.nodeId);
|
||||
const action = input.action;
|
||||
26
web/src/lib/canvas/canvas-text-mention.ts
Normal file
26
web/src/lib/canvas/canvas-text-mention.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { applyCanvasConnectionPromptSync, buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences } from "./canvas-resource-references";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export function connectCanvasTextMention(nodes: CanvasNodeData[], connections: CanvasConnection[], targetId: string, sourceId: string, connectionId: string) {
|
||||
const target = nodes.find((node) => node.id === targetId);
|
||||
const source = buildCanvasResourceReferences(nodes, connections).find((reference) => reference.nodeId === sourceId && reference.kind === "text");
|
||||
if (!target || !source || sourceId === targetId || target.metadata?.locked) throw new Error("无法引用该文本节点");
|
||||
const configId = connections.find((edge) => edge.fromNodeId === targetId && nodes.find((node) => node.id === edge.toNodeId)?.type === CanvasNodeType.Config)?.toNodeId;
|
||||
const configReferences = configId ? buildCanvasNodeMentionReferenceMap(nodes, connections).get(configId) : [];
|
||||
const receiverId = configReferences?.some((reference) => reference.nodeId !== targetId) ? configId! : targetId;
|
||||
if (nodes.find((node) => node.id === receiverId)?.metadata?.locked) throw new Error("无法修改锁定节点的引用");
|
||||
const pending = [receiverId];
|
||||
const visited = new Set<string>();
|
||||
while (pending.length) {
|
||||
const id = pending.pop()!;
|
||||
if (id === sourceId) throw new Error("该引用会形成循环连线");
|
||||
if (visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
pending.push(...connections.filter((edge) => edge.fromNodeId === id).map((edge) => edge.toNodeId));
|
||||
}
|
||||
const nextConnections = connections.some((edge) => edge.fromNodeId === sourceId && edge.toNodeId === receiverId) ? connections : [...connections, { id: connectionId, fromNodeId: sourceId, toNodeId: receiverId }];
|
||||
const nextNodes = applyCanvasConnectionPromptSync(nodes, connections, nodes, nextConnections);
|
||||
const reference = buildCanvasNodeMentionReferenceMap(nextNodes, nextConnections).get(targetId)?.find((item) => item.nodeId === sourceId);
|
||||
if (!reference) throw new Error("文本引用未建立");
|
||||
return { nodes: nextNodes, connections: nextConnections, reference };
|
||||
}
|
||||
@@ -12,6 +12,28 @@ export type CanvasViewportSize = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
// 在浮窗四周选择最大的完整矩形;坐标均相对画布容器。
|
||||
export function unobscuredCanvasArea(size: CanvasViewportSize, overlay?: CanvasBounds): CanvasBounds {
|
||||
const full = { left: 0, top: 0, right: size.width, bottom: size.height };
|
||||
if (!overlay || overlay.right <= 0 || overlay.bottom <= 0 || overlay.left >= size.width || overlay.top >= size.height) return full;
|
||||
const left = Math.max(0, overlay.left);
|
||||
const top = Math.max(0, overlay.top);
|
||||
const right = Math.min(size.width, overlay.right);
|
||||
const bottom = Math.min(size.height, overlay.bottom);
|
||||
const candidates = [
|
||||
{ ...full, right: left }, { ...full, left: right },
|
||||
{ ...full, bottom: top }, { ...full, top: bottom },
|
||||
].filter((area) => area.right - area.left >= 160 && area.bottom - area.top >= 160);
|
||||
return candidates.sort((a, b) => (b.right - b.left) * (b.bottom - b.top) - (a.right - a.left) * (a.bottom - a.top))[0] ?? full;
|
||||
}
|
||||
|
||||
export function viewportForAgentNodes(nodes: CanvasNodeData[], current: ViewportTransform, area: CanvasBounds): ViewportTransform | null {
|
||||
const bounds = getCanvasNodesBounds(nodes);
|
||||
if (!bounds) return null;
|
||||
const next = viewportForBounds(bounds, { width: area.right - area.left, height: area.bottom - area.top }, { padding: 56, maxScale: current.k });
|
||||
return { ...next, x: next.x + area.left, y: next.y + area.top };
|
||||
}
|
||||
|
||||
export function getCanvasNodesBounds(nodes: CanvasNodeData[]): CanvasBounds | null {
|
||||
if (!nodes.length) return null;
|
||||
let left = Number.POSITIVE_INFINITY;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { workflowStarterPrompt } from "@/lib/prompts";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import type { CanvasAgentOp, CanvasAgentSnapshot } from "./canvas-agent-ops";
|
||||
import type { CanvasOperation, CanvasSnapshot } from "./canvas-operation-contract";
|
||||
import { createShortDramaPipeline } from "./canvas-short-drama";
|
||||
import { createStoryboardRow } from "./canvas-project-domain";
|
||||
|
||||
@@ -56,7 +56,7 @@ const WORKFLOW_KINDS = new Set<CanvasWorkflowNodeKind>([
|
||||
const DEFAULT_GAP = 120;
|
||||
const WORKFLOW_PREFIX = "agent-workflow";
|
||||
|
||||
export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: CanvasAgentSnapshot, config: AiConfig, identityPrefix?: string): CanvasAgentOp[] {
|
||||
export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: CanvasSnapshot, config: AiConfig, identityPrefix?: string): CanvasOperation[] {
|
||||
if (!Array.isArray(input.nodes) || input.nodes.length === 0) throw new Error("工作流至少需要一个节点");
|
||||
const refs = new Set<string>();
|
||||
input.nodes.forEach((node) => {
|
||||
@@ -82,7 +82,7 @@ export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: Can
|
||||
const gap = Math.max(48, input.gap ?? DEFAULT_GAP);
|
||||
const positions = layoutWorkflowNodes(input.nodes, snapshot, direction, gap, input.start);
|
||||
const ids = new Map(input.nodes.map((node) => [node.ref, identityPrefix ? `${identityPrefix}:node:${encodeURIComponent(node.ref)}` : `${WORKFLOW_PREFIX}-${slug(node.ref)}-${nanoid(8)}`]));
|
||||
const ops: CanvasAgentOp[] = [];
|
||||
const ops: CanvasOperation[] = [];
|
||||
|
||||
input.nodes.forEach((node, index) => {
|
||||
const id = ids.get(node.ref)!;
|
||||
@@ -176,7 +176,7 @@ export function isWorkflowNodeKind(kind: string): kind is CanvasWorkflowNodeKind
|
||||
return WORKFLOW_KINDS.has(kind as CanvasWorkflowNodeKind);
|
||||
}
|
||||
|
||||
function layoutWorkflowNodes(nodes: CanvasWorkflowNodeInput[], snapshot: CanvasAgentSnapshot, direction: "horizontal" | "vertical", gap: number, start?: { x: number; y: number }) {
|
||||
function layoutWorkflowNodes(nodes: CanvasWorkflowNodeInput[], snapshot: CanvasSnapshot, direction: "horizontal" | "vertical", gap: number, start?: { x: number; y: number }) {
|
||||
const maxX = snapshot.nodes.reduce((max, node) => Math.max(max, node.position.x + node.width), 0);
|
||||
const maxY = snapshot.nodes.reduce((max, node) => Math.max(max, node.position.y + node.height), 0);
|
||||
const origin = start || { x: snapshot.nodes.length ? maxX + 160 : 80, y: snapshot.nodes.length ? Math.max(80, maxY - 520) : 80 };
|
||||
@@ -235,6 +235,6 @@ function slug(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "node";
|
||||
}
|
||||
|
||||
export function workflowNodeIdsFromOps(ops: CanvasAgentOp[]) {
|
||||
return ops.filter((op): op is Extract<CanvasAgentOp, { type: "add_node" }> => op.type === "add_node" && Boolean(op.id)).map((op) => op.id!);
|
||||
export function workflowNodeIdsFromOps(ops: CanvasOperation[]) {
|
||||
return ops.filter((op): op is Extract<CanvasOperation, { type: "add_node" }> => op.type === "add_node" && Boolean(op.id)).map((op) => op.id!);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CanvasNodeMetadata } from "@/types/canvas";
|
||||
|
||||
export type CanvasWorkflowProvider = "model" | "runninghub" | "comfyui";
|
||||
export type CanvasWorkflowProvider = "model" | "runninghub";
|
||||
|
||||
/**
|
||||
* 统一解析画布节点的工作流渠道;旧节点可能没有 workflowProvider,
|
||||
@@ -9,11 +9,10 @@ export type CanvasWorkflowProvider = "model" | "runninghub" | "comfyui";
|
||||
export function resolveCanvasWorkflowProvider(metadata?: CanvasNodeMetadata): CanvasWorkflowProvider {
|
||||
if (metadata?.workflowProvider) return metadata.workflowProvider;
|
||||
if (metadata?.runningHubWorkflowId?.trim()) return "runninghub";
|
||||
if (metadata?.comfyBridgeWorkflowId?.trim()) return "comfyui";
|
||||
return "model";
|
||||
}
|
||||
|
||||
export function isCanvasWorkflowProvider(metadata?: CanvasNodeMetadata) {
|
||||
const provider = resolveCanvasWorkflowProvider(metadata);
|
||||
return provider === "runninghub" || provider === "comfyui";
|
||||
return provider === "runninghub";
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
export type LocalAgentSetupPlatform = "unix" | "windows";
|
||||
|
||||
const CANVAS_REPOSITORY_URL = "https://github.com/ddcat-ai/open-ai-canvas.git";
|
||||
|
||||
export function detectLocalAgentSetupPlatform(userAgent = defaultUserAgent()): LocalAgentSetupPlatform {
|
||||
return /windows/i.test(userAgent) ? "windows" : "unix";
|
||||
}
|
||||
|
||||
export function buildLocalAgentSetupCommands(origin: string, platform: LocalAgentSetupPlatform) {
|
||||
const trustedOrigin = exactWebOrigin(origin);
|
||||
if (platform === "windows") {
|
||||
return {
|
||||
install: `git clone ${CANVAS_REPOSITORY_URL}\nSet-Location .\\open-ai-canvas\\canvas-agent\nnpm install\nnpm run build\nSet-Location ..\\..`,
|
||||
start: `Set-Location .\\open-ai-canvas\\canvas-agent\n$env:FRAMEFIELD_TRUSTED_WEB_ORIGINS=${powerShellArgument(trustedOrigin)}\nnode .\\dist\\index.js`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
install: `git clone ${CANVAS_REPOSITORY_URL}\ncd open-ai-canvas/canvas-agent\nnpm install\nnpm run build\ncd ../..`,
|
||||
start: `cd open-ai-canvas/canvas-agent\nFRAMEFIELD_TRUSTED_WEB_ORIGINS=${shellArgument(trustedOrigin)} node dist/index.js`,
|
||||
};
|
||||
}
|
||||
|
||||
function exactWebOrigin(value: string) {
|
||||
const url = new URL(value);
|
||||
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.pathname !== "/" || url.search || url.hash || url.origin !== value) {
|
||||
throw new Error("Web origin is invalid");
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function defaultUserAgent() {
|
||||
if (typeof navigator === "undefined") return "";
|
||||
return `${navigator.platform} ${navigator.userAgent}`;
|
||||
}
|
||||
|
||||
function powerShellArgument(value: string) {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function shellArgument(value: string) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
|
||||
export type LocalRuntimeEvent = {
|
||||
type: string;
|
||||
data: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type LocalRuntimeEventStreamOptions = {
|
||||
signal?: AbortSignal;
|
||||
lastEventId?: string;
|
||||
onEvent(event: LocalRuntimeEvent): void;
|
||||
};
|
||||
|
||||
export class CanvasRuntimeStreamError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CanvasRuntimeStreamError";
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_EVENT_BYTES = 256 * 1024;
|
||||
const AUTO_CONNECT_MODES = new Set(["new", "recent", "choose"]);
|
||||
|
||||
export function shouldAutoConnectCanvasRuntime(params: URLSearchParams) {
|
||||
return AUTO_CONNECT_MODES.has(params.get("mode") ?? "");
|
||||
}
|
||||
|
||||
type CanvasRuntimeStore = {
|
||||
getState(): {
|
||||
connection: string;
|
||||
modules: Array<{ id: string }>;
|
||||
error?: string;
|
||||
connect(signal?: AbortSignal): Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function prepareCanvasRuntimeConnection(store: CanvasRuntimeStore, signal?: AbortSignal) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
await store.getState().connect(signal);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const state = store.getState();
|
||||
if (state.connection !== "connected" || state.error) {
|
||||
throw new CanvasRuntimeStreamError("canvas_runtime_unavailable", "本机运行时尚未连接");
|
||||
}
|
||||
if (!state.modules.some((module) => module.id === "canvas-agent")) {
|
||||
throw new CanvasRuntimeStreamError("canvas_module_unavailable", "Canvas Agent 模块未加载");
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForCanvasRuntimeReconnect(signal: AbortSignal, delayMs = 1_000) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", finish);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, delayMs);
|
||||
signal.addEventListener("abort", finish, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export type CanvasRuntimeStateResult = {
|
||||
accepted: boolean;
|
||||
revision: number;
|
||||
stateHash: string;
|
||||
idempotent?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function postCanvasRuntimeState(client: LocalRuntimeTransport, clientId: string, snapshot: unknown): Promise<CanvasRuntimeStateResult> {
|
||||
try {
|
||||
const response = await client.request(`/canvas/state?clientId=${encodeURIComponent(clientId)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(snapshot),
|
||||
});
|
||||
const body = await response.json().catch(() => null) as Partial<CanvasRuntimeStateResult> | null;
|
||||
if (!response.ok && response.status !== 409) throw new Error("Canvas Agent 状态同步请求失败");
|
||||
if (body?.accepted !== true || typeof body.revision !== "number" || typeof body.stateHash !== "string") {
|
||||
throw new CanvasRuntimeStreamError("canvas_state_conflict", body?.reason === "stale_revision" || body?.reason === "revision_conflict" ? "画布状态已被其他操作更新,请重新读取后再试" : "画布状态同步被拒绝");
|
||||
}
|
||||
return { accepted: true, revision: body.revision, stateHash: body.stateHash, idempotent: body.idempotent, reason: body.reason };
|
||||
} catch (error) {
|
||||
if (error instanceof CanvasRuntimeStreamError) throw error;
|
||||
throw new CanvasRuntimeStreamError("canvas_state_sync_failed", "画布状态同步失败");
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeLocalRuntimeEventStream(client: LocalRuntimeTransport, path: string, options: LocalRuntimeEventStreamOptions) {
|
||||
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const headers = new Headers();
|
||||
if (options.lastEventId) headers.set("Last-Event-ID", options.lastEventId);
|
||||
const response = await client.request(path, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!response.ok) throw new CanvasRuntimeStreamError("canvas_stream_unavailable", "Canvas Agent 事件流不可用");
|
||||
if (!response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream") || !response.body) {
|
||||
throw new CanvasRuntimeStreamError("canvas_stream_invalid", "Canvas Agent 事件流响应无效");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
let buffer = "";
|
||||
let eventType = "";
|
||||
let eventId: string | undefined;
|
||||
let data: string[] = [];
|
||||
let eventBytes = 0;
|
||||
const dispatch = () => {
|
||||
if (data.length) {
|
||||
options.onEvent({
|
||||
type: eventType || "message",
|
||||
data: data.join("\n"),
|
||||
...(eventId !== undefined ? { id: eventId } : {}),
|
||||
});
|
||||
}
|
||||
eventType = "";
|
||||
eventId = undefined;
|
||||
data = [];
|
||||
eventBytes = 0;
|
||||
};
|
||||
const consumeLine = (line: string) => {
|
||||
eventBytes += new TextEncoder().encode(line).byteLength + 1;
|
||||
if (eventBytes > MAX_EVENT_BYTES) {
|
||||
throw new CanvasRuntimeStreamError("canvas_event_too_large", "Canvas Agent 事件超过安全上限");
|
||||
}
|
||||
if (!line) {
|
||||
dispatch();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) return;
|
||||
const separator = line.indexOf(":");
|
||||
const field = separator < 0 ? line : line.slice(0, separator);
|
||||
const value = separator < 0 ? "" : line.slice(separator + 1).replace(/^ /, "");
|
||||
if (field === "event") eventType = value;
|
||||
else if (field === "data") data.push(value);
|
||||
else if (field === "id" && !value.includes("\0")) eventId = value;
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const item = await reader.read();
|
||||
if (item.done) break;
|
||||
buffer += decoder.decode(item.value, { stream: true });
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
||||
buffer = buffer.slice(newline + 1);
|
||||
consumeLine(line);
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
if (eventBytes + new TextEncoder().encode(buffer).byteLength > MAX_EVENT_BYTES) {
|
||||
throw new CanvasRuntimeStreamError("canvas_event_too_large", "Canvas Agent 事件超过安全上限");
|
||||
}
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
if (buffer) consumeLine(buffer.replace(/\r$/, ""));
|
||||
dispatch();
|
||||
} catch (error) {
|
||||
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (error instanceof CanvasRuntimeStreamError) throw error;
|
||||
throw new CanvasRuntimeStreamError("canvas_stream_invalid", "Canvas Agent 事件流响应无效");
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -165,20 +165,6 @@ const BUILTIN_NODE_TRAITS = {
|
||||
resourceKind: () => "image",
|
||||
inputKind: "image",
|
||||
},
|
||||
[CanvasNodeType.MediaConversion]: {
|
||||
label: "转换",
|
||||
icon: <WandSparkles />,
|
||||
minSize: { width: 400, height: 360 },
|
||||
showInCreateMenu: true,
|
||||
resourceKind: (node: CanvasNodeData) => {
|
||||
const conversion = node.metadata?.mediaConversion;
|
||||
if (conversion?.status !== "completed" || !conversion.resultStorageKey) return null;
|
||||
return conversion.outputKind === "video" ? "video" : "image";
|
||||
},
|
||||
acceptsInputKind: ["image", "video"],
|
||||
maxInputCount: 1,
|
||||
inputKind: "image",
|
||||
},
|
||||
} satisfies Record<string, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;
|
||||
|
||||
export const BUILTIN_NODE_DEFINITIONS: CanvasNodeDefinition[] = (Object.keys(BUILTIN_NODE_TRAITS) as CanvasNodeType[]).map((type) => {
|
||||
|
||||
@@ -20,7 +20,6 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
{ id: "folder", label: "文件夹", icon: <Folder />, badge: "6 款", section: "node", defaultOrder: 45, run: (ctx) => ctx.handlers.onAddFolder() },
|
||||
nodeCommand(CanvasNodeType.Image, { defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() }),
|
||||
nodeCommand(CanvasNodeType.Video, { defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() }),
|
||||
nodeCommand(CanvasNodeType.MediaConversion, { badge: "本地", defaultOrder: 65, run: (ctx) => ctx.handlers.onAddExtensionNode(CanvasNodeType.MediaConversion) }),
|
||||
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
|
||||
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
|
||||
nodeCommand(CanvasNodeType.Audio, { defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() }),
|
||||
|
||||
@@ -142,19 +142,6 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
|
||||
applicable: (ctx) => hasImage(ctx) || hasVideo(ctx) || hasAudio(ctx),
|
||||
run: (ctx) => ctx.handlers.onNodeDownload(ctx.node!),
|
||||
},
|
||||
{
|
||||
id: "createConversion",
|
||||
toolbar: "node-hover",
|
||||
category: "node-state",
|
||||
label: "创建转换节点",
|
||||
displayLabel: "转换",
|
||||
icon: <WandSparkles className="size-3.5" />,
|
||||
defaultVisible: true,
|
||||
defaultOrder: 65,
|
||||
nodeToolbar: { group: "primary", order: 15, description: "在右侧创建转换节点并自动连接" },
|
||||
applicable: (ctx) => (hasImage(ctx) || hasVideo(ctx)) && Boolean(ctx.handlers.onNodeCreateConversion),
|
||||
run: (ctx) => ctx.handlers.onNodeCreateConversion?.(ctx.node!),
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
toolbar: "node-hover",
|
||||
|
||||
@@ -81,8 +81,6 @@ export type ToolbarHandlers = {
|
||||
onNodeUpload: (node: CanvasNodeData) => void;
|
||||
onNodeDownload: (node: CanvasNodeData) => void;
|
||||
onNodeSaveAsset: (node: CanvasNodeData) => void;
|
||||
/** 从已有图片或视频创建并自动连接一个转换节点。 */
|
||||
onNodeCreateConversion?: (node: CanvasNodeData) => void;
|
||||
onNodeMaskEdit: (node: CanvasNodeData) => void;
|
||||
onNodeEmotion: (node: CanvasNodeData) => void;
|
||||
onNodePortraitTexture: (node: CanvasNodeData) => void;
|
||||
|
||||
Reference in New Issue
Block a user