feat: add list mode for AI-powered batch table generation

- Add text/list toggle in prompt panel
- List mode: AI analyzes connected images + prompt, outputs structured JSON table
- Batch table node renders AI-generated text columns as read-only content
- Column names and row count determined by AI (3-6 columns, N rows)
This commit is contained in:
Codex committed 2026-09-19 14:25:24 +08:00
1 parent 6f1b45777b
commit e921deac74
5 files changed
+285 -19

No files matched your search

@@ -48,6 +48,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]); const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]);
const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]); const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]);
const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length; const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length;
const isAiTable = Boolean(table.aiGenerated);
const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length); const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length);
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null); const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
const [draggingCell, setDraggingCell] = useState<ReferenceCell | null>(null); const [draggingCell, setDraggingCell] = useState<ReferenceCell | null>(null);
@@ -254,7 +255,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
</div> </div>
<div className="min-h-0 flex-1 overflow-auto rounded-b-[inherit]" onMouseDown={(event) => event.stopPropagation()}> <div className="min-h-0 flex-1 overflow-auto rounded-b-[inherit]" onMouseDown={(event) => event.stopPropagation()}>
<div className="sticky top-0 z-10 grid min-w-[820px] items-center gap-2 border-b px-3 py-2 font-medium" style={{ borderColor: theme.node.stroke, gridTemplateColumns, background: theme.node.panel, color: theme.node.muted }}> <div className="sticky top-0 z-10 grid min-w-[900px] items-center gap-2 border-b px-3 py-2 font-medium" style={{ borderColor: theme.node.stroke, gridTemplateColumns, background: theme.node.panel, color: theme.node.muted }}>
<span>#</span> <span>#</span>
<span>任务</span> <span>任务</span>
{referenceColumns.map((column) => ( {referenceColumns.map((column) => (
@@ -273,7 +274,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
const item = batchItemByRowId.get(row.id); const item = batchItemByRowId.get(row.id);
const status = rowStatus(item, output); const status = rowStatus(item, output);
return ( return (
<div key={row.id} className="grid min-w-[820px] items-center gap-2 border-b px-3 py-3" style={{ borderColor: theme.node.stroke, gridTemplateColumns }}> <div key={row.id} className="grid min-w-[900px] items-center gap-2 border-b px-3 py-3" style={{ borderColor: theme.node.stroke, gridTemplateColumns }}>
<span className="tabular-nums opacity-45">{index + 1}</span> <span className="tabular-nums opacity-45">{index + 1}</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Switch size="small" checked={row.enabled !== false} disabled={readOnly} onChange={(enabled) => onUpdateRow(row.id, { enabled })} aria-label={`任务 ${index + 1}`} /> <Switch size="small" checked={row.enabled !== false} disabled={readOnly} onChange={(enabled) => onUpdateRow(row.id, { enabled })} aria-label={`任务 ${index + 1}`} />
@@ -302,8 +303,14 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
/> />
))} ))}
{textColumns.map((column, columnIndex) => ( {textColumns.map((column, columnIndex) => (
<div key={column.id} className={columnIndex === 0 ? "border-l pl-3" : undefined} style={columnIndex === 0 ? { borderColor: theme.node.stroke } : undefined}> <div key={column.id} className={`${columnIndex === 0 ? "border-l pl-3" : ""} min-w-0`} style={columnIndex === 0 ? { borderColor: theme.node.stroke } : undefined}>
<TextNodeCell node={row.textNodeIds?.[columnIndex] ? nodeById.get(row.textNodeIds[columnIndex]) : undefined} theme={theme} /> {table.aiGenerated ? (
<div className="line-clamp-4 text-[11px] leading-relaxed" style={{ color: theme.node.text }}>
{row.cells?.[column.id] || ""}
</div>
) : (
<TextNodeCell node={row.textNodeIds?.[columnIndex] ? nodeById.get(row.textNodeIds[columnIndex]) : undefined} theme={theme} />
)}
</div> </div>
))} ))}
<Input.TextArea value={batchPromptForRow(table, row)} readOnly={readOnly || hasGlobalPrompt} autoSize={{ minRows: 2, maxRows: 4 }} placeholder={hasGlobalPrompt ? "已由全局提示词覆盖" : "描述这一行要生成的画面"} onChange={(event) => onUpdateRow(row.id, { prompt: event.target.value })} /> <Input.TextArea value={batchPromptForRow(table, row)} readOnly={readOnly || hasGlobalPrompt} autoSize={{ minRows: 2, maxRows: 4 }} placeholder={hasGlobalPrompt ? "已由全局提示词覆盖" : "描述这一行要生成的画面"} onChange={(event) => onUpdateRow(row.id, { prompt: event.target.value })} />
@@ -1,7 +1,7 @@
import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd"; import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip"; import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, LayoutList, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -51,6 +51,7 @@ type CanvasNodePromptPanelProps = {
onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void; onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void;
onImageSettingsOpenChange?: (open: boolean) => void; onImageSettingsOpenChange?: (open: boolean) => void;
workspaceMode?: CanvasWorkspaceMode; workspaceMode?: CanvasWorkspaceMode;
onListGenerate?: (nodeId: string, prompt: string) => void;
}; };
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
@@ -69,7 +70,7 @@ const PROMPT_EDITOR_MODAL_WIDTH = "min(1200px, 92vw)";
const PROMPT_EDITOR_MODAL_DEFAULT_WIDTH = 1200; const PROMPT_EDITOR_MODAL_DEFAULT_WIDTH = 1200;
const PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT = 420; const PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT = 420;
export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional", onListGenerate }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig(); const globalConfig = useEffectiveConfig();
const themeName = useActiveTheme(); const themeName = useActiveTheme();
const theme = canvasThemes[themeName]; const theme = canvasThemes[themeName];
@@ -256,7 +257,11 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const submit = () => { const submit = () => {
const text = prompt.trim(); const text = prompt.trim();
if (!text || isRunning) return false; if (!text || isRunning) return false;
onGenerate(node.id, mode, text); if (mode === "text" && node.metadata?.listMode && onListGenerate) {
onListGenerate(node.id, text);
} else {
onGenerate(node.id, mode, text);
}
return true; return true;
}; };
@@ -411,17 +416,44 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
compact={!expanded} compact={!expanded}
/> />
{mode === "text" ? ( {mode === "text" ? (
<Tooltip title={`文本生成份数(默认 1,可在生成配置中调整)`}> <>
<InputNumber <div className="flex h-7 items-center overflow-hidden rounded-md border" style={{ borderColor: theme.node.stroke }}>
size="small" <button
min={1} type="button"
max={15} onClick={() => onConfigChange(node.id, { listMode: false })}
value={Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1))))} className={`flex h-full items-center gap-1 px-2 text-[var(--fs-tiny)] transition-colors ${!node.metadata?.listMode ? "font-medium" : ""}`}
onChange={(value) => onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} style={!node.metadata?.listMode ? { background: theme.accent.primary, color: "#fff" } : { color: theme.node.muted }}
aria-label="文本生成份数" >
className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]" <FileText className="size-3" />
/> 文本
</Tooltip> </button>
<button
type="button"
onClick={() => onConfigChange(node.id, { listMode: true })}
className={`flex h-full items-center gap-1 px-2 text-[var(--fs-tiny)] transition-colors ${node.metadata?.listMode ? "font-medium" : ""}`}
style={node.metadata?.listMode ? { background: theme.accent.primary, color: "#fff" } : { color: theme.node.muted }}
>
<LayoutList className="size-3" />
列表
</button>
</div>
{!node.metadata?.listMode ? (
<Tooltip title={`文本生成份数(默认 1,可在生成配置中调整)`}>
<InputNumber
size="small"
min={1}
max={15}
value={Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1))))}
onChange={(value) => onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })}
aria-label="文本生成份数"
className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]"
/>
</Tooltip>
) : null}
{node.metadata?.listMode ? (
<span className="text-[10px]" style={{ color: theme.node.muted }}>行数和列结构由模型判断</span>
) : null}
</>
) : mode === "image" ? ( ) : mode === "image" ? (
// 图片模式下,显示相机配置与镜头配置 // 图片模式下,显示相机配置与镜头配置
<> <>
+204
View File
@@ -0,0 +1,204 @@
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 { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
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>[];
};
function parseListModeJson(text: string): ListGenerationResult | null {
// Try to extract JSON from the response
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
try {
const parsed = JSON.parse(jsonMatch[0]);
if (Array.isArray(parsed.columns) && Array.isArray(parsed.rows) && parsed.columns.length > 0) {
return parsed as ListGenerationResult;
}
} catch {
// Try removing markdown code blocks
const cleaned = jsonMatch[0].replace(/^```json\s*/, "").replace(/\s*```$/, "");
try {
const parsed = JSON.parse(cleaned);
if (Array.isArray(parsed.columns) && Array.isArray(parsed.rows)) {
return parsed as ListGenerationResult;
}
} catch {
return null;
}
}
return null;
}
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 && n.metadata?.content);
if (imageNodes.length === 0) {
message.warning("请先连接至少一张图片到当前文本节点");
return;
}
setRunningNodeId(sourceNodeId);
try {
const model = resolveCanvasGenerationModel(config, undefined, "text");
const listConfig = { ...config, model };
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);
}
}
+15
View File
@@ -131,6 +131,7 @@ import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow";
import { useCanvasDirector } from "./use-canvas-director"; import { useCanvasDirector } from "./use-canvas-director";
import { useCanvasGeneration } from "./use-canvas-generation"; import { useCanvasGeneration } from "./use-canvas-generation";
import { useCanvasGenerationBatches } from "./use-canvas-generation-batches"; import { useCanvasGenerationBatches } from "./use-canvas-generation-batches";
import { handleListGenerate } from "./list-mode-generator";
import { useCanvasBatchTable } from "./use-canvas-batch-table"; import { useCanvasBatchTable } from "./use-canvas-batch-table";
import { useCanvasGenerationExecutor, type CanvasNodeGenerationOptions } from "./use-canvas-generation-executor"; import { useCanvasGenerationExecutor, type CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
import { useCanvasGenerationRetry } from "./use-canvas-generation-retry"; import { useCanvasGenerationRetry } from "./use-canvas-generation-retry";
@@ -2106,6 +2107,20 @@ function InfiniteCanvasPage() {
setNodeImageSettingsOpen(open); setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null); if (open) setToolbarNodeId(null);
}} }}
onListGenerate={(nodeId, listPrompt) => {
void handleListGenerate({
sourceNodeId: nodeId,
prompt: listPrompt,
nodes: nodesRef.current,
connections: connectionsRef.current,
config: effectiveConfig,
projectId,
setNodes,
setConnections,
setRunningNodeId,
setDialogNodeId,
});
}}
/> />
); );
}, },
+9 -1
View File
@@ -172,10 +172,14 @@ export type CanvasBatchRow = {
textNodeIds?: string[]; textNodeIds?: string[];
prompt: string; prompt: string;
outputNodeId?: string; outputNodeId?: string;
/** AI-generated cell content keyed by column id (for aiGenerated tables). */
cells?: Record<string, string>;
}; };
export type CanvasBatchColumnType = "image" | "text";
export type CanvasBatchReferenceColumn = { export type CanvasBatchReferenceColumn = {
id: string; id: string;
label: string; label: string;
type?: CanvasBatchColumnType;
}; };
export type CanvasBatchTableData = { export type CanvasBatchTableData = {
operation: CanvasBatchOperation; operation: CanvasBatchOperation;
@@ -185,6 +189,8 @@ export type CanvasBatchTableData = {
referenceColumns?: CanvasBatchReferenceColumn[]; referenceColumns?: CanvasBatchReferenceColumn[];
textColumns?: CanvasBatchReferenceColumn[]; textColumns?: CanvasBatchReferenceColumn[];
rows: CanvasBatchRow[]; rows: CanvasBatchRow[];
/** When true, columns are AI-generated with dynamic headers. */
aiGenerated?: boolean;
}; };
export type CanvasSkillSnapshot = { export type CanvasSkillSnapshot = {
@@ -224,6 +230,8 @@ export type CanvasNodeMetadata = {
}; };
content?: string; content?: string;
previewContent?: string; previewContent?: string;
/** When true, text prompt panel uses list/table generation mode. */
listMode?: boolean;
videoPreview?: { videoPreview?: {
content: string; content: string;
storageKey?: string; storageKey?: string;
@@ -488,7 +496,7 @@ export type CanvasConnection = {
toHandleId?: string; toHandleId?: string;
fromAnchorRatio?: number; fromAnchorRatio?: number;
toAnchorRatio?: number; toAnchorRatio?: number;
relation?: "storyboard-output" | "storyboard-asset-reference" | "batch-output"; relation?: "storyboard-output" | "storyboard-asset-reference" | "batch-output" | "batch-input";
storyboardRowId?: string; storyboardRowId?: string;
}; };