fix(agent): ask before replacing refused pinned image models and settle completed actions
This commit is contained in:
parent
16b65dc8d0
commit
82bc90680a
@ -27,7 +27,7 @@ export function CanvasAgentProgressMessage(props: {
|
||||
<li key={activity.id} className="flex items-center gap-2">
|
||||
{activity.status === "started" ? <Loader2 className="h-3 w-3 shrink-0 animate-spin" /> : <span className="h-1 w-1 shrink-0 rounded-full bg-current" />}
|
||||
<span>{canvasAgentProgressLabel(activity.title)}</span>
|
||||
<span className="text-[11px] opacity-75">{activity.status === "succeeded" ? "已完成" : activity.status === "failed" ? "待处理" : activity.status === "queued" ? "待执行" : "进行中"}</span>
|
||||
<span className="text-[11px] opacity-75">{activity.status === "succeeded" ? "已完成" : activity.status === "failed" ? "未完成" : activity.status === "queued" ? "待执行" : "进行中"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@ -14,11 +14,12 @@ const oldTitles: Record<string, string> = {
|
||||
generateCanvasImages: "生成图片", editTargetImage: "编辑图片", readProjectService: "读取所需资料",
|
||||
getWeather: "查询天气", createDocument: "创建文档", updateDocument: "更新文档", requestSuggestions: "整理修改建议",
|
||||
runProjectService: "处理创作内容", listProjectServices: "查找所需功能", loadProjectSkill: "读取技能要求",
|
||||
controlCanvasView: "调整画布视图", image_add_to_chat: "添加图片到对话", image_rename: "重命名图片",
|
||||
};
|
||||
export function canvasAgentActivities(snapshot: AgentRunSnapshot) {
|
||||
return snapshot.calls.slice(-12).map(call => ({
|
||||
id: call.id,
|
||||
title: canvasAgentProgressLabel((call.title && call.title !== call.name ? call.title : oldTitles[call.name]) || "处理任务"),
|
||||
title: canvasAgentProgressLabel(oldTitles[call.name] || (call.title && call.title !== call.name ? call.title : "处理任务")),
|
||||
status: call.status,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -25,7 +25,8 @@ export async function registerProjectAgentTools(params: {
|
||||
const userId = params.session.user!.id!;
|
||||
function register(name: string, description: string, schema: z.ZodTypeAny, execute: CanvasAgentTool["execute"], readOnly = true) {
|
||||
params.definitions[name] = tool({ description, inputSchema: schema });
|
||||
params.executable[name] = { title: description.slice(0, 32), readOnly, concurrentSafe: readOnly, execute };
|
||||
const titles: Record<string, string> = { listProjectServices: "查找所需功能", readProjectService: "读取所需资料", runProjectService: "处理创作内容", loadProjectSkill: "读取技能要求", getWeather: "查询天气", createDocument: "创建文档", updateDocument: "更新文档", requestSuggestions: "整理修改建议" };
|
||||
params.executable[name] = { title: titles[name] || "处理项目任务", readOnly, concurrentSafe: readOnly, execute };
|
||||
}
|
||||
register("listProjectServices", "列出图片画布之外的全部项目业务工具、真实接口及输入提示。可按分组筛选;authorized=false的分镜/视频功能暂不授权。", z.object({ group: z.string().max(100).optional(), operationId: z.string().max(120).optional() }),
|
||||
async input => ({ services: PROJECT_AGENT_OPERATIONS.filter(item => (!input.group || item.group === input.group) && (!input.operationId || item.id === input.operationId))
|
||||
@ -37,6 +38,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 (operation.id === "appearance.post" && !/主题|深色模式|浅色模式|暗黑模式|界面外观|theme|appearance/i.test(params.objective))
|
||||
throw new AgentToolRejectedError("本次任务未要求修改界面主题,请继续处理用户要求的图片任务。");
|
||||
if (params.imagePreferences?.modelId && ["style-generate.post", "wash-generate.post"].includes(operation.id))
|
||||
throw new AgentToolRejectedError("用户已指定生图模型,请用 generateCanvasImages 执行并保留当前图片参数;不能通过另一条生成接口换用默认模型。");
|
||||
if (!projectOperationAuthorized(operation)) throw new Error("用户暂未授权视频画布和分镜工具");
|
||||
|
||||
@ -15,6 +15,8 @@ export type AgentCallRecord = AgentCall & {
|
||||
title?: string;
|
||||
status: "queued" | "started" | "succeeded" | "failed";
|
||||
uncertain?: boolean;
|
||||
/** Provider explicitly refused submission; no result exists to reconcile. */
|
||||
confirmedNoResult?: boolean;
|
||||
rejected?: boolean;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
|
||||
@ -10,6 +10,17 @@ export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallR
|
||||
!/下载|导出|保存.*(?:本地|电脑)|download|export/i.test(intent);
|
||||
}
|
||||
|
||||
/** This command merely adds an existing image to the composer. It does not
|
||||
* open an editor or require the user to supply files/confirm a media operation. */
|
||||
export function isCompletedComposerReference(call: AgentCallRecord) {
|
||||
return call.name === "image_add_to_chat" && call.status === "succeeded" &&
|
||||
call.output?.status === "succeeded" && call.output?.mode === "interactive";
|
||||
}
|
||||
|
||||
export function requiresCanvasInteraction(snapshot: AgentRunSnapshot, call: AgentCallRecord) {
|
||||
return call.output?.mode === "interactive" && !isAncillaryDownload(snapshot, call) && !isCompletedComposerReference(call);
|
||||
}
|
||||
|
||||
export function finalImageResultNodeIds(
|
||||
snapshot: AgentRunSnapshot,
|
||||
nodes: Array<{ id: string; type: string }>,
|
||||
|
||||
@ -5,7 +5,7 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
|
||||
import type { AgentRunSnapshot } from "./protocol";
|
||||
import { finalResultNodeIds, originalSourceNodeIds, isAncillaryDownload } from "./results";
|
||||
import { finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
|
||||
import { isCompletedSimpleCutout } from "./cutout-completion";
|
||||
|
||||
export async function inspectImageAlpha(source: Buffer) {
|
||||
@ -123,7 +123,7 @@ export async function verifyCanvasAgentResults(params: {
|
||||
const incomplete = calls.filter(
|
||||
(call) =>
|
||||
call.status !== "succeeded" ||
|
||||
(call.output?.mode === "interactive" && !isAncillaryDownload(snapshot, call)) ||
|
||||
requiresCanvasInteraction(snapshot, call) ||
|
||||
(typeof call.output?.status === "string" && call.output.status !== "succeeded"),
|
||||
);
|
||||
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { ImageSubmissionRejectedError } from "@/lib/ai/runtime/image-generation-error";
|
||||
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
@ -450,6 +451,8 @@ async function submitGeminiTask({
|
||||
);
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
if ([400, 401, 403, 413, 415, 422, 429].includes(response.status))
|
||||
throw new ImageSubmissionRejectedError(`${extractErrorMessage(payload)}(HTTP ${response.status})`, response.status);
|
||||
throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`);
|
||||
}
|
||||
const taskId = extractTaskId(payload);
|
||||
@ -529,6 +532,8 @@ async function submitGptImageTask({
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
if ([400, 401, 403, 413, 415, 422, 429].includes(response.status))
|
||||
throw new ImageSubmissionRejectedError(`${extractErrorMessage(payload)}(HTTP ${response.status})`, response.status);
|
||||
throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`);
|
||||
}
|
||||
const taskId = extractTaskId(payload);
|
||||
|
||||
30
lib/ai/runtime/image-generation-error.ts
Normal file
30
lib/ai/runtime/image-generation-error.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/** Only adapters with an explicit rejection of the submission may use this.
|
||||
* Polling, result downloads and storage failures are not submission refusals. */
|
||||
export class ImageSubmissionRejectedError extends Error {
|
||||
constructor(message: string, readonly status: number) { super(message); }
|
||||
}
|
||||
|
||||
export class AgentImageGenerationError extends Error {
|
||||
constructor(message: string, readonly confirmedNoResult: boolean) { super(message); }
|
||||
}
|
||||
|
||||
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
|
||||
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
|
||||
const rejected = error instanceof ImageSubmissionRejectedError;
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const suffix = mode === "pinned" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。";
|
||||
if (rejected && /余额|insufficient.*(?:balance|credit)|credits? insufficient/i.test(message))
|
||||
return new AgentImageGenerationError(`生图通道余额不足,本次未生成图片。${suffix}`, true);
|
||||
if (rejected && error.status === 429)
|
||||
return new AgentImageGenerationError(`生图通道额度不足或请求过多,本次未生成图片。${suffix}`, true);
|
||||
if (rejected && [401, 403].includes(error.status))
|
||||
return new AgentImageGenerationError(`生图通道鉴权或权限未通过,本次未生成图片。${suffix}`, true);
|
||||
if (/content.?policy|moderation|safety|违规|敏感/i.test(message))
|
||||
return new AgentImageGenerationError("当前图片内容无法处理,请调整描述或参考图后重试。", rejected);
|
||||
if ((error as { providerOutcomeUnknown?: boolean })?.providerOutcomeUnknown || /may still be running|仍在处理|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset/i.test(message))
|
||||
return new AgentImageGenerationError("图片任务的结果暂未确认,已保留任务记录,请先确认原任务结果,避免重复生成。", false);
|
||||
if (rejected) return new AgentImageGenerationError(`生图通道拒绝了本次请求,未生成图片。${suffix}`, true);
|
||||
return new AgentImageGenerationError(mode === "pinned"
|
||||
? "你指定的生图模型本次未能完成,请稍后重试或手动更换模型;系统未自动切换。"
|
||||
: "暂时未能完成本次出图,请检查描述、参考图或稍后继续;已有结果会保留。", rejected);
|
||||
}
|
||||
@ -6,7 +6,8 @@ import { runNativeZcode } from "./native-runtime";
|
||||
import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
|
||||
import { AgentToolRejectedError, publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
|
||||
import { isAncillaryDownload } from "@/lib/ai/canvas-agent-runtime/results";
|
||||
import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
|
||||
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||
|
||||
type Ports = {
|
||||
native?: typeof runNativeZcode;
|
||||
@ -35,6 +36,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
await ports.checkpoint(structuredClone(snapshot), status); return status;
|
||||
}
|
||||
let modelError = "";
|
||||
let toolFailure = "";
|
||||
let verifiedCompletion = "";
|
||||
async function finishVerifiedCutout() {
|
||||
snapshot.nativeRuntime!.state = "stopped";
|
||||
@ -58,6 +60,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
|
||||
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
|
||||
"用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。",
|
||||
"用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。",
|
||||
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
|
||||
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
|
||||
"查询项目级能力先用 listProjectServices;技能先 loadProjectSkill。项目工具的任务受理、文件选择与后台缺失不等于处理完成。",
|
||||
@ -71,6 +74,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
? ["继续当前任务,保留所有已完成结果。", `本轮用户的图片设置:${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) {
|
||||
toolFailure = "";
|
||||
const control = await exclusive(() => ports.consumeInputs(snapshot));
|
||||
if (control.cancelled || ports.signal.aborted) return await save("已停止后续处理,完成的结果保留在画布。", "cancelled");
|
||||
await save("正在准备处理任务");
|
||||
@ -123,6 +127,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
throw new Error("此工具结果待确认,禁止自动重复执行");
|
||||
}
|
||||
if (!entry.readOnly && snapshot.calls.some(call => call.uncertain)) throw new Error("先核对未确认的操作,禁止重复提交收费任务");
|
||||
if (!entry.readOnly && toolFailure) throw new AgentToolRejectedError(toolFailure);
|
||||
if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded"))
|
||||
throw new Error("请先制定任务计划再执行画布修改");
|
||||
if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) {
|
||||
@ -173,7 +178,10 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
} catch (error) {
|
||||
await exclusive(async () => {
|
||||
const call = snapshot.calls.find(call => call.id === callId)!;
|
||||
call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError; call.uncertain = !entry.readOnly && !call.rejected;
|
||||
call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError;
|
||||
call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult;
|
||||
call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult;
|
||||
if (call.confirmedNoResult) toolFailure = call.error;
|
||||
await save(call.error);
|
||||
});
|
||||
throw error;
|
||||
@ -190,18 +198,19 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
await ports.consumeInputs(snapshot);
|
||||
});
|
||||
if (await ports.isCancelled()) return await save("已停止后续处理,完成的结果保留在画布。", "cancelled");
|
||||
if (toolFailure) return await save(toolFailure, "needs_review");
|
||||
// A failed read is not an uncertain paid operation. Independently verify
|
||||
// actual outputs below instead of failing a task because an earlier read timed out.
|
||||
if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected && !ports.tools[call.name]?.readOnly) || call.uncertain))
|
||||
if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain))
|
||||
return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review");
|
||||
const unresolvedRead = snapshot.calls.some((call, index) => call.status === "failed" && !call.rejected &&
|
||||
const unresolvedRead = snapshot.calls.some((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly &&
|
||||
!snapshot.calls.slice(index + 1).some(later => later.name === call.name && later.status === "succeeded" &&
|
||||
JSON.stringify(later.input) === JSON.stringify(call.input)));
|
||||
// Without a real result to independently inspect, a viewport/UI action
|
||||
// cannot turn a failed analysis into a completed task.
|
||||
if (unresolvedRead && !snapshot.resultNodeIds.length)
|
||||
return await save("图片或资料暂未读取成功,已保留记录,请稍后继续核对。", "needs_review");
|
||||
if (question || snapshot.calls.some(call => call.output?.mode === "interactive" && !isAncillaryDownload(snapshot, call)))
|
||||
if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call)))
|
||||
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
|
||||
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));
|
||||
if (supplements.length) { prompt = supplements.map(input => input.text).join("\n"); continue; }
|
||||
@ -220,7 +229,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
return await save(expectsChange ? "尚未执行画布操作,请核对任务计划后继续。" : result.response || "已完成画布分析。", expectsChange ? "needs_review" : "completed");
|
||||
}
|
||||
await save("正在核对画布中的实际结果");
|
||||
const reviewSnapshot = { ...snapshot, calls: snapshot.calls.filter(call => !call.rejected &&
|
||||
const reviewSnapshot = { ...snapshot, calls: snapshot.calls.filter(call => !call.rejected && !call.confirmedNoResult &&
|
||||
!(call.status === "failed" && ports.tools[call.name]?.readOnly)) };
|
||||
const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification;
|
||||
snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens;
|
||||
@ -236,6 +245,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped";
|
||||
const cancelled = await ports.isCancelled().catch(() => false);
|
||||
if (verifiedCompletion && !cancelled && !ports.signal.aborted) return finishVerifiedCutout();
|
||||
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");
|
||||
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import sharp from "sharp";
|
||||
import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
|
||||
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
||||
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
||||
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||||
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
||||
@ -5868,13 +5869,7 @@ export async function generateCanvasImage(
|
||||
}
|
||||
|
||||
if (request.agentImageMode) {
|
||||
const message = String((caughtError as { message?: string })?.message || "");
|
||||
if (/content.?policy|moderation|safety|违规|敏感/i.test(message)) throw new Error("当前图片内容无法处理,请调整描述或参考图后重试。");
|
||||
if ((caughtError as { providerOutcomeUnknown?: boolean })?.providerOutcomeUnknown || /may still be running|仍在处理|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset/i.test(message))
|
||||
throw new Error("图片任务的结果暂未确认,已保留任务记录,请先确认原任务结果,避免重复生成。");
|
||||
throw new Error(request.agentImageMode === "pinned"
|
||||
? "你指定的生图模型本次未能完成,请稍后重试或手动更换模型;系统未自动切换。"
|
||||
: "暂时未能完成本次出图,请检查描述、参考图或稍后继续;已有结果会保留。");
|
||||
throw agentImageGenerationError(caughtError, request.agentImageMode);
|
||||
}
|
||||
throw caughtError;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
import { runZcodeCanvasTask } from '../../lib/ai/zcode-agent/task';
|
||||
import { AgentToolRejectedError, newAgentRunSnapshot, type AgentRunSnapshot } from '../../lib/ai/canvas-agent-runtime/protocol';
|
||||
import type { runNativeZcode } from '../../services/zcode-agent/runner.mjs';
|
||||
import { AgentImageGenerationError } from '../../lib/ai/runtime/image-generation-error';
|
||||
type NativeOptions = Parameters<typeof runNativeZcode>[0];
|
||||
function fixture(objective = '修改图片') {
|
||||
const snapshot = newAgentRunSnapshot(objective, []); let writes = 0; const saved: string[] = [];
|
||||
@ -23,6 +24,36 @@ function fixture(objective = '修改图片') {
|
||||
const result = { type: 'result', sessionId: 'sess_fixture', response: '已完成' };
|
||||
async function main() {
|
||||
let checks = 0;
|
||||
{
|
||||
const h = fixture();let paid = 0;
|
||||
h.ports.tools.edit.execute = async () => { paid++; throw new AgentImageGenerationError('生图通道余额不足,本次未生成图片。你希望切换模型还是稍后重试?', true); };
|
||||
h.ports.native = async options => {
|
||||
await options.execute('planCanvasTask', {}, 'plan', options.signal!);
|
||||
await assert.rejects(options.execute('edit', {nodeIds:['a']}, 'refused', options.signal!));
|
||||
await assert.rejects(options.execute('edit', {nodeIds:['a']}, 'do-not-silently-retry', options.signal!));
|
||||
return result;
|
||||
};
|
||||
assert.equal(await runZcodeCanvasTask(h.ports), 'needs_review'); assert.equal(paid,1);
|
||||
const call=h.snapshot.calls.find(c=>c.id==='refused')!;
|
||||
assert.equal(call.confirmedNoResult,true); assert.equal(call.uncertain,false); assert.match(h.snapshot.message,/余额不足.*切换模型/);
|
||||
// A later user-confirmed retry may proceed, while keeping the old failure in
|
||||
// history and independently checking the newly returned result.
|
||||
h.ports.tools.edit.execute=async()=>{paid++;return {createdNodeIds:['real-result']};};
|
||||
h.ports.native=async options=>{await options.execute('edit',{nodeIds:['a']},'user-retry',options.signal!);return result;};
|
||||
h.ports.verify=async snapshot=>{assert(!snapshot.calls.some(c=>c.confirmedNoResult));return {passed:true,issues:[]};};
|
||||
assert.equal(await runZcodeCanvasTask(h.ports),'completed'); assert.equal(paid,2);checks++;
|
||||
}
|
||||
{
|
||||
const h = fixture(); h.ports.tools.edit.execute=async()=>{throw new AgentImageGenerationError('原任务结果未知,禁止重复提交',false);};
|
||||
h.ports.native=async options=>{await options.execute('planCanvasTask',{},'plan',options.signal!);await options.execute('edit',{nodeIds:['a']},'unknown',options.signal!);return result;};
|
||||
assert.equal(await runZcodeCanvasTask(h.ports),'needs_review'); assert.equal(h.snapshot.calls.at(-1)?.uncertain,true);
|
||||
let submitted=false;h.ports.native=async()=>{submitted=true;return result;};
|
||||
assert.equal(await runZcodeCanvasTask(h.ports),'needs_review');assert.equal(submitted,false);checks++;
|
||||
}
|
||||
{
|
||||
const h=fixture();h.snapshot.calls.push({id:'reference',name:'image_add_to_chat',input:{},status:'succeeded',output:{mode:'interactive',status:'succeeded'}});
|
||||
assert.equal(await runZcodeCanvasTask(h.ports),'completed');checks++;
|
||||
}
|
||||
for (const scenario of ["verified", "rejected-result", "download-requested", "no-result", "editor-panel"]) {
|
||||
const h = fixture(scenario === "download-requested" ? "修改并下载图片" : "把柜子角度改为正面");
|
||||
if (scenario !== "no-result") h.snapshot.resultNodeIds = ["real-result"];
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { serverLoader } from "./canvas-download-server-loader";
|
||||
import * as generationErrors from "@/lib/ai/runtime/image-generation-error";
|
||||
|
||||
export function agentImageProviderHarness(options: { generate?: (id: string, count: number, call: number) => string; models?: any[] } = {}) {
|
||||
const calls: any[] = [], paused = new Set<string>();
|
||||
const models = options.models || ["primary", "backup"].map((id, i) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, isDefault: !i, imageGenConfig: { maxImages: 12 }, capabilities: { kind: "image" } }, provider: { id: "same-provider", type: "openai-image", apiKey: "synthetic", isEnabled: true } }));
|
||||
const mocks: Record<string, any> = {
|
||||
"@/lib/ai/runtime/image-generation-error": generationErrors,
|
||||
"@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models },
|
||||
"@/lib/ai/rmbg/service": {},
|
||||
"@/lib/net/url-access": { filterReachableRemoteUrls: async (urls: string[]) => ({ kept: urls, dropped: [] }) },
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { agentImageProviderHarness as harness } from "../helpers/agent-image-provider-harness";
|
||||
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
||||
import { ImageSubmissionRejectedError, agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||
import * as generationErrors from "@/lib/ai/runtime/image-generation-error";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
|
||||
|
||||
test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => {
|
||||
@ -92,3 +95,31 @@ test("a parameter rejection can recover on a compatible alternate without pausin
|
||||
expect(h.paused.size).toBe(0);
|
||||
expect(h.calls[1].imageParams.aspectRatio).toBe("3:4");
|
||||
});
|
||||
|
||||
test("a pinned quota refusal remains a confirmed failure and asks before switching", async () => {
|
||||
const h = harness({ generate: () => { throw new ImageSubmissionRejectedError("余额不足(HTTP 429)", 429); } });
|
||||
await expect(h.generate({ ...h.request, agentImageMode: "pinned" })).rejects.toMatchObject({
|
||||
confirmedNoResult: true, message: expect.stringMatching(/余额不足.*未生成图片.*切换生图模型.*稍后重试/),
|
||||
});
|
||||
expect(h.calls.map(c => c.id)).toEqual(["primary"]);
|
||||
});
|
||||
|
||||
test("timeouts and post-generation failures never acquire submission refusal evidence", () => {
|
||||
for (const error of [new Error("task timed out"), new Error("结果图片下载失败(HTTP 429)"), new Error("storage failed"), Object.assign(new Error("HTTP 503"), { status: 503 })])
|
||||
expect(agentImageGenerationError(error, "pinned").confirmedNoResult).toBe(false);
|
||||
});
|
||||
|
||||
test("both BananaImage submission APIs mark quota refusal before polling or archiving", async () => {
|
||||
for (const id of ["gemini-3-pro-image-preview", "gpt-image-2"]) {
|
||||
let requests = 0, archived = 0;
|
||||
const { createBananaImage } = serverLoader({
|
||||
"@/lib/ai/runtime/image-generation-error": generationErrors,
|
||||
"@/lib/storage/image-archive": { createDeferredUploadedImage: () => { archived++; throw new Error("unexpected archive"); } },
|
||||
"@/lib/storage/service": {},
|
||||
fetch: async () => { requests++; return Response.json({ error: { message: "余额不足" } }, { status: 429 }); },
|
||||
})("lib/ai/providers/bananaimage.ts");
|
||||
await expect(createBananaImage({ apiKey: "synthetic", baseURL: "https://fixture.invalid" }).chat(id).doGenerate({ messages: [{ role: "user", content: "cat" }] }))
|
||||
.rejects.toBeInstanceOf(ImageSubmissionRejectedError);
|
||||
expect(requests).toBe(1);expect(archived).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
@ -42,6 +42,15 @@ test("progress renders as inline assistant content with one stop action and no s
|
||||
expect(html).toContain("生成图片"); expect(html).toContain("停止后续步骤");
|
||||
expect(html).not.toMatch(/ZCode|沙箱|sandbox|Auto Agent|<input|<form|已处理\s*3\s*轮/i);
|
||||
});
|
||||
test("failed steps are terminal and legacy project descriptions are replaced by short titles", () => {
|
||||
const snapshot=newAgentRunSnapshot("改图",[]);
|
||||
snapshot.calls.push({id:"refused",name:"runProjectService",title:"执行固定项目业务接口。先查清单和真实数据、制定计划;仅当前用户权限",input:{},status:"failed"});
|
||||
const activities=canvasAgentActivities(snapshot);
|
||||
expect(activities[0].title).toBe("处理创作内容");
|
||||
const run={...fixture(),status:"needs_review" as const,message:"通道余额不足,请选择其他模型或稍后重试",activities};
|
||||
const html=renderToStaticMarkup(createElement(CanvasAgentProgressMessage,{progress:canvasAgentProgress(run)}));
|
||||
expect(html).toContain("未完成");expect(html).not.toContain("待处理");expect(html).not.toContain("operationId");expect(html).not.toContain("固定项目业务接口");
|
||||
});
|
||||
test("completed execution details remain expandable in saved assistant history without active controls", () => {
|
||||
const run = { ...fixture(), status: "completed" as const };
|
||||
const part = JSON.parse(JSON.stringify({ type: "data-canvasAgentProgress", data: canvasAgentProgress(run) }));
|
||||
|
||||
@ -198,6 +198,13 @@ async function cutoutFixture() {
|
||||
return { ...f, bytes };
|
||||
}
|
||||
|
||||
test("a completed add-to-conversation command does not keep a generated image waiting for an editor", async () => {
|
||||
const f = await fixture('{"passed":true,"issues":[],"failedNodeIds":[]}');
|
||||
f.params.snapshot.calls.push({ id: "reference", name: "image_add_to_chat", input: {}, status: "succeeded", output: { mode: "interactive", status: "succeeded" } });
|
||||
const result = await verifyCanvasAgentResults(f.params);
|
||||
expect(result.passed).toBe(true);
|
||||
});
|
||||
|
||||
test("a real transparent cutout finishes from original file evidence without a vision request", async () => {
|
||||
const f = await cutoutFixture();
|
||||
expect(isCompletedSimpleCutout(f.params.snapshot)).toBe(true);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user