feat(canvas): unify image composer with native Agent and honor generation controls

This commit is contained in:
134 2026-10-20 23:30:04 +08:00
parent 85aa711633
commit 4ee3e369c6
12 changed files with 246 additions and 45 deletions

View File

@ -1,3 +1,4 @@
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
import { registerProjectAgentTools } from "@/lib/ai/canvas-agent-runtime/project-tools";
import { isZcodeWorkspaceAuthorized } from "@/lib/ai/canvas-agent-runtime/authorization";
import {
@ -67,6 +68,7 @@ export const runtime = "nodejs";
export const maxDuration = 1200;
const requestSchema = postRequestBodySchema.extend({
resumeRunId: z.string().uuid().optional(),
selectedImageModel: z.string().min(1).max(180).optional(),
workspaceVariant: z.enum(["canvas", "video"]).default("canvas"),
videoAgentSettings: videoAgentSettingsSchema.optional(),
});
@ -161,6 +163,12 @@ export async function POST(request: Request) {
? configured
: (await getMultimodalAiModelsWithProvider()).find((entry) => entry.model.supportsVision);
const visionModel = visionEntry ? await getLanguageModelById(visionEntry.model.id) : null;
// Validate the user's image choice independently of the Agent planning model.
if (body.selectedImageModel) {
const images = await getStyleProImageModelEntries();
if (!images.some(({ model }) => model.id === body.selectedImageModel && model.healthStatus !== "auto_disabled"))
throw new AgentRunError("你选择的生图模型当前不可用,请重新选择;不会自动换模型生成。", 400);
}
const assistantMessageId = crypto.randomUUID();
const previousVideoMessages =
existingChat
@ -205,6 +213,12 @@ export async function POST(request: Request) {
snapshot: initialSnapshot,
});
const snapshot = run.snapshot;
snapshot.imagePreferences = {
...snapshot.imagePreferences,
// Each submitted turn captures the current controls, including switching back to Auto.
modelId: body.selectedImageModel,
parameters: body.imageParams || snapshot.imagePreferences?.parameters,
};
const taskSourceNodeIds = snapshot.sourceNodeIds ?? selectedNodeIds;
const context = {
recentConversation: previousVideoMessages,
@ -221,7 +235,7 @@ export async function POST(request: Request) {
title: node.data.title,
})),
skillInstructions: body.hiddenPrompt || undefined,
imageParameters: body.imageParams,
imagePreferences: snapshot.imagePreferences,
targetReason: conversation.targetReason,
previousConversationContext: conversation.previousContext,
};
@ -302,8 +316,9 @@ export async function POST(request: Request) {
request,
model: visionModel || model,
supportsVision: Boolean(visionModel),
imageSize: body.imageParams?.imageSize,
preferredModelId: preset?.type === "image" ? preset.defaultModelId || undefined : undefined,
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
imagePreferences: snapshot.imagePreferences,
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
operationSelectedNodeIds: selectedNodeIds,
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
"\n",
@ -311,7 +326,8 @@ export async function POST(request: Request) {
emit,
});
await registerProjectAgentTools({ session, projectId, request, objective: snapshot.objective,
definitions: tools.definitions, executable: tools.executable, emit });
definitions: tools.definitions, executable: tools.executable, emit,
imagePreferences: snapshot.imagePreferences });
const gateway = await createZcodeModelGateway(configured);
activeModelController = new AbortController();
const status = await runZcodeCanvasTask({

View File

@ -11077,6 +11077,7 @@ export default function CanvasEditorLegacyPage({
onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode("fileList")}
onRequireLogin={openLoginDialog}
onBeforeAgentRequest={() => persistCanvasState("autosave")}
selectedAgentPresetId={
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
}

View File

@ -422,6 +422,7 @@ interface CanvasChatPanelProps {
canvasToolbarConfig?: CanvasToolbarConfig;
canvasAgentFeatureSettings?: CanvasAgentFeatureSettings;
onRequireLogin?: (message?: string) => void;
onBeforeAgentRequest?: () => Promise<boolean>;
}
type CanvasComposerMode = "agent" | "image" | "video" | "detail" | "clawd";
@ -466,16 +467,16 @@ function getCanvasComposerModeSessionLabel(mode: CanvasComposerMode) {
if (mode === "clawd") return CLAW_MODE_LABEL;
if (mode === "video") return "视频";
if (mode === "detail") return "AI详情";
if (mode === "image") return "图像";
if (mode === "image") return "Agent";
return "Agent";
}
function normalizeCanvasComposerSessionMode(
value: unknown,
fallback: CanvasComposerMode = "image"
fallback: CanvasComposerMode = "agent"
): CanvasComposerMode {
if (value === "image") return "agent"; // Merge historical image conversations into Agent.
return value === "agent" ||
value === "image" ||
value === "video" ||
value === "detail" ||
value === "clawd"
@ -485,7 +486,7 @@ function normalizeCanvasComposerSessionMode(
function getCanvasChatSessionMode(
session: Pick<CanvasChatSession, "mode" | "clawdSkillTagId"> | null | undefined,
fallback: CanvasComposerMode = "image"
fallback: CanvasComposerMode = "agent"
) {
if (!session) return fallback;
if (session.mode) {
@ -3227,6 +3228,7 @@ export function CanvasChatPanel({
canvasToolbarConfig: rawCanvasToolbarConfig,
canvasAgentFeatureSettings: propCanvasAgentFeatureSettings,
onRequireLogin,
onBeforeAgentRequest,
}: CanvasChatPanelProps) {
const isWorkspacePresentation = presentation === "workspace";
const isVideoWorkspace = workspaceVariant === "video";
@ -5987,7 +5989,7 @@ export function CanvasChatPanel({
[isCanvasImageModel, isCanvasVideoModel]
);
const isVideoModel = Boolean(
currentModel && isCanvasVideoModel(currentModel)
isVideoWorkspace && currentModel && isCanvasVideoModel(currentModel)
);
const isConversationModel = Boolean(
currentModel && isCanvasConversationOnlyModel(currentModel)
@ -6006,11 +6008,15 @@ export function CanvasChatPanel({
if (isDetailGenerationMode) return "detail";
if (isVideoModel) return "video";
if (isAgentModel) return "agent";
return "image";
}, [isAgentModel, isDetailGenerationMode, isVideoModel]);
return isVideoWorkspace || isWorkspacePresentation ? "image" : "agent";
}, [isAgentModel, isDetailGenerationMode, isVideoModel, isVideoWorkspace, isWorkspacePresentation]);
const [composerModeOverride, setComposerModeOverride] =
useState<CanvasComposerMode | null>(null);
const activeComposerMode = composerModeOverride || computedComposerMode;
const unifiedImageAgent = !isVideoWorkspace && !isWorkspacePresentation;
const rawComposerMode = composerModeOverride || computedComposerMode;
const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video")
? "agent" : rawComposerMode;
const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent";
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
const isClawdMode = activeComposerMode === "clawd" && isClawdModeEnabled;
const activeChatSessionMode = getCanvasChatSessionMode(
@ -6244,13 +6250,14 @@ export function CanvasChatPanel({
return videoOnlyModels;
}
if (activeComposerMode === "agent") {
return agentSelectableModels;
return unifiedImageAgent ? [SMART_AUTO_MODEL, ...imageOnlyModels] : agentSelectableModels;
}
return imageOnlyModels;
}, [
SMART_AUTO_MODEL,
activeComposerMode,
agentSelectableModels,
unifiedImageAgent,
imageOnlyModels,
videoOnlyModels,
]);
@ -6269,7 +6276,7 @@ export function CanvasChatPanel({
activeComposerMode === "agent"
? isPresetAgentSelection
? selectedAgentPreset?.name || "预设Agent"
: "Auto Agent"
: "Agent"
: currentModel?.displayName || selectDisplayModel?.displayName || "Agent模式";
const videoFrameSlots = useMemo(() => {
if (!isVideoModel || !videoParams) return [] as string[];
@ -6644,8 +6651,8 @@ export function CanvasChatPanel({
? "视频"
: activeComposerMode === "detail"
? getToolbarItem("chatModeDetailActive").label
: activeComposerMode === "image"
? "图像"
: activeComposerMode === "image" || isImageAgentComposer
? "Agent"
: isPresetAgentSelection
? getToolbarItem("chatModePresetAgent").label
: "Agent";
@ -6742,7 +6749,7 @@ export function CanvasChatPanel({
useEffect(() => {
if (!effectiveAgentEnabled) return;
if (activeComposerMode !== "agent") return;
if (activeComposerMode !== "agent" || unifiedImageAgent) return;
if (!isPresetAgentSelection) return;
if (!selectedAgentPresetId) return;
if (!selectedAgentPreset?.defaultModelId) return;
@ -6755,6 +6762,7 @@ export function CanvasChatPanel({
}
}, [
activeComposerMode,
unifiedImageAgent,
effectiveAgentEnabled,
isPresetAgentSelection,
isDetailGenerationMode,
@ -6965,6 +6973,7 @@ export function CanvasChatPanel({
const handleSelectGenerationMode = useCallback(
(mode: CanvasComposerMode): string | null => {
if (unifiedImageAgent && (mode === "video" || mode === "image")) return null;
hiddenPromptRef.current = null;
autoContextRef.current = null;
if (mode === "clawd" && !isClawdModeEnabled) {
@ -7066,8 +7075,10 @@ export function CanvasChatPanel({
}
if (mode === "agent") {
currentModelIdRef.current = "auto";
setSelectedModelId("auto");
// Keep the image model selected by the user when returning from Claw.
const imageModelId = isCanvasImageModel(currentModel) ? currentModel!.id : lastImageModelId || "auto";
currentModelIdRef.current = unifiedImageAgent ? imageModelId : "auto";
setSelectedModelId(currentModelIdRef.current);
setIsDetailGenerationMode(false);
return targetChatSessionId;
}
@ -7099,6 +7110,8 @@ export function CanvasChatPanel({
canUseDetailGeneration,
effectiveAgentEnabled,
isClawdModeEnabled,
unifiedImageAgent,
currentModel,
allModels,
activeChatId,
activeComposerMode,
@ -7903,7 +7916,7 @@ export function CanvasChatPanel({
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
async prepareSendMessagesRequest(request) {
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
const rawId = request.id.startsWith('canvas-')
? request.id.replace('canvas-', '')
@ -7951,6 +7964,8 @@ export function CanvasChatPanel({
? selectedAgentPreset.id
: undefined,
selectedChatModel: effectiveSelectedChatModel,
selectedImageModel: effectiveComposerMode === "agent" && isCanvasImageModel(allModels.find(model => model.id === currentModelIdRef.current))
? currentModelIdRef.current : undefined,
composerMode: effectiveComposerMode,
workspaceVariant: isVideoWorkspace ? "video" : "canvas",
selectedVisibilityType: "private",
@ -7964,7 +7979,7 @@ export function CanvasChatPanel({
: deepThinkingEnabledRef.current,
videoParams: requestIsVideoModel ? videoParamsRef.current || undefined : undefined,
imageParams:
requestIsVideoModel || requestIsConversationModel
effectiveComposerMode !== "agent" && (requestIsVideoModel || requestIsConversationModel)
? undefined
: imageParamsRef.current || undefined,
hiddenPrompt,
@ -7973,8 +7988,14 @@ export function CanvasChatPanel({
...(runtimeResumeRunId ? { resumeRunId: runtimeResumeRunId } : {}),
...request.body,
};
const useAgent = shouldUseCanvasAgentRuntime(preparedBody);
// Persist references before the isolated Agent reads the owned canvas.
// Transport admission is already guarded against duplicate sends.
if (useAgent && onBeforeAgentRequest && !(await onBeforeAgentRequest())) {
throw new Error("画布保存未完成,未开始 Agent 任务,请稍后重试。");
}
return {
api: shouldUseCanvasAgentRuntime(preparedBody) ? "/api/canvas/agent-chat" : "/api/chat",
api: useAgent ? "/api/canvas/agent-chat" : "/api/chat",
body: preparedBody,
};
},
@ -14791,6 +14812,8 @@ export function CanvasChatPanel({
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: isVideoWorkspace
? "描述镜头、主体和运动方式,也可以先切到首帧或首尾帧模式"
: isImageAgentComposer
? "描述需求,或选择画布图片继续编辑"
: activeComposerMode === "agent" && !isPresetAgentSelection
? ""
: hasAgentUploadSlots
@ -14807,6 +14830,8 @@ export function CanvasChatPanel({
? isVideoModel && videoFrameSlots.length > 0
? `描述镜头、主体和运动方式;可从画布填充${videoFrameSlots.join(" / ")}`
: "描述视频镜头、主体、运动方式与时长,也可以切换到首帧或首尾帧模式"
: isImageAgentComposer
? "描述需求,或选择画布图片继续编辑"
: activeComposerMode === "agent" && !isPresetAgentSelection
? ""
: activeComposerMode === "agent" && selectedAgentPreset?.type === "text"
@ -15189,7 +15214,7 @@ export function CanvasChatPanel({
isClawdMode
? `开始 ${CLAW_MODE_LABEL} 对话`
: activeComposerMode === "agent" && !isPresetAgentSelection
? "开始 Auto Agent 对话"
? "开始 Agent 对话"
: hasAgentUploadSlots && attachments.length < agentRequiredUploadCount
? `请先补齐图片槽位(${attachments.length}/${agentRequiredUploadCount})`
: "准备开始生成";
@ -17912,12 +17937,12 @@ export function CanvasChatPanel({
<div
className={cn(
"relative pb-0",
!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image"
!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && (activeComposerMode === "image" || isImageAgentComposer)
? "px-0 pt-0"
: "px-3 pt-2.5",
isDetailGenerationMode
? "min-h-[96px]"
: activeComposerMode === "image"
: (activeComposerMode === "image" || isImageAgentComposer)
? "min-h-[92px]"
: "min-h-[104px]"
)}
@ -18066,7 +18091,7 @@ export function CanvasChatPanel({
</div>
</div>
)}
{!isDetailGenerationMode && activeComposerMode === "agent" && (
{!unifiedImageAgent && !isDetailGenerationMode && activeComposerMode === "agent" && (
<div className="mb-2 flex flex-wrap items-center gap-2">
{hasSelectedAgentPreset ? (
<>
@ -18292,7 +18317,7 @@ export function CanvasChatPanel({
) : null}
</div>
) : null}
{!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? (
{!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && (activeComposerMode === "image" || isImageAgentComposer) ? (
<div className="lovart-reference-upload mx-[8px] mt-[8px] flex items-center">
<button
type="button"
@ -18384,7 +18409,7 @@ export function CanvasChatPanel({
"max-h-[128px] overflow-y-auto outline-none empty:before:content-[attr(data-placeholder)]",
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
? "min-h-[36px] text-[16px] leading-6 md:text-[14px]"
: activeComposerMode === "image"
: (activeComposerMode === "image" || isImageAgentComposer)
? "min-h-[38px] text-[16px] leading-6 md:text-[14px]"
: "min-h-[50px] text-[16px] leading-6 md:text-[14px]",
isVideoWorkspace
@ -18798,7 +18823,7 @@ export function CanvasChatPanel({
: "dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100"
)}
>
{effectiveAgentEnabled ? (
{(effectiveAgentEnabled || unifiedImageAgent) ? (
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("agent")}
className={getComposerModeMenuItemClass("agent")}
@ -18828,7 +18853,7 @@ export function CanvasChatPanel({
) : null}
</DropdownMenuItem>
) : null}
{canUseDetailGeneration ? (
{!unifiedImageAgent && canUseDetailGeneration ? (
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("detail")}
className={getComposerModeMenuItemClass("detail")}
@ -18846,6 +18871,7 @@ export function CanvasChatPanel({
) : null}
</DropdownMenuItem>
) : null}
{!unifiedImageAgent ? <>
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("image")}
className={getComposerModeMenuItemClass("image")}
@ -18858,7 +18884,7 @@ export function CanvasChatPanel({
<span className="flex-1">
图像
</span>
{activeComposerMode === "image" ? (
{(activeComposerMode === "image" || isImageAgentComposer) ? (
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
@ -18878,6 +18904,7 @@ export function CanvasChatPanel({
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
</> : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
@ -18900,7 +18927,7 @@ export function CanvasChatPanel({
</div>
<div className="flex shrink-0 items-center gap-1">
{!isVideoModel && !isWorkspacePresentation && activeComposerMode === "image" ? (
{!isVideoModel && !isWorkspacePresentation && (activeComposerMode === "image" || isImageAgentComposer) ? (
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
@ -18928,7 +18955,7 @@ export function CanvasChatPanel({
const nextIsConversationModel = isCanvasConversationOnlyModel(nextModel);
currentModelIdRef.current = value;
setSelectedModelId(value);
if (value === "auto" || nextIsConversationModel) {
if (unifiedImageAgent || value === "auto" || nextIsConversationModel) {
setComposerModeOverride("agent");
} else if (nextIsVideoModel) {
setComposerModeOverride("video");
@ -18942,7 +18969,7 @@ export function CanvasChatPanel({
>
<SelectTrigger
className={cn(
cn(!isVideoWorkspace && activeComposerMode === "image" && "lovart-model-selector"),
cn(!isVideoWorkspace && (activeComposerMode === "image" || isImageAgentComposer) && "lovart-model-selector"),
"h-8 w-8 shrink-0 justify-center rounded-full p-0 outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&>svg]:hidden",
isVideoToolbarDark
? "border border-white/12 bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]"
@ -18950,7 +18977,7 @@ export function CanvasChatPanel({
)}
title={getToolbarItem("chatModelSelector").label}
>
{!isVideoWorkspace && activeComposerMode === "image" ? (
{!isVideoWorkspace && (activeComposerMode === "image" || isImageAgentComposer) ? (
<>
<ModelIdentityIcon name={selectDisplayModel?.displayName || "自动模型"} identity={`${selectDisplayModel?.id || ""} ${selectDisplayModel?.providerType || ""}`} avatarUrl={selectDisplayModel?.avatarUrl ? buildPreviewImageUrl(selectDisplayModel.avatarUrl, 64) : undefined} size={18} />
<span className="min-w-0 flex-1 truncate">{selectDisplayModel?.displayName || "自动模型"}</span>
@ -19063,7 +19090,7 @@ export function CanvasChatPanel({
? "停止处理并重新编辑"
: isWorkspacePresentation || isDetailGenerationMode
? getToolbarItem("chatStartGeneration").label
: !isVideoWorkspace && activeComposerMode === "image" && estimatedPointsCost
: !isVideoWorkspace && (activeComposerMode === "image" || isImageAgentComposer) && estimatedPointsCost
? `${getToolbarItem("chatSend").label} · ${estimatedPointsCost.title}`
: getToolbarItem("chatSend").label
}
@ -19098,7 +19125,7 @@ export function CanvasChatPanel({
</span>
) : null}
</>
) : !isWorkspacePresentation && !isVideoWorkspace && activeComposerMode === "image" && estimatedPointsCost ? (
) : !isWorkspacePresentation && !isVideoWorkspace && (activeComposerMode === "image" || isImageAgentComposer) && estimatedPointsCost ? (
<>
<CanvasToolbarIcon fallback={Sparkles} item={getToolbarItem("chatPoints")} referenceName="points" referenceSize={14} />
<span>{estimatedPointsCost.cost}</span>

View File

@ -0,0 +1,36 @@
/** User image controls are execution constraints, separate from the planning model. */
export type AgentImageParameters = {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
n?: number;
quality?: "auto" | "low" | "medium" | "high";
size?: string;
negativePrompt?: string;
promptExtend?: boolean;
watermark?: boolean;
seed?: number;
};
export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters };
export function applyAgentImagePreferences<T extends Record<string, unknown>>(
input: T,
preferences?: AgentImagePreferences,
): T & AgentImageParameters & { modelId?: string } {
const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {})
.filter(([, value]) => value !== undefined));
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
return {
...input,
...parameters,
...(count !== undefined ? { imageCount: count } : {}),
...(preferences?.modelId ? { modelId: preferences.modelId } : {}),
};
}
export function agentProviderImageParameters(parameters: AgentImageParameters) {
return Object.fromEntries(["quality", "size", "watermark", "seed"].flatMap(key => {
const value = parameters[key as keyof AgentImageParameters];
return value !== undefined ? [[key, value]] : [];
}));
}

View File

@ -1,3 +1,4 @@
import type { AgentImagePreferences } from "./image-preferences";
import "server-only";
import { AgentToolRejectedError } from "./protocol";
import { tool, type ToolSet } from "ai";
@ -16,6 +17,7 @@ import { isExplicitDestructiveIntent } from "./capability-access";
import type { CanvasAgentTool } from "./loop";
export async function registerProjectAgentTools(params: {
imagePreferences?: AgentImagePreferences;
session: Session; projectId: string; request: Request; objective: string;
definitions: ToolSet; executable: Record<string, CanvasAgentTool>;
emit: (type: string, data: unknown) => void;
@ -35,6 +37,8 @@ export async function registerProjectAgentTools(params: {
const args = invokeSchema.parse(input);
const operation = PROJECT_AGENT_OPERATIONS.find(item => item.id === args.operationId);
if (!operation || operation.readOnly !== readOnly) throw new Error("请使用清单中对应读写类别的固定业务工具");
if (params.imagePreferences?.modelId && ["style-generate.post", "wash-generate.post"].includes(operation.id))
throw new AgentToolRejectedError("用户已指定生图模型,请用 generateCanvasImages 执行并保留当前图片参数;不能通过另一条生成接口换用默认模型。");
if (!projectOperationAuthorized(operation)) throw new Error("用户暂未授权视频画布和分镜工具");
if (JSON.stringify(args.body || {}).length > 2 * 1024 * 1024) throw new Error("业务工具输入超过大小限制");
if (args.body?.workspaceType === "video" || args.query.workspaceType === "video" || args.body?.type === "video") throw new Error("视频画布暂未授权");

View File

@ -29,6 +29,7 @@ export type AgentRunSnapshot = {
traceId?: string;
};
workspace?: "canvas" | "video";
imagePreferences?: import("./image-preferences").AgentImagePreferences;
videoSettings?: import("./video-protocol").VideoAgentSettings;
videoRequirements?: import("./video-protocol").VideoTaskRequirements;
objective: string;

View File

@ -1,3 +1,4 @@
import { applyAgentImagePreferences, agentProviderImageParameters, type AgentImagePreferences } from "./image-preferences";
import { tool, type LanguageModel, type ToolSet } from "ai";
import { z } from "zod";
import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools";
@ -33,6 +34,7 @@ export async function buildCanvasRuntimeTools(params: {
supportsVision: boolean;
imageSize?: string;
preferredModelId?: string;
imagePreferences?: AgentImagePreferences;
emit: (type: string, data: unknown) => void;
objective?: string;
operationSelectedNodeIds?: string[];
@ -45,6 +47,8 @@ export async function buildCanvasRuntimeTools(params: {
userId: params.userId,
selectedNodeIds: defaults,
allowExplicitTargets: true,
defaultModelId: params.preferredModelId,
imagePreferences: params.imagePreferences,
authorizeDestructive: (ids) =>
isExplicitDestructiveIntent(params.objective || "") &&
ids.length > 0 &&
@ -78,6 +82,14 @@ export async function buildCanvasRuntimeTools(params: {
execute: async (input, callId) => {
if (!defaults.length && !Array.isArray(input.nodeIds))
throw new Error("请明确提供目标图片节点,不能猜测第一张图。");
// Generic redraw is the same constrained generator, including batch count,
// provider parameters and the no-silent-model-failover policy.
if (capability.id === "image.prompt_regenerate") {
const args = (input.args || {}) as Record<string, unknown>;
return executable.generateCanvasImages.execute({
...args, referenceNodeIds: input.nodeIds || defaults,
}, callId);
}
const result = await entry.execute!(input as never, { toolCallId: callId, messages: [] });
return result as Record<string, unknown>;
},
@ -113,7 +125,12 @@ export async function buildCanvasRuntimeTools(params: {
aspectRatio: z.string().max(24).optional(),
visualCriteria: z.array(z.string().max(200)).max(10),
}),
async (input) => ({ ...input }),
async (input) => ({ ...input,
...(input.expectedImageCount !== undefined && params.imagePreferences?.parameters?.imageCount !== undefined
? { expectedImageCount: params.imagePreferences.parameters.imageCount } : {}),
...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto"
? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}),
}),
);
register(
"readCanvas",
@ -165,10 +182,11 @@ export async function buildCanvasRuntimeTools(params: {
referenceNodeIds: z.array(z.string().min(1).max(180)).max(8).default([]),
imageCount: z.number().int().min(1).max(10).default(1),
aspectRatio: z.string().max(24).default("auto"),
imageSize: z.enum(["1K", "2K", "4K"]).optional(),
imageSize: z.enum(["0.5K", "1K", "2K", "4K", "8K", "10K", "12K"]).optional(),
modelId: z.string().max(180).optional().describe("指定已配置的图片模型 ID;省略使用预设或系统模型"),
}),
async (input, callId) => {
async (toolInput, callId) => {
const input = applyAgentImagePreferences(toolInput, params.imagePreferences);
// Bound per-task spend without introducing any global/model concurrency queue.
if (generatedCount + input.imageCount > 24)
throw new Error("本次任务已达到 24 张生成预算,请检查结果后继续。");
@ -182,7 +200,7 @@ export async function buildCanvasRuntimeTools(params: {
intent: sources.length ? "image_edit" : "image_gen",
hasImage: sources.length > 0,
}).catch(() => null);
const requestedModel = input.modelId || params.preferredModelId;
const requestedModel = params.imagePreferences?.modelId || input.modelId || params.preferredModelId;
const requestedEntry = requestedModel ? models.find(({ model }) => model.healthStatus !== "auto_disabled" && (model.id === requestedModel || model.modelId === requestedModel)) : null;
if (requestedModel && !requestedEntry) throw new Error("指定的图片模型不可用,未切换到其他模型收费生成");
const chosen = requestedEntry || models.find(
@ -217,6 +235,10 @@ export async function buildCanvasRuntimeTools(params: {
imageCount: input.imageCount,
imageSize,
aspectRatio: input.aspectRatio,
negativePrompt: input.negativePrompt,
promptExtend: input.promptExtend,
providerImageParams: agentProviderImageParameters(input),
allowFailover: !params.imagePreferences?.modelId,
ownerKey: params.userId,
referenceImages: sources.map((node) => node.data.src || node.data.url!),
debugLabel: "canvas-agent-runtime",
@ -278,6 +300,7 @@ export async function buildCanvasRuntimeTools(params: {
);
return {
status: "succeeded",
appliedImagePreferences: { modelId: chosen.model.id, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount },
createdNodeIds: operation.createdNodeIds,
outputs: result.images.map((image) => ({ url: image.originalUrl })),
model: result.modelRef,

View File

@ -49,13 +49,14 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
"你正在独立沙箱内运行原生 ZCode 核心。本站已授权的图片、节点、界面及项目业务能力通过 canvas MCP 提供;视频画布暂未授权,不得绕过限制;先读实际画布,不猜测节点 ID。",
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
"查询项目级能力先用 listProjectServices;技能先 loadProjectSkill。项目工具的任务受理、文件选择与后台缺失不等于处理完成。",
"需要澄清时调用 requestCanvasUserInput;不要使用终端交互。工具返回的 userUpdates 是用户的新补充,必须从下一步纳入。",
"工具错误涉及结果未确认时停止收费动作,不通过更换调用 ID 或另一个工具重复生成。用户明确要求修改的目标才能删除或覆盖。",
"只有真实工具返回成功才可声称完成;打开面板、等待用户选文件、后台缺少处理服务都不能声称媒体已处理。",
].join("\n");
let prompt = snapshot.nativeRuntime.sessionId
? ["继续当前任务,保留所有已完成结果。", ...snapshot.inputs.filter(input => input.applied).slice(-4).map(input => input.text)].join("\n")
? ["继续当前任务,保留所有已完成结果。", `本轮用户的图片设置:${JSON.stringify(snapshot.imagePreferences || {})},后续生图以此为准。`, ...snapshot.inputs.filter(input => input.applied).slice(-4).map(input => input.text)].join("\n")
: `${snapshot.objective}\n\n当前画布与历史对话上下文:${JSON.stringify(ports.context)}`;
while (true) {
const control = await exclusive(() => ports.consumeInputs(snapshot));

View File

@ -1,3 +1,4 @@
import { applyAgentImagePreferences, type AgentImagePreferences } from "@/lib/ai/canvas-agent-runtime/image-preferences";
import { tool, type ToolSet } from "ai";
import { z } from "zod";
@ -48,6 +49,7 @@ function buildToolDescription(capability: CanvasCapabilityDefinition) {
export async function createCanvasCapabilityTools(params: {
callbacks?: CanvasCapabilityToolCallbacks;
defaultModelId?: string;
imagePreferences?: AgentImagePreferences;
destructiveConfirmed?: boolean;
projectId: string;
requestHeaders: Headers;
@ -94,11 +96,13 @@ export async function createCanvasCapabilityTools(params: {
input.args && typeof input.args === "object" && !Array.isArray(input.args)
? input.args
: {};
const inheritedArgs = capability.modelPolicy === "agent-selected"
? applyAgentImagePreferences(rawArgs, params.imagePreferences) : rawArgs;
const args = {
...rawArgs,
...inheritedArgs,
...(capability.modelPolicy === "agent-selected" &&
params.defaultModelId &&
!("modelId" in rawArgs)
!("modelId" in inheritedArgs)
? { modelId: params.defaultModelId }
: {}),
};

View File

@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/routes",
testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video)?|canvas-agent-project-tools|agent-capability-access)\.test\.ts/,
testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences)?|canvas-agent-project-tools|agent-capability-access)\.test\.ts/,
fullyParallel: true,
retries: 0,
workers: 2,

View File

@ -72,6 +72,19 @@ let checks = 0;
await options.execute('edit', { nodeIds: ['a'] }, 'corrected', options.signal!); return result; };
assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.snapshot.calls.find(c=>c.id==='rejected')?.uncertain,false); checks++;
}
{
const h = fixture('画布里有什么');
h.snapshot.nativeRuntime = { engine: 'zcode', sessionId: 'previous-native-session' };
h.snapshot.imagePreferences = { modelId: 'user-selected-image-model', parameters: { imageSize: '4K', aspectRatio: '3:4', imageCount: 3, seed: 0, watermark: false } };
h.ports.native = async options => {
assert.equal(options.sessionId, 'previous-native-session');
assert.ok(options.prompt.includes('user-selected-image-model'));
assert.ok(options.prompt.includes('4K') && options.prompt.includes('3:4'));
assert.ok(options.instructions.includes('不得被技能默认值'));
return { ...result, response: '已读取本轮图片设置' };
};
assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); checks++;
}
console.log('NATIVE_TASK_ADAPTER_PASSED', { checks });
}

View File

@ -0,0 +1,75 @@
import { expect, test } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences";
const preferences = {
modelId: "user-image-model",
parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字",
quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 },
};
function harness() {
const generated: any[] = [];
const operations: any[] = [];
const mock = serverLoader({
"@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) },
"@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] },
"@/lib/canvas/agent-mutations": { applyCanvasOperationPlan: async (value: any) => { operations.push(value); return { createdNodeIds: ["new-image"] }; } },
"@/lib/canvas/generate-image": { generateCanvasImage: async (value: any) => { generated.push(value); return { modelRef: value.model, images: [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }] }; } },
"@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async () => {} },
"@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) },
"@/lib/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) },
"@/lib/style-pro/model-options": {
getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })),
sortStyleProImageModelEntries: (entries: any) => entries,
},
"./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: "https://test.example/reference.png" } }] },
"./review": {},
"./image-access-tools": { registerImageCanvasAccessTools() {} },
});
const build = async (imagePreferences: any = preferences) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({
userId: "u", chatId: "c", projectId: "p", runId: "r", assistantMessageId: "a", selectedNodeIds: ["reference"],
request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true,
preferredModelId: "system-default", imagePreferences, emit() {},
});
return { build, generated, operations };
}
test("explicit controls override conflicting planner settings without losing false or zero values", () => {
const resolved = applyAgentImagePreferences({ modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1", prompt: "产品图" }, preferences);
expect(resolved).toMatchObject({ ...preferences.parameters, modelId: preferences.modelId, prompt: "产品图" });
expect(agentProviderImageParameters(resolved)).toEqual({ quality: "high", size: "1536*2048", watermark: false, seed: 0 });
});
test("real generation tool honors the chosen image model and all parameters despite another default and planner choice", async () => {
const h = harness(); const tools = await h.build();
const output = await tools.executable.generateCanvasImages.execute({ prompt: "生成产品图", modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1" }, "call-1");
expect(h.generated).toHaveLength(1);
expect(h.generated[0]).toMatchObject({ prompt: "生成产品图", model: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4", negativePrompt: "不要文字", promptExtend: false, allowFailover: false,
providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } });
expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" });
});
test("redraw and a corrective retry inherit the same user model, batch and image controls", async () => {
const h = harness(); const tools = await h.build();
await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1");
await tools.executable.generateCanvasImages.execute({ prompt: "修正背景", modelId: "system-default" }, "retry-1");
expect(h.generated).toHaveLength(2);
for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", imageCount: 3, aspectRatio: "3:4", allowFailover: false });
});
test("an unavailable explicit choice never silently generates or charges through the default", async () => {
const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" });
await expect(tools.executable.generateCanvasImages.execute({ prompt: "画图" }, "bad")).rejects.toThrow("指定的图片模型不可用");
expect(h.generated).toHaveLength(0); expect(h.operations).toHaveLength(0);
});
test("Auto selection retains routing and the n count alias without overriding a supplied planner ratio", async () => {
const h = harness(); const tools = await h.build({ parameters: { n: 2, imageSize: "8K" } });
await tools.executable.generateCanvasImages.execute({ prompt: "画图", aspectRatio: "16:9" }, "auto");
expect(h.generated[0]).toMatchObject({ model: "system-default", imageCount: 2, imageSize: "8K", aspectRatio: "16:9", allowFailover: true });
});
test("planning uses the image count for generation tasks but does not turn analysis into an image task", async () => {
const h = harness(); const tools = await h.build();
const plan = { steps: ["查看画布"], visualCriteria: [] };
const analysis = await tools.executable.planCanvasTask.execute(plan, "analysis-plan");
expect(analysis.expectedImageCount).toBeUndefined();
const generation = await tools.executable.planCanvasTask.execute({ ...plan, expectedImageCount: 1, aspectRatio: "1:1" }, "generation-plan");
expect(generation).toMatchObject({ expectedImageCount: 3, aspectRatio: "3:4" });
expect(h.generated).toHaveLength(0);
});