Files
ttstd-infinite-canvas/web/src/pages/canvas/list-mode-generator.ts

269 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}