fix: improve canvas derived generation flows

This commit is contained in:
134 2026-06-22 19:36:35 +08:00
parent 47c7da3a1f
commit aaf3e08dd8
33 changed files with 5368 additions and 106 deletions

View File

@ -986,6 +986,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
stylePro: Sparkles,
batchProcessing: Layers,
addImage: ImagePlus,
addAgentImageSlot: ImagePlus,
addImageInput: ImagePlus,
addText: Type,
addScriptNode: Type,

View File

@ -1926,7 +1926,7 @@ const AGENT_WORKFLOW_SETTINGS_FIELD_LABELS: Record<AgentWorkflowSettingsField, s
agentWorkflowDefaultModelId: "风格化专用默认模型",
agentPresetLibraryConfig: "预设库",
agentWorkflowUiConfig: "工作流步骤",
agentWorkflowMaterialApiConfig: "素材搜索接口",
agentWorkflowMaterialApiConfig: "第三方图片素材",
agentWorkflowGoodsInfoApiConfig: "商品信息接口",
agentWorkflowBrandModelConfig: "品牌模特",
agentWorkflowShowcaseConfig: "页面展示",
@ -6859,17 +6859,19 @@ export function AgentWorkflowSettingsClient({
<Search className="h-4 w-4 text-emerald-600" />
</div>
<div>
<CardTitle className="text-base">素材搜索接口</CardTitle>
<CardDescription>配置白底图、详情参考图、SKU 参考图共用接口</CardDescription>
<CardTitle className="text-base">第三方图片素材配置</CardTitle>
<CardDescription>
配置白底图、任务-详情整套预览、详情参考图和 SKU 参考图共用接口
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">启用素材搜索</div>
<div className="text-sm font-medium">启用第三方图片素材</div>
<div className="text-xs text-muted-foreground">
开启后支持搜索白底素材图、详情参考图和 SKU 参考图
开启后前台和预留接口都可以查询标准模型白底图与任务-详情整套素材
</div>
</div>
<Switch
@ -6890,6 +6892,22 @@ export function AgentWorkflowSettingsClient({
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label className="text-xs">统一接口路径</Label>
<Input
value={materialApiConfig.endpointPath || "/standard-model-white-background"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
endpointPath: e.target.value,
}))
}
placeholder="/standard-model-white-background"
/>
<p className="text-xs text-muted-foreground">
按文档默认调用 /api/external/standard-model-white-background;也支持填写完整 URL。
</p>
</div>
<div className="space-y-2">
<Label className="text-xs">API Key</Label>
<Input
@ -6902,6 +6920,104 @@ export function AgentWorkflowSettingsClient({
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">标准模型白底图</div>
<div className="text-xs text-muted-foreground">
对应 resourceType=white-background
</div>
</div>
<Switch
checked={materialApiConfig.whiteBackgroundEnabled !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
whiteBackgroundEnabled: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">任务-详情整套预览</div>
<div className="text-xs text-muted-foreground">
对应 resourceType=task-detail
</div>
</div>
<Switch
checked={materialApiConfig.taskDetailEnabled !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
taskDetailEnabled: checked,
}))
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label className="text-xs">任务详情资源主域名</Label>
<Input
value={materialApiConfig.resourceBaseUrl || "https://s3.jiansun.vip"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
resourceBaseUrl: e.target.value,
}))
}
placeholder="https://s3.jiansun.vip"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">备用资源域名</Label>
<Input
value={materialApiConfig.fallbackResourceBaseUrl || "https://s3.yydd.ai"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
fallbackResourceBaseUrl: e.target.value,
}))
}
placeholder="https://s3.yydd.ai"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">请求超时 ms</Label>
<Input
type="number"
min={3000}
max={120000}
step={1000}
value={materialApiConfig.requestTimeoutMs || 30000}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
requestTimeoutMs: Number(e.target.value) || 30000,
}))
}
/>
</div>
</div>
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
已预留前端接口:
<code className="mx-1 rounded bg-muted px-1 py-0.5">
/api/third-party-image-materials/search
</code>
,支持
<code className="mx-1 rounded bg-muted px-1 py-0.5">
resourceType=white-background|task-detail
</code>
与
<code className="mx-1 rounded bg-muted px-1 py-0.5">
sku/spu
</code>
查询。
</div>
<Separator />
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">

View File

@ -6,6 +6,7 @@ import type {
ResolvedImageRef,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.stream");
@ -197,6 +198,8 @@ export function writeCanvasMediaChunk(
agentCategoryId?: string | null;
agentCategoryName?: string | null;
agentExecutionSnapshotId?: string | null;
mediaItemIds?: string[] | null;
handledCanvasMediaItemIds?: string[] | null;
},
label: string
) {
@ -242,6 +245,79 @@ export function writeCanvasMediaChunk(
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
agentExecutionSnapshotId:
String(params.agentExecutionSnapshotId || "").trim() || undefined,
mediaItemIds: Array.isArray(params.mediaItemIds)
? params.mediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [],
handledCanvasMediaItemIds: Array.isArray(params.handledCanvasMediaItemIds)
? params.handledCanvasMediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [],
},
label
);
}
export function writeCanvasOperationChunk(
writer: StreamWriter,
params: {
projectId: string;
operationId: string;
operation: string;
createdNodeIds: string[];
createdNodeIdsByMediaItemId?: Record<string, string>;
updatedNodeIds?: string[];
createdEdgeIds?: string[];
canvasRevision?: string;
anchorNodeId?: string | null;
handledCanvasMediaItemIds?: string[];
message?: string;
},
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasOperation",
{
projectId: params.projectId,
operationId: params.operationId,
operation: params.operation,
createdNodeIds: params.createdNodeIds || [],
createdNodeIdsByMediaItemId: params.createdNodeIdsByMediaItemId || {},
updatedNodeIds: params.updatedNodeIds || [],
createdEdgeIds: params.createdEdgeIds || [],
canvasRevision: params.canvasRevision,
anchorNodeId: params.anchorNodeId || undefined,
handledCanvasMediaItemIds: params.handledCanvasMediaItemIds || [],
message: params.message,
},
label
);
}
export function writeCanvasViewOperationChunk(
writer: StreamWriter,
params: CanvasViewOperationPlan,
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasViewOperation",
{
version: 1,
viewOperationId: params.viewOperationId,
operation: params.operation,
projectId: params.projectId,
nodeIds: params.nodeIds || [],
anchorNodeId: params.anchorNodeId,
zoom: params.zoom,
padding: params.padding,
durationMs: params.durationMs,
persist: false,
center: params.center,
reason: params.reason,
},
label
);

File diff suppressed because it is too large Load Diff

View File

@ -67,6 +67,10 @@ import {
EXTERNAL_IMAGE_API_CONFIG_KEY,
normalizeExternalImageApiConfig,
} from "@/lib/external-image-api/config";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const settingSchema = z.object({
@ -139,6 +143,12 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) {
const value = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
return Response.json({ setting });
}
@ -278,6 +288,19 @@ export async function POST(request: Request) {
});
}
if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) {
const saved = await upsertSystemSetting({
key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
value: normalizeAgentWorkflowMaterialApiConfig(value),
updatedBy: session.user.id,
});
clearScopedSiteSettingsCache();
return Response.json({
setting: saved[0],
});
}
if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) {
nextValue = normalizeVideoCanvasFunctionConfig(value);
}

View File

@ -8,6 +8,8 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import {
@ -677,6 +679,12 @@ export async function GET(request: NextRequest) {
{ status: 400 }
);
}
if (!isAgentWorkflowMaterialResourceEnabled(config, "white-background")) {
return NextResponse.json(
{ error: "白底图素材接口未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) {
return NextResponse.json(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
@ -696,15 +704,18 @@ export async function GET(request: NextRequest) {
return null;
})
: Promise.resolve(null);
const upstreamUrl = `${normalizeBaseUrl(
config.baseUrl
)}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`;
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, {
resourceType: "white-background",
queryType,
keyword: normalizedKeyword,
});
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
"X-API-Key": config.apiKey,
},
cache: "no-store",
signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000),
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse;

View File

@ -6,6 +6,8 @@ import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import {
@ -469,13 +471,11 @@ async function requestTaskDetailAttempt(params: {
verifiedGoodsBrandName?: string | null;
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
}) {
const upstreamParams = new URLSearchParams({
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(params.config, {
resourceType: "task-detail",
[params.queryType]: params.keyword,
queryType: params.queryType,
keyword: params.keyword,
});
const upstreamUrl = `${normalizeBaseUrl(
params.config.baseUrl
)}/standard-model-white-background?${upstreamParams.toString()}`;
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
@ -483,6 +483,7 @@ async function requestTaskDetailAttempt(params: {
"X-API-Key": params.config.apiKey,
},
cache: "no-store",
signal: AbortSignal.timeout(params.config.requestTimeoutMs || 30_000),
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
const upstreamBrandName = getExternalBrandName(upstreamResult.data || {});
@ -936,6 +937,12 @@ export async function GET(request: NextRequest) {
{ status: 400 }
);
}
if (!isAgentWorkflowMaterialResourceEnabled(config, "task-detail")) {
return jsonNoStore(
{ error: "任务-详情整套素材接口未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) {
return jsonNoStore(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },

View File

@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
applyCanvasOperationPlan,
CanvasAgentMutationError,
} from "@/lib/canvas/agent-mutations";
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
const canvasOperationPlanSchema = z
.object({
version: z.literal(1),
operationId: z.string().min(1).max(160),
operation: z.enum([
"fill_slot",
"insert_beside",
"append_revision",
"link_nodes",
"move_nodes",
"update_text",
]),
projectId: z.string().uuid().optional(),
baseCanvasRevision: z.string().max(160).optional(),
baseCanvasUpdatedAt: z.string().max(160).optional(),
sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
targetNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
targetSlotId: z.string().min(1).max(160).optional(),
anchorNodeId: z.string().min(1).max(160).optional(),
edgeType: z.enum(["default", "reference", "conversation"]).optional(),
textContent: z.string().min(1).max(4000).optional(),
confirmed: z.boolean().optional(),
layoutStrategy: z.enum(["row", "column", "grid"]).optional(),
spacing: z.number().positive().max(240).optional(),
targetPosition: z
.object({
x: z.number(),
y: z.number(),
})
.optional(),
placement: z
.enum(["right", "left", "below", "above", "viewport-center"])
.optional(),
placementStrategy: z
.enum(["single", "row", "grid", "overlay", "beside_each_source"])
.optional(),
preserveOriginal: z.boolean().optional(),
media: z
.object({
type: z.enum(["image", "video"]),
url: z.string().url(),
assetId: z.string().uuid().optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
title: z.string().max(256).optional(),
prompt: z.string().max(2000).optional(),
sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
metadata: z.record(z.unknown()).optional(),
})
.optional(),
mediaItems: z
.array(
z.object({
mediaItemId: z.string().min(1).max(160),
type: z.enum(["image", "video"]),
url: z.string().url(),
assetId: z.string().uuid().optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
title: z.string().max(256).optional(),
prompt: z.string().max(2000).optional(),
sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
metadata: z.record(z.unknown()).optional(),
})
)
.max(20)
.optional(),
metadata: z.record(z.unknown()).optional(),
})
.refine(
(value) =>
value.operation === "link_nodes" ||
value.operation === "update_text" ||
value.operation === "move_nodes" ||
value.media ||
(value.mediaItems?.length ?? 0) > 0,
{
message: "缺少媒体结果",
path: ["mediaItems"],
}
)
.refine(
(value) =>
value.operation !== "update_text" ||
Boolean(String(value.textContent || "").trim()),
{
message: "缺少文本内容",
path: ["textContent"],
}
);
const requestSchema = z.object({
operationPlan: canvasOperationPlanSchema,
});
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { id } = await params;
const body = requestSchema.parse(await request.json());
const result = await applyCanvasOperationPlan({
userId: session.user.id,
projectId: id,
plan: body.operationPlan as CanvasOperationPlan,
});
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
if (error instanceof CanvasAgentMutationError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status }
);
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: "请求参数无效", details: error.flatten() },
{ status: 400 }
);
}
console.error("[CanvasAgentOperations] apply operation failed:", error);
return NextResponse.json(
{ success: false, error: "执行画布操作失败" },
{ status: 500 }
);
}
}

View File

@ -13,6 +13,19 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorksp
return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
}
function parseRevisionDate(value: unknown) {
const raw = String(value || "").trim();
if (!raw) return null;
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function isStaleBaseRevision(baseRevision: unknown, currentUpdatedAt: Date) {
const baseDate = parseRevisionDate(baseRevision);
if (!baseDate) return false;
return baseDate.getTime() + 1000 < currentUpdatedAt.getTime();
}
// 获取单个画布
export async function GET(
request: NextRequest,
@ -61,18 +74,11 @@ export async function PUT(
const { id } = await params;
const body = await request.json();
const { title, description, thumbnail, canvasData } = body;
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
? resolveWorkspaceType(body.workspaceType)
: undefined;
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
// 先检查画布是否存在
const [existingProject] = await db
@ -86,6 +92,31 @@ export async function PUT(
);
if (existingProject) {
if (
canvasData !== undefined &&
baseCanvasRevision &&
isStaleBaseRevision(baseCanvasRevision, existingProject.updatedAt)
) {
return NextResponse.json(
{
success: false,
error: "画布已更新,请刷新后再保存",
code: "STALE_CANVAS_REVISION",
canvasUpdatedAt: existingProject.updatedAt.toISOString(),
},
{ status: 409 }
);
}
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
// 更新现有画布
const updateData: any = { updatedAt: new Date() };
if (title !== undefined) updateData.title = title;
@ -107,6 +138,15 @@ export async function PUT(
return NextResponse.json({ success: true, data: updatedProject });
} else {
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
// 创建新画布(使用指定 ID)
const [newProject] = await db
.insert(canvasProject)

View File

@ -34,6 +34,7 @@ type LocalEditRegionInput = {
color?: string;
prompt?: string;
rect?: LocalEditRect;
targetRect?: LocalEditRect;
referenceImages?: Array<{
id?: string;
url?: string;
@ -71,12 +72,24 @@ type NormalizedLocalEditRegion = {
width: number;
height: number;
};
targetRect?: {
x: number;
y: number;
width: number;
height: number;
} | null;
pixelRect: {
left: number;
top: number;
width: number;
height: number;
};
targetPixelRect?: {
left: number;
top: number;
width: number;
height: number;
} | null;
};
const MAX_LOCAL_EDIT_REGIONS = 8;
@ -87,8 +100,9 @@ const SCALE_ADJUST_CROP_MAX_EDGE = 1536;
const MIN_REGION_EDGE_PX = 24;
const SCALE_ADJUST_OPERATION = "scale-adjust";
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
const SCALE_ADJUST_PERCENT_OPTIONS = [-30, -20, -10, 10, 20, 30, 50, 100] as const;
type ScaleAdjustPercent = (typeof SCALE_ADJUST_PERCENT_OPTIONS)[number];
const MIN_SCALE_ADJUST_PERCENT = -80;
const MAX_SCALE_ADJUST_PERCENT = 200;
type ScaleAdjustPercent = number;
type ScaleAdjustCropReference = {
regionIndex: number;
url: string;
@ -284,8 +298,13 @@ function resolveLocalEditOperation(input: unknown) {
function normalizeScaleAdjustPercent(input: unknown): ScaleAdjustPercent {
const value = Math.round(Number(input));
if ((SCALE_ADJUST_PERCENT_OPTIONS as readonly number[]).includes(value)) {
return value as ScaleAdjustPercent;
if (
Number.isFinite(value) &&
value !== 0 &&
value >= MIN_SCALE_ADJUST_PERCENT &&
value <= MAX_SCALE_ADJUST_PERCENT
) {
return value;
}
throw new Error("请选择有效的比例调整幅度");
}
@ -385,6 +404,39 @@ function scaleRectAroundCenter(
};
}
function getScaleAdjustTargetRect(
region: NormalizedLocalEditRegion,
scalePercent: ScaleAdjustPercent
) {
return region.targetRect || scaleRectAroundCenter(region.rect, scalePercent);
}
function deriveScaleAdjustPercentFromTargetRect(
sourceRect: NormalizedLocalEditRegion["rect"],
targetRect: NormalizedLocalEditRegion["rect"] | null | undefined
) {
if (!targetRect || sourceRect.width <= 0 || sourceRect.height <= 0) {
return null;
}
const widthRatio = Math.max(0.05, targetRect.width / sourceRect.width);
const heightRatio = Math.max(0.05, targetRect.height / sourceRect.height);
const uniformRatio = Math.sqrt(widthRatio * heightRatio);
const percent = Math.round((uniformRatio - 1) * 100);
if (!Number.isFinite(percent) || percent === 0) return null;
return clamp(percent, MIN_SCALE_ADJUST_PERCENT, MAX_SCALE_ADJUST_PERCENT);
}
function deriveScaleAdjustPercentFromRegions(regions: NormalizedLocalEditRegion[]) {
for (const region of regions) {
const percent = deriveScaleAdjustPercentFromTargetRect(
region.rect,
region.targetRect
);
if (percent !== null) return percent;
}
return null;
}
function expandRectWithMargins(
rect: NormalizedLocalEditRegion["rect"],
marginX: number,
@ -428,7 +480,7 @@ function buildScaleAdjustCompositeRegions(
scalePercent: ScaleAdjustPercent
) {
return regions.flatMap((region) => {
const targetRect = scaleRectAroundCenter(region.rect, scalePercent);
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
const workspaceRect = getScaleAdjustWorkspaceRect(
region.rect,
targetRect,
@ -546,12 +598,16 @@ function normalizeRegions(
if (!rect) {
throw new Error(`区域 ${itemIndex + 1} 缺少有效框选范围`);
}
const targetRect = normalizeRect(region.targetRect);
const prompt = String(region.prompt || "").trim();
const resolvedPrompt = prompt || String(options.fallbackPrompt || "").trim();
if (options.requirePrompt !== false && !resolvedPrompt) {
throw new Error(`区域 ${itemIndex + 1} 请填写局部编辑提示词`);
}
const pixelRect = rectToPixels(rect, sourceWidth, sourceHeight);
const targetPixelRect = targetRect
? rectToPixels(targetRect, sourceWidth, sourceHeight)
: null;
if (pixelRect.width < MIN_REGION_EDGE_PX || pixelRect.height < MIN_REGION_EDGE_PX) {
throw new Error(`区域 ${itemIndex + 1} 过小,请重新框选更大的范围`);
}
@ -567,7 +623,9 @@ function normalizeRegions(
prompt: resolvedPrompt.slice(0, 300),
referenceImages: normalizeRegionReferenceImages(region, itemIndex),
rect,
targetRect,
pixelRect,
targetPixelRect,
};
});
}
@ -654,8 +712,9 @@ function buildScaleAdjustAnnotationSvg(params: {
const regionSvg = regions
.map((region) => {
const source = region.pixelRect;
const targetRect = scaleRectAroundCenter(region.rect, scalePercent);
const target = rectToPixels(targetRect, width, height);
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
const target =
region.targetPixelRect || rectToPixels(targetRect, width, height);
const radius = Math.max(8, Math.round(strokeWidth * 2));
const badgeSize = Math.max(22, Math.round(minSide * 0.022));
const badgeX = clamp(
@ -745,11 +804,31 @@ function buildScaleAdjustPrompt(
const cropIndex = cropReferences.findIndex(
(reference) => reference.regionIndex === region.index
);
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
const sourceCenterX = region.rect.x + region.rect.width / 2;
const sourceCenterY = region.rect.y + region.rect.height / 2;
const targetCenterX = targetRect.x + targetRect.width / 2;
const targetCenterY = targetRect.y + targetRect.height / 2;
const targetLeft = Math.round(targetRect.x * 1000) / 10;
const targetTop = Math.round(targetRect.y * 1000) / 10;
const targetWidth = Math.round(targetRect.width * 1000) / 10;
const targetHeight = Math.round(targetRect.height * 1000) / 10;
const regionScalePercent =
deriveScaleAdjustPercentFromTargetRect(region.rect, targetRect) ||
scalePercent;
const regionActionLabel = getScaleAdjustLabel(regionScalePercent);
const regionTargetLabel = getScaleAdjustTargetLabel(regionScalePercent);
const moved =
Math.hypot(targetCenterX - sourceCenterX, targetCenterY - sourceCenterY) >
0.01;
const targetPositionNote = moved
? `青色虚线框是用户指定的最终目标位置和目标大小,目标主体调整后的视觉中心应移动到青色虚线框中心附近;目标框坐标 x=${targetLeft}%,y=${targetTop}%,w=${targetWidth}%,h=${targetHeight}%。`
: `青色虚线框是用户指定的最终目标大小范围,目标框坐标 x=${targetLeft}%,y=${targetTop}%,w=${targetWidth}%,h=${targetHeight}%。`;
const cropNote =
cropIndex >= 0
? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例`
: "";
return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立缩放的商品/物体主体。红色实线框只是“寻找主体”的范围,不是放大后的硬边界,也不是要缩放或贴回的矩形补丁;放大时主体可以自然超出红色实线框,并由周边背景补全衔接。目标虚线框只是表达目标尺寸范围,不是要求主体填满矩形或变成矩形比例。请以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${actionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${targetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立缩放的商品/物体主体。红色实线框只是“寻找主体”的范围,不是放大后的硬边界,也不是要缩放或贴回的矩形补丁;放大时主体可以自然超出红色实线框,并由周边背景补全衔接。${targetPositionNote}目标虚线框不是要求主体填满矩形或变成矩形比例,请以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${regionActionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${regionTargetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
})
.join("\n");
const cropLines = cropReferences
@ -767,7 +846,7 @@ function buildScaleAdjustPrompt(
cropLines
? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}`
: "",
"输入图片2中的红色实线框表示输入图片1里目标主体当前大致位置;它只用于定位主体,不限制放大后的空间。青绿色或绿色虚线框只表示比例调整后的安全尺寸范围。最终画面不得出现这些框线,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。",
"输入图片2中的红色实线框表示输入图片1里目标主体当前大致位置;它只用于定位主体,不限制放大后的空间。青绿色或绿色虚线框表示用户指定的比例调整后目标大小和目标位置。最终画面不得出现这些框线,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。",
"如果系统或模型只能读取一张参考图,必须优先以输入图片1为输出基底,不得清理、改写或删除输入图片1中框选区域外的任何文字和图形。",
"系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片、蒙版边界,也不是放大后的空间上限,更不是要整体缩放的矩形图层。",
"最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度;严禁整张图整体缩放、裁切、平移、重新排版或重新生成。",
@ -781,8 +860,8 @@ function buildScaleAdjustPrompt(
`1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸;主体轮廓宽度和高度必须使用同一个倍率同步变化,宽高比必须与原主体一致,不能为了贴合虚线框而拉宽、压窄、拉高或压扁。`,
"2. 严禁把整个矩形框选区域、输入图片1整张图或任意裁切图当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
"2.1 严禁把输入图片1整张图整体放大、缩小、裁切或重新构图;如果画面中的标题、Logo、页面文字、边框、背景家具或非目标主体发生整体位移/缩放/消失,必须视为失败并重新生成。",
`3. 缩小时,目标主体应围绕原位置视觉中心自然变小到约 ${targetLabel};主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`,
`4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};允许主体自然超出用户手画的原始红框,使用周围真实背景完成边缘、阴影和遮挡衔接。宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。`,
`3. 缩小时,目标主体应自然变小到约 ${targetLabel};如果青色目标框中心与红框中心不同,允许主体同步移动到青色目标框中心附近。主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`,
`4. 放大时,目标主体应自然变大到约 ${targetLabel};如果青色目标框中心与红框中心不同,允许主体同步移动到青色目标框中心附近。允许主体自然超出用户手画的原始红框,使用周围真实背景完成边缘、阴影和遮挡衔接。宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。`,
"5. 如果主体靠近画布边界导致目标尺寸空间不足,请用能完整放入画面的最大统一倍率整体缩放;绝不能为了塞进画面而只压缩宽度或只拉伸高度。",
"6. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
"6.1 比例调整后的主体边缘必须和周围环境自然融合,选区边界不能被肉眼看出;不得出现一整块矩形区域清晰度、颜色、光照、纹理或透视突然变化。",
@ -823,19 +902,23 @@ export async function POST(request: NextRequest) {
const operation = resolveLocalEditOperation(body.operation);
let scalePercent: ScaleAdjustPercent | null = null;
if (operation === SCALE_ADJUST_OPERATION) {
try {
scalePercent = normalizeScaleAdjustPercent(body.scalePercent);
} catch (validationError) {
return NextResponse.json(
{
success: false,
error:
validationError instanceof Error
? validationError.message
: "比例调整参数无效",
},
{ status: 400 }
);
const hasRequestedScalePercent =
body.scalePercent !== undefined && body.scalePercent !== null;
if (hasRequestedScalePercent) {
try {
scalePercent = normalizeScaleAdjustPercent(body.scalePercent);
} catch (validationError) {
return NextResponse.json(
{
success: false,
error:
validationError instanceof Error
? validationError.message
: "比例调整参数无效",
},
{ status: 400 }
);
}
}
}
@ -845,6 +928,8 @@ export async function POST(request: NextRequest) {
fallbackPrompt:
operation === SCALE_ADJUST_OPERATION && scalePercent !== null
? `比例调整:${getScaleAdjustLabel(scalePercent)}`
: operation === SCALE_ADJUST_OPERATION
? "比例调整"
: undefined,
requirePrompt: operation !== SCALE_ADJUST_OPERATION,
});
@ -860,6 +945,18 @@ export async function POST(request: NextRequest) {
{ status: 400 }
);
}
if (operation === SCALE_ADJUST_OPERATION && scalePercent === null) {
scalePercent = deriveScaleAdjustPercentFromRegions(regions);
if (scalePercent === null) {
return NextResponse.json(
{
success: false,
error: "请提供比例调整幅度,或为每个区域提供有效的目标框 targetRect",
},
{ status: 400 }
);
}
}
const shouldUseScaleAdjustDetailModel =
operation === SCALE_ADJUST_OPERATION && scalePercent !== null;
@ -1023,8 +1120,10 @@ export async function POST(request: NextRequest) {
regionCount: regions.length,
scalePercent,
scaleAdjustCropReferenceCount: scaleAdjustCropReferenceImageUrls.length,
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
.length,
scaleAdjustPromptStrategy: isScaleAdjustOperation
? "locked-plate-local-subject-v3"
? "locked-plate-local-subject-target-box-v4"
: undefined,
scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation,
sourceHeight,

View File

@ -1803,7 +1803,13 @@ const updateSchema = z.object({
.object({
enabled: z.boolean().optional(),
baseUrl: z.string().trim().max(255).optional(),
endpointPath: z.string().trim().max(500).optional(),
apiKey: z.string().trim().max(500).optional(),
whiteBackgroundEnabled: z.boolean().optional(),
taskDetailEnabled: z.boolean().optional(),
resourceBaseUrl: z.string().trim().max(500).optional(),
fallbackResourceBaseUrl: z.string().trim().max(500).optional(),
requestTimeoutMs: z.number().int().min(3000).max(120000).optional(),
productDetailEnabled: z.boolean().optional(),
productDetailBaseUrl: z.string().trim().max(500).optional(),
productDetailApiKey: z.string().trim().max(500).optional(),

View File

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getThirdPartyImageMaterialPublicConfig } from "@/lib/agents/third-party-image-material-service";
function jsonNoStore(body: Parameters<typeof NextResponse.json>[0], init?: ResponseInit) {
return NextResponse.json(body, {
...init,
headers: {
"Cache-Control": "no-store, max-age=0",
...(init?.headers || {}),
},
});
}
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return jsonNoStore({ error: "未登录" }, { status: 401 });
}
const config = await getThirdPartyImageMaterialPublicConfig();
return jsonNoStore({
success: true,
data: config,
});
}

View File

@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
queryThirdPartyImageMaterials,
ThirdPartyImageMaterialError,
} from "@/lib/agents/third-party-image-material-service";
import type {
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialResourceType,
} from "@/types/agent";
export const maxDuration = 180;
const querySchema = z.object({
resourceType: z.enum(["white-background", "task-detail"]).default("white-background"),
sku: z.string().trim().max(64).optional(),
spu: z.string().trim().max(64).optional(),
keyword: z.string().trim().max(64).optional(),
queryType: z.enum(["sku", "spu"]).optional(),
});
function jsonNoStore(body: Parameters<typeof NextResponse.json>[0], init?: ResponseInit) {
return NextResponse.json(body, {
...init,
headers: {
"Cache-Control": "no-store, max-age=0",
...(init?.headers || {}),
},
});
}
function resolveQuery(params: z.infer<typeof querySchema>) {
const spu = String(params.spu || "").trim();
const sku = String(params.sku || "").trim();
const keyword = String(params.keyword || "").trim();
if (spu) {
return {
queryType: "spu" as AgentWorkflowMaterialQueryType,
keyword: spu,
};
}
if (sku) {
return {
queryType: "sku" as AgentWorkflowMaterialQueryType,
keyword: sku,
};
}
if (keyword) {
return {
queryType: (params.queryType || "sku") as AgentWorkflowMaterialQueryType,
keyword,
};
}
return null;
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return jsonNoStore({ error: "未登录" }, { status: 401 });
}
const parsed = querySchema.parse({
resourceType: request.nextUrl.searchParams.get("resourceType") || undefined,
sku: request.nextUrl.searchParams.get("sku") || undefined,
spu: request.nextUrl.searchParams.get("spu") || undefined,
keyword: request.nextUrl.searchParams.get("keyword") || undefined,
queryType: request.nextUrl.searchParams.get("queryType") || undefined,
});
const resolvedQuery = resolveQuery(parsed);
if (!resolvedQuery) {
return jsonNoStore(
{
success: false,
error: {
code: "MISSING_PARAM",
message: "sku 和 spu 二选一必填;也可以传 keyword + queryType",
},
},
{ status: 400 }
);
}
const result = await queryThirdPartyImageMaterials({
resourceType: parsed.resourceType as AgentWorkflowMaterialResourceType,
...resolvedQuery,
});
return jsonNoStore(result);
} catch (error) {
if (error instanceof ThirdPartyImageMaterialError) {
return jsonNoStore(
{
success: false,
error: {
code: error.code,
message: error.message,
},
},
{ status: error.status }
);
}
if (error instanceof z.ZodError) {
return jsonNoStore(
{
success: false,
error: {
code: "INVALID_QUERY",
message: "第三方图片素材查询参数无效",
details: error.errors,
},
},
{ status: 400 }
);
}
console.error("[Third Party Image Materials] Search failed:", error);
return jsonNoStore(
{
success: false,
error: {
code: "SERVER_ERROR",
message: "第三方图片素材查询失败,请稍后重试",
},
},
{ status: 500 }
);
}
}

View File

@ -3118,6 +3118,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
if (result.success && result.data) {
useCanvasStore.getState().setProject(id, result.data.title);
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
if (result.data.canvasData) {
useCanvasStore.getState().loadCanvas(result.data.canvasData);
const persistedWorkflow = result.data.canvasData.workflowState;
@ -4447,13 +4450,25 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const persistCanvasState = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
const canvasData = buildPersistedCanvasData();
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
const result = await canvasService.saveCanvasState(
id,
canvasData,
activeCanvasWorkspaceType
activeCanvasWorkspaceType,
{
baseCanvasRevision: canvasRevision,
baseCanvasUpdatedAt: canvasUpdatedAt,
}
);
if (!result.success) {
console.error(`[Canvas] ${reason} save failed:`, result.error);
if (result.status === 409) {
toast.warning("画布已被 Agent 更新,请刷新后再保存当前修改");
}
} else if (result.data?.updatedAt) {
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
}
return result.success;
},
@ -4515,12 +4530,15 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const handleBeforeUnload = () => {
try {
const canvasData = buildPersistedCanvasData();
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
void fetch(`/api/canvas/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
canvasData,
workspaceType: activeCanvasWorkspaceType,
baseCanvasRevision: canvasRevision,
baseCanvasUpdatedAt: canvasUpdatedAt,
}),
keepalive: true,
});
@ -6755,6 +6773,34 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
});
}, [createCanvasNode, handleAddWorkflowNode, isVideoMode]);
const handleAddAgentImageSlotNode = useCallback(() => {
if (isVideoMode) return;
const origin = getVisibleCanvasOrigin();
createCanvasNode({
type: "agent-slot",
position: {
x: origin.x + 40,
y: origin.y + 40,
},
size: { width: 320, height: 320 },
data: {
slotType: "image",
label: "图片槽位",
status: "empty",
aspectRatio: "1:1",
fillPolicy: "replace-empty",
acceptedSources: ["text", "image", "selection", "annotation"],
filledNodeIds: [],
metadata: {
createdBy: "toolbar",
createdAt: new Date().toISOString(),
},
},
connections: [],
});
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
const handleAddVideoNode = useCallback(() => {
if (isVideoMode) {
handleAddWorkflowNode("video-input");
@ -10352,6 +10398,40 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
</TooltipContent>
</Tooltip>
{!isVideoMode &&
isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"addAgentImageSlot"
) ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-9 w-9 rounded-xl"
onClick={handleAddAgentImageSlotNode}
size="icon"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"addAgentImageSlot"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{
getCanvasToolbarItem(
canvasToolbarConfig,
"addAgentImageSlot"
).label
}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@ -58,6 +58,7 @@ import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { canvasService } from "@/services/canvas";
import {
inferOriginalImageUrl,
} from "@/lib/image-url";
@ -115,6 +116,8 @@ import {
type AgentProgressStreamPayload,
type AgentTargetLockRefPreview,
type AgentTargetLockStreamPayload,
type CanvasOperationStreamPayload,
type CanvasViewOperationStreamPayload,
type CanvasMediaStreamPayload,
getAgentProgressFallbackTitle,
getAgentTargetLockTitle,
@ -1238,6 +1241,7 @@ export function CanvasChatPanel({
const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null);
const contextPayloadRef = useRef<any>(null);
const handledCanvasMediaItemIdsRef = useRef<Set<string>>(new Set());
const lastWorkflowAutofillSignatureRef = useRef<string | null>(null);
const lastWorkflowAutofillUrlsRef = useRef<string[]>([]);
const pendingLaunchRef = useRef<CanvasLaunchPayload | null>(null);
@ -1627,8 +1631,10 @@ export function CanvasChatPanel({
const projectTitle = useCanvasStore((state) => state.projectTitle);
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
const viewport = useCanvasStore((state) => state.viewport);
const canvasRevision = useCanvasStore((state) => state.canvasRevision);
const canvasUpdatedAt = useCanvasStore((state) => state.canvasUpdatedAt);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const setViewport = useCanvasStore((state) => state.setViewport);
const setTransientViewport = useCanvasStore((state) => state.setTransientViewport);
const clearSelection = useCanvasStore((state) => state.clearSelection);
const pendingChatAttachments = useCanvasStore((state) => state.pendingChatAttachments);
const clearPendingChatAttachments = useCanvasStore((state) => state.clearPendingChatAttachments);
@ -1756,14 +1762,126 @@ export function CanvasChatPanel({
const centerX = node.position.x + width / 2;
const centerY = node.position.y + height / 2;
window.requestAnimationFrame(() => {
setViewport({
setTransientViewport({
x: window.innerWidth * 0.52 - centerX * zoom,
y: window.innerHeight * 0.46 - centerY * zoom,
zoom,
});
});
},
[setViewport]
[setTransientViewport]
);
const focusCanvasOnNodes = useCallback(
(
targetNodes: Array<Pick<CanvasNode, "position" | "size">>,
padding = 120
) => {
if (typeof window === "undefined" || targetNodes.length === 0) return;
if (targetNodes.length === 1) {
focusCanvasOnNode(targetNodes[0]);
return;
}
const minX = Math.min(...targetNodes.map((node) => Number(node.position.x || 0)));
const minY = Math.min(...targetNodes.map((node) => Number(node.position.y || 0)));
const maxX = Math.max(
...targetNodes.map(
(node) => Number(node.position.x || 0) + Number(node.size?.width || 300)
)
);
const maxY = Math.max(
...targetNodes.map(
(node) => Number(node.position.y || 0) + Number(node.size?.height || 300)
)
);
const contentWidth = Math.max(1, maxX - minX + padding * 2);
const contentHeight = Math.max(1, maxY - minY + padding * 2);
const availableWidth = Math.max(360, window.innerWidth * 0.54);
const availableHeight = Math.max(320, window.innerHeight * 0.58);
const zoom = Math.max(
0.08,
Math.min(1.4, availableWidth / contentWidth, availableHeight / contentHeight, 4)
);
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
window.requestAnimationFrame(() => {
setTransientViewport({
x: window.innerWidth * 0.52 - centerX * zoom,
y: window.innerHeight * 0.46 - centerY * zoom,
zoom,
});
});
},
[focusCanvasOnNode, setTransientViewport]
);
const applyCanvasViewOperation = useCallback(
(payload: CanvasViewOperationStreamPayload) => {
if (!payload || typeof payload !== "object") return;
const targetProjectId = String(payload.projectId || "").trim();
if (targetProjectId && targetProjectId !== canvasId) return;
const store = useCanvasStore.getState();
const currentNodes = store.nodes;
const nodeIds = Array.isArray(payload.nodeIds)
? payload.nodeIds.map((id) => String(id || "").trim()).filter(Boolean)
: [];
const selectedIds = store.selectedNodeIds.filter(Boolean);
const zoomByFactor = (factor: number) => {
if (typeof window === "undefined") return;
const currentViewport = useCanvasStore.getState().viewport;
const nextZoom = Math.max(0.1, Math.min(4, currentViewport.zoom * factor));
const anchorX = window.innerWidth * 0.52;
const anchorY = window.innerHeight * 0.46;
const scaleChange = nextZoom / currentViewport.zoom;
setTransientViewport({
x: anchorX - (anchorX - currentViewport.x) * scaleChange,
y: anchorY - (anchorY - currentViewport.y) * scaleChange,
zoom: nextZoom,
});
};
if (payload.operation === "zoom_in") {
zoomByFactor(1.22);
return;
}
if (payload.operation === "zoom_out") {
zoomByFactor(0.82);
return;
}
if (payload.operation === "zoom_to" && Number.isFinite(Number(payload.zoom))) {
const currentViewport = useCanvasStore.getState().viewport;
setTransientViewport({
...currentViewport,
zoom: Math.max(0.1, Math.min(4, Number(payload.zoom))),
});
return;
}
const targetIds =
payload.operation === "focus_selection" && nodeIds.length === 0
? selectedIds
: nodeIds.length > 0
? nodeIds
: payload.anchorNodeId
? [payload.anchorNodeId]
: [];
const targetNodes =
payload.operation === "fit_all"
? currentNodes.filter((node) =>
["image", "video", "agent-slot", "ui-screen", "text"].includes(String(node.type))
)
: currentNodes.filter((node) => targetIds.includes(node.id));
if (targetNodes.length === 0) return;
if (targetIds.length > 0) {
store.selectNodes(targetIds);
}
focusCanvasOnNodes(targetNodes, Number(payload.padding || 120));
},
[canvasId, focusCanvasOnNodes, setTransientViewport]
);
const handleAssistantVote = useCallback(
@ -3867,6 +3985,50 @@ export function CanvasChatPanel({
updateNode,
]
);
const refreshCanvasAfterAgentOperation = useCallback(
async (params: { projectId: string; createdNodeIds?: string[] }) => {
const targetProjectId = String(params.projectId || "").trim();
if (!targetProjectId || targetProjectId !== canvasId) {
return false;
}
const createdNodeIds = Array.isArray(params.createdNodeIds)
? params.createdNodeIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [];
try {
const result = await canvasService.getProject(targetProjectId);
if (!result.success || !result.data?.canvasData) {
void mutate(`/api/canvas/${targetProjectId}`);
return false;
}
useCanvasStore.getState().loadCanvas(result.data.canvasData);
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
if (createdNodeIds.length > 0) {
useCanvasStore.getState().selectNodes(createdNodeIds);
const focusNode = useCanvasStore
.getState()
.nodes.find((node) => node.id === createdNodeIds[0]);
if (focusNode) {
focusCanvasOnNode(focusNode);
}
}
return true;
} catch (error) {
console.error("[CanvasChat] Failed to refresh canvas after agent operation:", error);
void mutate(`/api/canvas/${targetProjectId}`);
return false;
}
},
[canvasId, focusCanvasOnNode, mutate]
);
const {
messages,
@ -3992,11 +4154,67 @@ export function CanvasChatPanel({
return;
}
if (dataPart.type === "data-canvasOperation") {
try {
const payload: CanvasOperationStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
const handledIds = Array.isArray(payload.handledCanvasMediaItemIds)
? payload.handledCanvasMediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [];
handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id));
const targetProjectId = String(payload.projectId || "").trim();
if (targetProjectId) {
void refreshCanvasAfterAgentOperation({
projectId: targetProjectId,
createdNodeIds: payload.createdNodeIds,
});
}
if (payload.message) {
toast.success(payload.message);
}
} catch (e) {
console.error("[CanvasChat] Failed to parse data-canvasOperation:", e);
}
return;
}
if (dataPart.type === "data-canvasViewOperation") {
try {
const payload: CanvasViewOperationStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
applyCanvasViewOperation(payload);
} catch (e) {
console.error("[CanvasChat] Failed to parse data-canvasViewOperation:", e);
}
return;
}
if (dataPart.type === "data-canvasMedia") {
try {
const payload: CanvasMediaStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
const handledIds = Array.isArray(payload.handledCanvasMediaItemIds)
? payload.handledCanvasMediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [];
handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id));
const mediaItemIds = Array.isArray(payload.mediaItemIds)
? payload.mediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [];
if (
mediaItemIds.length > 0 &&
mediaItemIds.every((id) => handledCanvasMediaItemIdsRef.current.has(id))
) {
clearStreamImageGenerationStateForChat(eventChatId);
return;
}
const imageUrls = Array.isArray(payload.imageUrls)
? payload.imageUrls.filter(
(url): url is string =>
@ -5559,6 +5777,8 @@ export function CanvasChatPanel({
version: 1,
source: "canvas",
projectId,
canvasRevision: canvasRevision || undefined,
canvasUpdatedAt: canvasUpdatedAt || undefined,
viewport: {
x: Number(viewport.x || 0),
y: Number(viewport.y || 0),
@ -5613,7 +5833,16 @@ export function CanvasChatPanel({
})),
canvasAgentContext,
};
}, [activeComposerMode, canvasId, nodes, selectedImageNodes, selectedNodeIds, viewport]);
}, [
activeComposerMode,
canvasId,
canvasRevision,
canvasUpdatedAt,
nodes,
selectedImageNodes,
selectedNodeIds,
viewport,
]);
const updateAttachmentAtVideoSlot = useCallback(
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
@ -11887,10 +12116,8 @@ export function CanvasChatPanel({
>
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full leading-none",
isVideoWorkspace
? "bg-background dark:bg-transparent"
: "bg-white dark:bg-transparent"
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white text-zinc-700 leading-none ring-1 ring-black/5 dark:bg-white dark:text-zinc-900 dark:ring-white/20",
isVideoWorkspace ? "border border-border/60" : "border border-transparent"
)}
>
{selectDisplayModel?.avatarUrl ? (
@ -11908,7 +12135,7 @@ export function CanvasChatPanel({
/>
) : getToolbarItem("chatModelSelector").iconUrl ? (
<CanvasToolbarIcon
className="h-4 w-4"
className="h-4 w-4 text-zinc-700 dark:text-zinc-900"
fallback={Bot}
item={getToolbarItem("chatModelSelector")}
/>
@ -11916,7 +12143,7 @@ export function CanvasChatPanel({
<span
className={cn(
"flex h-full w-full items-center justify-center text-[10px] font-medium leading-none",
isVideoWorkspace ? "text-foreground/70 dark:text-zinc-100" : "text-[#4b5563] dark:text-zinc-100"
isVideoWorkspace ? "text-zinc-700" : "text-[#4b5563] dark:text-zinc-900"
)}
>
{(selectDisplayModel?.displayName || "A").slice(0, 1)}

View File

@ -6,6 +6,7 @@ import { motion } from "framer-motion";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import type { CanvasEdge, CanvasNode, Position } from "@/types/canvas";
import { ImageNode } from "./nodes/ImageNode";
import { AgentSlotNode } from "./nodes/AgentSlotNode";
import { Model3DNode } from "./nodes/Model3DNode";
import { TextNode } from "./nodes/TextNode";
import { UiScreenNode } from "./nodes/UiScreenNode";
@ -1817,6 +1818,12 @@ export function CanvasWorkspace({
worldlabs3dEnabled={worldlabs3dEnabled}
/>
)}
{node.type === 'agent-slot' && (
<AgentSlotNode
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'text' && (
<TextNode
canvasToolbarConfig={canvasToolbarConfig}

View File

@ -1,3 +1,5 @@
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
export type CanvasMediaStreamPayload = {
messageId?: string;
imageUrls?: string[];
@ -9,8 +11,26 @@ export type CanvasMediaStreamPayload = {
agentCategoryId?: string;
agentCategoryName?: string;
agentExecutionSnapshotId?: string;
mediaItemIds?: string[];
handledCanvasMediaItemIds?: string[];
};
export type CanvasOperationStreamPayload = {
projectId?: string;
operationId?: string;
operation?: string;
createdNodeIds?: string[];
createdNodeIdsByMediaItemId?: Record<string, string>;
updatedNodeIds?: string[];
createdEdgeIds?: string[];
canvasRevision?: string;
anchorNodeId?: string;
handledCanvasMediaItemIds?: string[];
message?: string;
};
export type CanvasViewOperationStreamPayload = CanvasViewOperationPlan;
export type ImageGenerationStatusStreamPayload = {
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
providerType?: string;

View File

@ -0,0 +1,88 @@
"use client";
import { ImagePlus, MonitorSmartphone, Sparkles, Video } from "lucide-react";
import type { AgentSlotNodeData, CanvasNode } from "@/types/canvas";
import { cn } from "@/lib/utils";
interface AgentSlotNodeProps {
node: CanvasNode;
isSelected: boolean;
}
function getSlotIcon(slotType: AgentSlotNodeData["slotType"]) {
if (slotType === "video") return Video;
if (slotType === "ui-screen") return MonitorSmartphone;
return ImagePlus;
}
function getSlotTypeLabel(slotType: AgentSlotNodeData["slotType"]) {
if (slotType === "video") return "视频槽位";
if (slotType === "ui-screen") return "UI 槽位";
return "图片槽位";
}
function getStatusLabel(status: AgentSlotNodeData["status"]) {
if (status === "generating") return "生成中";
if (status === "completed") return "已填充";
if (status === "failed") return "失败";
return "等待生成";
}
export function AgentSlotNode({ node, isSelected }: AgentSlotNodeProps) {
const data = node.data as AgentSlotNodeData;
const slotType = data.slotType || "image";
const SlotIcon = getSlotIcon(slotType);
const filledCount = Array.isArray(data.filledNodeIds)
? data.filledNodeIds.length
: 0;
return (
<div
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-none border-2 border-dashed bg-white/92 text-slate-700 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition-all dark:bg-zinc-950/92 dark:text-slate-100",
isSelected
? "border-[#5b76fe] ring-4 ring-[#5b76fe]/12"
: "border-[#b8c3d8] hover:border-[#8ea2d6] dark:border-zinc-700",
data.status === "generating" && "border-[#7c9cff] bg-[#f7f9ff]",
data.status === "failed" && "border-red-300 bg-red-50 text-red-900"
)}
data-agent-slot-node="true"
data-slot-type={slotType}
>
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-5 py-4 text-center">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full border border-[#dbe2ee] bg-white text-[#5b76fe] shadow-sm dark:border-zinc-700 dark:bg-zinc-900",
data.status === "generating" && "animate-pulse",
data.status === "failed" && "border-red-200 text-red-600"
)}
>
{data.status === "generating" ? (
<Sparkles className="h-5 w-5" />
) : (
<SlotIcon className="h-5 w-5" />
)}
</div>
<div className="min-w-0 space-y-1">
<div className="truncate font-semibold text-[15px] leading-5">
{data.label || getSlotTypeLabel(slotType)}
</div>
<div className="text-[12px] leading-5 text-slate-500 dark:text-slate-400">
{getStatusLabel(data.status)}
{filledCount > 0 ? ` · ${filledCount} 个结果` : ""}
</div>
</div>
</div>
<div className="flex items-center justify-between border-[#e6ebf5] border-t px-3 py-2 text-[11px] text-slate-500 dark:border-zinc-800 dark:text-slate-400">
<span className="truncate">{data.aspectRatio || "1:1"}</span>
<span className="truncate">
{data.fillPolicy === "append-version"
? "追加版本"
: data.fillPolicy === "replace-existing"
? "允许替换"
: "填充空槽"}
</span>
</div>
</div>
);
}

View File

@ -390,12 +390,16 @@ type LocalEditRegionInteractionState = {
handle: CropInteractionHandle;
pointerId: number;
regionId: string;
target: "source" | "scaleTarget";
startPoint: BoxCutoutPoint;
startRect: NormalizedCropRect;
};
type CanvasLocalEditOperationMode = "local_edit" | "scale_adjust";
type CanvasScaleAdjustPercent = -30 | -20 | -10 | 10 | 20 | 30 | 50 | 100;
const CANVAS_SCALE_TARGET_DEFAULT_PERCENT: CanvasScaleAdjustPercent = 50;
const CANVAS_SCALE_TARGET_MIN_PERCENT = -80;
const CANVAS_SCALE_TARGET_MAX_PERCENT = 200;
const CANVAS_SCALE_ADJUST_OPTIONS: CanvasScaleAdjustPercent[] = [
-30,
@ -408,22 +412,27 @@ const CANVAS_SCALE_ADJUST_OPTIONS: CanvasScaleAdjustPercent[] = [
100,
];
function getCanvasScaleAdjustLabel(scalePercent: CanvasScaleAdjustPercent) {
function getCanvasScaleAdjustLabel(scalePercent: number) {
return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`;
}
function getCanvasScaleAdjustTargetLabel(scalePercent: CanvasScaleAdjustPercent) {
return `${Math.round((1 + scalePercent / 100) * 100)}%`;
function getCanvasScaleAdjustTargetLabel(scalePercent: number) {
return `${Math.max(5, Math.round((1 + scalePercent / 100) * 100))}%`;
}
function getCanvasScaleAdjustRegionPrompt(scalePercent: CanvasScaleAdjustPercent | null) {
function getCanvasScaleAdjustRegionPrompt(
scalePercent: number | null,
hasTargetPosition = false
) {
if (!scalePercent) return "比例调整";
const actionLabel = getCanvasScaleAdjustLabel(scalePercent);
const targetLabel = getCanvasScaleAdjustTargetLabel(scalePercent);
const scaleVerb = scalePercent > 0 ? "放大" : "缩小";
return [
`对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`,
"框选区域只用于识别要调整的主体,不是放大后的边界;放大时主体可以自然超出原始框选范围,并由周边背景补全衔接。",
hasTargetPosition
? "红色框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,放大后的主体可以自然超出红色框,并由周边背景补全衔接。"
: "框选区域只用于识别要调整的主体,不是放大后的边界;放大时主体可以自然超出原始框选范围,并由周边背景补全衔接。",
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
scalePercent < 0
? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。"
@ -431,6 +440,53 @@ function getCanvasScaleAdjustRegionPrompt(scalePercent: CanvasScaleAdjustPercent
].join(" ");
}
function scaleCanvasRectAroundCenter(
rect: NormalizedCropRect,
scalePercent: number
): NormalizedCropRect {
const requestedScale = Math.max(0.05, 1 + scalePercent / 100);
const maxUniformScale = Math.max(
0.05,
Math.min(
rect.width > 0 ? 1 / rect.width : requestedScale,
rect.height > 0 ? 1 / rect.height : requestedScale
)
);
const scale = Math.min(requestedScale, maxUniformScale);
const width = Math.min(1, rect.width * scale);
const height = Math.min(1, rect.height * scale);
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
const x = Math.min(Math.max(centerX - width / 2, 0), Math.max(0, 1 - width));
const y = Math.min(Math.max(centerY - height / 2, 0), Math.max(0, 1 - height));
return { x, y, width, height };
}
function getCanvasScaleTargetRect(
region: Pick<LocalEditRegion, "rect" | "targetRect">,
fallbackPercent: number
): NormalizedCropRect {
return region.targetRect || scaleCanvasRectAroundCenter(region.rect, fallbackPercent);
}
function deriveCanvasScalePercentFromRects(
sourceRect: NormalizedCropRect,
targetRect: NormalizedCropRect | null | undefined
) {
if (!targetRect || sourceRect.width <= 0 || sourceRect.height <= 0) {
return 0;
}
const widthRatio = Math.max(0.05, targetRect.width / sourceRect.width);
const heightRatio = Math.max(0.05, targetRect.height / sourceRect.height);
const uniformRatio = Math.sqrt(widthRatio * heightRatio);
const percent = Math.round((uniformRatio - 1) * 100);
return Math.min(
CANVAS_SCALE_TARGET_MAX_PERCENT,
Math.max(CANVAS_SCALE_TARGET_MIN_PERCENT, percent)
);
}
type LocalEditPromptCompositionSnapshot = {
end: number;
start: number;
@ -1462,6 +1518,8 @@ export function ImageNode({
}, [publicModelAliasConfig.worldModels]);
const [isHovered, setIsHovered] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isCreatingDetachedResultNode, setIsCreatingDetachedResultNode] =
useState(false);
const [imageAdjustPanelOpen, setImageAdjustPanelOpen] = useState(false);
const [imageAdjustTab, setImageAdjustTab] =
useState<ImageAdjustPanelTab>("light");
@ -2318,6 +2376,27 @@ export function ImageNode({
style: getNormalizedRectOverlayStyle(region.rect, boxCutoutMetrics),
}));
}, [boxCutoutMetrics, localEditRegions]);
const localEditScaleTargetOverlays = useMemo(() => {
if (!boxCutoutMetrics || localEditOperationMode !== "scale_adjust") {
return [];
}
const fallbackPercent =
selectedLocalEditScalePercent || CANVAS_SCALE_TARGET_DEFAULT_PERCENT;
return localEditRegions.map((region) => {
const targetRect = getCanvasScaleTargetRect(region, fallbackPercent);
return {
region,
targetRect,
scalePercent: deriveCanvasScalePercentFromRects(region.rect, targetRect),
style: getNormalizedRectOverlayStyle(targetRect, boxCutoutMetrics),
};
});
}, [
boxCutoutMetrics,
localEditOperationMode,
localEditRegions,
selectedLocalEditScalePercent,
]);
const localEditPromptInlineOverlays = useMemo(() => {
if (!localEditMode || !boxCutoutMetrics) {
return [];
@ -3446,7 +3525,9 @@ export function ImageNode({
resetBoxCutoutState();
setLocalEditRegions(getLocalEditRegionsFromMetadata(data.metadata));
setLocalEditOperationMode(operationMode);
setSelectedLocalEditScalePercent(operationMode === "scale_adjust" ? -30 : null);
setSelectedLocalEditScalePercent(
operationMode === "scale_adjust" ? CANVAS_SCALE_TARGET_DEFAULT_PERCENT : null
);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setIsLocalEditDrawing(false);
@ -3456,7 +3537,7 @@ export function ImageNode({
});
toast.info(
operationMode === "scale_adjust"
? "拖拽框选要调整比例的主体,并选择缩小或放大幅度"
? "先框选要调整比例的主体,再拖动青色目标框调整放大后的大小和位置"
: "拖拽框选要局部编辑的位置,并在框下方填写提示词"
);
},
@ -3551,20 +3632,29 @@ export function ImageNode({
nextHeightPx >= LOCAL_EDIT_MIN_EDGE_PX
) {
const isScaleAdjustMode = localEditOperationMode === "scale_adjust";
const scalePercent =
selectedLocalEditScalePercent || CANVAS_SCALE_TARGET_DEFAULT_PERCENT;
const targetRect = isScaleAdjustMode
? scaleCanvasRectAroundCenter(nextRect, scalePercent)
: undefined;
const nextRegion: LocalEditRegion = {
id: createLocalEditRegionId(),
index: localEditRegions.length + 1,
color:
LOCAL_EDIT_COLORS[localEditRegions.length % LOCAL_EDIT_COLORS.length],
prompt: isScaleAdjustMode
? getCanvasScaleAdjustRegionPrompt(selectedLocalEditScalePercent)
? getCanvasScaleAdjustRegionPrompt(scalePercent, true)
: "",
rect: nextRect,
...(targetRect ? { targetRect } : {}),
referenceImages: [],
createdAt: Date.now(),
};
commitLocalEditRegions([...localEditRegions, nextRegion]);
setEditingLocalEditRegionId(isScaleAdjustMode ? null : nextRegion.id);
if (isScaleAdjustMode) {
toast.info("已生成青色目标框,可拖动它调整放大后的大小和位置");
}
} else {
toast.info("框选区域太小,请至少拖出 24px 以上的区域");
}
@ -3611,11 +3701,29 @@ export function ImageNode({
);
const updateLocalEditRegionRect = useCallback(
(regionId: string, rect: NormalizedCropRect, shouldCommit = false) => {
(
regionId: string,
rect: NormalizedCropRect,
target: LocalEditRegionInteractionState["target"] = "source",
shouldCommit = false
) => {
const nextRegions = reindexLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId ? { ...region, rect } : region
)
localEditRegionsRef.current.map((region) => {
if (region.id !== regionId) return region;
if (target === "scaleTarget") {
setSelectedLocalEditScalePercent(null);
const scalePercent = deriveCanvasScalePercentFromRects(
region.rect,
rect
);
return {
...region,
targetRect: rect,
prompt: getCanvasScaleAdjustRegionPrompt(scalePercent, true),
};
}
return { ...region, rect };
})
);
localEditRegionsRef.current = nextRegions;
setLocalEditRegions(nextRegions);
@ -3627,7 +3735,12 @@ export function ImageNode({
);
const handleLocalEditRegionPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>, region: LocalEditRegion) => {
(
event: React.PointerEvent<HTMLDivElement>,
region: LocalEditRegion,
target: LocalEditRegionInteractionState["target"] = "source",
targetRect?: NormalizedCropRect
) => {
if (!localEditMode || isProcessing || isSubmittingLocalEdit) return;
event.preventDefault();
@ -3655,8 +3768,9 @@ export function ImageNode({
handle,
pointerId: event.pointerId,
regionId: region.id,
target,
startPoint: pointerResult.point,
startRect: region.rect,
startRect: target === "scaleTarget" ? targetRect || region.rect : region.rect,
});
event.currentTarget.setPointerCapture?.(event.pointerId);
},
@ -3706,7 +3820,11 @@ export function ImageNode({
startRect: localEditRegionInteraction.startRect,
});
updateLocalEditRegionRect(regionId, nextRect);
updateLocalEditRegionRect(
regionId,
nextRect,
localEditRegionInteraction.target
);
},
[
getBoxCutoutPointerPoint,
@ -3883,6 +4001,23 @@ export function ImageNode({
setEditingLocalEditRegionId(null);
}, [commitLocalEditRegions]);
const applyLocalEditScalePercent = useCallback(
(scalePercent: CanvasScaleAdjustPercent) => {
setSelectedLocalEditScalePercent(scalePercent);
const nextRegions = reindexLocalEditRegions(
localEditRegionsRef.current.map((region) => ({
...region,
targetRect: scaleCanvasRectAroundCenter(region.rect, scalePercent),
prompt: getCanvasScaleAdjustRegionPrompt(scalePercent, true),
}))
);
localEditRegionsRef.current = nextRegions;
setLocalEditRegions(nextRegions);
commitLocalEditRegions(nextRegions);
},
[commitLocalEditRegions]
);
const handleSubmitLocalEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
@ -3898,15 +4033,29 @@ export function ImageNode({
}
const isScaleAdjustMode = localEditOperationMode === "scale_adjust";
const scaleFallbackPercent =
selectedLocalEditScalePercent || CANVAS_SCALE_TARGET_DEFAULT_PERCENT;
const validRegions = reindexLocalEditRegions(
localEditRegions.map((region) => ({
...region,
prompt: isScaleAdjustMode
? getCanvasScaleAdjustRegionPrompt(selectedLocalEditScalePercent)
: (
localEditPromptDraftRefs.current[region.id] ?? region.prompt
).trim(),
}))
localEditRegions.map((region) => {
if (isScaleAdjustMode) {
const targetRect = getCanvasScaleTargetRect(region, scaleFallbackPercent);
const regionScalePercent = deriveCanvasScalePercentFromRects(
region.rect,
targetRect
);
return {
...region,
targetRect,
prompt: getCanvasScaleAdjustRegionPrompt(regionScalePercent, true),
};
}
return {
...region,
prompt: (
localEditPromptDraftRefs.current[region.id] ?? region.prompt
).trim(),
};
})
);
if (validRegions.length === 0) {
@ -3914,8 +4063,15 @@ export function ImageNode({
return;
}
if (isScaleAdjustMode) {
if (!selectedLocalEditScalePercent) {
toast.error("请选择放大或缩小比例");
const unchangedRegion = validRegions.find((region) => {
const regionScalePercent = deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
);
return Math.abs(regionScalePercent) < 1;
});
if (unchangedRegion) {
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小`);
return;
}
} else {
@ -3930,7 +4086,10 @@ export function ImageNode({
const actionName = isScaleAdjustMode ? "比例调整" : "局部编辑";
const editMode = isScaleAdjustMode ? "scale-adjust" : "local-edit";
const scalePercent = isScaleAdjustMode
? selectedLocalEditScalePercent || undefined
? deriveCanvasScalePercentFromRects(
validRegions[0].rect,
validRegions[0].targetRect
)
: undefined;
setIsSubmittingLocalEdit(true);
@ -3974,6 +4133,7 @@ export function ImageNode({
color: region.color,
prompt: region.prompt,
rect: region.rect,
targetRect: region.targetRect,
referenceImages: (region.referenceImages || []).map((reference) => ({
id: reference.id,
url: reference.url,
@ -4140,6 +4300,7 @@ export function ImageNode({
return;
}
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
const actionName = "框选抠图";
const newNodeId = addNode({
@ -4222,6 +4383,7 @@ export function ImageNode({
});
toast.error(`${actionName}失败,请重试`);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
resetBoxCutoutState();
}
@ -5014,6 +5176,7 @@ export function ImageNode({
}
setIsApplyingAngleEdit(true);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
const newNodeId = addNode({
@ -5107,6 +5270,7 @@ export function ImageNode({
toast.error(message);
} finally {
setIsApplyingAngleEdit(false);
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
@ -5656,6 +5820,7 @@ export function ImageNode({
}
setIsApplyingTextEdit(true);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
setTextEditError(null);
@ -5724,6 +5889,7 @@ export function ImageNode({
toast.error(message);
} finally {
setIsApplyingTextEdit(false);
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
@ -5936,6 +6102,7 @@ export function ImageNode({
setNotePanelOpen(false);
resetBoxCutoutState();
resetLocalEditState();
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
setIsSubmittingAiWashQuick(true);
setAiWashQuickStatus("submitting");
@ -6064,6 +6231,7 @@ export function ImageNode({
setAiWashQuickStatus("failed");
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
setIsSubmittingAiWashQuick(false);
}
@ -6194,6 +6362,7 @@ export function ImageNode({
toast.info("图层拆分功能未开启");
return;
}
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const result = await imageGenerationService.splitImageLayers(data.src);
@ -6218,6 +6387,7 @@ export function ImageNode({
console.error("图层拆分失败:", error);
toast.error("图层拆分失败");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
return;
@ -6288,6 +6458,7 @@ export function ImageNode({
setCropInteraction(null);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
// 先创建一个"处理中"状态的节点,显示在原图右侧
const newNodeId = addNode({
@ -6374,6 +6545,7 @@ export function ImageNode({
}
toast.error("处理失败,请重试");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
@ -7340,6 +7512,7 @@ export function ImageNode({
selectNode(newNodeId);
persistCanvasStateSnapshot();
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const createResult = await model3DGenerationService.createTask({
@ -7432,6 +7605,7 @@ export function ImageNode({
persistCanvasStateSnapshot();
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
@ -7493,6 +7667,7 @@ export function ImageNode({
selectNode(newNodeId);
persistCanvasStateSnapshot();
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const uploadResult = await worldLabsGenerationService.uploadMediaFromUrl(
@ -7594,6 +7769,7 @@ export function ImageNode({
persistCanvasStateSnapshot();
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
@ -7631,6 +7807,7 @@ export function ImageNode({
}
setPromptDialogOpen(false);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
const newNodeId = addNode({
@ -7699,6 +7876,7 @@ export function ImageNode({
});
toast.error("重绘失败");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
@ -8169,19 +8347,108 @@ export function ImageNode({
}}
/>
) : null}
{localEditScaleTargetOverlays.map(
({ region, scalePercent, style, targetRect }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const active =
localEditRegionInteraction?.regionId === region.id &&
localEditRegionInteraction.target === "scaleTarget";
return (
<div
className={cn(
"nodrag nopan nowheel pointer-events-auto absolute z-[162] rounded-none border-[1.5px] border-dashed border-cyan-300 bg-cyan-300/5 shadow-[0_0_0_1px_rgba(8,145,178,0.16)]",
active ? "cursor-grabbing" : "cursor-grab"
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
key={`scale-target-${region.id}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={(event) =>
handleLocalEditRegionPointerCancel(event, region.id)
}
onPointerDown={(event) =>
handleLocalEditRegionPointerDown(
event,
region,
"scaleTarget",
targetRect
)
}
onPointerMove={(event) =>
handleLocalEditRegionPointerMove(event, region.id)
}
onPointerUp={(event) =>
handleLocalEditRegionPointerUp(event, region.id)
}
style={{
left,
top,
width: Math.max(width, 1),
height: Math.max(height, 1),
}}
title={`目标框:${getCanvasScaleAdjustLabel(scalePercent)}`}
>
<div className="-top-6 left-0 pointer-events-none absolute rounded-full bg-cyan-500/90 px-2 py-0.5 text-[11px] font-semibold text-white shadow-sm">
目标 {scalePercent > 0 ? `+${scalePercent}%` : `${scalePercent}%`}
</div>
{(
[
{
handle: "nw",
className: "-left-[5px] -top-[5px] cursor-nwse-resize",
},
{
handle: "ne",
className: "-right-[5px] -top-[5px] cursor-nesw-resize",
},
{
handle: "sw",
className: "-left-[5px] -bottom-[5px] cursor-nesw-resize",
},
{
handle: "se",
className: "-right-[5px] -bottom-[5px] cursor-nwse-resize",
},
] as const
).map((item) => (
<button
aria-label={`调整区域 ${region.index} 的目标框`}
className={cn(
"nodrag nopan nowheel absolute h-[10px] w-[10px] rounded-none border-[1.5px] border-cyan-300 bg-white shadow-none",
item.className
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
data-local-edit-region-handle={item.handle}
key={item.handle}
type="button"
/>
))}
</div>
);
}
)}
{localEditRegionOverlays.map(({ region, style }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const active =
localEditRegionInteraction?.regionId === region.id &&
localEditRegionInteraction.target === "source";
return (
<div
className={cn(
"nodrag nopan nowheel pointer-events-auto absolute z-[160] rounded-none border-[1.5px] bg-transparent shadow-none",
localEditRegionInteraction?.regionId === region.id
? "cursor-grabbing"
: "cursor-grab"
active ? "cursor-grabbing" : "cursor-grab"
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
@ -8675,7 +8942,9 @@ export function ImageNode({
)}
{/* 处理中遮罩 */}
{isProcessing && !isSubmittingLocalEdit && (
{isProcessing &&
!isSubmittingLocalEdit &&
!isCreatingDetachedResultNode && (
<ImageNodeProcessingOverlay
detailProgressLabel={detailProgressLabel || undefined}
generationProgress={generationProgress}
@ -9045,7 +9314,9 @@ export function ImageNode({
disabled={isSubmittingLocalEdit}
onClick={() => {
setLocalEditOperationMode("scale_adjust");
setSelectedLocalEditScalePercent((current) => current || -30);
if (!selectedLocalEditScalePercent) {
applyLocalEditScalePercent(CANVAS_SCALE_TARGET_DEFAULT_PERCENT);
}
setEditingLocalEditRegionId(null);
}}
type="button"
@ -9059,25 +9330,28 @@ export function ImageNode({
{localEditOperationMode === "scale_adjust" ? (
<div className="inline-flex shrink-0 items-center gap-1">
<span className="hidden whitespace-nowrap px-1 text-[12px] font-medium text-slate-500 dark:text-slate-300 lg:inline">
拖动青色目标框
</span>
{CANVAS_SCALE_ADJUST_OPTIONS.map((option) => {
const active = selectedLocalEditScalePercent === option;
const Icon = option > 0 ? Maximize2 : Minimize2;
return (
<Button
className={cn(
"h-9 rounded-xl border px-2 text-[12px] font-semibold",
"inline-flex h-9 items-center justify-center rounded-xl border px-2 text-[12px] font-semibold",
active
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-300/45 dark:bg-emerald-400/16 dark:text-emerald-100"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-950 dark:border-white/12 dark:bg-zinc-900 dark:text-slate-200 dark:hover:bg-white/10"
)}
disabled={isSubmittingLocalEdit}
key={option}
onClick={() => setSelectedLocalEditScalePercent(option)}
onClick={() => applyLocalEditScalePercent(option)}
title={getCanvasScaleAdjustLabel(option)}
type="button"
variant="ghost"
>
<Icon className="mr-1 h-3.5 w-3.5" />
<Icon className="mr-1 h-3.5 w-3.5 shrink-0" />
{option > 0 ? `+${option}%` : `${option}%`}
</Button>
);
@ -9089,9 +9363,7 @@ export function ImageNode({
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-950 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white disabled:bg-slate-300 disabled:text-slate-500 dark:border dark:border-white/14 dark:bg-black dark:text-white dark:disabled:border-transparent dark:disabled:bg-slate-700 dark:disabled:text-slate-400 dark:hover:bg-zinc-900"
disabled={
isSubmittingLocalEdit ||
localEditRegions.length === 0 ||
(localEditOperationMode === "scale_adjust" &&
!selectedLocalEditScalePercent)
localEditRegions.length === 0
}
onClick={() => void handleSubmitLocalEdit()}
title={

View File

@ -14,6 +14,7 @@ export type LocalEditRegion = {
color: string;
prompt: string;
rect: NormalizedCropRect;
targetRect?: NormalizedCropRect | null;
referenceImages?: LocalEditReferenceImage[];
createdAt: number;
};
@ -131,6 +132,7 @@ export const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] =>
const record = item as Record<string, unknown>;
const rect = normalizeLocalEditRect(record.rect);
if (!rect) return;
const targetRect = normalizeLocalEditRect(record.targetRect);
regions.push({
id:
typeof record.id === "string" && record.id.trim()
@ -146,6 +148,7 @@ export const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] =>
: LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length],
prompt: typeof record.prompt === "string" ? record.prompt.trim() : "",
rect,
...(targetRect ? { targetRect } : {}),
referenceImages: normalizeLocalEditReferenceImages(record.referenceImages),
createdAt:
typeof record.createdAt === "number" && Number.isFinite(record.createdAt)

View File

@ -0,0 +1,426 @@
import "server-only";
import { createHash } from "node:crypto";
import { getSystemSetting } from "@/lib/db/queries";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import type {
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialResourceType,
} from "@/types/agent";
type UpstreamMaterialResponse = {
success?: boolean;
data?: Record<string, unknown>;
error?: {
code?: string;
message?: string;
};
};
type UpstreamImageRecord = {
key?: string;
label?: string;
fileName?: string;
fileSize?: number;
previewUrl?: string;
downloadUrl?: string;
};
type ThirdPartyImageMaterialDiagnostics = {
status: number;
traceId: string | null;
serverDurationMs: string | null;
serverTiming: string | null;
resourceType: string | null;
requestSku: string | null;
requestSpu: string | null;
};
export class ThirdPartyImageMaterialError extends Error {
readonly code: string;
readonly status: number;
constructor(code: string, message: string, status = 500) {
super(message);
this.name = "ThirdPartyImageMaterialError";
this.code = code;
this.status = status;
}
}
function sanitizeText(value: unknown, maxLength = 500) {
return String(value || "").trim().slice(0, maxLength);
}
function normalizeUrlList(value: unknown) {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => sanitizeText(item, 2000))
.filter((item) => /^https?:\/\//i.test(item))
)
);
}
function inferMimeTypeFromFilename(filename: string) {
const normalized = filename.toLowerCase();
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg";
if (normalized.endsWith(".webp")) return "image/webp";
if (normalized.endsWith(".gif")) return "image/gif";
return "image/png";
}
function getRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function getDownloadUrls(value: unknown) {
return getRecord(value).downloadUrls &&
typeof getRecord(value).downloadUrls === "object" &&
!Array.isArray(getRecord(value).downloadUrls)
? (getRecord(value).downloadUrls as Record<string, unknown>)
: {};
}
function mapUpstreamErrorStatus(code: string) {
if (code === "MISSING_PARAM" || code === "RESOURCE_TYPE_INVALID") return 400;
if (
code === "SKU_NOT_FOUND" ||
code === "SPU_NOT_FOUND" ||
code === "NO_SKUS" ||
code === "WHITE_BACKGROUND_NOT_FOUND" ||
code === "TASK_DETAIL_NOT_FOUND" ||
code === "FULL_SET_NOT_FOUND"
) {
return 404;
}
if (code === "SKU_NOT_UNIQUE") return 409;
if (code === "INVALID_API_KEY") return 500;
return 502;
}
function readDiagnostics(response: Response): ThirdPartyImageMaterialDiagnostics {
return {
status: response.status,
traceId: response.headers.get("x-trace-id"),
serverDurationMs: response.headers.get("x-server-duration-ms"),
serverTiming: response.headers.get("server-timing"),
resourceType: response.headers.get("x-resource-type"),
requestSku: response.headers.get("x-request-sku"),
requestSpu: response.headers.get("x-request-spu"),
};
}
function mapWhiteBackgroundImage(params: {
matchedSku: string;
group: "fixed" | "other";
sortOrder: number;
image: UpstreamImageRecord;
}) {
const previewUrl = sanitizeText(params.image.previewUrl, 2000);
const downloadUrl = sanitizeText(params.image.downloadUrl, 2000);
const imageUrl = previewUrl || downloadUrl;
if (!imageUrl) return null;
const filename =
sanitizeText(params.image.fileName, 255) ||
sanitizeText(params.image.label, 128) ||
`${params.matchedSku}-${params.group}-${params.sortOrder + 1}.webp`;
const identityHash = createHash("sha1")
.update(`${params.group}:${params.sortOrder}:${imageUrl}`, "utf8")
.digest("hex")
.slice(0, 12);
return {
id: `third-party-${params.group}-${params.sortOrder + 1}-${identityHash}`,
key: sanitizeText(params.image.key, 64) || `${params.group}-${params.sortOrder + 1}`,
label: sanitizeText(params.image.label, 128) || `图片${params.sortOrder + 1}`,
fileName: filename,
fileSize: Number.isFinite(Number(params.image.fileSize))
? Math.max(0, Math.round(Number(params.image.fileSize)))
: null,
imageUrl,
previewUrl: previewUrl || null,
downloadUrl: downloadUrl || null,
mimeType: inferMimeTypeFromFilename(filename),
group: params.group,
sortOrder: params.sortOrder,
};
}
function normalizeWhiteBackgroundData(params: {
query: string;
queryType: AgentWorkflowMaterialQueryType;
data: Record<string, unknown>;
}) {
const data = params.data;
const matchedSku = sanitizeText(data.sku, 64);
const matchedSpu =
sanitizeText(data.spu, 64) || sanitizeText(data.productModel, 128) || null;
const skuList = Array.isArray(data.skuList) ? data.skuList : [];
const skuOptions = skuList
.map((item) => {
const raw = getRecord(item);
const skuName = sanitizeText(raw.skuName, 64);
if (!skuName) return null;
const previewImage = getRecord(raw.previewImage);
return {
skuName,
skuId: sanitizeText(raw.skuId, 128) || null,
hasWhiteBackground: raw.hasWhiteBackground === true,
whiteBackgroundCount: Number.isFinite(Number(raw.whiteBackgroundCount))
? Math.max(0, Math.round(Number(raw.whiteBackgroundCount)))
: 0,
previewImageUrl: sanitizeText(previewImage.previewUrl, 2000) || null,
downloadUrl: sanitizeText(previewImage.downloadUrl, 2000) || null,
previewFileName: sanitizeText(previewImage.fileName, 255) || null,
previewFileSize: Number.isFinite(Number(previewImage.fileSize))
? Math.max(0, Math.round(Number(previewImage.fileSize)))
: null,
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
const whiteBackground = getRecord(data.whiteBackground);
const fixedImages = Array.isArray(whiteBackground.fixed) ? whiteBackground.fixed : [];
const otherImages = Array.isArray(whiteBackground.other) ? whiteBackground.other : [];
const images = [
...fixedImages.map((image, index) =>
mapWhiteBackgroundImage({
matchedSku: matchedSku || params.query,
group: "fixed",
sortOrder: index,
image: getRecord(image),
})
),
...otherImages.map((image, index) =>
mapWhiteBackgroundImage({
matchedSku: matchedSku || params.query,
group: "other",
sortOrder: fixedImages.length + index,
image: getRecord(image),
})
),
].filter((item): item is NonNullable<typeof item> => Boolean(item));
return {
resourceType: "white-background" as const,
query: params.query,
queryType: params.queryType,
matchedSku: matchedSku || null,
matchedSpu,
materialId: sanitizeText(data.materialId, 128) || null,
productModel: sanitizeText(data.productModel, 128) || matchedSpu,
categoryName: sanitizeText(data.categoryName, 128) || null,
shopName: sanitizeText(data.shopName, 128) || sanitizeText(data.categoryName, 128) || null,
whiteBackgroundUploaded: data.whiteBackgroundUploaded === true,
whiteBackgroundComplete: data.whiteBackgroundComplete === true,
fixedCount: Number.isFinite(Number(data.whiteBackgroundFixedCount))
? Math.max(0, Math.round(Number(data.whiteBackgroundFixedCount)))
: images.filter((item) => item.group === "fixed").length,
otherCount: Number.isFinite(Number(data.whiteBackgroundOtherCount))
? Math.max(0, Math.round(Number(data.whiteBackgroundOtherCount)))
: images.filter((item) => item.group === "other").length,
totalCount: Number.isFinite(Number(data.whiteBackgroundCount))
? Math.max(0, Math.round(Number(data.whiteBackgroundCount)))
: images.length || skuOptions.length,
missingViews: Array.isArray(data.missingViews) ? data.missingViews : [],
images,
skuOptions,
downloadUrls: {
zip: sanitizeText(getDownloadUrls(data).zip, 2000) || null,
},
};
}
function normalizeTaskDetailData(params: {
query: string;
queryType: AgentWorkflowMaterialQueryType;
data: Record<string, unknown>;
}) {
const candidates = (Array.isArray(params.data.candidates) ? params.data.candidates : [])
.map((item, index) => {
const raw = getRecord(item);
const downloadUrls = getDownloadUrls(raw);
const mainImageUrls = normalizeUrlList(downloadUrls.main);
const skuImageUrls = normalizeUrlList(downloadUrls.sku);
const detailImageUrls = normalizeUrlList(downloadUrls.detail);
const allImageUrls = normalizeUrlList(downloadUrls.all);
const mergedAllImageUrls =
allImageUrls.length > 0
? allImageUrls
: Array.from(new Set([...mainImageUrls, ...skuImageUrls, ...detailImageUrls]));
const materialId = sanitizeText(raw.materialId, 128) || null;
const taskNo = sanitizeText(raw.taskNo, 128) || null;
return {
id: taskNo || materialId || `candidate-${index + 1}`,
materialId,
taskNo,
productModel: sanitizeText(raw.productModel, 128) || null,
categoryName: sanitizeText(raw.categoryName, 128) || null,
shopName: sanitizeText(raw.shopName, 128) || sanitizeText(raw.categoryName, 128) || null,
sceneName: sanitizeText(raw.sceneName, 128) || null,
uploadedAt: sanitizeText(raw.uploadedAt, 64) || null,
isFullSet: raw.isFullSet !== false,
mainImageUrls,
skuImageUrls,
detailImageUrls,
allImageUrls: mergedAllImageUrls,
};
})
.sort((left, right) => {
const leftTime = left.uploadedAt ? new Date(left.uploadedAt).getTime() : 0;
const rightTime = right.uploadedAt ? new Date(right.uploadedAt).getTime() : 0;
return rightTime - leftTime;
});
return {
resourceType: "task-detail" as const,
query: params.query,
queryType: params.queryType,
matchedSku: sanitizeText(params.data.sku, 64) || null,
matchedSpu: sanitizeText(params.data.spu, 64) || null,
categoryName: sanitizeText(params.data.categoryName, 128) || null,
shopName:
sanitizeText(params.data.shopName, 128) ||
sanitizeText(params.data.categoryName, 128) ||
null,
candidateCount: Number.isFinite(Number(params.data.candidateCount))
? Math.max(0, Math.round(Number(params.data.candidateCount)))
: candidates.length,
candidates,
};
}
export async function getThirdPartyImageMaterialPublicConfig() {
const setting = await getSystemSetting({
key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
});
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
return {
enabled: config.enabled,
endpoint: "/api/third-party-image-materials/search",
queryTypes: ["sku", "spu"] as const,
resourceTypes: [
{
value: "white-background" as const,
label: "标准模型白底图",
enabled:
config.enabled &&
isAgentWorkflowMaterialResourceEnabled(config, "white-background"),
},
{
value: "task-detail" as const,
label: "任务-详情整套预览",
enabled:
config.enabled &&
isAgentWorkflowMaterialResourceEnabled(config, "task-detail"),
},
],
requestTimeoutMs: config.requestTimeoutMs || 30_000,
};
}
export async function queryThirdPartyImageMaterials(params: {
resourceType: AgentWorkflowMaterialResourceType;
queryType: AgentWorkflowMaterialQueryType;
keyword: string;
}) {
const setting = await getSystemSetting({
key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
});
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
const keyword = sanitizeText(params.keyword, 64).toUpperCase();
if (!config.enabled) {
throw new ThirdPartyImageMaterialError(
"MATERIAL_API_DISABLED",
"第三方图片素材接口未开启,请先在系统设置中启用",
400
);
}
if (!isAgentWorkflowMaterialResourceEnabled(config, params.resourceType)) {
throw new ThirdPartyImageMaterialError(
"RESOURCE_TYPE_DISABLED",
params.resourceType === "task-detail"
? "任务-详情整套素材接口未开启,请先在系统设置中启用"
: "白底图素材接口未开启,请先在系统设置中启用",
400
);
}
if (!config.apiKey) {
throw new ThirdPartyImageMaterialError(
"MATERIAL_API_KEY_MISSING",
"第三方图片素材 API Key 未配置,请先在系统设置中保存",
500
);
}
if (!keyword) {
throw new ThirdPartyImageMaterialError("MISSING_PARAM", "sku 和 spu 二选一必填", 400);
}
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, {
resourceType: params.resourceType,
queryType: params.queryType,
keyword,
});
let response: Response;
try {
response = await fetch(upstreamUrl, {
method: "GET",
headers: {
Accept: "application/json",
"X-API-Key": config.apiKey,
},
cache: "no-store",
signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000),
});
} catch (error) {
const message = error instanceof Error ? error.message : "上游接口访问失败";
throw new ThirdPartyImageMaterialError(
"UPSTREAM_FETCH_FAILED",
message.includes("timeout") ? "第三方图片素材接口访问超时" : "第三方图片素材接口访问失败",
message.includes("timeout") ? 504 : 502
);
}
const diagnostics = readDiagnostics(response);
const upstreamResult = (await response.json().catch(() => ({}))) as UpstreamMaterialResponse;
if (!response.ok || upstreamResult.success !== true || !upstreamResult.data) {
const code = sanitizeText(upstreamResult.error?.code, 64) || "UPSTREAM_ERROR";
const message =
sanitizeText(upstreamResult.error?.message, 500) ||
`第三方图片素材接口返回异常(${response.status})`;
throw new ThirdPartyImageMaterialError(code, message, mapUpstreamErrorStatus(code));
}
const data =
params.resourceType === "task-detail"
? normalizeTaskDetailData({
query: keyword,
queryType: params.queryType,
data: upstreamResult.data,
})
: normalizeWhiteBackgroundData({
query: keyword,
queryType: params.queryType,
data: upstreamResult.data,
});
return {
success: true,
data,
diagnostics,
};
}

View File

@ -1,6 +1,8 @@
import type {
AgentWorkflowMaterialApiConfig,
AgentWorkflowMaterialImage,
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialResourceType,
AgentWorkflowMaterialSkuOption,
AgentWorkflowMaterialSearchResult,
AgentWorkflowMaterialSelectionState,
@ -8,11 +10,28 @@ import type {
} from "@/types/agent";
export const AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY = "agent_workflow_material_api_config";
export const AGENT_WORKFLOW_MATERIAL_DEFAULT_BASE_URL =
"https://sc.jiansun.vip/api/external";
export const AGENT_WORKFLOW_MATERIAL_DEFAULT_ENDPOINT_PATH =
"/standard-model-white-background";
export const AGENT_WORKFLOW_MATERIAL_DEFAULT_RESOURCE_BASE_URL =
"https://s3.jiansun.vip";
export const AGENT_WORKFLOW_MATERIAL_FALLBACK_RESOURCE_BASE_URL =
"https://s3.yydd.ai";
export const AGENT_WORKFLOW_MATERIAL_DEFAULT_TIMEOUT_MS = 30_000;
export const AGENT_WORKFLOW_MATERIAL_MIN_TIMEOUT_MS = 3_000;
export const AGENT_WORKFLOW_MATERIAL_MAX_TIMEOUT_MS = 120_000;
export const defaultAgentWorkflowMaterialApiConfig: AgentWorkflowMaterialApiConfig = {
enabled: false,
baseUrl: "https://sc.jiansun.vip/api/external",
baseUrl: AGENT_WORKFLOW_MATERIAL_DEFAULT_BASE_URL,
endpointPath: AGENT_WORKFLOW_MATERIAL_DEFAULT_ENDPOINT_PATH,
apiKey: "",
whiteBackgroundEnabled: true,
taskDetailEnabled: true,
resourceBaseUrl: AGENT_WORKFLOW_MATERIAL_DEFAULT_RESOURCE_BASE_URL,
fallbackResourceBaseUrl: AGENT_WORKFLOW_MATERIAL_FALLBACK_RESOURCE_BASE_URL,
requestTimeoutMs: AGENT_WORKFLOW_MATERIAL_DEFAULT_TIMEOUT_MS,
productDetailEnabled: false,
productDetailBaseUrl: "",
productDetailApiKey: "",
@ -27,6 +46,30 @@ function sanitizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
function normalizeBaseUrl(value: unknown, fallback: string) {
return (sanitizeText(value, 500) || fallback).replace(/\/+$/, "");
}
function normalizeEndpointPath(value: unknown) {
const normalized =
sanitizeText(value, 500) || AGENT_WORKFLOW_MATERIAL_DEFAULT_ENDPOINT_PATH;
if (/^https?:\/\//i.test(normalized)) {
return normalized;
}
return `/${normalized.replace(/^\/+/, "")}`;
}
function normalizeTimeoutMs(value: unknown) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return AGENT_WORKFLOW_MATERIAL_DEFAULT_TIMEOUT_MS;
}
return Math.max(
AGENT_WORKFLOW_MATERIAL_MIN_TIMEOUT_MS,
Math.min(AGENT_WORKFLOW_MATERIAL_MAX_TIMEOUT_MS, Math.round(parsed))
);
}
function normalizeMaterialImage(
value: unknown,
index: number
@ -213,9 +256,23 @@ export function normalizeAgentWorkflowMaterialApiConfig(
return {
enabled: raw.enabled === true,
baseUrl:
sanitizeText(raw.baseUrl, 255) || defaultAgentWorkflowMaterialApiConfig.baseUrl,
baseUrl: normalizeBaseUrl(
raw.baseUrl,
defaultAgentWorkflowMaterialApiConfig.baseUrl
),
endpointPath: normalizeEndpointPath(raw.endpointPath),
apiKey: sanitizeText(raw.apiKey, 500),
whiteBackgroundEnabled: raw.whiteBackgroundEnabled !== false,
taskDetailEnabled: raw.taskDetailEnabled !== false,
resourceBaseUrl: normalizeBaseUrl(
raw.resourceBaseUrl,
AGENT_WORKFLOW_MATERIAL_DEFAULT_RESOURCE_BASE_URL
),
fallbackResourceBaseUrl: normalizeBaseUrl(
raw.fallbackResourceBaseUrl,
AGENT_WORKFLOW_MATERIAL_FALLBACK_RESOURCE_BASE_URL
),
requestTimeoutMs: normalizeTimeoutMs(raw.requestTimeoutMs),
productDetailEnabled: raw.productDetailEnabled === true,
productDetailBaseUrl: sanitizeText(raw.productDetailBaseUrl, 500),
productDetailApiKey: sanitizeText(raw.productDetailApiKey, 500),
@ -232,6 +289,35 @@ export function normalizeAgentWorkflowMaterialApiConfig(
};
}
export function isAgentWorkflowMaterialResourceEnabled(
config: AgentWorkflowMaterialApiConfig,
resourceType: AgentWorkflowMaterialResourceType
) {
if (resourceType === "task-detail") {
return config.taskDetailEnabled !== false;
}
return config.whiteBackgroundEnabled !== false;
}
export function buildAgentWorkflowMaterialApiUrl(
config: AgentWorkflowMaterialApiConfig,
params: {
resourceType: AgentWorkflowMaterialResourceType;
queryType: AgentWorkflowMaterialQueryType;
keyword: string;
}
) {
const endpointPath = normalizeEndpointPath(config.endpointPath);
const baseUrl = normalizeBaseUrl(config.baseUrl, AGENT_WORKFLOW_MATERIAL_DEFAULT_BASE_URL);
const requestUrl = /^https?:\/\//i.test(endpointPath)
? endpointPath
: `${baseUrl}/${endpointPath.replace(/^\/+/, "")}`;
const url = new URL(requestUrl);
url.searchParams.set("resourceType", params.resourceType);
url.searchParams.set(params.queryType, params.keyword.trim().toUpperCase());
return url.toString();
}
export function normalizeAgentWorkflowMaterialApiConfigForPublic(
value: unknown
): AgentWorkflowMaterialApiConfig {

View File

@ -64,6 +64,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
label: "添加图片",
description: "在普通画布添加图片节点",
},
{
key: "addAgentImageSlot",
group: "画布主工具栏",
label: "图片槽位",
description: "在普通画布添加 Agent 图片输出槽位",
},
{
key: "addImageInput",
group: "画布主工具栏",

View File

@ -0,0 +1,148 @@
import type {
CanvasOperationPlan,
CanvasViewOperationPlan,
} from "@/types/canvas/agent-protocol";
export type PendingCanvasOperation = {
version: 1;
pendingId: string;
projectId: string;
operationId: string;
operation: string;
riskOperation: string;
targetNodeIds: string[];
operationPlan?: CanvasOperationPlan;
viewPlan?: CanvasViewOperationPlan;
prompt: string;
reply: string;
requestedAt: string;
requestedByMessageId: string;
};
function trimString(value: unknown, maxLength: number) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function normalizeStringArray(value: unknown, maxItems: number) {
if (!Array.isArray(value)) return [];
return value
.map((item) => trimString(item, 160))
.filter(Boolean)
.slice(0, maxItems);
}
function normalizeOperationPlan(value: unknown): CanvasOperationPlan | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as CanvasOperationPlan;
if (raw.version !== 1 || !raw.operationId || !raw.operation || !raw.projectId) {
return undefined;
}
return raw;
}
function normalizeViewPlan(value: unknown): CanvasViewOperationPlan | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as CanvasViewOperationPlan;
if (raw.version !== 1 || !raw.viewOperationId || !raw.operation) {
return undefined;
}
return raw;
}
export function extractPendingCanvasOperationFromWorkingState(
workingState?: { lastTurnSummary?: unknown } | null
): PendingCanvasOperation | null {
const summary = workingState?.lastTurnSummary;
if (!summary || typeof summary !== "object" || Array.isArray(summary)) return null;
const pending = (summary as Record<string, unknown>).pendingCanvasOperation;
if (!pending || typeof pending !== "object" || Array.isArray(pending)) return null;
const record = pending as Record<string, unknown>;
const projectId = trimString(record.projectId, 160);
const operationId = trimString(record.operationId, 180);
const operation = trimString(record.operation, 80);
const riskOperation = trimString(record.riskOperation, 80);
const requestedByMessageId = trimString(record.requestedByMessageId, 160);
const prompt = trimString(record.prompt, 1000);
const reply = trimString(record.reply, 500);
if (!projectId || !operationId || !operation || !riskOperation || !requestedByMessageId) {
return null;
}
return {
version: 1,
pendingId: trimString(record.pendingId, 180) || operationId,
projectId,
operationId,
operation,
riskOperation,
targetNodeIds: normalizeStringArray(record.targetNodeIds, 20),
operationPlan: normalizeOperationPlan(record.operationPlan),
viewPlan: normalizeViewPlan(record.viewPlan),
prompt,
reply,
requestedAt: trimString(record.requestedAt, 80) || new Date(0).toISOString(),
requestedByMessageId,
};
}
export function isCanvasOperationConfirmText(text: string) {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "");
if (!normalized) return false;
return /确认|同意|可以执行|执行吧|就这样|没问题|按这个|开始执行|确认执行/.test(
normalized
);
}
export function isCanvasOperationCancelText(text: string) {
const normalized = String(text || "").toLowerCase().replace(/\s+/g, "");
if (!normalized) return false;
return /取消|先不|不要|别执行|不执行|算了|停止/.test(normalized);
}
export function buildPendingCanvasOperation(params: {
projectId: string;
messageId: string;
userText: string;
riskOperation: string;
targetNodeIds: string[];
reply: string;
operationPlan?: CanvasOperationPlan;
viewPlan?: CanvasViewOperationPlan;
}): PendingCanvasOperation {
const operationId =
params.operationPlan?.operationId || `${params.messageId}:pending-canvas-operation`;
return {
version: 1,
pendingId: operationId,
projectId: params.projectId,
operationId,
operation: params.operationPlan?.operation || params.riskOperation,
riskOperation: params.riskOperation,
targetNodeIds: params.targetNodeIds.slice(0, 20),
operationPlan: params.operationPlan,
viewPlan: params.viewPlan,
prompt: params.userText.slice(0, 1000),
reply: params.reply.slice(0, 500),
requestedAt: new Date().toISOString(),
requestedByMessageId: params.messageId,
};
}
export function withConfirmedCanvasOperationPlan(
pending: PendingCanvasOperation,
confirmingMessageId: string
): CanvasOperationPlan | null {
if (!pending.operationPlan) return null;
return {
...pending.operationPlan,
confirmed: true,
metadata: {
...(pending.operationPlan.metadata || {}),
confirmedByUser: true,
confirmedByMessageId: confirmingMessageId,
confirmedAt: new Date().toISOString(),
pendingOperationId: pending.pendingId,
},
};
}

View File

@ -0,0 +1,546 @@
import { generateObject, type LanguageModel } from "ai";
import { z } from "zod";
import type {
CanvasAgentContext,
CanvasOperationPlan,
CanvasViewOperationPlan,
} from "@/types/canvas/agent-protocol";
const plannerDecisionSchema = z.object({
action: z.enum([
"none",
"focus_nodes",
"fit_all",
"zoom_in",
"zoom_out",
"link_nodes",
"update_text",
"confirmation_required",
]),
nodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
textContent: z.string().max(4000).optional(),
riskOperation: z
.enum([
"replace_node_media",
"delete_nodes",
"move_nodes",
"overwrite_slot",
"batch_move",
"other",
])
.optional(),
reply: z.string().max(280).optional(),
reason: z.string().max(280).optional(),
});
type PlannerDecision = z.infer<typeof plannerDecisionSchema>;
export type CanvasAgentModelPlannerOutcome =
| {
kind: "view";
viewPlan: CanvasViewOperationPlan;
reply: string;
reason?: string;
}
| {
kind: "operation";
operationPlan: CanvasOperationPlan;
viewPlan?: CanvasViewOperationPlan;
reply: string;
reason?: string;
}
| {
kind: "confirmation_required";
riskOperation: string;
targetNodeIds: string[];
operationPlan?: CanvasOperationPlan;
viewPlan?: CanvasViewOperationPlan;
reply: string;
reason?: string;
}
| {
kind: "none";
reason?: string;
};
function trimString(value: unknown, maxLength: number) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function uniqueStrings(values: Array<string | undefined | null>, maxItems = 20) {
return Array.from(
new Set(values.map((value) => trimString(value, 160)).filter(Boolean))
).slice(0, maxItems);
}
function buildKnownNodeMap(context: CanvasAgentContext) {
const nodeMap = new Map<string, { nodeId: string; type: string; isSelected?: boolean }>();
[...context.candidateNodes, ...context.selectedNodes].forEach((node) => {
nodeMap.set(node.nodeId, {
nodeId: node.nodeId,
type: node.type,
isSelected: node.isSelected,
});
});
context.slots.forEach((slot) => {
nodeMap.set(slot.nodeId, {
nodeId: slot.nodeId,
type: "agent-slot",
isSelected: slot.isSelected,
});
});
return nodeMap;
}
function getSelectedKnownNodeIds(context: CanvasAgentContext, maxItems = 20) {
const selectedFromRefs = [
...context.selectedNodes.map((node) => node.nodeId),
...context.slots
.filter((slot) => slot.isSelected || context.selectedNodeIds.includes(slot.nodeId))
.map((slot) => slot.nodeId),
];
return uniqueStrings([...context.selectedNodeIds, ...selectedFromRefs], maxItems);
}
function getDecisionNodeIds(params: {
decision: PlannerDecision;
context: CanvasAgentContext;
allowSelectedFallback: boolean;
maxItems?: number;
}) {
const knownNodes = buildKnownNodeMap(params.context);
const requestedIds = uniqueStrings(params.decision.nodeIds || [], params.maxItems || 20).filter(
(nodeId) => knownNodes.has(nodeId)
);
if (requestedIds.length > 0 || !params.allowSelectedFallback) {
return requestedIds;
}
return getSelectedKnownNodeIds(params.context, params.maxItems || 20).filter((nodeId) =>
knownNodes.has(nodeId)
);
}
function inferHighRiskOperation(userText: string) {
const text = userText.toLowerCase();
if (/删除|删掉|移除/.test(text)) return "delete_nodes";
if (/覆盖|替换当前|替换原图|直接替换|覆盖原图/.test(text)) {
return "replace_node_media";
}
if (/批量移动|整体移动|移动到|移到|整理|排列|排版/.test(text)) {
return "move_nodes";
}
return "";
}
function defaultConfirmationReply(riskOperation: string) {
if (riskOperation === "delete_nodes") {
return "这个动作会删除画布节点,需要先确认后才能执行。";
}
if (riskOperation === "replace_node_media") {
return "这个动作会覆盖或替换现有图片,需要先确认后才能执行。";
}
if (riskOperation === "move_nodes" || riskOperation === "batch_move") {
return "这个动作会移动或重排画布节点,需要先确认后才能执行。";
}
return "这个画布动作风险较高,需要先确认后才能执行。";
}
function inferMoveLayoutStrategy(userText: string): "row" | "column" | "grid" {
const text = userText.toLowerCase();
if (/网格|宫格|矩阵|两列|三列|多列|grid/.test(text)) return "grid";
if (/竖|纵|列|上下|从上到下|column/.test(text)) return "column";
return "row";
}
function buildViewPlan(params: {
action: PlannerDecision["action"];
context: CanvasAgentContext;
projectId: string;
operationId: string;
nodeIds?: string[];
reason?: string;
}): CanvasViewOperationPlan | null {
if (params.action === "fit_all") {
return {
version: 1,
viewOperationId: `${params.operationId}:view`,
operation: "fit_all",
projectId: params.projectId,
padding: 120,
persist: false,
reason: params.reason || "model_planned_fit_all",
};
}
if (params.action === "zoom_in" || params.action === "zoom_out") {
return {
version: 1,
viewOperationId: `${params.operationId}:view`,
operation: params.action,
projectId: params.projectId,
persist: false,
reason: params.reason || `model_planned_${params.action}`,
};
}
const nodeIds =
params.nodeIds && params.nodeIds.length > 0
? params.nodeIds
: getSelectedKnownNodeIds(params.context, 10);
if (nodeIds.length === 0) return null;
return {
version: 1,
viewOperationId: `${params.operationId}:view`,
operation: nodeIds.length > 1 ? "focus_nodes" : "focus_selection",
projectId: params.projectId,
nodeIds,
anchorNodeId: nodeIds[0],
padding: 120,
persist: false,
reason: params.reason || "model_planned_focus",
};
}
function buildContextSummary(context: CanvasAgentContext) {
return {
projectId: context.projectId,
canvasRevision: context.canvasRevision,
selectedNodeIds: context.selectedNodeIds,
viewport: context.viewport,
selectedNodes: context.selectedNodes.slice(0, 12).map((node) => ({
nodeId: node.nodeId,
type: node.type,
title: node.title,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
})),
candidateNodes: context.candidateNodes.slice(0, 12).map((node) => ({
nodeId: node.nodeId,
type: node.type,
title: node.title,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
})),
slots: context.slots.slice(0, 12).map((slot) => ({
nodeId: slot.nodeId,
slotType: slot.slotType,
label: slot.label,
isSelected: slot.isSelected,
fillPolicy: slot.fillPolicy,
})),
annotations: context.annotations.slice(0, 12).map((annotation) => ({
id: annotation.id,
sourceNodeId: annotation.sourceNodeId,
type: annotation.type,
text: annotation.text,
})),
};
}
export function shouldConsiderCanvasAgentModelPlanner(params: {
userText: string;
context?: CanvasAgentContext | null;
}) {
if (!params.context?.projectId) return false;
const text = params.userText.trim().toLowerCase();
if (!text) return false;
const hasViewIntent =
/视角|视野|画布|聚焦|定位|居中|看全局|看整体|看全部|缩小|放大|拉近|拉远|zoom/.test(
text
);
const hasLinkIntent = /连起来|连线|连接|关联|参考关系|版本关系|关系/.test(text);
const hasTextIntent =
/文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test(
text
);
const hasHighRiskIntent = Boolean(inferHighRiskOperation(text));
const hasMediaGenerationIntent =
/生成|出图|做一版|再出|换背景|背景|抠图|去水印|高清|扩图|重绘|视频|变体|图层|多角度|洗图/.test(
text
);
if (
hasMediaGenerationIntent &&
!hasViewIntent &&
!hasLinkIntent &&
!hasTextIntent &&
!hasHighRiskIntent
) {
return false;
}
return hasViewIntent || hasLinkIntent || hasTextIntent || hasHighRiskIntent;
}
export function normalizeCanvasAgentModelPlannerDecision(params: {
decision: unknown;
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
}): CanvasAgentModelPlannerOutcome {
const parsed = plannerDecisionSchema.safeParse(params.decision);
if (!parsed.success) {
return { kind: "none", reason: "invalid_model_planner_decision" };
}
const decision = parsed.data;
const reason = trimString(decision.reason, 280) || undefined;
const operationId = `${params.messageId}:canvas-model-${decision.action}`;
const explicitRisk = trimString(decision.riskOperation, 80);
const inferredRisk = inferHighRiskOperation(params.userText);
const riskOperation =
decision.action === "confirmation_required"
? explicitRisk || inferredRisk || "other"
: inferredRisk;
if (riskOperation) {
const targetNodeIds = getDecisionNodeIds({
decision,
context: params.context,
allowSelectedFallback: true,
maxItems: 20,
});
const canBuildMovePlan =
(riskOperation === "move_nodes" || riskOperation === "batch_move") &&
targetNodeIds.length > 0;
const operationPlan: CanvasOperationPlan | undefined = canBuildMovePlan
? {
version: 1,
operationId: operationId.replace(`canvas-model-${decision.action}`, "canvas-move"),
operation: "move_nodes",
projectId: params.projectId,
baseCanvasRevision: params.context.canvasRevision,
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
targetNodeIds,
layoutStrategy: inferMoveLayoutStrategy(params.userText),
spacing: 40,
preserveOriginal: true,
metadata: {
messageId: params.messageId,
chatId: params.chatId,
planner: "model",
prompt: params.userText,
pendingConfirmation: true,
riskOperation,
},
}
: undefined;
return {
kind: "confirmation_required",
riskOperation,
targetNodeIds,
operationPlan,
viewPlan: operationPlan
? buildViewPlan({
action: "focus_nodes",
context: params.context,
projectId: params.projectId,
operationId: operationPlan.operationId,
nodeIds: targetNodeIds,
reason: "focus_pending_move_nodes",
}) || undefined
: undefined,
reply: trimString(decision.reply, 280) || defaultConfirmationReply(riskOperation),
reason,
};
}
if (decision.action === "none") {
return { kind: "none", reason };
}
if (
decision.action === "focus_nodes" ||
decision.action === "fit_all" ||
decision.action === "zoom_in" ||
decision.action === "zoom_out"
) {
const nodeIds = getDecisionNodeIds({
decision,
context: params.context,
allowSelectedFallback: decision.action === "focus_nodes",
maxItems: 10,
});
const viewPlan = buildViewPlan({
action: decision.action,
context: params.context,
projectId: params.projectId,
operationId,
nodeIds,
reason,
});
if (!viewPlan) return { kind: "none", reason: "missing_view_target" };
return {
kind: "view",
viewPlan,
reply:
trimString(decision.reply, 280) ||
(decision.action === "fit_all"
? "已切到画布全局视图。"
: decision.action === "zoom_in"
? "已放大画布视角。"
: decision.action === "zoom_out"
? "已缩小画布视角。"
: "已聚焦到当前画布对象。"),
reason,
};
}
if (decision.action === "link_nodes") {
const nodeIds = getDecisionNodeIds({
decision,
context: params.context,
allowSelectedFallback: true,
maxItems: 20,
});
if (nodeIds.length < 2) {
return { kind: "none", reason: "link_nodes_requires_two_targets" };
}
const operationPlan: CanvasOperationPlan = {
version: 1,
operationId,
operation: "link_nodes",
projectId: params.projectId,
baseCanvasRevision: params.context.canvasRevision,
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
sourceNodeIds: nodeIds,
edgeType: "reference",
preserveOriginal: true,
metadata: {
messageId: params.messageId,
chatId: params.chatId,
planner: "model",
prompt: params.userText,
},
};
return {
kind: "operation",
operationPlan,
viewPlan: buildViewPlan({
action: "focus_nodes",
context: params.context,
projectId: params.projectId,
operationId,
nodeIds,
reason: "focus_model_linked_nodes",
}) || undefined,
reply: trimString(decision.reply, 280) || "已为选中的画布节点建立参考连线。",
reason,
};
}
if (decision.action === "update_text") {
const knownNodes = buildKnownNodeMap(params.context);
const requestedIds = getDecisionNodeIds({
decision,
context: params.context,
allowSelectedFallback: true,
maxItems: 10,
});
const targetNodeIds = requestedIds.filter(
(nodeId) => knownNodes.get(nodeId)?.type === "text"
);
const textContent = trimString(decision.textContent, 4000);
if (targetNodeIds.length === 0 || !textContent) {
return { kind: "none", reason: "update_text_missing_target_or_content" };
}
const operationPlan: CanvasOperationPlan = {
version: 1,
operationId,
operation: "update_text",
projectId: params.projectId,
baseCanvasRevision: params.context.canvasRevision,
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
targetNodeIds,
textContent,
preserveOriginal: false,
metadata: {
messageId: params.messageId,
chatId: params.chatId,
planner: "model",
prompt: params.userText,
},
};
return {
kind: "operation",
operationPlan,
viewPlan: buildViewPlan({
action: "focus_nodes",
context: params.context,
projectId: params.projectId,
operationId,
nodeIds: targetNodeIds,
reason: "focus_model_updated_text_nodes",
}) || undefined,
reply: trimString(decision.reply, 280) || "已更新选中的文本节点。",
reason,
};
}
return { kind: "none", reason: "unsupported_model_planner_action" };
}
export async function planCanvasAgentOperationWithModel(params: {
model: LanguageModel;
userText: string;
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
abortSignal?: AbortSignal;
}): Promise<CanvasAgentModelPlannerOutcome> {
if (
!shouldConsiderCanvasAgentModelPlanner({
userText: params.userText,
context: params.context,
})
) {
return { kind: "none", reason: "planner_not_relevant" };
}
const { object } = await generateObject({
model: params.model,
schema: plannerDecisionSchema,
temperature: 0,
abortSignal: params.abortSignal || AbortSignal.timeout(18_000),
system: [
"你是无限画布 Agent 的受控操作规划器。",
"你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点。",
"图片/视频生成、换背景、抠图、高清、扩图、重绘等媒体任务必须返回 action=none,交给其他执行器。",
"删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。",
"只能使用上下文中出现的 nodeId。不要编造节点 ID。",
"如果用户只是聊天或目标不明确,返回 action=none。",
].join("\n"),
prompt: JSON.stringify(
{
userText: params.userText,
context: buildContextSummary(params.context),
outputContract: {
action:
"none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | update_text | confirmation_required",
nodeIds: "只填上下文存在的 nodeId",
textContent: "仅 update_text 时填写最终文本",
riskOperation:
"confirmation_required 时填写 replace_node_media/delete_nodes/move_nodes/overwrite_slot/batch_move/other",
reply: "一句给用户看的中文短回复",
},
},
null,
2
),
});
return normalizeCanvasAgentModelPlannerDecision({
decision: object,
context: params.context,
projectId: params.projectId,
messageId: params.messageId,
chatId: params.chatId,
userText: params.userText,
});
}

View File

@ -0,0 +1,662 @@
import "server-only";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { syncCanvasNodesForProject } from "@/lib/db/queries";
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
import type {
CanvasMediaItem,
CanvasOperationPlan,
} from "@/types/canvas/agent-protocol";
type CanvasDataLike = {
nodes?: any[];
edges?: any[];
selectedNodeIds?: string[];
viewport?: { x: number; y: number; zoom: number };
[key: string]: unknown;
};
export type CanvasOperationResult = {
createdNodeIds: string[];
createdNodeIdsByMediaItemId: Record<string, string>;
updatedNodeIds: string[];
createdEdgeIds: string[];
canvasData: CanvasDataLike;
canvasRevision: string;
canvasUpdatedAt: string;
baseCanvasWasStale: boolean;
};
export class CanvasAgentMutationError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "CanvasAgentMutationError";
this.status = status;
}
}
function generateId() {
return crypto.randomUUID();
}
function normalizeCanvasData(value: unknown): CanvasDataLike {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
};
}
const raw = value as CanvasDataLike;
return {
...raw,
nodes: Array.isArray(raw.nodes) ? [...raw.nodes] : [],
edges: Array.isArray(raw.edges) ? [...raw.edges] : [],
selectedNodeIds: Array.isArray(raw.selectedNodeIds)
? raw.selectedNodeIds.filter((id): id is string => typeof id === "string")
: [],
viewport: raw.viewport || { x: 0, y: 0, zoom: 1 },
};
}
function normalizeMediaItems(plan: CanvasOperationPlan): CanvasMediaItem[] {
if (Array.isArray(plan.mediaItems) && plan.mediaItems.length > 0) {
return plan.mediaItems
.map((item, index) => ({
...item,
mediaItemId:
typeof item.mediaItemId === "string" && item.mediaItemId.trim()
? item.mediaItemId.trim()
: `${plan.operationId}:media-${index + 1}`,
url: typeof item.url === "string" ? item.url.trim() : "",
}))
.filter((item) => item.url);
}
if (plan.media?.url) {
return [
{
...plan.media,
mediaItemId: `${plan.operationId}:media-1`,
url: plan.media.url.trim(),
},
];
}
return [];
}
function findNode(nodes: any[], nodeId?: string) {
if (!nodeId) return null;
return nodes.find((node) => node?.id === nodeId) || null;
}
function mediaNodeType(item: CanvasMediaItem) {
return item.type === "video" ? "video" : "image";
}
function buildMediaNode(params: {
item: CanvasMediaItem;
plan: CanvasOperationPlan;
position: { x: number; y: number };
size: { width: number; height: number };
sourceNodeIds: string[];
}) {
const now = new Date().toISOString();
const type = mediaNodeType(params.item);
const metadata = {
...(params.item.metadata || {}),
generatedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
canvasAgentMediaItemId: params.item.mediaItemId,
canvasAgentPlacement: params.plan.placement,
canvasAgentPlacementStrategy: params.plan.placementStrategy,
sourceNodeIds: params.sourceNodeIds,
revisionOf:
params.plan.operation === "append_revision"
? params.sourceNodeIds[0] || null
: undefined,
};
return {
id: generateId(),
type,
position: params.position,
size: params.size,
data:
type === "video"
? {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 视频结果",
metadata,
}
: {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 图片结果",
metadata,
},
connections: params.sourceNodeIds,
createdAt: now,
updatedAt: now,
};
}
function buildEdge(source: string, target: string, type: "default" | "reference") {
return {
id: generateId(),
source,
target,
type,
};
}
function rebuildConnections(nodes: any[], edges: any[]) {
const connectionMap = new Map<string, Set<string>>();
nodes.forEach((node) => connectionMap.set(node.id, new Set()));
edges.forEach((edge) => {
connectionMap.get(edge.source)?.add(edge.target);
connectionMap.get(edge.target)?.add(edge.source);
});
nodes.forEach((node) => {
node.connections = Array.from(connectionMap.get(node.id) || []);
});
}
function findExistingOperationNodes(nodes: any[], operationId: string) {
return nodes.filter(
(node) => node?.data?.metadata?.canvasAgentOperationId === operationId
);
}
function getPlanBaseTime(plan: CanvasOperationPlan) {
const raw = String(plan.baseCanvasUpdatedAt || plan.baseCanvasRevision || "").trim();
if (!raw) return null;
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function isPlanBaseStale(plan: CanvasOperationPlan, projectUpdatedAt: Date) {
const baseTime = getPlanBaseTime(plan);
if (!baseTime) return false;
return baseTime.getTime() + 1000 < projectUpdatedAt.getTime();
}
function normalizeSlotFillPolicy(value: unknown) {
return value === "append-version" || value === "replace-existing"
? value
: "replace-empty";
}
function normalizeEdgeType(value: unknown): "default" | "reference" | "conversation" {
return value === "default" || value === "conversation" ? value : "reference";
}
function edgeMatches(edge: any, source: string, target: string) {
return (
(edge?.source === source && edge?.target === target) ||
(edge?.source === target && edge?.target === source)
);
}
function getOperationTargetNodeIds(plan: CanvasOperationPlan) {
return Array.from(
new Set(
[
plan.anchorNodeId || "",
...(plan.targetNodeIds || []),
...(plan.sourceNodeIds || []),
].filter(Boolean)
)
);
}
function getNodeSize(node: any) {
return {
width: Math.max(1, Number(node?.size?.width || 220) || 220),
height: Math.max(1, Number(node?.size?.height || 160) || 160),
};
}
function getNodePosition(node: any) {
return {
x: Number(node?.position?.x || 0) || 0,
y: Number(node?.position?.y || 0) || 0,
};
}
function isConfirmedHighRiskPlan(plan: CanvasOperationPlan) {
const metadata = (plan.metadata || {}) as Record<string, unknown>;
return plan.confirmed === true || metadata.confirmedByUser === true;
}
function normalizeLayoutStrategy(value: unknown): "row" | "column" | "grid" {
return value === "column" || value === "grid" ? value : "row";
}
function resolveMoveStartPosition(nodes: any[], plan: CanvasOperationPlan) {
const target = plan.targetPosition;
if (
target &&
Number.isFinite(Number(target.x)) &&
Number.isFinite(Number(target.y))
) {
return {
x: Number(target.x),
y: Number(target.y),
};
}
const positions = nodes.map(getNodePosition);
return {
x: Math.min(...positions.map((position) => position.x)),
y: Math.min(...positions.map((position) => position.y)),
};
}
function applyMoveNodesOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
if (!isConfirmedHighRiskPlan(params.plan)) {
throw new CanvasAgentMutationError("移动画布节点需要用户确认", 409);
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要移动的节点");
}
const targetNodes = targetNodeIds.map((nodeId) => findNode(params.nodes, nodeId));
const missingIndex = targetNodes.findIndex((node) => !node);
if (missingIndex >= 0) {
throw new CanvasAgentMutationError(`移动节点不存在:${targetNodeIds[missingIndex]}`);
}
const orderedNodes = (targetNodes as any[]).sort((a, b) => {
const posA = getNodePosition(a);
const posB = getNodePosition(b);
return posA.y === posB.y ? posA.x - posB.x : posA.y - posB.y;
});
const spacing = Math.max(8, Math.min(240, Number(params.plan.spacing || 40) || 40));
const layoutStrategy = normalizeLayoutStrategy(params.plan.layoutStrategy);
const start = resolveMoveStartPosition(orderedNodes, params.plan);
const sizes = orderedNodes.map(getNodeSize);
const maxWidth = Math.max(...sizes.map((size) => size.width));
const maxHeight = Math.max(...sizes.map((size) => size.height));
const now = new Date().toISOString();
orderedNodes.forEach((node, index) => {
let position = { x: start.x, y: start.y };
if (layoutStrategy === "column") {
const previousHeights = sizes
.slice(0, index)
.reduce((sum, size) => sum + size.height + spacing, 0);
position = { x: start.x, y: start.y + previousHeights };
} else if (layoutStrategy === "grid") {
const cols = Math.max(1, Math.ceil(Math.sqrt(orderedNodes.length)));
const col = index % cols;
const row = Math.floor(index / cols);
position = {
x: start.x + col * (maxWidth + spacing),
y: start.y + row * (maxHeight + spacing),
};
} else {
const previousWidths = sizes
.slice(0, index)
.reduce((sum, size) => sum + size.width + spacing, 0);
position = { x: start.x + previousWidths, y: start.y };
}
const previousPosition = getNodePosition(node);
const data = (node.data || {}) as Record<string, any>;
node.position = position;
node.data = {
...data,
metadata: {
...(data.metadata || {}),
movedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
canvasAgentLayoutStrategy: layoutStrategy,
previousPosition,
agentMovedAt: now,
},
};
node.updatedAt = now;
});
return {
canvasData: {
...params.canvasData,
nodes: params.nodes,
edges: params.edges,
selectedNodeIds: orderedNodes.map((node) => node.id),
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: orderedNodes.map((node) => node.id),
createdEdgeIds: [],
};
}
function applyOperationToCanvasData(params: {
canvasData: CanvasDataLike;
plan: CanvasOperationPlan;
baseCanvasWasStale?: boolean;
}) {
if (!params.plan.operationId) {
throw new CanvasAgentMutationError("缺少 operationId");
}
if (
params.plan.operation !== "insert_beside" &&
params.plan.operation !== "append_revision" &&
params.plan.operation !== "fill_slot" &&
params.plan.operation !== "link_nodes" &&
params.plan.operation !== "move_nodes" &&
params.plan.operation !== "update_text"
) {
throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作");
}
const nodes = [...(params.canvasData.nodes || [])];
const edges = [...(params.canvasData.edges || [])];
if (params.plan.operation === "move_nodes") {
return applyMoveNodesOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "update_text") {
const textContent = String(params.plan.textContent || "").trim();
if (!textContent) {
throw new CanvasAgentMutationError("缺少文本内容");
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要更新的文本节点");
}
const now = new Date().toISOString();
const updatedNodeIds: string[] = [];
targetNodeIds.forEach((nodeId) => {
const node = findNode(nodes, nodeId);
if (!node) {
throw new CanvasAgentMutationError(`文本节点不存在:${nodeId}`);
}
if (node.type !== "text") {
throw new CanvasAgentMutationError("update_text 只能修改文本节点");
}
const data = (node.data || {}) as Record<string, any>;
node.data = {
...data,
content: textContent,
metadata: {
...(data.metadata || {}),
updatedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
previousContent:
typeof data.content === "string" ? String(data.content).slice(0, 2000) : undefined,
agentUpdatedAt: now,
},
};
node.updatedAt = now;
updatedNodeIds.push(node.id);
});
return {
canvasData: {
...params.canvasData,
nodes,
edges,
selectedNodeIds: updatedNodeIds,
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds,
createdEdgeIds: [],
};
}
if (params.plan.operation === "link_nodes") {
const nodeIds = getOperationTargetNodeIds(params.plan);
if (nodeIds.length < 2) {
throw new CanvasAgentMutationError("连线操作至少需要两个节点");
}
const missingNodeId = nodeIds.find((nodeId) => !findNode(nodes, nodeId));
if (missingNodeId) {
throw new CanvasAgentMutationError(`连线节点不存在:${missingNodeId}`);
}
const edgeType = normalizeEdgeType(params.plan.edgeType);
const [sourceNodeId, ...targetNodeIds] = nodeIds;
const createdEdgeIds: string[] = [];
targetNodeIds.forEach((targetNodeId) => {
const exists = edges.some((edge) => edgeMatches(edge, sourceNodeId, targetNodeId));
if (exists) return;
const edge = {
id: generateId(),
source: sourceNodeId,
target: targetNodeId,
type: edgeType,
};
edges.push(edge);
createdEdgeIds.push(edge.id);
});
rebuildConnections(nodes, edges);
return {
canvasData: {
...params.canvasData,
nodes,
edges,
selectedNodeIds: nodeIds,
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: nodeIds,
createdEdgeIds,
};
}
const existingOperationNodes = findExistingOperationNodes(
nodes,
params.plan.operationId
);
if (existingOperationNodes.length > 0) {
const byMediaItemId: Record<string, string> = {};
existingOperationNodes.forEach((node) => {
const mediaItemId = String(
node?.data?.metadata?.canvasAgentMediaItemId || ""
).trim();
if (mediaItemId) byMediaItemId[mediaItemId] = node.id;
});
return {
canvasData: params.canvasData,
createdNodeIds: existingOperationNodes.map((node) => node.id),
createdNodeIdsByMediaItemId: byMediaItemId,
updatedNodeIds: [],
createdEdgeIds: [],
};
}
const mediaItems = normalizeMediaItems(params.plan);
if (mediaItems.length === 0) {
throw new CanvasAgentMutationError("缺少可写回的媒体结果");
}
const targetSlot =
params.plan.operation === "fill_slot"
? findNode(nodes, params.plan.targetSlotId)
: null;
if (params.plan.operation === "fill_slot" && !targetSlot) {
throw new CanvasAgentMutationError("目标 slot 不存在");
}
if (targetSlot && params.plan.operation === "fill_slot") {
const targetSlotData = (targetSlot.data || {}) as Record<string, unknown>;
const fillPolicy = normalizeSlotFillPolicy(targetSlotData.fillPolicy);
const filledNodeIds = Array.isArray(targetSlotData.filledNodeIds)
? targetSlotData.filledNodeIds.filter((id) => typeof id === "string")
: [];
if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) {
throw new CanvasAgentMutationError(
"当前 slot 已有结果,替换已有结果需要用户确认",
409
);
}
if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) {
throw new CanvasAgentMutationError("当前 slot 已被填充,请新建 slot 或使用追加版本", 409);
}
if (params.baseCanvasWasStale && fillPolicy === "replace-existing") {
throw new CanvasAgentMutationError(
"画布已更新,替换 slot 前需要刷新并确认",
409
);
}
}
const createdNodeIds: string[] = [];
const createdNodeIdsByMediaItemId: Record<string, string> = {};
const createdEdgeIds: string[] = [];
const updatedNodeIds: string[] = [];
const placements = resolveCanvasAgentMediaPlacements({
plan: params.plan,
mediaItems,
nodes,
targetSlot,
viewport: params.canvasData.viewport,
});
placements.forEach((placement) => {
const node = buildMediaNode({
item: placement.item,
plan: params.plan,
position: placement.position,
size: placement.size,
sourceNodeIds: placement.sourceNodeIds,
});
nodes.push(node);
createdNodeIds.push(node.id);
createdNodeIdsByMediaItemId[placement.item.mediaItemId] = node.id;
const edgeSource = placement.edgeSourceId;
if (edgeSource && edgeSource !== node.id) {
const edge = buildEdge(
edgeSource,
node.id,
params.plan.operation === "append_revision" ? "reference" : "default"
);
edges.push(edge);
createdEdgeIds.push(edge.id);
}
if (targetSlot && params.plan.operation === "fill_slot") {
const data = { ...(targetSlot.data || {}) };
const filledNodeIds = Array.isArray(data.filledNodeIds)
? data.filledNodeIds
: [];
data.status = "completed";
data.filledNodeIds = Array.from(new Set([...filledNodeIds, node.id]));
targetSlot.data = data;
if (!updatedNodeIds.includes(targetSlot.id)) {
updatedNodeIds.push(targetSlot.id);
}
}
});
rebuildConnections(nodes, edges);
return {
canvasData: {
...params.canvasData,
nodes,
edges,
selectedNodeIds: createdNodeIds,
},
createdNodeIds,
createdNodeIdsByMediaItemId,
updatedNodeIds,
createdEdgeIds,
};
}
export async function applyCanvasOperationPlan(params: {
userId: string;
projectId: string;
plan: CanvasOperationPlan;
}): Promise<CanvasOperationResult> {
if (params.plan.projectId && params.plan.projectId !== params.projectId) {
throw new CanvasAgentMutationError("operation projectId 与请求项目不一致");
}
const [project] = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, params.projectId),
eq(canvasProject.userId, params.userId)
)
);
if (!project) {
throw new CanvasAgentMutationError("画布不存在", 404);
}
const currentCanvasData = normalizeCanvasData(project.canvasData);
const baseCanvasWasStale = isPlanBaseStale(params.plan, project.updatedAt);
const applied = applyOperationToCanvasData({
canvasData: currentCanvasData,
plan: {
...params.plan,
projectId: params.projectId,
},
baseCanvasWasStale,
});
const syncedCanvasData = await syncCanvasNodesForProject({
projectId: params.projectId,
userId: params.userId,
canvasData: applied.canvasData,
});
const updatedAt = new Date();
const [updatedProject] = await db
.update(canvasProject)
.set({
canvasData: syncedCanvasData,
updatedAt,
})
.where(
and(
eq(canvasProject.id, params.projectId),
eq(canvasProject.userId, params.userId)
)
)
.returning();
return {
...applied,
canvasData: (updatedProject?.canvasData || syncedCanvasData) as CanvasDataLike,
canvasRevision: updatedAt.toISOString(),
canvasUpdatedAt: updatedAt.toISOString(),
baseCanvasWasStale,
};
}

View File

@ -0,0 +1,307 @@
import type {
CanvasMediaItem,
CanvasOperationPlan,
} from "@/types/canvas/agent-protocol";
export type CanvasAgentPlacementNode = {
id: string;
position?: { x?: number; y?: number };
size?: { width?: number; height?: number };
};
export type ResolvedCanvasAgentMediaPlacement = {
item: CanvasMediaItem;
position: { x: number; y: number };
size: { width: number; height: number };
sourceNodeIds: string[];
edgeSourceId?: string;
};
function uniqueStrings(values: Array<string | undefined | null>) {
return Array.from(
new Set(values.map((value) => String(value || "").trim()).filter(Boolean))
);
}
function findNode(nodes: CanvasAgentPlacementNode[], nodeId?: string | null) {
if (!nodeId) return null;
return nodes.find((node) => node.id === nodeId) || null;
}
function getNodeBounds(node?: CanvasAgentPlacementNode | null) {
if (!node) return null;
const width = Math.max(1, Number(node.size?.width || 320) || 320);
const height = Math.max(1, Number(node.size?.height || 320) || 320);
return {
x: Number(node.position?.x || 0) || 0,
y: Number(node.position?.y || 0) || 0,
width,
height,
};
}
function getViewportCenter(viewport?: { x: number; y: number; zoom: number }) {
const zoom = Math.max(0.1, Number(viewport?.zoom || 1) || 1);
return {
x: -Number(viewport?.x || 0) / zoom,
y: -Number(viewport?.y || 0) / zoom,
};
}
function getViewportCenteredPosition(params: {
index: number;
itemCount: number;
groupIndex: number;
size: { width: number; height: number };
strategy: CanvasOperationPlan["placementStrategy"];
viewport?: { x: number; y: number; zoom: number };
}) {
const stepGap = 24;
const center = getViewportCenter(params.viewport);
const placementIndex =
params.strategy === "beside_each_source" ? params.groupIndex : params.index;
if (params.strategy === "grid") {
const cols = Math.max(1, Math.ceil(Math.sqrt(params.itemCount)));
const rows = Math.max(1, Math.ceil(params.itemCount / cols));
const col = placementIndex % cols;
const row = Math.floor(placementIndex / cols);
const totalWidth = cols * params.size.width + (cols - 1) * stepGap;
const totalHeight = rows * params.size.height + (rows - 1) * stepGap;
return {
x: center.x - totalWidth / 2 + col * (params.size.width + stepGap),
y: center.y - totalHeight / 2 + row * (params.size.height + stepGap),
};
}
const totalWidth =
params.itemCount * params.size.width + (params.itemCount - 1) * stepGap;
return {
x:
center.x -
totalWidth / 2 +
placementIndex * (params.size.width + stepGap),
y: center.y - params.size.height / 2,
};
}
function getItemSourceNodeIds(item: CanvasMediaItem, plan: CanvasOperationPlan) {
const itemSourceNodeIds = Array.isArray(item.sourceNodeIds)
? item.sourceNodeIds
: [];
const primarySourceIds =
itemSourceNodeIds.length > 0 ? itemSourceNodeIds : plan.sourceNodeIds || [];
return uniqueStrings([
...primarySourceIds,
plan.operation === "fill_slot" ? plan.targetSlotId : plan.anchorNodeId,
]);
}
function getAnchorNode(params: {
plan: CanvasOperationPlan;
item: CanvasMediaItem;
nodes: CanvasAgentPlacementNode[];
targetSlot?: CanvasAgentPlacementNode | null;
sourceNodeIds: string[];
}) {
if (params.targetSlot && params.plan.operation === "fill_slot") {
return params.targetSlot;
}
if (params.plan.placementStrategy === "beside_each_source") {
const itemSourceNodeId = Array.isArray(params.item.sourceNodeIds)
? params.item.sourceNodeIds.find(Boolean)
: null;
const itemSourceNode = findNode(params.nodes, itemSourceNodeId);
if (itemSourceNode) return itemSourceNode;
}
return (
findNode(params.nodes, params.plan.anchorNodeId) ||
params.sourceNodeIds
.map((nodeId) => findNode(params.nodes, nodeId))
.find(Boolean) ||
null
);
}
function resolveSize(params: {
item: CanvasMediaItem;
anchorNode?: CanvasAgentPlacementNode | null;
targetSlot?: CanvasAgentPlacementNode | null;
isSlotFill: boolean;
}) {
const slotBounds = getNodeBounds(params.targetSlot);
if (params.isSlotFill && slotBounds) {
return {
width: slotBounds.width,
height: slotBounds.height,
};
}
const anchorBounds = getNodeBounds(params.anchorNode);
return {
width: Math.max(1, Number(params.item.width || anchorBounds?.width || 320)),
height: Math.max(1, Number(params.item.height || anchorBounds?.height || 320)),
};
}
function resolvePosition(params: {
plan: CanvasOperationPlan;
index: number;
itemCount: number;
groupIndex: number;
size: { width: number; height: number };
anchorNode?: CanvasAgentPlacementNode | null;
targetSlot?: CanvasAgentPlacementNode | null;
viewport?: { x: number; y: number; zoom: number };
}) {
const gap = 40;
const stepGap = 24;
const targetSlotBounds = getNodeBounds(params.targetSlot);
if (params.plan.operation === "fill_slot" && targetSlotBounds) {
return { x: targetSlotBounds.x, y: targetSlotBounds.y };
}
const anchorBounds = getNodeBounds(params.anchorNode);
const placement = params.plan.placement || "right";
const strategy = params.plan.placementStrategy || "single";
const placementIndex =
strategy === "beside_each_source" ? params.groupIndex : params.index;
if (strategy === "overlay" && anchorBounds) {
return { x: anchorBounds.x, y: anchorBounds.y };
}
if (placement === "viewport-center") {
return getViewportCenteredPosition({
index: params.index,
itemCount: params.itemCount,
groupIndex: params.groupIndex,
size: params.size,
strategy,
viewport: params.viewport,
});
}
if (strategy === "grid") {
const cols = Math.max(1, Math.ceil(Math.sqrt(params.itemCount)));
const col = placementIndex % cols;
const row = Math.floor(placementIndex / cols);
if (anchorBounds) {
if (placement === "left") {
return {
x: anchorBounds.x - gap - (col + 1) * params.size.width - col * stepGap,
y: anchorBounds.y + row * (params.size.height + stepGap),
};
}
if (placement === "above") {
return {
x: anchorBounds.x + col * (params.size.width + stepGap),
y: anchorBounds.y - gap - (row + 1) * params.size.height - row * stepGap,
};
}
if (placement === "below") {
return {
x: anchorBounds.x + col * (params.size.width + stepGap),
y: anchorBounds.y + anchorBounds.height + gap + row * (params.size.height + stepGap),
};
}
return {
x: anchorBounds.x + anchorBounds.width + gap + col * (params.size.width + stepGap),
y: anchorBounds.y + row * (params.size.height + stepGap),
};
}
const center = getViewportCenter(params.viewport);
return {
x: center.x + col * (params.size.width + stepGap),
y: center.y + row * (params.size.height + stepGap),
};
}
if (anchorBounds) {
if (placement === "left") {
return {
x: anchorBounds.x - gap - params.size.width - placementIndex * (params.size.width + stepGap),
y: anchorBounds.y,
};
}
if (placement === "above") {
return {
x: anchorBounds.x + placementIndex * (params.size.width + stepGap),
y: anchorBounds.y - gap - params.size.height,
};
}
if (placement === "below") {
return {
x: anchorBounds.x + placementIndex * (params.size.width + stepGap),
y: anchorBounds.y + anchorBounds.height + gap,
};
}
return {
x: anchorBounds.x + anchorBounds.width + gap + placementIndex * (params.size.width + stepGap),
y: anchorBounds.y,
};
}
const center = getViewportCenter(params.viewport);
return {
x: center.x + placementIndex * (params.size.width + stepGap),
y: center.y,
};
}
export function resolveCanvasAgentMediaPlacements(params: {
plan: CanvasOperationPlan;
mediaItems: CanvasMediaItem[];
nodes: CanvasAgentPlacementNode[];
targetSlot?: CanvasAgentPlacementNode | null;
viewport?: { x: number; y: number; zoom: number };
}): ResolvedCanvasAgentMediaPlacement[] {
const groupCounts = new Map<string, number>();
return params.mediaItems.map((item, index) => {
const sourceNodeIds = getItemSourceNodeIds(item, params.plan);
const anchorNode = getAnchorNode({
plan: params.plan,
item,
nodes: params.nodes,
targetSlot: params.targetSlot,
sourceNodeIds,
});
const anchorKey = anchorNode?.id || "__canvas";
const groupIndex = groupCounts.get(anchorKey) || 0;
groupCounts.set(anchorKey, groupIndex + 1);
const isSlotFill = Boolean(params.targetSlot && params.plan.operation === "fill_slot");
const size = resolveSize({
item,
anchorNode,
targetSlot: params.targetSlot,
isSlotFill,
});
const position = resolvePosition({
plan: params.plan,
index,
itemCount: params.mediaItems.length,
groupIndex,
size,
anchorNode,
targetSlot: params.targetSlot,
viewport: params.viewport,
});
return {
item,
position,
size,
sourceNodeIds,
edgeSourceId:
params.targetSlot?.id ||
anchorNode?.id ||
sourceNodeIds[0] ||
params.plan.anchorNodeId,
};
});
}

View File

@ -32,6 +32,7 @@ interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
status?: number;
pagination?: {
page: number;
limit: number;
@ -194,11 +195,17 @@ export const canvasService = {
async saveCanvasState(
id: string,
canvasData: CanvasState,
workspaceType?: CanvasWorkspaceType
workspaceType?: CanvasWorkspaceType,
options?: {
baseCanvasRevision?: string | null;
baseCanvasUpdatedAt?: string | null;
}
): Promise<ApiResponse> {
try {
const requestBody = JSON.stringify(
workspaceType ? { canvasData, workspaceType } : { canvasData }
workspaceType
? { canvasData, workspaceType, ...options }
: { canvasData, ...options }
);
const doSave = async () =>
fetch(`/api/canvas/${id}`, {
@ -218,7 +225,11 @@ export const canvasService = {
const data = await response.json().catch(() => ({}));
if (!response.ok) {
return { success: false, error: String(data?.error || data?.message || '保存画布状态失败') };
return {
success: false,
error: String(data?.error || data?.message || '保存画布状态失败'),
status: response.status,
};
}
return data as ApiResponse;
} catch (error) {
@ -329,6 +340,7 @@ export const imageGenerationService = {
color: string;
prompt: string;
rect: NormalizedCropRect;
targetRect?: NormalizedCropRect | null;
referenceImages?: Array<{
id?: string;
url: string;
@ -340,7 +352,7 @@ export const imageGenerationService = {
imageSize?: string;
aspectRatio?: string;
operation?: "scale-adjust";
scalePercent?: -30 | -20 | -10 | 10 | 20 | 30 | 50 | 100;
scalePercent?: number;
}): Promise<
ApiResponse<{
annotatedReferenceUrl: string;

View File

@ -81,17 +81,23 @@ export interface ImageNodeActionRequest {
requestId: string;
}
type CanvasViewportSource = 'user' | 'agent-transient';
// Store 状态接口
interface CanvasStore {
// 项目信息
projectId: string | null;
projectTitle: string;
canvasRevision: string | null;
canvasUpdatedAt: string | null;
// 画布状态
nodes: CanvasNode[];
edges: CanvasEdge[];
selectedNodeIds: string[];
viewport: Viewport;
viewportSource: CanvasViewportSource;
lastPersistentViewport: Viewport;
tool: ToolType;
suppressedMediaKeys: string[];
styleProState: StyleProPersistedState;
@ -121,6 +127,7 @@ interface CanvasStore {
// 项目操作
setProject: (id: string | null, title?: string) => void;
setProjectTitle: (title: string) => void;
setCanvasRevision: (revision?: string | null, updatedAt?: string | null) => void;
// 节点操作
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
@ -141,6 +148,7 @@ interface CanvasStore {
// 视口操作
setViewport: (viewport: Partial<Viewport>) => void;
setTransientViewport: (viewport: Partial<Viewport>) => void;
zoomIn: () => void;
zoomOut: () => void;
zoomTo: (zoom: number) => void;
@ -198,10 +206,14 @@ export const useCanvasStore = create<CanvasStore>()(
// 初始状态
projectId: null,
projectTitle: '未命名画布',
canvasRevision: null,
canvasUpdatedAt: null,
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: defaultViewport,
viewportSource: 'user',
lastPersistentViewport: defaultViewport,
tool: 'select',
suppressedMediaKeys: [],
styleProState: { history: [] },
@ -230,6 +242,11 @@ export const useCanvasStore = create<CanvasStore>()(
state.projectTitle = title;
}),
setCanvasRevision: (revision, updatedAt) => set((state) => {
state.canvasRevision = String(revision || "").trim() || null;
state.canvasUpdatedAt = String(updatedAt || revision || "").trim() || null;
}),
// 节点操作
addNode: (nodeData) => {
const imageMetadata =
@ -453,24 +470,40 @@ export const useCanvasStore = create<CanvasStore>()(
// 视口操作
setViewport: (viewport) => set((state) => {
const nextViewport = { ...state.viewport, ...viewport };
state.viewport = nextViewport;
state.viewportSource = 'user';
state.lastPersistentViewport = nextViewport;
}),
setTransientViewport: (viewport) => set((state) => {
state.viewport = { ...state.viewport, ...viewport };
state.viewportSource = 'agent-transient';
}),
zoomIn: () => set((state) => {
state.viewport.zoom = Math.min(state.viewport.zoom * 1.2, 4);
state.viewportSource = 'user';
state.lastPersistentViewport = { ...state.viewport };
}),
zoomOut: () => set((state) => {
state.viewport.zoom = Math.max(state.viewport.zoom / 1.2, 0.1);
state.viewportSource = 'user';
state.lastPersistentViewport = { ...state.viewport };
}),
zoomTo: (zoom) => set((state) => {
state.viewport.zoom = Math.max(0.1, Math.min(zoom, 4));
state.viewportSource = 'user';
state.lastPersistentViewport = { ...state.viewport };
}),
fitToScreen: () => set((state) => {
// TODO: 计算所有节点的边界并调整视口
state.viewport = { x: 0, y: 0, zoom: 1 };
state.viewportSource = 'user';
state.lastPersistentViewport = state.viewport;
}),
centerNode: (nodeId) => {
@ -479,6 +512,8 @@ export const useCanvasStore = create<CanvasStore>()(
set((state) => {
state.viewport.x = -node.position.x - node.size.width / 2;
state.viewport.y = -node.position.y - node.size.height / 2;
state.viewportSource = 'user';
state.lastPersistentViewport = { ...state.viewport };
});
}
},
@ -551,7 +586,11 @@ export const useCanvasStore = create<CanvasStore>()(
if (canvasState.edges) state.edges = canvasState.edges;
rebuildNodeConnections(state.nodes, state.edges);
if (canvasState.selectedNodeIds) state.selectedNodeIds = canvasState.selectedNodeIds;
if (canvasState.viewport) state.viewport = canvasState.viewport;
if (canvasState.viewport) {
state.viewport = canvasState.viewport;
state.lastPersistentViewport = canvasState.viewport;
state.viewportSource = 'user';
}
if (canvasState.tool) state.tool = canvasState.tool;
state.suppressedMediaKeys = Array.isArray(canvasState.suppressedMediaKeys)
? canvasState.suppressedMediaKeys.map((item) => String(item || "").trim()).filter(Boolean)
@ -564,6 +603,10 @@ export const useCanvasStore = create<CanvasStore>()(
state.edges = [];
state.selectedNodeIds = [];
state.viewport = defaultViewport;
state.viewportSource = 'user';
state.lastPersistentViewport = defaultViewport;
state.canvasRevision = null;
state.canvasUpdatedAt = null;
state.suppressedMediaKeys = [];
state.styleProState = { history: [] };
state.history = [];
@ -572,11 +615,15 @@ export const useCanvasStore = create<CanvasStore>()(
getCanvasState: () => {
const state = get();
const persistedViewport =
state.viewportSource === 'agent-transient'
? state.lastPersistentViewport
: state.viewport;
return {
nodes: state.nodes,
edges: state.edges,
selectedNodeIds: state.selectedNodeIds,
viewport: state.viewport,
viewport: persistedViewport,
tool: state.tool,
suppressedMediaKeys: state.suppressedMediaKeys,
styleProState: normalizeStyleProPersistedState(state.styleProState),

View File

@ -7,6 +7,19 @@ import {
stripMediaMarkup,
} from "@/components/canvas/chat-panel/canvas-chat-media";
import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context";
import {
buildPendingCanvasOperation,
extractPendingCanvasOperationFromWorkingState,
isCanvasOperationCancelText,
isCanvasOperationConfirmText,
withConfirmedCanvasOperationPlan,
} from "@/lib/canvas/agent-confirmation";
import {
normalizeCanvasAgentModelPlannerDecision,
shouldConsiderCanvasAgentModelPlanner,
} from "@/lib/canvas/agent-model-planner";
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
import { useCanvasStore } from "@/store/canvas";
import {
buildDetailResultMarkdown,
buildDetailReviewAppendMarkdown,
@ -47,9 +60,366 @@ import {
validateCanvasImageUploadFile,
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
import type { ChatMessage } from "@/lib/types";
test.describe("canvas chat pure helpers", () => {
const plannerContext: CanvasAgentContext = {
version: 1,
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
canvasRevision: "2026-06-22T00:00:00.000Z",
canvasUpdatedAt: "2026-06-22T00:00:00.000Z",
viewport: { x: 0, y: 0, zoom: 1 },
selectedNodeIds: ["image-1", "text-1"],
selectedNodes: [
{
nodeId: "image-1",
type: "image",
title: "主图",
x: 10,
y: 20,
width: 300,
height: 240,
isSelected: true,
},
{
nodeId: "text-1",
type: "text",
title: "旧标题",
x: 40,
y: 80,
width: 240,
height: 80,
isSelected: true,
},
],
candidateNodes: [],
slots: [],
annotations: [],
focusPolicy: "selected",
};
test("keeps canvas model planner scoped to low-risk canvas operations", () => {
expect(
shouldConsiderCanvasAgentModelPlanner({
userText: "把选中的文案润色得更高级",
context: plannerContext,
})
).toBe(true);
expect(
shouldConsiderCanvasAgentModelPlanner({
userText: "给这张图换成夏天户外背景,新图放右边",
context: plannerContext,
})
).toBe(false);
});
test("normalizes model-planned focus to known canvas nodes only", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "focus_nodes",
nodeIds: ["missing-node", "image-1"],
reply: "已聚焦主图。",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "帮我定位到主图",
});
expect(outcome.kind).toBe("view");
expect(outcome.kind === "view" ? outcome.viewPlan.nodeIds : []).toEqual([
"image-1",
]);
});
test("normalizes model-planned text updates to selected text nodes", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "update_text",
nodeIds: ["image-1", "text-1"],
textContent: "轻盈强吸力,家务更省心",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中文案改得更高级",
});
expect(outcome.kind).toBe("operation");
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
operation: "update_text",
targetNodeIds: ["text-1"],
textContent: "轻盈强吸力,家务更省心",
});
});
test("turns destructive model-planned canvas requests into confirmation", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "none",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "删除选中的这两项",
});
expect(outcome).toMatchObject({
kind: "confirmation_required",
riskOperation: "delete_nodes",
targetNodeIds: ["image-1", "text-1"],
});
});
test("builds pending move-node operation from model-planned high-risk layout", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "confirmation_required",
riskOperation: "move_nodes",
nodeIds: ["image-1", "text-1"],
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中的两项整理成一行",
});
expect(outcome.kind).toBe("confirmation_required");
expect(
outcome.kind === "confirmation_required" ? outcome.operationPlan : null
).toMatchObject({
operation: "move_nodes",
targetNodeIds: ["image-1", "text-1"],
layoutStrategy: "row",
});
expect(
outcome.kind === "confirmation_required" ? outcome.operationPlan?.confirmed : true
).toBeUndefined();
});
test("extracts and confirms pending canvas operations from working state", () => {
const pending = buildPendingCanvasOperation({
projectId: plannerContext.projectId || "",
messageId: "message-1",
userText: "把选中的两项整理成一行",
riskOperation: "move_nodes",
targetNodeIds: ["image-1", "text-1"],
reply: "移动节点需要确认。",
operationPlan: {
version: 1,
operationId: "message-1:canvas-move",
operation: "move_nodes",
projectId: plannerContext.projectId || "",
targetNodeIds: ["image-1", "text-1"],
layoutStrategy: "row",
metadata: {
pendingConfirmation: true,
},
},
});
const extracted = extractPendingCanvasOperationFromWorkingState({
lastTurnSummary: {
pendingCanvasOperation: pending,
},
});
expect(extracted?.operationPlan?.operation).toBe("move_nodes");
expect(isCanvasOperationConfirmText("确认执行")).toBe(true);
expect(isCanvasOperationCancelText("先不执行了")).toBe(true);
const confirmedPlan = extracted
? withConfirmedCanvasOperationPlan(extracted, "message-2")
: null;
expect(confirmedPlan).toMatchObject({
operation: "move_nodes",
confirmed: true,
metadata: {
confirmedByUser: true,
confirmedByMessageId: "message-2",
},
});
});
test("keeps agent transient viewport out of persisted canvas state", () => {
const store = useCanvasStore.getState();
store.clearCanvas();
store.setViewport({ x: 10, y: 20, zoom: 2 });
store.setTransientViewport({ x: 100, y: 200, zoom: 3 });
expect(useCanvasStore.getState().viewport).toEqual({ x: 100, y: 200, zoom: 3 });
expect(useCanvasStore.getState().getCanvasState().viewport).toEqual({
x: 10,
y: 20,
zoom: 2,
});
store.clearCanvas();
});
test("resolves grid media placements to the right of an anchor node", () => {
const placements = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-grid",
operation: "insert_beside",
projectId: "project-1",
anchorNodeId: "anchor-1",
placement: "right",
placementStrategy: "grid",
},
mediaItems: [
{ mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 },
{ mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 },
{ mediaItemId: "media-3", type: "image", url: "https://cdn.example.com/3.png", width: 100, height: 80 },
],
nodes: [
{
id: "anchor-1",
position: { x: 10, y: 20 },
size: { width: 300, height: 200 },
},
],
});
expect(placements.map((placement) => placement.position)).toEqual([
{ x: 350, y: 20 },
{ x: 474, y: 20 },
{ x: 350, y: 124 },
]);
expect(placements.map((placement) => placement.edgeSourceId)).toEqual([
"anchor-1",
"anchor-1",
"anchor-1",
]);
});
test("resolves batch media beside each item source node", () => {
const placements = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-source-batch",
operation: "insert_beside",
projectId: "project-1",
placement: "right",
placementStrategy: "beside_each_source",
},
mediaItems: [
{
mediaItemId: "media-a-1",
type: "image",
url: "https://cdn.example.com/a1.png",
width: 20,
height: 20,
sourceNodeIds: ["source-a"],
},
{
mediaItemId: "media-b-1",
type: "image",
url: "https://cdn.example.com/b1.png",
width: 20,
height: 20,
sourceNodeIds: ["source-b"],
},
{
mediaItemId: "media-a-2",
type: "image",
url: "https://cdn.example.com/a2.png",
width: 20,
height: 20,
sourceNodeIds: ["source-a"],
},
],
nodes: [
{
id: "source-a",
position: { x: 0, y: 0 },
size: { width: 50, height: 50 },
},
{
id: "source-b",
position: { x: 1000, y: 10 },
size: { width: 50, height: 50 },
},
],
});
expect(placements.map((placement) => placement.position)).toEqual([
{ x: 90, y: 0 },
{ x: 1090, y: 10 },
{ x: 134, y: 0 },
]);
expect(placements.map((placement) => placement.edgeSourceId)).toEqual([
"source-a",
"source-b",
"source-a",
]);
expect(placements[0]?.sourceNodeIds).toEqual(["source-a"]);
});
test("resolves overlay and viewport-centered media placements", () => {
const [overlay] = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-overlay",
operation: "append_revision",
projectId: "project-1",
anchorNodeId: "source-1",
placementStrategy: "overlay",
},
mediaItems: [
{
mediaItemId: "media-overlay",
type: "image",
url: "https://cdn.example.com/overlay.png",
},
],
nodes: [
{
id: "source-1",
position: { x: 120, y: 80 },
size: { width: 160, height: 90 },
},
],
});
expect(overlay?.position).toEqual({ x: 120, y: 80 });
expect(overlay?.size).toEqual({ width: 160, height: 90 });
const centeredPlacements = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-center",
operation: "insert_beside",
projectId: "project-1",
anchorNodeId: "source-1",
placement: "viewport-center",
placementStrategy: "grid",
},
mediaItems: [
{ mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 },
{ mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 },
],
nodes: [
{
id: "source-1",
position: { x: 1000, y: 1000 },
size: { width: 200, height: 200 },
},
],
viewport: { x: -200, y: -100, zoom: 1 },
});
expect(centeredPlacements.map((placement) => placement.position)).toEqual([
{ x: 88, y: 60 },
{ x: 212, y: 60 },
]);
});
test("normalizes slot-only canvas agent context without image nodes", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",
@ -98,6 +468,46 @@ test.describe("canvas chat pure helpers", () => {
expect(context?.focusPolicy).toBe("slot");
});
test("normalizes selected text-only canvas agent context", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["text-1"],
canvasImageNodes: [],
canvasAgentContext: {
version: 1,
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["text-1"],
selectedNodes: [
{
nodeId: "text-1",
type: "text",
title: "旧标题",
x: 40,
y: 80,
width: 240,
height: 80,
isSelected: true,
},
],
candidateNodes: [],
slots: [],
annotations: [],
viewport: { x: 0, y: 0, zoom: 1 },
focusPolicy: "selected",
},
});
expect(context?.selectedNodeIds).toEqual(["text-1"]);
expect(context?.selectedNodes[0]).toMatchObject({
nodeId: "text-1",
type: "text",
title: "旧标题",
});
expect(context?.focusPolicy).toBe("selected");
});
test("builds canvas agent context from legacy canvas image nodes", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",

View File

@ -206,10 +206,18 @@ export interface AgentWorkflowBrandStoreEntry {
sortOrder?: number;
}
export type AgentWorkflowMaterialResourceType = "white-background" | "task-detail";
export interface AgentWorkflowMaterialApiConfig {
enabled: boolean;
baseUrl: string;
endpointPath?: string;
apiKey: string;
whiteBackgroundEnabled?: boolean;
taskDetailEnabled?: boolean;
resourceBaseUrl?: string;
fallbackResourceBaseUrl?: string;
requestTimeoutMs?: number;
productDetailEnabled?: boolean;
productDetailBaseUrl?: string;
productDetailApiKey?: string;

View File

@ -121,8 +121,18 @@ export interface CanvasOperationPlan {
baseCanvasRevision?: string;
baseCanvasUpdatedAt?: string;
sourceNodeIds?: string[];
targetNodeIds?: string[];
targetSlotId?: string;
anchorNodeId?: string;
edgeType?: "default" | "reference" | "conversation";
textContent?: string;
confirmed?: boolean;
layoutStrategy?: "row" | "column" | "grid";
spacing?: number;
targetPosition?: {
x: number;
y: number;
};
placement?: "right" | "left" | "below" | "above" | "viewport-center";
placementStrategy?: "single" | "row" | "grid" | "overlay" | "beside_each_source";
preserveOriginal?: boolean;

View File

@ -15,8 +15,10 @@ export type NodeType =
| 'workflow'
| 'scene-fusion'
| 'model3d'
| 'world3d';
| 'world3d'
| 'agent-slot';
export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed';
export type AgentSlotStatus = 'empty' | 'generating' | 'completed' | 'failed';
export type ToolType = 'select' | 'pan' | 'draw' | 'text';
// 画布节点位置
@ -137,6 +139,17 @@ export interface UiScreenNodeData {
metadata?: Record<string, any>;
}
export interface AgentSlotNodeData {
slotType: "image" | "video" | "ui-screen";
label?: string;
status: AgentSlotStatus;
acceptedSources?: Array<"text" | "image" | "selection" | "annotation">;
fillPolicy?: "replace-empty" | "append-version" | "replace-existing";
filledNodeIds?: string[];
aspectRatio?: string;
metadata?: Record<string, any>;
}
// 工作流节点数据
export interface WorkflowNodeData {
templateType: string;
@ -217,7 +230,8 @@ export interface CanvasNode {
| WorkflowNodeData
| SceneFusionNodeData
| Model3DNodeData
| World3DNodeData;
| World3DNodeData
| AgentSlotNodeData;
connections: string[]; // 连接的节点 ID
parentId?: string; // 父节点 ID(用于对话上下文)
createdAt: Date;