269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
import { nanoid } from "nanoid";
|
||
import { message } from "antd";
|
||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasBatchTableData, type CanvasBatchRow } from "@/types/canvas";
|
||
import { runBackendGenerationTask } from "@/services/api/generation-task";
|
||
import { buildGenerationConfig, resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
|
||
import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||
import type { AiConfig } from "@/stores/use-config-store";
|
||
|
||
const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户会给你多张产品图片和一个任务描述。
|
||
请分析每张图片,为每张图片生成一行数据。
|
||
|
||
输出严格为以下 JSON 格式(不要输出其他内容):
|
||
{
|
||
"columns": ["列名1", "列名2", "列名3"],
|
||
"rows": [
|
||
{"列名1": "内容", "列名2": "内容", "列名3": "内容"}
|
||
]
|
||
}
|
||
|
||
规则:
|
||
1. 第一列不需要输出(它是图片本身),从第二列开始定义分析维度
|
||
2. 列名根据任务需求自动决定(如:核心卖点、视觉细节、文案标题、搭配建议等)
|
||
3. 每张图片对应一行,rows 数量等于图片数量
|
||
4. 每个单元格内容简洁有力,适合电商/社交媒体使用
|
||
5. 列数建议 3-6 列,根据任务复杂度决定
|
||
6. 只输出 JSON,不要有任何其他文字`;
|
||
|
||
type ListGenerationResult = {
|
||
columns: string[];
|
||
rows: Record<string, string>[];
|
||
};
|
||
|
||
export function parseListModeJson(text: string): ListGenerationResult | null {
|
||
const candidates = [
|
||
text.trim(),
|
||
...Array.from(text.matchAll(/```(?:json)?\s*([\s\S]*?)\s*```/gi), (match) => match[1].trim()),
|
||
...balancedJsonObjects(text),
|
||
];
|
||
for (const candidate of candidates) {
|
||
try {
|
||
const parsed = JSON.parse(candidate) as { columns?: unknown; rows?: unknown };
|
||
const columns = normalizeListColumns(parsed.columns);
|
||
if (!columns.length || !Array.isArray(parsed.rows)) continue;
|
||
const rows = parsed.rows
|
||
.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object" && !Array.isArray(row))
|
||
.map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, listCellText(value)])));
|
||
return { columns, rows };
|
||
} catch {
|
||
// Try the next candidate. Models often wrap valid JSON in prose or a code block.
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function balancedJsonObjects(text: string) {
|
||
const results: string[] = [];
|
||
let start = -1;
|
||
let depth = 0;
|
||
let inString = false;
|
||
let escaped = false;
|
||
for (let index = 0; index < text.length; index += 1) {
|
||
const char = text[index];
|
||
if (inString) {
|
||
if (escaped) escaped = false;
|
||
else if (char === "\\") escaped = true;
|
||
else if (char === '"') inString = false;
|
||
continue;
|
||
}
|
||
if (char === '"') {
|
||
inString = true;
|
||
continue;
|
||
}
|
||
if (char === "{") {
|
||
if (depth === 0) start = index;
|
||
depth += 1;
|
||
} else if (char === "}" && depth > 0) {
|
||
depth -= 1;
|
||
if (depth === 0 && start >= 0) {
|
||
results.push(text.slice(start, index + 1));
|
||
start = -1;
|
||
}
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
function normalizeListColumns(value: unknown) {
|
||
if (!Array.isArray(value)) return [];
|
||
const used = new Set<string>();
|
||
return value.flatMap((item) => {
|
||
const label = typeof item === "string" ? item.trim() : "";
|
||
if (!label || used.has(label)) return [];
|
||
used.add(label);
|
||
return [label];
|
||
});
|
||
}
|
||
|
||
function listCellText(value: unknown): string {
|
||
if (typeof value === "string") return value.trim();
|
||
if (value === null || value === undefined) return "";
|
||
if (Array.isArray(value)) return value.map(listCellText).filter(Boolean).join("、");
|
||
if (typeof value === "object") return JSON.stringify(value);
|
||
return String(value);
|
||
}
|
||
|
||
type HandleListGenerateOptions = {
|
||
sourceNodeId: string;
|
||
prompt: string;
|
||
nodes: CanvasNodeData[];
|
||
connections: CanvasConnection[];
|
||
config: AiConfig;
|
||
projectId: string;
|
||
setNodes: (updater: (prev: CanvasNodeData[]) => CanvasNodeData[]) => void;
|
||
setConnections: (updater: (prev: CanvasConnection[]) => CanvasConnection[]) => void;
|
||
setRunningNodeId: (id: string | null) => void;
|
||
setDialogNodeId: (id: string | null) => void;
|
||
};
|
||
|
||
export async function handleListGenerate({
|
||
sourceNodeId,
|
||
prompt,
|
||
nodes,
|
||
connections,
|
||
config,
|
||
projectId,
|
||
setNodes,
|
||
setConnections,
|
||
setRunningNodeId,
|
||
setDialogNodeId,
|
||
}: HandleListGenerateOptions) {
|
||
const sourceNode = nodes.find((n) => n.id === sourceNodeId);
|
||
if (!sourceNode) return;
|
||
|
||
// Find connected image nodes
|
||
const connectedImageIds = connections
|
||
.filter((c) => c.toNodeId === sourceNodeId && c.fromNodeId !== sourceNodeId)
|
||
.map((c) => c.fromNodeId);
|
||
const imageNodes = nodes.filter((n) => connectedImageIds.includes(n.id) && n.type === CanvasNodeType.Image && Boolean(n.metadata?.content || n.metadata?.storageKey));
|
||
|
||
if (imageNodes.length === 0) {
|
||
message.warning("请先连接至少一张图片到当前文本节点");
|
||
return;
|
||
}
|
||
|
||
setRunningNodeId(sourceNodeId);
|
||
|
||
try {
|
||
// 列表模式必须沿用文本节点当前选中的模型。此前这里清空 node.metadata.model,
|
||
// 会退回全局 textModel;当全局模型是普通文本模型时,界面虽显示 Gemini,
|
||
// 实际请求却会发给不支持图片的旧模型。
|
||
const requirements: ModelRequirements = {
|
||
capability: "text",
|
||
input: { textCount: 1, imageCount: imageNodes.length, videoCount: 0, audioCount: 0, characterCount: 0 },
|
||
options: modelRequestOptions(config, "text"),
|
||
};
|
||
const preferredModel = resolveCanvasGenerationModel(config, sourceNode.metadata?.model, "text") || resolveCanvasGenerationModel(config, config.textModel, "text");
|
||
if (!preferredModel) throw new Error("当前没有可用的文本模型,请先在模型设置中选择模型");
|
||
const compatibleModel = resolveCompatibleModel(config, preferredModel, requirements);
|
||
if (!compatibleModel || modelCompatibilityError(config, compatibleModel, requirements)) {
|
||
throw new Error("当前文本模型不支持图片理解,请在节点底部选择支持图片输入的 Gemini 或其他多模态模型");
|
||
}
|
||
const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: compatibleModel } };
|
||
const listConfig = { ...buildGenerationConfig(config, sourceNodeForConfig, "text", requirements), model: compatibleModel };
|
||
|
||
const referenceImages = imageNodes.map((node) => ({
|
||
id: node.id,
|
||
name: node.title || "image",
|
||
type: "image",
|
||
dataUrl: node.metadata?.content || "",
|
||
storageKey: node.metadata?.storageKey,
|
||
}));
|
||
|
||
const fullPrompt = `${LIST_MODE_SYSTEM_PROMPT}\n\n用户任务:${prompt}\n\n图片数量:${imageNodes.length} 张`;
|
||
|
||
const result = await runBackendGenerationTask({
|
||
projectId,
|
||
mode: "text",
|
||
prompt: fullPrompt,
|
||
config: listConfig,
|
||
referenceImages,
|
||
streamText: false,
|
||
});
|
||
|
||
const text = result.text || "";
|
||
const parsed = parseListModeJson(text);
|
||
|
||
if (!parsed) {
|
||
message.error("AI 返回格式异常,请重试");
|
||
return;
|
||
}
|
||
|
||
// Build batch table data
|
||
const textColumns = parsed.columns.map((name, i) => ({
|
||
id: `ai-col-${i}`,
|
||
label: name,
|
||
type: "text" as const,
|
||
}));
|
||
|
||
const referenceColumn = { id: "ai-ref", label: "输入", type: "image" as const };
|
||
|
||
const rows: CanvasBatchRow[] = parsed.rows.map((row, rowIndex) => {
|
||
const imageNode = imageNodes[rowIndex % imageNodes.length];
|
||
const cells: Record<string, string> = {};
|
||
parsed.columns.forEach((colName, colIndex) => {
|
||
cells[`ai-col-${colIndex}`] = row[colName] || "";
|
||
});
|
||
// Build prompt from all text cells
|
||
const cellPrompts = parsed.columns.map((colName, colIndex) => {
|
||
const val = row[colName] || "";
|
||
return val ? `${colName}:${val}` : "";
|
||
}).filter(Boolean);
|
||
|
||
return {
|
||
id: `batch-row-${nanoid()}`,
|
||
enabled: true,
|
||
inputNodeIds: imageNode ? [imageNode.id] : [],
|
||
prompt: cellPrompts.join("\n"),
|
||
cells,
|
||
};
|
||
});
|
||
|
||
const batchTable: CanvasBatchTableData = {
|
||
operation: "creative",
|
||
concurrency: 10,
|
||
aiGenerated: true,
|
||
referenceColumns: [referenceColumn],
|
||
textColumns,
|
||
rows,
|
||
};
|
||
|
||
// Create the batch table node
|
||
const sourcePos = sourceNode.position;
|
||
const batchNodeId = `batch-table-${nanoid()}`;
|
||
const batchNode: CanvasNodeData = {
|
||
id: batchNodeId,
|
||
type: CanvasNodeType.BatchTable,
|
||
title: "AI 多维表格",
|
||
position: { x: sourcePos.x + 400, y: sourcePos.y },
|
||
width: 900,
|
||
height: 400,
|
||
metadata: {
|
||
batchTable,
|
||
model: listConfig.model,
|
||
status: "idle",
|
||
},
|
||
};
|
||
|
||
// Create connections from each image node to the batch table's reference handle
|
||
const newConnections: CanvasConnection[] = imageNodes.map((imgNode) => ({
|
||
id: `conn-${nanoid()}`,
|
||
fromNodeId: imgNode.id,
|
||
fromHandleId: "output",
|
||
toNodeId: batchNodeId,
|
||
toHandleId: "batch-reference:ai-ref",
|
||
relation: "batch-input",
|
||
}));
|
||
|
||
setNodes((prev) => [...prev, batchNode]);
|
||
setConnections((prev) => [...prev, ...newConnections]);
|
||
setDialogNodeId(batchNodeId);
|
||
|
||
message.success(`已生成多维表格:${parsed.columns.length} 列 × ${rows.length} 行`);
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : "列表生成失败");
|
||
} finally {
|
||
setRunningNodeId(null);
|
||
}
|
||
}
|