fix: harden list mode and batch generation flow

This commit is contained in:
Codex
2026-09-19 15:35:11 +08:00
parent e94eab883e
commit af96824011
6 changed files with 140 additions and 42 deletions

View File

@@ -12,7 +12,7 @@ import (
const (
maxCloudAgentBatchRows = 500
maxCloudAgentBatchReferences = 6
maxCloudAgentBatchReferences = 10
maxCloudAgentBatchPromptRunes = 20000
)
@@ -96,7 +96,7 @@ func batchTableNodeFromDocument(doc map[string]any, nodeID string) (map[string]a
}
columns := creationMaps(table["referenceColumns"])
if len(columns) < 1 || len(columns) > maxCloudAgentBatchReferences {
return nil, nil, nil, nil, BadAuthRequest("批量创作表必须包含 1 到 6 个参考图列")
return nil, nil, nil, nil, BadAuthRequest("批量创作表必须包含 1 到 10 个参考图列")
}
columnIDs := map[string]bool{}
for _, column := range columns {
@@ -299,7 +299,7 @@ func prepareCloudAgentBatchTableEdit(repo *repository.Repository, userID, canvas
fields = []string{"并发数"}
case "add_reference_column":
if len(columns) >= maxCloudAgentBatchReferences {
return nil, BadAuthRequest("批量创作表最多支持6组参考图")
return nil, BadAuthRequest("批量创作表最多支持10组参考图")
}
if args.RowID != "" || len(args.Patch) != 0 || args.Operation != "" || args.Concurrency != 0 {
return nil, BadAuthRequest("新增参考图列不接受其他修改参数")

View File

@@ -6,10 +6,12 @@ import { ImageSettingsPanel } from "@/components/image-settings-panel";
import { ModelPicker } from "@/components/model-picker";
import { canvasThemes } from "@/lib/canvas-theme";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import { CanvasCameraControlPopover } from "./canvas-camera-control-popover";
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
export type BatchGenerationSettings = Pick<AiConfig, "model" | "imageModel" | "quality" | "size" | "transparentBackground" | "count">;
export type BatchGenerationSettings = Pick<AiConfig, "model" | "imageModel" | "quality" | "size" | "transparentBackground" | "count"> & { cameraControl?: CameraControlOptions };
type BatchGenerationSettingsDialogProps = {
open: boolean;
@@ -23,9 +25,13 @@ type BatchGenerationSettingsDialogProps = {
export function BatchGenerationSettingsDialog({ open, config, rowCount, concurrency, onClose, onConfirm }: BatchGenerationSettingsDialogProps) {
const theme = canvasThemes[useActiveTheme()];
const [generationConfig, setGenerationConfig] = useState<AiConfig>(config);
const [cameraControl, setCameraControl] = useState<CameraControlOptions | undefined>(undefined);
useEffect(() => {
if (open) setGenerationConfig(config);
if (open) {
setGenerationConfig(config);
setCameraControl(undefined);
}
}, [config, open]);
const handleConfigChange = (key: "quality" | "size" | "transparentBackground" | "count", value: string) => {
@@ -78,6 +84,14 @@ export function BatchGenerationSettingsDialog({ open, config, rowCount, concurre
/>
</div>
<div className="flex items-center justify-between border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<div>
<div className="text-sm font-medium">摄像机控制</div>
<div className="mt-1 text-xs opacity-60">为本批次统一应用镜头、焦段和光圈设置</div>
</div>
<CanvasCameraControlPopover cameraControl={cameraControl} onCameraControlChange={setCameraControl} theme={theme} compact />
</div>
<div className="mt-2 flex justify-end gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>取消</Button>
<Button
@@ -90,6 +104,7 @@ export function BatchGenerationSettingsDialog({ open, config, rowCount, concurre
size: generationConfig.size,
transparentBackground: generationConfig.transparentBackground,
count: generationConfig.count,
cameraControl,
})}
>
开始生成 {rowCount} 个任务

View File

@@ -1,9 +1,10 @@
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 } from "@/lib/canvas/canvas-project-generation";
import type { AiConfig } from "@/stores/use-config-store";
import { runBackendGenerationTask } from "@/services/api/generation-task";
import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";
import { modelRequestOptions, type ModelRequirements } from "@/lib/model-selection";
import type { AiConfig } from "@/stores/use-config-store";
const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户会给你多张产品图片和一个任务描述。
请分析每张图片,为每张图片生成一行数据。
@@ -29,29 +30,78 @@ type ListGenerationResult = {
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;
}
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;
@@ -85,7 +135,7 @@ export async function handleListGenerate({
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);
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("请先连接至少一张图片到当前文本节点");
@@ -95,13 +145,14 @@ export async function handleListGenerate({
setRunningNodeId(sourceNodeId);
try {
// Force text model: strip node.metadata.model so buildGenerationConfig uses config.textModel
const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: undefined } };
let listConfig = buildGenerationConfig(config, sourceNodeForConfig, "text");
// Explicitly set model to the configured text model
if (config.textModel) {
listConfig = { ...listConfig, model: config.textModel };
}
// Ask model selection for a text model that can receive all connected images.
const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: undefined } };
const requirements: ModelRequirements = {
capability: "text",
input: { textCount: 1, imageCount: imageNodes.length, videoCount: 0, audioCount: 0, characterCount: 0 },
options: modelRequestOptions(config, "text"),
};
const listConfig = buildGenerationConfig(config, sourceNodeForConfig, "text", requirements);
const referenceImages = imageNodes.map((node) => ({
id: node.id,

View File

@@ -162,8 +162,9 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
batchSourceNodeId: nodeId,
batchRowId: row.id,
batchOperation: table.operation,
batchInputNodeIds: row.inputNodeIds,
};
batchInputNodeIds: row.inputNodeIds,
cameraControl: settings.cameraControl,
};
const output = existingIndex >= 0
? { ...nextNodes[existingIndex], metadata }
: createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata);

View File

@@ -85,7 +85,7 @@ describe("batch creation table", () => {
expect(withoutText).not.toContain("repeat(0,");
expect(withoutText).toContain("68px");
expect(withoutText).toContain("repeat(3, 88px)");
expect(withoutText).toContain("repeat(3, 72px)");
expect(withText).toContain("repeat(1, minmax(168px, 0.75fr))");
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { parseListModeJson } from "@/pages/canvas/list-mode-generator";
describe("list mode response parsing", () => {
test("parses plain JSON", () => {
expect(parseListModeJson('{"columns":["卖点"],"rows":[{"卖点":"轻便"}]}')).toEqual({
columns: ["卖点"],
rows: [{ 卖点: "轻便" }],
});
});
test("parses markdown JSON with surrounding explanation", () => {
expect(parseListModeJson('结果如下:\n```json\n{"columns":["标题"],"rows":[{"标题":"新品"}]}\n```\n')).toEqual({
columns: ["标题"],
rows: [{ 标题: "新品" }],
});
});
test("normalizes duplicate columns and non-string cells", () => {
expect(parseListModeJson('{"columns":["卖点","卖点","细节"],"rows":[{"卖点":["轻","薄"],"细节":123}]}')).toEqual({
columns: ["卖点", "细节"],
rows: [{ 卖点: "轻、薄", 细节: "123" }],
});
});
test("rejects malformed responses", () => {
expect(parseListModeJson("模型暂时无法分析")).toBeNull();
expect(parseListModeJson('{"columns":[],"rows":[]}')).toBeNull();
});
});