fix: harden list mode and batch generation flow
This commit is contained in:
@@ -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("新增参考图列不接受其他修改参数")
|
||||
|
||||
@@ -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} 个任务
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))");
|
||||
});
|
||||
|
||||
|
||||
31
web/test/list-mode-generator.test.ts
Normal file
31
web/test/list-mode-generator.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user