feat(agent): 画布 Agent - 重构工作流上下文与技能交互
This commit is contained in:
@@ -19,8 +19,24 @@ export type CanvasResourceSummary = {
|
||||
isReady: boolean;
|
||||
};
|
||||
|
||||
export type CanvasGenerationTaskSummary = {
|
||||
taskId: string;
|
||||
nodeId: string;
|
||||
nodeTitle: string;
|
||||
mode?: string;
|
||||
nodeStatus: string;
|
||||
status: string;
|
||||
progress?: number;
|
||||
stage?: string;
|
||||
provider?: string;
|
||||
errorCode?: string;
|
||||
officialStatus?: string;
|
||||
resourceReady: boolean;
|
||||
};
|
||||
|
||||
export type CanvasContext = {
|
||||
schemaVersion: 1;
|
||||
revision: number;
|
||||
stateHash: string;
|
||||
canvas: {
|
||||
projectId?: string;
|
||||
@@ -67,6 +83,7 @@ export function buildCanvasContext(state: CanvasSnapshot | null): CanvasContext
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision: state.revision ?? 0,
|
||||
stateHash: hashState(state),
|
||||
canvas: {
|
||||
projectId: state.projectId,
|
||||
@@ -106,6 +123,66 @@ export function findCanvasNodes(state: CanvasSnapshot | null, input: { query?: s
|
||||
return { query: input.query || "", total: nodes.length, truncated: nodes.length > limit, nodes: nodes.slice(0, limit).map(compactContextNode) };
|
||||
}
|
||||
|
||||
export function getCanvasNode(state: CanvasSnapshot | null, input: { id: string }) {
|
||||
if (!state) throw new Error("当前没有已连接画布");
|
||||
const node = (state.nodes || []).find((candidate) => candidate.id === input.id);
|
||||
if (!node) return { found: false, id: input.id, node: null, connections: [] };
|
||||
const nodeById = new Map((state.nodes || []).map((candidate) => [candidate.id, candidate]));
|
||||
const connections = (state.connections || [])
|
||||
.filter((connection) => connection.fromNodeId === node.id || connection.toNodeId === node.id)
|
||||
.map((connection) => connectionSummary(connection, nodeById));
|
||||
return {
|
||||
found: true,
|
||||
id: node.id,
|
||||
node: compactContextNode(node),
|
||||
resource: resourceSummary(node),
|
||||
connections,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCanvasConnection(state: CanvasSnapshot | null, input: { id: string }) {
|
||||
if (!state) throw new Error("当前没有已连接画布");
|
||||
const connection = (state.connections || []).find((candidate) => candidate.id === input.id);
|
||||
if (!connection) return { found: false, id: input.id, connection: null };
|
||||
const nodeById = new Map((state.nodes || []).map((candidate) => [candidate.id, candidate]));
|
||||
return {
|
||||
found: true,
|
||||
id: connection.id,
|
||||
connection: connectionSummary(connection, nodeById),
|
||||
fromNode: nodeById.get(connection.fromNodeId) ? compactContextNode(nodeById.get(connection.fromNodeId)!) : null,
|
||||
toNode: nodeById.get(connection.toNodeId) ? compactContextNode(nodeById.get(connection.toNodeId)!) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCanvasGenerationTasks(state: CanvasSnapshot | null, input: { status?: string; nodeIds?: string[]; limit?: number }) {
|
||||
if (!state) throw new Error("当前没有已连接画布");
|
||||
const nodeIds = input.nodeIds?.length ? new Set(input.nodeIds) : null;
|
||||
const tasks = (state.nodes || []).flatMap<CanvasGenerationTaskSummary>((node) => {
|
||||
const metadata = node.metadata || {};
|
||||
const taskId = stringValue(metadata.taskId);
|
||||
if (!taskId || (nodeIds && !nodeIds.has(node.id))) return [];
|
||||
const status = stringValue(metadata.taskStatus) || stringValue(metadata.status) || "unknown";
|
||||
if (input.status && status !== input.status) return [];
|
||||
const resource = resourceSummary(node);
|
||||
return [{
|
||||
taskId,
|
||||
nodeId: node.id,
|
||||
nodeTitle: node.title || "未命名节点",
|
||||
mode: stringValue(metadata.generationMode),
|
||||
nodeStatus: stringValue(metadata.status) || "idle",
|
||||
status,
|
||||
progress: numberValue(metadata.taskProgress ?? metadata.progress),
|
||||
stage: stringValue(metadata.taskStage),
|
||||
provider: stringValue(metadata.taskProvider),
|
||||
errorCode: stringValue(metadata.taskErrorCode || metadata.generationErrorCode),
|
||||
officialStatus: stringValue(metadata.taskOfficialStatus),
|
||||
resourceReady: Boolean(resource?.isReady),
|
||||
}];
|
||||
});
|
||||
const limit = Math.min(Math.max(input.limit || 100, 1), 200);
|
||||
return { total: tasks.length, truncated: tasks.length > limit, tasks: tasks.slice(0, limit) };
|
||||
}
|
||||
|
||||
export function getCanvasResources(state: CanvasSnapshot | null, input: { nodeIds?: string[]; status?: string; limit?: number }) {
|
||||
if (!state) throw new Error("当前没有已连接画布");
|
||||
const nodeIds = input.nodeIds?.length ? new Set(input.nodeIds) : null;
|
||||
@@ -123,11 +200,23 @@ export function validateCanvasOps(state: CanvasSnapshot | null, ops: unknown[])
|
||||
if (!state) throw new Error("当前没有已连接画布");
|
||||
const nodes = state.nodes || [];
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const connectionKeys = new Set((state.connections || []).map(connectionKey));
|
||||
const liveConnections = new Map<string, { key: string; fromNodeId: string; toNodeId: string }>();
|
||||
(state.connections || []).forEach((connection) => {
|
||||
liveConnections.set(connection.id, { key: connectionKey(connection), fromNodeId: connection.fromNodeId, toNodeId: connection.toNodeId });
|
||||
});
|
||||
const issues: Array<{ index: number; severity: "error" | "warning"; message: string }> = [];
|
||||
const addedIds = new Set<string>();
|
||||
const liveNodeIds = new Set(nodeIds);
|
||||
const removeConnection = (id: string) => {
|
||||
const connection = liveConnections.get(id);
|
||||
if (!connection) return;
|
||||
liveConnections.delete(id);
|
||||
if (![...liveConnections.values()].some((item) => item.key === connection.key)) connectionKeys.delete(connection.key);
|
||||
};
|
||||
const requireNode = (index: number, id: unknown, label: string) => {
|
||||
if (typeof id !== "string" || !id) issues.push({ index, severity: "error", message: `${label} 缺少节点 id` });
|
||||
else if (!nodeIds.has(id) && !addedIds.has(id)) issues.push({ index, severity: "error", message: `${label}「${id}」不在当前画布状态中;先重新读取 canvas_get_context 或 canvas_find_nodes` });
|
||||
else if (!liveNodeIds.has(id)) issues.push({ index, severity: "error", message: `${label}「${id}」不在当前画布状态中;先重新读取 canvas_get_context 或 canvas_find_nodes` });
|
||||
};
|
||||
ops.forEach((raw, index) => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
@@ -137,32 +226,84 @@ export function validateCanvasOps(state: CanvasSnapshot | null, ops: unknown[])
|
||||
const op = raw as Record<string, unknown>;
|
||||
switch (op.type) {
|
||||
case "add_node":
|
||||
if (op.nodeType !== undefined && !["image", "text", "script", "config", "video", "audio", "frame"].includes(String(op.nodeType))) issues.push({ index, severity: "error", message: `新增节点类型「${String(op.nodeType)}」不受支持` });
|
||||
validateFiniteNumber(issues, index, op.x, "x 坐标");
|
||||
validateFiniteNumber(issues, index, op.y, "y 坐标");
|
||||
validatePosition(issues, index, op.position, "position");
|
||||
validatePositiveNumber(issues, index, op.width, "节点宽度");
|
||||
validatePositiveNumber(issues, index, op.height, "节点高度");
|
||||
if (typeof op.id === "string") {
|
||||
if (nodeIds.has(op.id) || addedIds.has(op.id)) issues.push({ index, severity: "error", message: `新增节点 id「${op.id}」重复` });
|
||||
addedIds.add(op.id);
|
||||
liveNodeIds.add(op.id);
|
||||
}
|
||||
break;
|
||||
case "update_node":
|
||||
case "update_node": {
|
||||
requireNode(index, op.id, "更新目标");
|
||||
const targetNode = nodes.find((node) => node.id === op.id);
|
||||
if (op.patch && typeof op.patch === "object" && !Array.isArray(op.patch)) {
|
||||
const patch = op.patch as Record<string, unknown>;
|
||||
if ("id" in patch || "type" in patch) issues.push({ index, severity: "error", message: "不能通过 update_node 修改节点 id 或类型" });
|
||||
validateFiniteNumber(issues, index, patch.x, "节点 x 坐标");
|
||||
validateFiniteNumber(issues, index, patch.y, "节点 y 坐标");
|
||||
validatePosition(issues, index, patch.position, "节点 position");
|
||||
validatePositiveNumber(issues, index, patch.width, "节点宽度");
|
||||
validatePositiveNumber(issues, index, patch.height, "节点高度");
|
||||
}
|
||||
const metadata = op.metadata && typeof op.metadata === "object" && !Array.isArray(op.metadata) ? op.metadata as Record<string, unknown> : null;
|
||||
const patchMetadata = op.patch && typeof op.patch === "object" && !Array.isArray(op.patch) && (op.patch as Record<string, unknown>).metadata && typeof (op.patch as Record<string, unknown>).metadata === "object" && !Array.isArray((op.patch as Record<string, unknown>).metadata) ? (op.patch as Record<string, unknown>).metadata as Record<string, unknown> : null;
|
||||
if (isMediaNodeType(targetNode?.type) && ((metadata && "status" in metadata) || (patchMetadata && "status" in patchMetadata))) issues.push({ index, severity: "error", message: "不能直接修改媒体节点 status;生成状态必须由任务结果回写" });
|
||||
break;
|
||||
}
|
||||
case "run_generation":
|
||||
requireNode(index, op.nodeId ?? op.id, op.type === "run_generation" ? "生成目标" : "更新目标");
|
||||
requireNode(index, op.nodeId, "生成目标");
|
||||
if (typeof op.mode === "string") {
|
||||
const node = nodes.find((item) => item.id === op.nodeId);
|
||||
if (node && op.mode !== node.type && !(op.mode === "text" && node.type === "script")) issues.push({ index, severity: "error", message: `生成模式「${op.mode}」与节点类型「${node.type}」不匹配` });
|
||||
}
|
||||
break;
|
||||
case "delete_node":
|
||||
if (Array.isArray(op.ids)) op.ids.forEach((id) => requireNode(index, id, "删除目标"));
|
||||
else requireNode(index, op.id, "删除目标");
|
||||
case "delete_node": {
|
||||
const ids = Array.isArray(op.ids) ? op.ids : (typeof op.id === "string" ? [op.id] : op.nodeType ? nodes.filter((node) => node.type === op.nodeType).map((node) => node.id) : []);
|
||||
if (!ids.length) issues.push({ index, severity: "error", message: "删除节点必须提供 id、ids 或 nodeType" });
|
||||
const unique = new Set(ids);
|
||||
if (unique.size !== ids.length) issues.push({ index, severity: "error", message: "删除节点 id 不能重复" });
|
||||
ids.forEach((id) => requireNode(index, id, "删除目标"));
|
||||
ids.forEach((id) => liveNodeIds.delete(id));
|
||||
for (const [connectionId, connection] of liveConnections) if (ids.includes(connection.fromNodeId) || ids.includes(connection.toNodeId)) removeConnection(connectionId);
|
||||
break;
|
||||
case "connect_nodes":
|
||||
}
|
||||
case "connect_nodes": {
|
||||
requireNode(index, op.fromNodeId, "连接起点");
|
||||
requireNode(index, op.toNodeId, "连接终点");
|
||||
if (op.fromNodeId === op.toNodeId) issues.push({ index, severity: "error", message: "不能连接节点自身" });
|
||||
if (typeof op.id === "string" && liveConnections.has(op.id)) issues.push({ index, severity: "error", message: `连线 id「${op.id}」重复` });
|
||||
const key = connectionKey(op as CanvasConnection);
|
||||
if (connectionKeys.has(key)) issues.push({ index, severity: "error", message: "相同端点和 handle 的连线已存在" });
|
||||
connectionKeys.add(key);
|
||||
liveConnections.set(typeof op.id === "string" ? op.id : `__anonymous_${index}`, { key, fromNodeId: String(op.fromNodeId), toNodeId: String(op.toNodeId) });
|
||||
break;
|
||||
}
|
||||
case "select_nodes":
|
||||
if (Array.isArray(op.ids)) op.ids.forEach((id) => requireNode(index, id, "选区节点"));
|
||||
break;
|
||||
case "delete_connections":
|
||||
if (op.all && (op.id || (Array.isArray(op.ids) && op.ids.length))) issues.push({ index, severity: "error", message: "delete_connections 不能同时使用 all 和 id/ids" });
|
||||
if (!op.all && !op.id && !(Array.isArray(op.ids) && op.ids.length)) issues.push({ index, severity: "error", message: "删除连线必须提供 id、ids 或 all=true" });
|
||||
const connectionTargets = Array.isArray(op.ids) ? op.ids : (typeof op.id === "string" ? [op.id] : []);
|
||||
if (new Set(connectionTargets).size !== connectionTargets.length) issues.push({ index, severity: "error", message: "删除连线 id 不能重复" });
|
||||
connectionTargets.filter((id) => !liveConnections.has(id)).forEach((id) => issues.push({ index, severity: "error", message: `连线「${id}」不存在,请先读取 canvas_get_context` }));
|
||||
if (op.all) [...liveConnections.keys()].forEach(removeConnection);
|
||||
else connectionTargets.forEach(removeConnection);
|
||||
break;
|
||||
case "set_viewport":
|
||||
if (!op.viewport || typeof op.viewport !== "object") issues.push({ index, severity: "error", message: "视口参数无效" });
|
||||
else {
|
||||
const viewport = op.viewport as Record<string, unknown>;
|
||||
validateFiniteNumber(issues, index, viewport.x, "视口 x");
|
||||
validateFiniteNumber(issues, index, viewport.y, "视口 y");
|
||||
validateFiniteNumber(issues, index, viewport.k, "视口缩放");
|
||||
if (typeof viewport.k === "number" && (viewport.k < 0.05 || viewport.k > 8)) issues.push({ index, severity: "error", message: "视口缩放 k 必须在 0.05 到 8 之间" });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
issues.push({ index, severity: "error", message: `不支持的操作类型「${String(op.type)}」` });
|
||||
@@ -172,6 +313,33 @@ export function validateCanvasOps(state: CanvasSnapshot | null, ops: unknown[])
|
||||
return { ok: errors.length === 0, issues, operationCount: ops.length, currentStateHash: hashState(state) };
|
||||
}
|
||||
|
||||
function validateFiniteNumber(issues: Array<{ index: number; severity: "error" | "warning"; message: string }>, index: number, value: unknown, label: string) {
|
||||
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) issues.push({ index, severity: "error", message: `${label}必须是有限数字` });
|
||||
}
|
||||
|
||||
function validatePositiveNumber(issues: Array<{ index: number; severity: "error" | "warning"; message: string }>, index: number, value: unknown, label: string) {
|
||||
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) issues.push({ index, severity: "error", message: `${label}必须是正数` });
|
||||
}
|
||||
|
||||
function validatePosition(issues: Array<{ index: number; severity: "error" | "warning"; message: string }>, index: number, value: unknown, label: string) {
|
||||
if (value === undefined) return;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
issues.push({ index, severity: "error", message: `${label}必须是包含 x/y 的对象` });
|
||||
return;
|
||||
}
|
||||
const position = value as Record<string, unknown>;
|
||||
validateFiniteNumber(issues, index, position.x, `${label}.x`);
|
||||
validateFiniteNumber(issues, index, position.y, `${label}.y`);
|
||||
}
|
||||
|
||||
function connectionKey(connection: Pick<CanvasConnection, "fromNodeId" | "toNodeId" | "fromHandleId" | "toHandleId">) {
|
||||
return [connection.fromNodeId, connection.toNodeId, connection.fromHandleId || "", connection.toHandleId || ""].join("\0");
|
||||
}
|
||||
|
||||
function isMediaNodeType(type: CanvasNode["type"] | undefined) {
|
||||
return type === "image" || type === "video" || type === "audio";
|
||||
}
|
||||
|
||||
export function hashState(state: CanvasSnapshot) {
|
||||
return crypto.createHash("sha256").update(stableStringify({
|
||||
projectId: state.projectId,
|
||||
@@ -197,12 +365,19 @@ function compactContextNode(node: CanvasNode) {
|
||||
status: String(metadata.status || "idle"),
|
||||
content: preview(metadata.content, 240),
|
||||
prompt: preview(metadata.prompt || metadata.composerContent, 300),
|
||||
generation: metadata.generationMode || metadata.workflowKind ? {
|
||||
generation: metadata.generationMode || metadata.workflowKind || metadata.taskId ? {
|
||||
mode: metadata.generationMode,
|
||||
model: metadata.model,
|
||||
workflowKind: metadata.workflowKind,
|
||||
workflowTitle: metadata.workflowTitle,
|
||||
taskId: metadata.taskId,
|
||||
status: metadata.taskStatus || metadata.status,
|
||||
progress: numberValue(metadata.taskProgress ?? metadata.progress),
|
||||
stage: metadata.taskStage,
|
||||
provider: metadata.taskProvider,
|
||||
errorCode: metadata.taskErrorCode || metadata.generationErrorCode,
|
||||
} : undefined,
|
||||
error: metadata.status === "error" ? preview(metadata.errorDetails || metadata.generationErrorCode, 360) : undefined,
|
||||
asset: metadata.assetId || metadata.characterAssetId ? {
|
||||
assetId: metadata.assetId || metadata.characterAssetId,
|
||||
versionId: metadata.characterVersionId,
|
||||
|
||||
Reference in New Issue
Block a user