Compare commits
No commits in common. "main" and "codex/canvas-leave-save-once" have entirely different histories.
main
...
codex/canv
@ -329,7 +329,6 @@ export async function POST(request: Request) {
|
|||||||
withModel,
|
withModel,
|
||||||
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
|
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
|
||||||
imagePreferences: snapshot.imagePreferences,
|
imagePreferences: snapshot.imagePreferences,
|
||||||
priorCalls: snapshot.calls,
|
|
||||||
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
|
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
|
||||||
operationSelectedNodeIds: selectedNodeIds,
|
operationSelectedNodeIds: selectedNodeIds,
|
||||||
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
|
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/clien
|
|||||||
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||||
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
|
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||||
import equal from "fast-deep-equal";
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -172,7 +171,6 @@ import {
|
|||||||
type VideoCanvasSymbolMarkerId,
|
type VideoCanvasSymbolMarkerId,
|
||||||
} from "@/lib/canvas/video-canvas-markers";
|
} from "@/lib/canvas/video-canvas-markers";
|
||||||
import {
|
import {
|
||||||
isCanvasImageUploadPending,
|
|
||||||
LOCAL_UPLOAD_PENDING_METADATA_KEY,
|
LOCAL_UPLOAD_PENDING_METADATA_KEY,
|
||||||
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
|
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
|
||||||
} from "@/lib/canvas/local-upload-state";
|
} from "@/lib/canvas/local-upload-state";
|
||||||
@ -5129,14 +5127,13 @@ export default function CanvasEditorLegacyPage({
|
|||||||
canvasPersistenceScope.current = id;
|
canvasPersistenceScope.current = id;
|
||||||
const hasUnsavedCanvasChanges = useCallback(() => {
|
const hasUnsavedCanvasChanges = useCallback(() => {
|
||||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
|
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
|
||||||
if (useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) return true;
|
|
||||||
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
|
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
|
||||||
const current = JSON.parse(JSON.stringify(buildPersistedCanvasData())) as CanvasState;
|
const current = buildPersistedCanvasData();
|
||||||
const content = (value: CanvasState) => [value.nodes.map(node => {
|
const content = (value: CanvasState) => [value.nodes.map(node => {
|
||||||
const { updatedAt: _updatedAt, ...nodeData } = node;
|
const { updatedAt: _updatedAt, ...nodeData } = node;
|
||||||
return { ...nodeData, connections: node.connections || [] };
|
return { ...nodeData, connections: node.connections || [] };
|
||||||
}), value.edges || [], value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
|
}), value.edges, value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
|
||||||
return !equal(content(saved), content(current));
|
return JSON.stringify(content(saved)) !== JSON.stringify(content(current));
|
||||||
}, [buildPersistedCanvasData, id]);
|
}, [buildPersistedCanvasData, id]);
|
||||||
const requestCanvasLeave = useCallback((url: string) => {
|
const requestCanvasLeave = useCallback((url: string) => {
|
||||||
if (hasUnsavedCanvasChanges()) {
|
if (hasUnsavedCanvasChanges()) {
|
||||||
@ -5179,7 +5176,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
try {
|
try {
|
||||||
let canvasData = buildPersistedCanvasData();
|
let canvasData = buildPersistedCanvasData();
|
||||||
let snapshot = JSON.stringify({ canvasData });
|
let snapshot = JSON.stringify({ canvasData });
|
||||||
if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) {
|
if (savedCanvasSnapshotRef.current?.id === id && savedCanvasSnapshotRef.current.snapshot === snapshot) {
|
||||||
clearCanvasDraft(id);
|
clearCanvasDraft(id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -5227,8 +5224,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||||
});
|
});
|
||||||
canvasSyncErrorRef.current = result.status === 409
|
canvasSyncErrorRef.current = result.status === 409
|
||||||
? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
|
? "画布同步发生冲突,本次任务未提交,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
|
||||||
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`;
|
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"},本次任务未提交。${retained ? "本地草稿已保留,请检查网络后再次发送。" : "请保留当前页面,检查网络后再次发送,暂勿刷新。"}`;
|
||||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||||
} else {
|
} else {
|
||||||
canvasSyncErrorRef.current = "";
|
canvasSyncErrorRef.current = "";
|
||||||
@ -5261,10 +5258,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const finishCanvasLeave = useCallback(async (save: boolean) => {
|
const finishCanvasLeave = useCallback(async (save: boolean) => {
|
||||||
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
|
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
|
||||||
setCanvasLeaveError("");
|
setCanvasLeaveError("");
|
||||||
if (save && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
|
||||||
setCanvasLeaveError("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再保存并离开。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (canvasAutosaveTimer.current) {
|
if (canvasAutosaveTimer.current) {
|
||||||
clearTimeout(canvasAutosaveTimer.current);
|
clearTimeout(canvasAutosaveTimer.current);
|
||||||
canvasAutosaveTimer.current = null;
|
canvasAutosaveTimer.current = null;
|
||||||
@ -5328,7 +5321,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return canvasSaveQueue.current!.enqueueOperation(
|
return canvasSaveQueue.current!.enqueueOperation(
|
||||||
async () => {
|
async () => {
|
||||||
const saved = await persistCanvasStateNow("autosave");
|
const saved = await persistCanvasStateNow("autosave");
|
||||||
if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
|
if (!saved) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
@ -5350,18 +5343,20 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const persistCanvasBeforeAgent = useCallback(async () => {
|
const persistCanvasBeforeAgent = useCallback(async () => {
|
||||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
|
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
|
||||||
throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
|
throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
|
||||||
if (savedCanvasSnapshotRef.current?.id === id) {
|
if (savedCanvasSnapshotRef.current?.id === id) {
|
||||||
if (!hasUnsavedCanvasChanges()) return true;
|
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData;
|
||||||
|
const current = buildPersistedCanvasData();
|
||||||
|
if (JSON.stringify(saved.nodes) === JSON.stringify(current.nodes) && JSON.stringify(saved.edges) === JSON.stringify(current.edges)) return true;
|
||||||
}
|
}
|
||||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||||
if (canvasAutosaveTimer.current) {
|
if (canvasAutosaveTimer.current) {
|
||||||
clearTimeout(canvasAutosaveTimer.current);
|
clearTimeout(canvasAutosaveTimer.current);
|
||||||
canvasAutosaveTimer.current = null;
|
canvasAutosaveTimer.current = null;
|
||||||
}
|
}
|
||||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
|
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
|
||||||
return true;
|
return true;
|
||||||
}, [persistCanvasState, hasUnsavedCanvasChanges, id]);
|
}, [persistCanvasState, buildPersistedCanvasData, id]);
|
||||||
|
|
||||||
const canvasRemoteOperationContext = useMemo(() => ({
|
const canvasRemoteOperationContext = useMemo(() => ({
|
||||||
run: runCanvasRemoteOperation,
|
run: runCanvasRemoteOperation,
|
||||||
@ -5372,10 +5367,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
if (isVideoMode) return;
|
if (isVideoMode) return;
|
||||||
const beforeRefresh = (event: Event) => {
|
const beforeRefresh = (event: Event) => {
|
||||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||||
if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
|
||||||
request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (useCanvasStore.getState().isGenerating) {
|
if (useCanvasStore.getState().isGenerating) {
|
||||||
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
|
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
|
||||||
return;
|
return;
|
||||||
@ -5552,7 +5543,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
if (!retainDraft()) return;
|
if (!retainDraft()) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.returnValue = "true";
|
event.returnValue = "";
|
||||||
};
|
};
|
||||||
const handlePageHide = () => {
|
const handlePageHide = () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -68,7 +68,6 @@ export function AppUpdateNotice() {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
|
|
||||||
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
|
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@ -8972,10 +8972,6 @@ export function CanvasChatPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const beforeRefresh = (event: Event) => {
|
const beforeRefresh = (event: Event) => {
|
||||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||||
if (composerUploadProgress) {
|
|
||||||
request.block("素材仍在上传,请等待上传完成后再刷新。当前页面和输入已保留。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
|
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
|
||||||
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
|
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
|
||||||
return;
|
return;
|
||||||
@ -8985,17 +8981,9 @@ export function CanvasChatPanel({
|
|||||||
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = "true";
|
|
||||||
};
|
|
||||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||||
if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload);
|
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||||
return () => {
|
}, [activeChatId, input, attachments, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
|
||||||
window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
|
||||||
window.removeEventListener("beforeunload", beforeUnload);
|
|
||||||
};
|
|
||||||
}, [activeChatId, input, attachments, composerUploadProgress, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
|
|
||||||
|
|
||||||
const restorePendingClawdRequestToComposer = useCallback(
|
const restorePendingClawdRequestToComposer = useCallback(
|
||||||
(snapshot: PendingClawdRequestRestoreSnapshot) => {
|
(snapshot: PendingClawdRequestRestoreSnapshot) => {
|
||||||
|
|||||||
@ -13,18 +13,13 @@ export type AgentImageParameters = {
|
|||||||
};
|
};
|
||||||
export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters };
|
export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters };
|
||||||
|
|
||||||
export function selectedAgentImageCount(preferences?: AgentImagePreferences) {
|
|
||||||
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
|
|
||||||
return count === undefined ? undefined : Math.floor(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyAgentImagePreferences<T extends Record<string, unknown>>(
|
export function applyAgentImagePreferences<T extends Record<string, unknown>>(
|
||||||
input: T,
|
input: T,
|
||||||
preferences?: AgentImagePreferences,
|
preferences?: AgentImagePreferences,
|
||||||
): T & AgentImageParameters & { modelId?: string } {
|
): T & AgentImageParameters & { modelId?: string } {
|
||||||
const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {})
|
const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {})
|
||||||
.filter(([, value]) => value !== undefined));
|
.filter(([, value]) => value !== undefined));
|
||||||
const count = selectedAgentImageCount(preferences);
|
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
|
||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
...parameters,
|
...parameters,
|
||||||
|
|||||||
@ -1,14 +1,5 @@
|
|||||||
import type { AgentCallRecord, AgentRunSnapshot } from "./protocol";
|
import type { AgentCallRecord, AgentRunSnapshot } from "./protocol";
|
||||||
|
|
||||||
export function countAgentGeneratedImages(calls: AgentCallRecord[]) {
|
|
||||||
return calls.reduce((count, call) => {
|
|
||||||
const output = call.output;
|
|
||||||
if (call.status !== "succeeded" || !output?.appliedImagePreferences ||
|
|
||||||
!Array.isArray(output.createdNodeIds)) return count;
|
|
||||||
return count + output.createdNodeIds.length;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A spontaneous download command is not an unfinished image edit. Keep genuine
|
// A spontaneous download command is not an unfinished image edit. Keep genuine
|
||||||
// download requests and all editor panels waiting for their actual interaction.
|
// download requests and all editor panels waiting for their actual interaction.
|
||||||
export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) {
|
export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) {
|
||||||
|
|||||||
@ -5,8 +5,7 @@ import sharp from "sharp";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
|
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
|
||||||
import type { AgentRunSnapshot } from "./protocol";
|
import type { AgentRunSnapshot } from "./protocol";
|
||||||
import { countAgentGeneratedImages, finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
|
import { finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
|
||||||
import { selectedAgentImageCount } from "./image-preferences";
|
|
||||||
import { isCompletedSimpleCutout } from "./cutout-completion";
|
import { isCompletedSimpleCutout } from "./cutout-completion";
|
||||||
|
|
||||||
export async function inspectImageAlpha(source: Buffer) {
|
export async function inspectImageAlpha(source: Buffer) {
|
||||||
@ -125,16 +124,14 @@ export async function verifyCanvasAgentResults(params: {
|
|||||||
(call) =>
|
(call) =>
|
||||||
call.status !== "succeeded" ||
|
call.status !== "succeeded" ||
|
||||||
requiresCanvasInteraction(snapshot, call) ||
|
requiresCanvasInteraction(snapshot, call) ||
|
||||||
(typeof call.output?.status === "string" && call.output.status !== "succeeded" &&
|
(typeof call.output?.status === "string" && call.output.status !== "succeeded"),
|
||||||
!(call.output.status === "partial" && call.output.appliedImagePreferences)),
|
|
||||||
);
|
);
|
||||||
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };
|
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };
|
||||||
const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output;
|
const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output;
|
||||||
if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] };
|
if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] };
|
||||||
// Intermediate images consumed by later write tools do not count as final deliverables.
|
// Intermediate images consumed by later write tools do not count as final deliverables.
|
||||||
const finalIds = finalResultNodeIds(snapshot);
|
const finalIds = finalResultNodeIds(snapshot);
|
||||||
const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount ??
|
const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount;
|
||||||
(countAgentGeneratedImages(calls) > 0 ? selectedAgentImageCount(snapshot.imagePreferences) : undefined);
|
|
||||||
if (expectedCount && finalIds.length !== expectedCount) {
|
if (expectedCount && finalIds.length !== expectedCount) {
|
||||||
return {
|
return {
|
||||||
passed: false,
|
passed: false,
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
import type { AgentVisionExecutor } from "../runtime/agent-vision-failover";
|
import type { AgentVisionExecutor } from "../runtime/agent-vision-failover";
|
||||||
import {
|
import { resolveUserImageModel, applyAgentImagePreferences, agentProviderImageParameters, type AgentImagePreferences } from "./image-preferences";
|
||||||
agentProviderImageParameters,
|
|
||||||
applyAgentImagePreferences,
|
|
||||||
resolveUserImageModel,
|
|
||||||
selectedAgentImageCount,
|
|
||||||
type AgentImagePreferences,
|
|
||||||
} from "./image-preferences";
|
|
||||||
import { tool, type LanguageModel, type ToolSet } from "ai";
|
import { tool, type LanguageModel, type ToolSet } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools";
|
import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools";
|
||||||
@ -24,8 +18,6 @@ import {
|
|||||||
sortStyleProImageModelEntries,
|
sortStyleProImageModelEntries,
|
||||||
} from "@/lib/style-pro/model-options";
|
} from "@/lib/style-pro/model-options";
|
||||||
import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop";
|
import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop";
|
||||||
import { AgentToolRejectedError, type AgentCallRecord } from "./protocol";
|
|
||||||
import { countAgentGeneratedImages } from "./results";
|
|
||||||
import { readCanvasImages, readOwnedCanvas } from "./canvas";
|
import { readCanvasImages, readOwnedCanvas } from "./canvas";
|
||||||
import { analyzeRuntimeImages } from "./review";
|
import { analyzeRuntimeImages } from "./review";
|
||||||
import { isExplicitDestructiveIntent } from "./capability-access";
|
import { isExplicitDestructiveIntent } from "./capability-access";
|
||||||
@ -45,13 +37,11 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
imageSize?: string;
|
imageSize?: string;
|
||||||
preferredModelId?: string;
|
preferredModelId?: string;
|
||||||
imagePreferences?: AgentImagePreferences;
|
imagePreferences?: AgentImagePreferences;
|
||||||
priorCalls?: AgentCallRecord[];
|
|
||||||
emit: (type: string, data: unknown) => void;
|
emit: (type: string, data: unknown) => void;
|
||||||
objective?: string;
|
objective?: string;
|
||||||
operationSelectedNodeIds?: string[];
|
operationSelectedNodeIds?: string[];
|
||||||
}) {
|
}) {
|
||||||
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
|
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
|
||||||
const selectedImageCount = selectedAgentImageCount(params.imagePreferences);
|
|
||||||
const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id);
|
const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id);
|
||||||
const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id));
|
const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id));
|
||||||
const sdk = await createCanvasCapabilityTools({
|
const sdk = await createCanvasCapabilityTools({
|
||||||
@ -138,8 +128,8 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
visualCriteria: z.array(z.string().max(200)).max(10),
|
visualCriteria: z.array(z.string().max(200)).max(10),
|
||||||
}),
|
}),
|
||||||
async (input) => ({ ...input,
|
async (input) => ({ ...input,
|
||||||
...(input.expectedImageCount !== undefined && selectedImageCount !== undefined
|
...(input.expectedImageCount !== undefined && params.imagePreferences?.parameters?.imageCount !== undefined
|
||||||
? { expectedImageCount: selectedImageCount } : {}),
|
? { expectedImageCount: params.imagePreferences.parameters.imageCount } : {}),
|
||||||
...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto"
|
...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto"
|
||||||
? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}),
|
? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}),
|
||||||
}),
|
}),
|
||||||
@ -173,8 +163,6 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const inspectedImages = new Map<string, Buffer>();
|
|
||||||
let inspectedImageBytes = 0;
|
|
||||||
register(
|
register(
|
||||||
"analyzeCanvasImages",
|
"analyzeCanvasImages",
|
||||||
"检查图片",
|
"检查图片",
|
||||||
@ -183,28 +171,10 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4),
|
nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4),
|
||||||
question: z.string().min(1).max(2000),
|
question: z.string().min(1).max(2000),
|
||||||
}),
|
}),
|
||||||
async (input) => analyzeRuntimeImages({
|
async (input) => analyzeRuntimeImages({ ...params, ...input }),
|
||||||
...params,
|
|
||||||
...input,
|
|
||||||
reviewPorts: {
|
|
||||||
readImage: async (url) => {
|
|
||||||
const cached = inspectedImages.get(url);
|
|
||||||
if (cached) return cached;
|
|
||||||
const { fetchImageBufferFromUrl } = await import("@/lib/ai/rmbg/service");
|
|
||||||
const original = await fetchImageBufferFromUrl(url);
|
|
||||||
if (!inspectedImages.has(url) && inspectedImageBytes + original.length <= 32 * 1024 * 1024) {
|
|
||||||
inspectedImages.set(url, original);
|
|
||||||
inspectedImageBytes += original.length;
|
|
||||||
}
|
|
||||||
return original;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let submittedCount = 0;
|
let generatedCount = 0;
|
||||||
let occupiedImageSlots = countAgentGeneratedImages(params.priorCalls ?? []);
|
|
||||||
const countLimitMessage = "本次设定的生成张数已占满,请先查看正在生成或已经生成的结果。";
|
|
||||||
register(
|
register(
|
||||||
"generateCanvasImages",
|
"generateCanvasImages",
|
||||||
"生成图片",
|
"生成图片",
|
||||||
@ -219,8 +189,9 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
}),
|
}),
|
||||||
async (toolInput, callId) => {
|
async (toolInput, callId) => {
|
||||||
const input = applyAgentImagePreferences(toolInput, params.imagePreferences);
|
const input = applyAgentImagePreferences(toolInput, params.imagePreferences);
|
||||||
if (selectedImageCount !== undefined && occupiedImageSlots >= selectedImageCount)
|
// Bound per-task spend without introducing any global/model concurrency queue.
|
||||||
throw new AgentToolRejectedError(countLimitMessage);
|
if (generatedCount + input.imageCount > 24)
|
||||||
|
throw new Error("本次任务已达到 24 张生成预算,请检查结果后继续。");
|
||||||
const sources = await readCanvasImages(
|
const sources = await readCanvasImages(
|
||||||
params.userId,
|
params.userId,
|
||||||
params.projectId,
|
params.projectId,
|
||||||
@ -243,17 +214,10 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
if (!chosen) throw new Error("系统未配置可用的图片生成模型");
|
if (!chosen) throw new Error("系统未配置可用的图片生成模型");
|
||||||
const imageSize = input.imageSize || params.imageSize || "2K";
|
const imageSize = input.imageSize || params.imageSize || "2K";
|
||||||
const quota = await checkUserImageQuota(params.userId);
|
const quota = await checkUserImageQuota(params.userId);
|
||||||
const points = await getPointsSystemConfig();
|
|
||||||
const user = await getUserById({ id: params.userId });
|
|
||||||
// Reserve the remaining slots before dispatch, including calls still in flight.
|
|
||||||
if (selectedImageCount !== undefined) {
|
|
||||||
input.imageCount = Math.min(input.imageCount, selectedImageCount - occupiedImageSlots);
|
|
||||||
if (input.imageCount < 1) throw new AgentToolRejectedError(countLimitMessage);
|
|
||||||
}
|
|
||||||
if (submittedCount + input.imageCount > 24)
|
|
||||||
throw new AgentToolRejectedError("本次任务已达到 24 张生成预算,请检查结果后继续。");
|
|
||||||
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
|
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
|
||||||
throw new Error("本月图片生成配额不足。");
|
throw new Error("本月图片生成配额不足。");
|
||||||
|
const points = await getPointsSystemConfig();
|
||||||
|
const user = await getUserById({ id: params.userId });
|
||||||
const cost =
|
const cost =
|
||||||
getModelGenerationCreditCost(
|
getModelGenerationCreditCost(
|
||||||
{ imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null },
|
{ imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null },
|
||||||
@ -267,8 +231,7 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
(user?.pointsBalance ?? 0) < cost
|
(user?.pointsBalance ?? 0) < cost
|
||||||
)
|
)
|
||||||
throw new Error("积分不足,请充值或联系管理员。");
|
throw new Error("积分不足,请充值或联系管理员。");
|
||||||
submittedCount += input.imageCount;
|
generatedCount += input.imageCount;
|
||||||
occupiedImageSlots += input.imageCount;
|
|
||||||
const result = await generateCanvasImage({
|
const result = await generateCanvasImage({
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
model: chosen.model.id,
|
model: chosen.model.id,
|
||||||
@ -284,7 +247,6 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined,
|
? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined,
|
||||||
ownerKey: params.userId,
|
ownerKey: params.userId,
|
||||||
referenceImages: sources.map((node) => node.data.src || node.data.url!),
|
referenceImages: sources.map((node) => node.data.src || node.data.url!),
|
||||||
referenceImageBuffers: inspectedImages,
|
|
||||||
debugLabel: "canvas-agent-runtime",
|
debugLabel: "canvas-agent-runtime",
|
||||||
debugMeta: { runId: params.runId, callId },
|
debugMeta: { runId: params.runId, callId },
|
||||||
});
|
});
|
||||||
@ -349,7 +311,6 @@ export async function buildCanvasRuntimeTools(params: {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
occupiedImageSlots += result.images.length - input.imageCount;
|
|
||||||
return {
|
return {
|
||||||
status: result.images.length < input.imageCount ? "partial" : "succeeded",
|
status: result.images.length < input.imageCount ? "partial" : "succeeded",
|
||||||
appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount },
|
appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount },
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import sharp from "sharp";
|
|||||||
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
|
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
|
||||||
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
|
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
|
||||||
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||||
import { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
|
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
@ -166,7 +165,7 @@ async function fetchImageBufferWithRetry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError ?? new Error("下载图片失败");
|
throw lastError instanceof Error ? lastError : new Error(String(lastError || "下载图片失败"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string {
|
function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string {
|
||||||
@ -290,20 +289,12 @@ async function prepareAlibabaInputImageUrl(
|
|||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
inputRole?: AlibabaImageInputRole,
|
inputRole?: AlibabaImageInputRole,
|
||||||
original?: Buffer,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const normalizedUrl = String(imageUrl || "").trim();
|
const normalizedUrl = String(imageUrl || "").trim();
|
||||||
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
|
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
|
||||||
|
|
||||||
const maxInputBytes = getAlibabaMaxInputBytes(modelId);
|
const maxInputBytes = getAlibabaMaxInputBytes(modelId);
|
||||||
const { buffer, contentType } = original
|
const { buffer, contentType } = await fetchImageBufferWithRetry(normalizedUrl);
|
||||||
? { buffer: original, contentType: null }
|
|
||||||
: await fetchImageBufferWithRetry(normalizedUrl).catch((error) => {
|
|
||||||
if (["AbortError", "TimeoutError"].includes(error?.name)) {
|
|
||||||
throw new ImageReferenceReadTimeoutError();
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
let metadata: sharp.Metadata;
|
let metadata: sharp.Metadata;
|
||||||
try {
|
try {
|
||||||
metadata = await sharp(buffer, { failOn: "none" }).metadata();
|
metadata = await sharp(buffer, { failOn: "none" }).metadata();
|
||||||
@ -570,7 +561,7 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
url: params.requestURL,
|
url: params.requestURL,
|
||||||
});
|
});
|
||||||
const taskId = String(submitted.output?.task_id || "").trim();
|
const taskId = String(submitted.output?.task_id || "").trim();
|
||||||
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认");
|
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id");
|
||||||
|
|
||||||
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
|
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@ -594,7 +585,7 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
console.log("[Alibaba Image] Async task completed:", taskId);
|
console.log("[Alibaba Image] Async task completed:", taskId);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
if (["FAILED", "CANCELED"].includes(taskStatus)) {
|
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
||||||
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
|
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
|
||||||
const code = task.output?.code || task.code;
|
const code = task.output?.code || task.code;
|
||||||
throw new AlibabaImageTerminalTaskError(
|
throw new AlibabaImageTerminalTaskError(
|
||||||
@ -610,7 +601,7 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
error.status < 500 &&
|
error.status < 500 &&
|
||||||
![408, 425, 429].includes(error.status)
|
![408, 425, 429].includes(error.status)
|
||||||
) {
|
) {
|
||||||
throw new Error(`阿里图片任务已提交,结果尚未确认(task_id: ${taskId})`, { cause: error });
|
throw error;
|
||||||
}
|
}
|
||||||
lastPollError = error;
|
lastPollError = error;
|
||||||
console.warn("[Alibaba Image] Async task poll failed; retrying:", {
|
console.warn("[Alibaba Image] Async task poll failed; retrying:", {
|
||||||
@ -732,7 +723,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
doGenerate: async (options: {
|
doGenerate: async (options: {
|
||||||
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>;
|
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>;
|
||||||
imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number };
|
imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number };
|
||||||
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
|
|
||||||
modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示
|
modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示
|
||||||
overrideModelId?: string; // 🆕 备用模型 ID
|
overrideModelId?: string; // 🆕 备用模型 ID
|
||||||
}) => {
|
}) => {
|
||||||
@ -808,7 +798,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
activeModelId,
|
activeModelId,
|
||||||
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
||||||
options.referenceImageBuffers?.get(imageUrl),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -949,9 +938,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 处理响应
|
// 处理响应
|
||||||
const images = await processResponse(response).catch((error) => {
|
const images = await processResponse(response);
|
||||||
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: error });
|
|
||||||
});
|
|
||||||
const markdownText = formatImageResponse(images, params.size, imageSize);
|
const markdownText = formatImageResponse(images, params.size, imageSize);
|
||||||
|
|
||||||
console.log(`[Alibaba Image] Generated ${images.length} images successfully`);
|
console.log(`[Alibaba Image] Generated ${images.length} images successfully`);
|
||||||
|
|||||||
@ -8,12 +8,6 @@ export class AgentImageGenerationError extends Error {
|
|||||||
constructor(message: string, readonly confirmedNoResult: boolean) { super(message); }
|
constructor(message: string, readonly confirmedNoResult: boolean) { super(message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageReferenceReadTimeoutError extends AgentImageGenerationError {
|
|
||||||
constructor() {
|
|
||||||
super("提交生图前读取参考图超时,任务尚未提交,可以稍后重试。", true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
|
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
|
||||||
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
|
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
|
||||||
const rejected = error instanceof ImageSubmissionRejectedError;
|
const rejected = error instanceof ImageSubmissionRejectedError;
|
||||||
|
|||||||
@ -7,8 +7,7 @@ import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
|
|||||||
import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
|
import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
|
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
|
||||||
import { countAgentGeneratedImages, requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
|
import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
|
||||||
import { selectedAgentImageCount } from "@/lib/ai/canvas-agent-runtime/image-preferences";
|
|
||||||
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { publicAssistantText } from "./assistant-output";
|
import { publicAssistantText } from "./assistant-output";
|
||||||
|
|
||||||
@ -50,16 +49,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。";
|
if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。";
|
||||||
return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`;
|
return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`;
|
||||||
}
|
}
|
||||||
function imageCountReached() {
|
|
||||||
const count = snapshot.workspace === "video" ? undefined : selectedAgentImageCount(snapshot.imagePreferences);
|
|
||||||
return count !== undefined && countAgentGeneratedImages(snapshot.calls) >= count;
|
|
||||||
}
|
|
||||||
function reviewMessage(issues: string[]) {
|
|
||||||
const summary = `结果待核对:${issues.join(";")}`;
|
|
||||||
return imageCountReached()
|
|
||||||
? `${summary}\n\n已保留本次生成的图片。如需再生成,请发送新指令。`
|
|
||||||
: summary;
|
|
||||||
}
|
|
||||||
let modelError = "";
|
let modelError = "";
|
||||||
let toolFailure = "";
|
let toolFailure = "";
|
||||||
let nativeFailed = false;
|
let nativeFailed = false;
|
||||||
@ -95,7 +84,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
|
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
|
||||||
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
|
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
|
||||||
"用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。",
|
"用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。",
|
||||||
"用户设置的生成张数是本次任务的总数,包括验收未通过的已生成图片。达到张数后,保留结果并说明问题;如需再次生成,等待用户发送新指令。",
|
|
||||||
"用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。",
|
"用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。",
|
||||||
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
|
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
|
||||||
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
|
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
|
||||||
@ -289,12 +277,12 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification;
|
const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification;
|
||||||
snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens;
|
snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens;
|
||||||
if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed");
|
if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed");
|
||||||
if (verification.retryable && snapshot.repairs < 2 && !imageCountReached()) {
|
if (verification.retryable && snapshot.repairs < 2) {
|
||||||
snapshot.repairs++;
|
snapshot.repairs++;
|
||||||
prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`;
|
prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return await save(reviewMessage(verification.issues), "needs_review");
|
return await save(`结果待核对:${verification.issues.join(";")}`, "needs_review");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped";
|
if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped";
|
||||||
@ -318,7 +306,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
|||||||
if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled");
|
if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled");
|
||||||
if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review");
|
if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review");
|
||||||
return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。"
|
return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。"
|
||||||
: `图片已返回画布,${reviewMessage(verification.issues)}`, verification.passed ? "completed" : "needs_review");
|
: `图片已返回画布,结果待核对:${verification.issues.join(";")}`, verification.passed ? "completed" : "needs_review");
|
||||||
} catch { /* Keep the original interruption if independent inspection also fails. */ }
|
} catch { /* Keep the original interruption if independent inspection also fails. */ }
|
||||||
}
|
}
|
||||||
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");
|
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import sharp from "sharp";
|
|||||||
import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
|
import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
|
||||||
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
||||||
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
||||||
import { agentImageGenerationError, ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
|
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||||
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||||||
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
||||||
@ -181,7 +181,6 @@ export interface CanvasGenerateImageRequest {
|
|||||||
providerImageParams?: Record<string, unknown>;
|
providerImageParams?: Record<string, unknown>;
|
||||||
referenceImage?: string;
|
referenceImage?: string;
|
||||||
referenceImages?: string[];
|
referenceImages?: string[];
|
||||||
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
|
|
||||||
billingFeature?: "multi-view" | "repaint";
|
billingFeature?: "multi-view" | "repaint";
|
||||||
requiredReferenceImageCount?: number;
|
requiredReferenceImageCount?: number;
|
||||||
ownerKey?: string;
|
ownerKey?: string;
|
||||||
@ -5122,11 +5121,8 @@ export async function generateCanvasImage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const referenceImageBuffers = provider.type === "alibaba-image" ? request.referenceImageBuffers : undefined;
|
const { kept: reachableReferenceImages, dropped: unreachableReferenceImages } =
|
||||||
const referencesToCheck = normalizedReferenceImages.filter(url => !referenceImageBuffers?.has(url));
|
await filterReachableRemoteUrls(normalizedReferenceImages, { timeoutMs: 3000 });
|
||||||
const { kept, dropped: unreachableReferenceImages } =
|
|
||||||
await filterReachableRemoteUrls(referencesToCheck, { timeoutMs: 3000 });
|
|
||||||
const reachableReferenceImages = normalizedReferenceImages.filter(url => referenceImageBuffers?.has(url) || kept.includes(url));
|
|
||||||
const effectiveReferenceImages = selectCanvasReferenceImages({
|
const effectiveReferenceImages = selectCanvasReferenceImages({
|
||||||
requested: normalizedReferenceImages, reachable: reachableReferenceImages,
|
requested: normalizedReferenceImages, reachable: reachableReferenceImages,
|
||||||
requiredCount: request.requiredReferenceImageCount,
|
requiredCount: request.requiredReferenceImageCount,
|
||||||
@ -5237,7 +5233,6 @@ export async function generateCanvasImage(
|
|||||||
executeGenerateBatch = async (batchImageCount) => {
|
executeGenerateBatch = async (batchImageCount) => {
|
||||||
const result = await modelInstance.doGenerate({
|
const result = await modelInstance.doGenerate({
|
||||||
messages,
|
messages,
|
||||||
referenceImageBuffers,
|
|
||||||
imageParams: {
|
imageParams: {
|
||||||
...imageParams,
|
...imageParams,
|
||||||
imageCount: batchImageCount,
|
imageCount: batchImageCount,
|
||||||
@ -5667,11 +5662,6 @@ export async function generateCanvasImage(
|
|||||||
await slot.release();
|
await slot.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (caughtError instanceof ImageReferenceReadTimeoutError) {
|
|
||||||
if (!collectedImages.length) throw caughtError;
|
|
||||||
return { images: collectedImages, model: selectedModel.model.displayName, modelRef: selectedModel.model.id, prompt: resultPrompt };
|
|
||||||
}
|
|
||||||
|
|
||||||
// An accepted or uncertain request must not start another paid model/variant.
|
// An accepted or uncertain request must not start another paid model/variant.
|
||||||
if (caughtError && (
|
if (caughtError && (
|
||||||
caughtError instanceof ProviderTaskUncertainError ||
|
caughtError instanceof ProviderTaskUncertainError ||
|
||||||
|
|||||||
@ -217,9 +217,7 @@ export async function convertImageToPhotoshopPng(bytes: Buffer): Promise<Buffer>
|
|||||||
throw new Error("无法解析图片尺寸");
|
throw new Error("无法解析图片尺寸");
|
||||||
}
|
}
|
||||||
// RGB/RGBA, 8-bit, single-frame PNG is already compatible; preserve its exact bytes.
|
// RGB/RGBA, 8-bit, single-frame PNG is already compatible; preserve its exact bytes.
|
||||||
const rgb = (metadata.space === "srgb" && metadata.depth === "uchar")
|
if (metadata.format === "png" && metadata.space === "srgb" && metadata.depth === "uchar"
|
||||||
|| (metadata.space === "rgb16" && metadata.depth === "ushort");
|
|
||||||
if (metadata.format === "png" && rgb
|
|
||||||
&& !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes;
|
&& !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes;
|
||||||
return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
|
return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,13 +41,12 @@ export async function runImageArchiveJob() {
|
|||||||
try {
|
try {
|
||||||
if (!(await markImageArchiveTaskArchiving(task.id))) return;
|
if (!(await markImageArchiveTaskArchiving(task.id))) return;
|
||||||
const metadata = task.metadata as { inlineSource?: string } | null;
|
const metadata = task.metadata as { inlineSource?: string } | null;
|
||||||
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl, task.id);
|
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl);
|
||||||
await markImageArchiveTaskStored({
|
await markImageArchiveTaskStored({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
targetUrl: archived.url,
|
targetUrl: archived.url,
|
||||||
downloadAsset: archived.downloadAsset,
|
|
||||||
});
|
});
|
||||||
if (!archived.downloadAsset) scheduleImageDownloadPreparation(task.id);
|
scheduleImageDownloadPreparation(task.id);
|
||||||
storeSucceeded += 1;
|
storeSucceeded += 1;
|
||||||
console.log("[ImageArchiveJob] stored", {
|
console.log("[ImageArchiveJob] stored", {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
|
|||||||
@ -293,9 +293,6 @@ export function getRequestFailureDetails(
|
|||||||
if (/AOS结果下载超时/.test(text)) {
|
if (/AOS结果下载超时/.test(text)) {
|
||||||
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
|
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
|
||||||
}
|
}
|
||||||
if (/提交生图前读取参考图超时/.test(text)) {
|
|
||||||
return detail("IMAGE_REFERENCE_READ_TIMEOUT", "提交生图前读取参考图超时,任务尚未提交。", "可以稍后重试。");
|
|
||||||
}
|
|
||||||
const errorName = isRecord(error) ? String(error.name || "") : "";
|
const errorName = isRecord(error) ? String(error.name || "") : "";
|
||||||
if (["AbortError", "TimeoutError"].includes(errorName) || /operation was aborted/i.test(text)) {
|
if (["AbortError", "TimeoutError"].includes(errorName) || /operation was aborted/i.test(text)) {
|
||||||
return detail("RESULT_UNCONFIRMED", errorName === "TimeoutError" ? "请求等待超时,任务结果尚未确认。" : "本次请求连接已中止,任务结果尚未确认。", "请先核对原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
return detail("RESULT_UNCONFIRMED", errorName === "TimeoutError" ? "请求等待超时,任务结果尚未确认。" : "本次请求连接已中止,任务结果尚未确认。", "请先核对原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||||
|
|||||||
@ -138,7 +138,6 @@ export async function markImageArchiveTaskStored(params: {
|
|||||||
id: string;
|
id: string;
|
||||||
targetUrl: string;
|
targetUrl: string;
|
||||||
targetStorageConfigId?: string | null;
|
targetStorageConfigId?: string | null;
|
||||||
downloadAsset?: import("./qiniu-image-archive").ReadyImageDownload;
|
|
||||||
}) {
|
}) {
|
||||||
await db
|
await db
|
||||||
.update(imageArchiveTask)
|
.update(imageArchiveTask)
|
||||||
@ -146,7 +145,7 @@ export async function markImageArchiveTaskStored(params: {
|
|||||||
status: "stored",
|
status: "stored",
|
||||||
targetUrl: params.targetUrl,
|
targetUrl: params.targetUrl,
|
||||||
targetStorageConfigId: params.targetStorageConfigId || null,
|
targetStorageConfigId: params.targetStorageConfigId || null,
|
||||||
metadata: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || jsonb_build_object('downloadAsset', ${JSON.stringify(params.downloadAsset ?? { status: "pending" })}::jsonb)`,
|
metadata: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || jsonb_build_object('downloadAsset', jsonb_build_object('status', 'pending'))`,
|
||||||
archivedAt: new Date(),
|
archivedAt: new Date(),
|
||||||
replaceAfterAt: new Date(),
|
replaceAfterAt: new Date(),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import {
|
|||||||
imageArchiveSourceKey,
|
imageArchiveSourceKey,
|
||||||
} from "@/lib/storage/image-archive-db";
|
} from "@/lib/storage/image-archive-db";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { archiveImageThroughQiniu } from "./qiniu-image-archive";
|
|
||||||
|
|
||||||
const logger = createLogger("image-archive");
|
const logger = createLogger("image-archive");
|
||||||
|
|
||||||
@ -183,7 +182,7 @@ export async function downloadTemporaryImage(params: { sourceUrl: string; retrie
|
|||||||
});
|
});
|
||||||
return { ...data, extension: getImageExtension(data.contentType, params.sourceUrl) };
|
return { ...data, extension: getImageExtension(data.contentType, params.sourceUrl) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (attempt >= retries || /\bHTTP (?:404|410)\b/.test(String(error))) throw error;
|
if (attempt >= retries) throw error;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
|
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -191,14 +190,7 @@ export async function downloadTemporaryImage(params: { sourceUrl: string; retrie
|
|||||||
throw new Error("Image download failed");
|
throw new Error("Image download failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveTemporaryImage(sourceUrl: string, archiveId?: string) {
|
export async function archiveTemporaryImage(sourceUrl: string) {
|
||||||
if (archiveId) {
|
|
||||||
const direct = await archiveImageThroughQiniu(sourceUrl, archiveId).catch(error => {
|
|
||||||
logger.warn("cloud_archive_fallback", error, { archiveId });
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
if (direct) return direct;
|
|
||||||
}
|
|
||||||
const downloaded = await downloadTemporaryImage({ sourceUrl });
|
const downloaded = await downloadTemporaryImage({ sourceUrl });
|
||||||
const objectSuffix = `${Date.now()}-${randomUUID()}`;
|
const objectSuffix = `${Date.now()}-${randomUUID()}`;
|
||||||
const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`;
|
const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`;
|
||||||
@ -210,6 +202,5 @@ export async function archiveTemporaryImage(sourceUrl: string, archiveId?: strin
|
|||||||
pathname: uploaded.pathname,
|
pathname: uploaded.pathname,
|
||||||
contentType: uploaded.contentType,
|
contentType: uploaded.contentType,
|
||||||
bytes: downloaded.buffer.length,
|
bytes: downloaded.buffer.length,
|
||||||
downloadAsset: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
|
|||||||
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||||
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
|
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
|
||||||
import { uploadFile } from "./service";
|
import { uploadFile } from "./service";
|
||||||
import { getReadyQiniuImageDownload } from "./qiniu-image-archive";
|
|
||||||
import { getDownloadFailure } from "@/lib/canvas/download-error";
|
import { getDownloadFailure } from "@/lib/canvas/download-error";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
@ -16,11 +15,6 @@ export async function prepareImageArchiveDownload(id: string) {
|
|||||||
if (!task?.targetUrl) return;
|
if (!task?.targetUrl) return;
|
||||||
let phase: "read" | "convert" | "store" = "read";
|
let phase: "read" | "convert" | "store" = "read";
|
||||||
try {
|
try {
|
||||||
const ready = await getReadyQiniuImageDownload(task.targetUrl).catch(() => undefined);
|
|
||||||
if (ready) {
|
|
||||||
await saveImageDownloadAsset(id, ready);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const source = await fetchImageBytes(task.targetUrl);
|
const source = await fetchImageBytes(task.targetUrl);
|
||||||
phase = "convert";
|
phase = "convert";
|
||||||
const asset = await normalizeCanvasDownloadAsset({
|
const asset = await normalizeCanvasDownloadAsset({
|
||||||
|
|||||||
@ -1,121 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
|
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
import { createHmac } from "node:crypto";
|
|
||||||
import {
|
|
||||||
buildS3PublicUrl,
|
|
||||||
createS3Client,
|
|
||||||
resolveS3UploadTarget,
|
|
||||||
} from "@/lib/storage/service";
|
|
||||||
|
|
||||||
const QINIU_EAST_CHINA_IO = "iovip-z0.qiniuio.com";
|
|
||||||
const PNG_HEADER_LIMIT = 64 * 1024;
|
|
||||||
const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex");
|
|
||||||
|
|
||||||
export type ReadyImageDownload = {
|
|
||||||
status: "ready";
|
|
||||||
url: string;
|
|
||||||
contentType: "image/png";
|
|
||||||
bytes: number;
|
|
||||||
preparedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function encodeQiniu(value: string | Buffer) {
|
|
||||||
return (typeof value === "string" ? Buffer.from(value) : value)
|
|
||||||
.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDownloadReadyPng(prefix: Buffer) {
|
|
||||||
if (prefix.length < 41 || !prefix.subarray(0, 8).equals(PNG_SIGNATURE)
|
|
||||||
|| prefix.readUInt32BE(8) !== 13 || prefix.toString("ascii", 12, 16) !== "IHDR"
|
|
||||||
|| !prefix.readUInt32BE(16) || !prefix.readUInt32BE(20)
|
|
||||||
|| (prefix[24] !== 8 && prefix[24] !== 16) || (prefix[25] !== 2 && prefix[25] !== 6)) return false;
|
|
||||||
|
|
||||||
// Animation and EXIF orientation need the existing full-image preparation.
|
|
||||||
// An embedded color profile is fine: the browser gets the exact stored bytes.
|
|
||||||
for (let offset = 33; offset + 8 <= prefix.length;) {
|
|
||||||
const type = prefix.toString("ascii", offset + 4, offset + 8);
|
|
||||||
if (type === "IDAT") return true;
|
|
||||||
if (type === "acTL" || type === "eXIf" || type === "IEND") return false;
|
|
||||||
offset += prefix.readUInt32BE(offset) + 12;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getReadyQiniuImageDownload(url: string, target?: Awaited<ReturnType<typeof resolveS3UploadTarget>>) {
|
|
||||||
const config = (target ?? await resolveS3UploadTarget())?.config;
|
|
||||||
if (config?.provider !== "qiniu-kodo" || !config.publicUrl) return;
|
|
||||||
const base = buildS3PublicUrl(config, "");
|
|
||||||
if (!url.startsWith(base)) return;
|
|
||||||
const key = url.slice(base.length);
|
|
||||||
if (!/^uploads\/[\w.-]+$/.test(key)) return;
|
|
||||||
|
|
||||||
const client = createS3Client(config);
|
|
||||||
try {
|
|
||||||
const response = await client.send(new GetObjectCommand({
|
|
||||||
Bucket: config.bucket, Key: key, Range: `bytes=0-${PNG_HEADER_LIMIT - 1}`,
|
|
||||||
}), { abortSignal: AbortSignal.timeout(10_000) });
|
|
||||||
const range = response.ContentRange?.match(/^bytes 0-(\d+)\/(\d+)$/);
|
|
||||||
const length = response.ContentLength ?? 0;
|
|
||||||
const bytes = Number(range?.[2]);
|
|
||||||
if (response.$metadata.httpStatusCode !== 206 || response.ContentType !== "image/png"
|
|
||||||
|| !range || !response.Body || length <= 0 || length > PNG_HEADER_LIMIT
|
|
||||||
|| Number(range[1]) + 1 !== length || bytes < length) {
|
|
||||||
(response.Body as NodeJS.ReadableStream & { destroy?: () => void } | undefined)?.destroy?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prefix = Buffer.from(await response.Body.transformToByteArray());
|
|
||||||
if (prefix.length === length && isDownloadReadyPng(prefix)) {
|
|
||||||
return { status: "ready", url, contentType: "image/png", bytes, preparedAt: new Date().toISOString() } satisfies ReadyImageDownload;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveImageThroughQiniu(sourceUrl: string, archiveId: string) {
|
|
||||||
if (!/^https:\/\/api\.vip98k\.com\/api\/external\/assets\/[A-Za-z0-9_-]+$/.test(sourceUrl)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const target = await resolveS3UploadTarget();
|
|
||||||
const config = target?.config;
|
|
||||||
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The archive task owns this object key, including retries after a timeout.
|
|
||||||
const pathname = `uploads/ai-generated-archive-${archiveId}`;
|
|
||||||
const path = `/fetch/${encodeQiniu(sourceUrl)}/to/${encodeQiniu(`${config.bucket}:${pathname}`)}`;
|
|
||||||
const contentType = "application/x-www-form-urlencoded";
|
|
||||||
const signingData = `POST ${path}\nHost: ${QINIU_EAST_CHINA_IO}\nContent-Type: ${contentType}\n\n`;
|
|
||||||
const signature = encodeQiniu(createHmac("sha1", config.secretAccessKey).update(signingData).digest());
|
|
||||||
const response = await fetch(`https://${QINIU_EAST_CHINA_IO}${path}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Qiniu ${config.accessKeyId}:${signature}`,
|
|
||||||
"Content-Type": contentType,
|
|
||||||
},
|
|
||||||
body: "",
|
|
||||||
redirect: "error",
|
|
||||||
signal: AbortSignal.timeout(40_000),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
await response.body?.cancel();
|
|
||||||
throw new Error(`Qiniu archive fetch returned HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
const result = await response.json() as { key?: string; fsize?: number; mimeType?: string };
|
|
||||||
if (result.key !== pathname || !result.fsize || !result.mimeType?.startsWith("image/")) {
|
|
||||||
throw new Error("Qiniu archive fetch did not return an image");
|
|
||||||
}
|
|
||||||
const url = buildS3PublicUrl(config, pathname);
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
thumbnailUrl: url,
|
|
||||||
pathname,
|
|
||||||
contentType: result.mimeType,
|
|
||||||
bytes: result.fsize,
|
|
||||||
downloadAsset: result.mimeType === "image/png"
|
|
||||||
? await getReadyQiniuImageDownload(url, target).catch(() => undefined)
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -13,13 +13,9 @@ async function setup(page: Page, failRead = false) {
|
|||||||
writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[],
|
writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[],
|
||||||
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
|
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
|
||||||
nativeResultNext: false,
|
nativeResultNext: false,
|
||||||
holdUpload: false, uploadStarted: 0, finishUpload: null as (() => void) | null,
|
|
||||||
};
|
};
|
||||||
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
|
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
|
||||||
const project = () => ({ id: projectId, title: "同步验证", canvasData: JSON.parse(JSON.stringify(state.canvasData, (_key, value) =>
|
const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, updatedAt: revision() });
|
||||||
value && typeof value === "object" && !Array.isArray(value)
|
|
||||||
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
|
|
||||||
: value)), updatedAt: revision() });
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
page.on("pageerror", error => errors.push(error.message));
|
page.on("pageerror", error => errors.push(error.message));
|
||||||
await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
|
await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
|
||||||
@ -28,10 +24,6 @@ async function setup(page: Page, failRead = false) {
|
|||||||
const path = new URL(request.url()).pathname;
|
const path = new URL(request.url()).pathname;
|
||||||
let data: unknown = {};
|
let data: unknown = {};
|
||||||
let status = 200;
|
let status = 200;
|
||||||
if (path.startsWith("/api/files/") && request.method() === "POST" && state.holdUpload) {
|
|
||||||
state.uploadStarted++;
|
|
||||||
await new Promise<void>(resolve => { state.finishUpload = resolve; });
|
|
||||||
}
|
|
||||||
if (path === `/api/canvas/${projectId}`) {
|
if (path === `/api/canvas/${projectId}`) {
|
||||||
if (request.method() === "GET") {
|
if (request.method() === "GET") {
|
||||||
status = state.failRead ? 500 : 200;
|
status = state.failRead ? 500 : 200;
|
||||||
@ -116,9 +108,8 @@ async function moveImage(page: Page) {
|
|||||||
|
|
||||||
const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => {
|
const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => {
|
||||||
const event = new Event("beforeunload", { cancelable: true });
|
const event = new Event("beforeunload", { cancelable: true });
|
||||||
Object.defineProperty(event, "returnValue", { value: "", writable: true });
|
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
return event.defaultPrevented && Boolean(event.returnValue);
|
return event.defaultPrevented;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("background saving recovers when the network returns without a manual save", async ({ page }) => {
|
test("background saving recovers when the network returns without a manual save", async ({ page }) => {
|
||||||
@ -136,18 +127,6 @@ test("background saving recovers when the network returns without a manual save"
|
|||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selecting a saved image does not require another save before an Agent message", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
await page.locator('[data-canvas-node-id="source"]').click();
|
|
||||||
state.failSave = true;
|
|
||||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
|
||||||
await editor.fill("分析这张已经保存的图片");
|
|
||||||
await editor.press("Enter");
|
|
||||||
await expect.poll(() => state.submissions.length).toBe(1);
|
|
||||||
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source"]);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => {
|
test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => {
|
||||||
const { state, errors } = await setup(page);
|
const { state, errors } = await setup(page);
|
||||||
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
||||||
@ -280,7 +259,6 @@ test("toolbar save failures show the cause and recover without a paid submission
|
|||||||
state.failSave = true;
|
state.failSave = true;
|
||||||
await page.locator('[data-tool-key="enhance"]').click();
|
await page.locator('[data-tool-key="enhance"]').click();
|
||||||
await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible();
|
await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible();
|
||||||
await expect(page.getByText(/本次处理未提交/)).toBeVisible();
|
|
||||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
expect(state.capabilitySubmissions).toHaveLength(0);
|
||||||
state.failSave = false;
|
state.failSave = false;
|
||||||
await page.locator('[data-tool-key="enhance"]').click();
|
await page.locator('[data-tool-key="enhance"]').click();
|
||||||
@ -452,70 +430,6 @@ async function countRefreshes(page: Page, state: { appVersion: string }) {
|
|||||||
return () => navigations;
|
return () => navigations;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("unfinished image uploads remain on the page even after their canvas placeholder was saved", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
const refreshes = await countRefreshes(page, state);
|
|
||||||
state.holdUpload = true;
|
|
||||||
await page.mouse.click(100, 90);
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const bytes = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2d0AAAAASUVORK5CYII="), c => c.charCodeAt(0));
|
|
||||||
const transfer = new DataTransfer();
|
|
||||||
transfer.items.add(new File([bytes], "上传保护.png", { type: "image/png" }));
|
|
||||||
window.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
|
||||||
});
|
|
||||||
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
|
|
||||||
await expect.poll(() => state.canvasData.nodes.filter(node => node.data.src === "").length).toBe(1);
|
|
||||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
|
||||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
|
||||||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
|
||||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
|
||||||
await expect(dialog.getByRole("alert")).toContainText("还有图片未上传成功");
|
|
||||||
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
|
|
||||||
await showUpdate(page, state);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
|
||||||
await expect(page.getByRole("alertdialog")).toContainText("还有图片未上传成功");
|
|
||||||
expect(refreshes()).toBe(0);
|
|
||||||
state.holdUpload = false;
|
|
||||||
state.finishUpload?.();
|
|
||||||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
|
||||||
await expect.poll(refreshes).toBe(1);
|
|
||||||
expect(state.canvasData.nodes).toHaveLength(2);
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a composer upload blocks release refresh and closing until the image and input are retained", async ({ page }) => {
|
|
||||||
const { state, errors } = await setup(page);
|
|
||||||
const refreshes = await countRefreshes(page, state);
|
|
||||||
state.holdUpload = true;
|
|
||||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
|
||||||
await editor.fill("上传后保留这条输入");
|
|
||||||
await editor.evaluate(element => {
|
|
||||||
const transfer = new DataTransfer();
|
|
||||||
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "聊天上传保护.png", { type: "image/png" }));
|
|
||||||
element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
|
||||||
});
|
|
||||||
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
|
|
||||||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
|
||||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
|
||||||
await showUpdate(page, state);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
|
||||||
await expect(page.getByRole("alertdialog")).toContainText("素材仍在上传");
|
|
||||||
expect(refreshes()).toBe(0);
|
|
||||||
state.holdUpload = false;
|
|
||||||
state.finishUpload?.();
|
|
||||||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
|
||||||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toHaveCount(1);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
|
||||||
await expect.poll(refreshes).toBe(1);
|
|
||||||
await expect(editor).toContainText("上传后保留这条输入");
|
|
||||||
await expect(editor.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
|
||||||
expect(state.canvasData.nodes).toHaveLength(2);
|
|
||||||
expect(state.submissions).toHaveLength(0);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => {
|
test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => {
|
||||||
await page.context().addInitScript(() => {
|
await page.context().addInitScript(() => {
|
||||||
Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true });
|
Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true });
|
||||||
@ -590,19 +504,9 @@ test("an update keeps the current page when automatic synchronization fails", as
|
|||||||
await showUpdate(page, state);
|
await showUpdate(page, state);
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||||
await expect(page.getByRole("alertdialog")).toContainText("HTTP 503");
|
await expect(page.getByRole("alertdialog")).toContainText("HTTP 503");
|
||||||
await expect(page.getByRole("alertdialog")).not.toContainText("本次任务未提交");
|
|
||||||
await expect(page.getByRole("alertdialog")).not.toContainText("再次发送");
|
|
||||||
expect(refreshes()).toBe(0);
|
expect(refreshes()).toBe(0);
|
||||||
expect(state.submissions).toHaveLength(0);
|
expect(state.submissions).toHaveLength(0);
|
||||||
await page.screenshot({ path: test.info().outputPath("update-error-desktop.png"), animations: "disabled" });
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.screenshot({ path: test.info().outputPath("update-error-mobile.png"), animations: "disabled" });
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
|
||||||
await page.getByRole("button", { name: "返回处理", exact: true }).click();
|
|
||||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
|
||||||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
|
||||||
state.failSave = false;
|
state.failSave = false;
|
||||||
await showUpdate(page, state);
|
|
||||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||||
await expect.poll(refreshes).toBe(1);
|
await expect.poll(refreshes).toBe(1);
|
||||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||||
|
|||||||
@ -32,7 +32,6 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
|
|||||||
const jpeg = await sharp(png).jpeg().toBuffer();
|
const jpeg = await sharp(png).jpeg().toBuffer();
|
||||||
const webp = await sharp(png).webp().toBuffer();
|
const webp = await sharp(png).webp().toBuffer();
|
||||||
const originals = [png, jpeg, webp];
|
const originals = [png, jpeg, webp];
|
||||||
const downloads: number[] = [];
|
|
||||||
let submitted: any;
|
let submitted: any;
|
||||||
let posts = 0;
|
let posts = 0;
|
||||||
const loader = serverLoader({
|
const loader = serverLoader({
|
||||||
@ -42,16 +41,14 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
|
|||||||
if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); }
|
if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); }
|
||||||
if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } });
|
if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } });
|
||||||
const index = Number(new URL(url).pathname.slice(1));
|
const index = Number(new URL(url).pathname.slice(1));
|
||||||
downloads.push(index);
|
|
||||||
return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } });
|
return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
||||||
await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
|
await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
|
||||||
{ type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })),
|
{ type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })),
|
||||||
] }], imageParams: { aspectRatio: "auto", imageSize: "2K" }, referenceImageBuffers: new Map([["https://source.example/0", png]]) })).rejects.toThrow("transport-inspection-complete");
|
] }], imageParams: { aspectRatio: "auto", imageSize: "2K" } })).rejects.toThrow("transport-inspection-complete");
|
||||||
expect(posts).toBe(1);
|
expect(posts).toBe(1);
|
||||||
expect(downloads).toEqual([1, 2]);
|
|
||||||
expect(submitted.parameters.size).toBe("1024*2048");
|
expect(submitted.parameters.size).toBe("1024*2048");
|
||||||
const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image);
|
const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image);
|
||||||
expect(images).toHaveLength(3);
|
expect(images).toHaveLength(3);
|
||||||
@ -60,43 +57,3 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
|
|||||||
expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true);
|
expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a timed out reference read is safe to retry because no generation was submitted", async () => {
|
|
||||||
const methods: string[] = [];
|
|
||||||
const loader = serverLoader({
|
|
||||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
|
||||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 500 ? 0 : ms),
|
|
||||||
fetch: async (_url: string, init: RequestInit = {}) => {
|
|
||||||
methods.push(init.method || "GET");
|
|
||||||
throw new DOMException("This operation was aborted", "AbortError");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
|
||||||
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
|
|
||||||
{ type: "text", text: "fixture" }, { type: "image", image: "https://source.example/slow.png" },
|
|
||||||
] }] }).catch((error: unknown) => error);
|
|
||||||
expect(methods).toEqual(["GET", "GET"]);
|
|
||||||
expect(error).toMatchObject({ confirmedNoResult: true });
|
|
||||||
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({
|
|
||||||
code: "IMAGE_REFERENCE_READ_TIMEOUT", outcome: "failed", recommendation: "可以稍后重试。",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a failed status lookup after Alibaba accepts the request keeps its outcome unknown", async () => {
|
|
||||||
const methods: string[] = [];
|
|
||||||
const loader = serverLoader({
|
|
||||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
|
||||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
|
||||||
fetch: async (_url: string, init: RequestInit = {}) => {
|
|
||||||
methods.push(init.method || "GET");
|
|
||||||
return init.method === "POST"
|
|
||||||
? Response.json({ output: { task_id: "accepted-task", task_status: "PENDING" } })
|
|
||||||
: Response.json({ code: "NotFound", message: "Task lookup failed" }, { status: 404 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
|
||||||
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }] }).catch((error: unknown) => error);
|
|
||||||
expect(methods).toEqual(["POST", "GET"]);
|
|
||||||
expect(String(error)).toContain("accepted-task");
|
|
||||||
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({ code: "RESULT_UNCONFIRMED", outcome: "unknown" });
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,38 +1,21 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences";
|
import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences";
|
||||||
import type { AgentCallRecord } from "@/lib/ai/canvas-agent-runtime/protocol";
|
|
||||||
|
|
||||||
const preferences = {
|
const preferences = {
|
||||||
modelId: "user-image-model",
|
modelId: "user-image-model",
|
||||||
parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字",
|
parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字",
|
||||||
quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 },
|
quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 },
|
||||||
};
|
};
|
||||||
function harness(results?: any[] | ((count: number) => any[])) {
|
function harness(results?: any[]) {
|
||||||
const saved: any[] = [];
|
const saved: any[] = [];
|
||||||
const generated: any[] = [];
|
const generated: any[] = [];
|
||||||
const operations: any[] = [];
|
const operations: any[] = [];
|
||||||
const referenceUrl = "https://test.example/reference.png";
|
|
||||||
const original = Buffer.from("original reference bytes");
|
|
||||||
const mock = serverLoader({
|
const mock = serverLoader({
|
||||||
"@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) },
|
"@/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/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] },
|
||||||
"@/lib/canvas/agent-mutations": {
|
"@/lib/canvas/agent-mutations": { applyCanvasOperationPlan: async (value: any) => { operations.push(value); return { createdNodeIds: ["new-image"] }; } },
|
||||||
applyCanvasOperationPlan: async (value: any) => {
|
"@/lib/canvas/generate-image": { generateCanvasImage: async (value: any) => { generated.push(value); return { modelRef: results ? "backup" : value.model, images: results || [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }] }; } },
|
||||||
operations.push(value);
|
|
||||||
return { createdNodeIds: value.plan.mediaItems.map((item: { mediaItemId: string }) => item.mediaItemId) };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"@/lib/canvas/generate-image": {
|
|
||||||
generateCanvasImage: async (value: any) => {
|
|
||||||
generated.push(value);
|
|
||||||
const images = typeof results === "function" ? results(value.imageCount) : results;
|
|
||||||
return {
|
|
||||||
modelRef: Array.isArray(results) ? "backup" : value.model,
|
|
||||||
images: images ?? [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } },
|
"@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } },
|
||||||
"@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) },
|
"@/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/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) },
|
||||||
@ -40,30 +23,17 @@ function harness(results?: any[] | ((count: number) => any[])) {
|
|||||||
getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })),
|
getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })),
|
||||||
sortStyleProImageModelEntries: (entries: any) => entries,
|
sortStyleProImageModelEntries: (entries: any) => entries,
|
||||||
},
|
},
|
||||||
"./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: referenceUrl } }] },
|
"./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: "https://test.example/reference.png" } }] },
|
||||||
"./review": { analyzeRuntimeImages: async ({ reviewPorts }: { reviewPorts: { readImage(url: string): Promise<Buffer> } }) => ({ bytes: (await reviewPorts.readImage(referenceUrl)).length }) },
|
"./review": {},
|
||||||
"@/lib/ai/rmbg/service": { fetchImageBufferFromUrl: async () => original },
|
|
||||||
"./image-access-tools": { registerImageCanvasAccessTools() {} },
|
"./image-access-tools": { registerImageCanvasAccessTools() {} },
|
||||||
});
|
});
|
||||||
const build = async (imagePreferences: any = preferences, priorCalls?: AgentCallRecord[]) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({
|
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"],
|
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,
|
request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true,
|
||||||
preferredModelId: "system-default", imagePreferences, priorCalls, emit() {},
|
preferredModelId: "system-default", imagePreferences, emit() {},
|
||||||
});
|
});
|
||||||
return { build, generated, operations, saved, referenceUrl, original };
|
return { build, generated, operations, saved };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("the generation tool can reuse original bytes read by inspection within the same task", async () => {
|
|
||||||
const h = harness();
|
|
||||||
const selection = { parameters: { imageCount: 1 } };
|
|
||||||
const tools = await h.build(selection);
|
|
||||||
await tools.executable.analyzeCanvasImages.execute({ nodeIds: ["reference"], question: "识别素材" }, "inspect");
|
|
||||||
await tools.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "generate");
|
|
||||||
expect(h.generated[0].referenceImageBuffers.get(h.referenceUrl)).toBe(h.original);
|
|
||||||
const nextTask = await h.build(selection);
|
|
||||||
await nextTask.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "next");
|
|
||||||
expect(h.generated[1].referenceImageBuffers.has(h.referenceUrl)).toBe(false);
|
|
||||||
});
|
|
||||||
test("explicit controls override conflicting planner settings without losing false or zero values", () => {
|
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);
|
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(resolved).toMatchObject({ ...preferences.parameters, modelId: preferences.modelId, prompt: "产品图" });
|
||||||
@ -77,35 +47,12 @@ test("real generation tool honors the chosen image model and all parameters desp
|
|||||||
providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } });
|
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" });
|
expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" });
|
||||||
});
|
});
|
||||||
test("redraw and generation share the task limit and only fill the missing images", async () => {
|
test("redraw and a corrective retry inherit the same user model, batch and image controls", async () => {
|
||||||
const image = { originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" };
|
const h = harness(); const tools = await h.build();
|
||||||
const h = harness(count => Array.from({ length: count === 3 ? 1 : count }, () => image));
|
await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1");
|
||||||
const tools = await h.build();
|
await tools.executable.generateCanvasImages.execute({ prompt: "修正背景", modelId: "system-default" }, "retry-1");
|
||||||
const first = await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1");
|
|
||||||
expect(first.status).toBe("partial");
|
|
||||||
const filled = await tools.executable.generateCanvasImages.execute({ prompt: "补齐图片", modelId: "system-default" }, "fill-1");
|
|
||||||
expect(filled.status).toBe("succeeded");
|
|
||||||
await expect(tools.executable.generateCanvasImages.execute({ prompt: "再次修正" }, "retry-1")).rejects.toThrow("张数已占满");
|
|
||||||
expect(h.generated).toHaveLength(2);
|
|
||||||
expect(h.generated.map(call => call.imageCount)).toEqual([3, 2]);
|
|
||||||
for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", aspectRatio: "3:4", allowFailover: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("one selected image allows only one submission even when calls overlap or the task resumes", async () => {
|
|
||||||
const h = harness();
|
|
||||||
const selection = { parameters: { imageCount: 1 } };
|
|
||||||
const tools = await h.build(selection);
|
|
||||||
const generate = (callId: string) => tools.executable.generateCanvasImages.execute({ prompt: "修改图片" }, callId);
|
|
||||||
const [first, duplicate] = await Promise.allSettled([generate("original"), generate("parallel")]);
|
|
||||||
expect(first.status).toBe("fulfilled");
|
|
||||||
expect(duplicate.status).toBe("rejected");
|
|
||||||
if (first.status !== "fulfilled") throw first.reason;
|
|
||||||
const resumed = await h.build(selection, [{ id: "original", name: "generateCanvasImages", input: {}, status: "succeeded", output: first.value }]);
|
|
||||||
await expect(resumed.executable.generateCanvasImages.execute({ prompt: "继续" }, "resume")).rejects.toThrow("张数已占满");
|
|
||||||
expect(h.generated).toHaveLength(1);
|
|
||||||
const next = await h.build(selection);
|
|
||||||
await next.executable.generateCanvasImages.execute({ prompt: "用户新指令" }, "next");
|
|
||||||
expect(h.generated).toHaveLength(2);
|
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 () => {
|
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" });
|
const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" });
|
||||||
|
|||||||
@ -94,17 +94,6 @@ test("checks real image bytes and compares results to their source images", asyn
|
|||||||
expect(result.issues).toEqual([]);
|
expect(result.issues).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a partial image batch can be filled and verified without discarding the first result", async () => {
|
|
||||||
const f = await fixture('{"passed":true,"issues":[],"failedNodeIds":[]}');
|
|
||||||
const { snapshot } = f.params;
|
|
||||||
snapshot.requirements = { expectedImageCount: 2 };
|
|
||||||
snapshot.calls[1].output = { status: "partial", createdNodeIds: ["result"], appliedImagePreferences: { imageCount: 2 } };
|
|
||||||
await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: false, retryable: true });
|
|
||||||
snapshot.resultNodeIds.push("remaining-result");
|
|
||||||
snapshot.calls.push({ id: "fill", name: "generateCanvasImages", input: {}, status: "succeeded", output: { status: "succeeded", createdNodeIds: ["remaining-result"] } });
|
|
||||||
await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => {
|
test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => {
|
||||||
const bad = await fixture("not JSON");
|
const bad = await fixture("not JSON");
|
||||||
const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}');
|
const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}');
|
||||||
|
|||||||
@ -124,7 +124,6 @@ test("requested downloads are prioritized with two workers before scanning backg
|
|||||||
},
|
},
|
||||||
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
|
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
|
||||||
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
|
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
|
||||||
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
|
|
||||||
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
|
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
|
||||||
})("lib/storage/image-download-preparation.ts");
|
})("lib/storage/image-download-preparation.ts");
|
||||||
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");
|
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");
|
||||||
|
|||||||
@ -106,8 +106,6 @@ test("compatible PNG remains byte-identical; JPEG becomes RGB PNG without resizi
|
|||||||
const module = serverLoader({})("lib/canvas/photoshop-safe-download.ts");
|
const module = serverLoader({})("lib/canvas/photoshop-safe-download.ts");
|
||||||
const bytes = await sharp({ create: { width: 32, height: 48, channels: 4, background: "#12345680" } }).png().toBuffer();
|
const bytes = await sharp({ create: { width: 32, height: 48, channels: 4, background: "#12345680" } }).png().toBuffer();
|
||||||
expect((await module.convertImageToPhotoshopPng(bytes)).equals(bytes)).toBe(true);
|
expect((await module.convertImageToPhotoshopPng(bytes)).equals(bytes)).toBe(true);
|
||||||
const rgb16 = await sharp(bytes).toColourspace("rgb16").png().toBuffer();
|
|
||||||
expect((await module.convertImageToPhotoshopPng(rgb16)).equals(rgb16)).toBe(true);
|
|
||||||
const jpeg = await sharp(bytes).jpeg().toBuffer();
|
const jpeg = await sharp(bytes).jpeg().toBuffer();
|
||||||
const converted = await module.convertImageToPhotoshopPng(jpeg);
|
const converted = await module.convertImageToPhotoshopPng(jpeg);
|
||||||
const metadata = await sharp(converted).metadata();
|
const metadata = await sharp(converted).metadata();
|
||||||
@ -133,7 +131,6 @@ test("PNG preparation failure leaves the original archived and schedules a retry
|
|||||||
const module = serverLoader({
|
const module = serverLoader({
|
||||||
"./image-archive-db": { claimImageDownloadTask: async () => ({ id: "task", targetUrl: "https://cdn.example/original.png", metadata: { downloadAsset: { attempts: 2 } } }), saveImageDownloadAsset: async (_id: string, asset: any) => { saved = asset; } },
|
"./image-archive-db": { claimImageDownloadTask: async () => ({ id: "task", targetUrl: "https://cdn.example/original.png", metadata: { downloadAsset: { attempts: 2 } } }), saveImageDownloadAsset: async (_id: string, asset: any) => { saved = asset; } },
|
||||||
"./image-byte-cache": { fetchImageBytes: async () => { throw new Error("storage offline"); } },
|
"./image-byte-cache": { fetchImageBytes: async () => { throw new Error("storage offline"); } },
|
||||||
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
|
|
||||||
"./service": {},
|
"./service": {},
|
||||||
})("lib/storage/image-download-preparation.ts");
|
})("lib/storage/image-download-preparation.ts");
|
||||||
await module.prepareImageArchiveDownload("task");
|
await module.prepareImageArchiveDownload("task");
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import path from "node:path";
|
|||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
import * as crypto from "node:crypto";
|
import * as crypto from "node:crypto";
|
||||||
import ts from "typescript";
|
import ts from "typescript";
|
||||||
import sharp from "sharp";
|
|
||||||
import {
|
import {
|
||||||
collectImageArchiveSources,
|
collectImageArchiveSources,
|
||||||
replaceArchivedImageUrls,
|
replaceArchivedImageUrls,
|
||||||
@ -83,55 +82,6 @@ function serverLoader(mocks: Record<string, unknown>, env: Record<string, string
|
|||||||
}
|
}
|
||||||
const loggerMock = { createLogger: () => ({ info() {}, warn() {}, error() {} }) };
|
const loggerMock = { createLogger: () => ({ info() {}, warn() {}, error() {} }) };
|
||||||
|
|
||||||
test("Qiniu archives 98K originals without relaying full image bytes through the app", async () => {
|
|
||||||
const image = sharp({ create: { width: 4, height: 4, channels: 3, background: "blue" } });
|
|
||||||
const rgb = await image.clone().withIccProfile("srgb").png().toBuffer();
|
|
||||||
const rgb16 = await image.clone().toColourspace("rgb16").png().toBuffer();
|
|
||||||
const indexed = await image.clone().png({ palette: true }).toBuffer();
|
|
||||||
const config = { provider: "qiniu-kodo", region: "cn-east-1", bucket: "archive",
|
|
||||||
accessKeyId: "test-key", secretAccessKey: "test-secret", publicUrl: "https://bucket.example" };
|
|
||||||
const id = "test-task";
|
|
||||||
const key = `uploads/ai-generated-archive-${id}`;
|
|
||||||
const source = "https://api.vip98k.com/api/external/assets/test_asset";
|
|
||||||
const archiveUrl = `${config.publicUrl}/${key}`;
|
|
||||||
let stored = rgb;
|
|
||||||
const cloudCalls: string[] = [];
|
|
||||||
const reads: string[] = [];
|
|
||||||
const load = serverLoader({
|
|
||||||
"@/lib/storage/service": {
|
|
||||||
resolveS3UploadTarget: async () => ({ config }),
|
|
||||||
buildS3PublicUrl: (_: unknown, path: string) => `${config.publicUrl}/${path}`,
|
|
||||||
createS3Client: () => ({
|
|
||||||
send: async (cmd: { input: { Bucket: string; Key: string; Range: string } }) => {
|
|
||||||
expect(cmd.input).toEqual({ Bucket: config.bucket, Key: key, Range: "bytes=0-65535" });
|
|
||||||
reads.push(cmd.input.Range);
|
|
||||||
return { $metadata: { httpStatusCode: 206 }, ContentType: "image/png",
|
|
||||||
ContentLength: stored.length, ContentRange: `bytes 0-${stored.length - 1}/${stored.length}`,
|
|
||||||
Body: { transformToByteArray: async () => stored } };
|
|
||||||
},
|
|
||||||
destroy() {},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
fetch: async (url: string, options: RequestInit) => {
|
|
||||||
cloudCalls.push(url);
|
|
||||||
expect(options.method).toBe("POST");
|
|
||||||
const [, from, to] = new URL(url).pathname.split("/fetch/")[1].match(/^(.+)\/to\/(.+)$/)!;
|
|
||||||
expect(Buffer.from(from, "base64url").toString()).toBe(source);
|
|
||||||
expect(Buffer.from(to, "base64url").toString()).toBe(`${config.bucket}:${key}`);
|
|
||||||
return Response.json({ key, fsize: stored.length, mimeType: "image/png" });
|
|
||||||
},
|
|
||||||
})("lib/storage/qiniu-image-archive.ts");
|
|
||||||
const archived = await load.archiveImageThroughQiniu(source, id);
|
|
||||||
expect(archived.downloadAsset).toMatchObject({ status: "ready", url: archiveUrl, bytes: rgb.length });
|
|
||||||
stored = rgb16;
|
|
||||||
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toMatchObject({ status: "ready", url: archiveUrl, bytes: rgb16.length });
|
|
||||||
stored = indexed;
|
|
||||||
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toBeUndefined();
|
|
||||||
expect(await load.archiveImageThroughQiniu("https://another-provider.example/result.png", id)).toBeNull();
|
|
||||||
expect(cloudCalls).toHaveLength(3);
|
|
||||||
expect(reads).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("160 requests from the same user and model start without admission waiting", async () => {
|
test("160 requests from the same user and model start without admission waiting", async () => {
|
||||||
const module = serverLoader({})("lib/canvas/image-concurrency.ts");
|
const module = serverLoader({})("lib/canvas/image-concurrency.ts");
|
||||||
let queued = 0;
|
let queued = 0;
|
||||||
|
|||||||
@ -25,31 +25,11 @@ function fixture() {
|
|||||||
consumeInputs: async () => ({ cancelled: false }), isCancelled: async () => false,
|
consumeInputs: async () => ({ cancelled: false }), isCancelled: async () => false,
|
||||||
verify: async () => ({ passed: true, issues: [] }),
|
verify: async () => ({ passed: true, issues: [] }),
|
||||||
};
|
};
|
||||||
return { ports, run, snapshot, load };
|
return { ports, run, snapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = { type: "result", sessionId: "test-session", response: "已完成" };
|
const result = { type: "result", sessionId: "test-session", response: "已完成" };
|
||||||
|
|
||||||
test("a failed quality check keeps the single generated image without opening a repair round", async () => {
|
|
||||||
const { ports, run, snapshot } = fixture();
|
|
||||||
snapshot.imagePreferences = { parameters: { imageCount: 1 } };
|
|
||||||
let attempts = 0;
|
|
||||||
ports.tools.generateCanvasImages.execute = async () => ({
|
|
||||||
createdNodeIds: ["generated-image"], appliedImagePreferences: { imageCount: 1 },
|
|
||||||
});
|
|
||||||
ports.native = async options => {
|
|
||||||
attempts++;
|
|
||||||
await options.execute("planCanvasTask", {}, "plan", options.signal!);
|
|
||||||
await options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "gen", options.signal!);
|
|
||||||
return { ...result, response: "图片已生成,正在检查" };
|
|
||||||
};
|
|
||||||
ports.verify = async () => ({ passed: false, issues: ["主体细节不符"], retryable: true, failedNodeIds: ["generated-image"] });
|
|
||||||
expect(await run(ports)).toBe("needs_review");
|
|
||||||
expect(attempts).toBe(1);
|
|
||||||
expect(snapshot.resultNodeIds).toEqual(["generated-image"]);
|
|
||||||
expect(snapshot.message).toMatch(/主体细节不符[\s\S]*如需再生成,请发送新指令/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => {
|
test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => {
|
||||||
const { ports, run, snapshot } = fixture();
|
const { ports, run, snapshot } = fixture();
|
||||||
const emitted: string[] = [], saved: string[] = [];
|
const emitted: string[] = [], saved: string[] = [];
|
||||||
@ -81,26 +61,6 @@ test("an unconfirmed paid tool stops the planner and immediately preserves the r
|
|||||||
expect(submissions).toBe(1);
|
expect(submissions).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a pre-submission reference timeout offers a safe retry without an automatic resubmission", async () => {
|
|
||||||
const { ports, run, snapshot, load } = fixture();
|
|
||||||
const { ImageReferenceReadTimeoutError } = load("lib/ai/runtime/image-generation-error.ts");
|
|
||||||
let attempts = 0;
|
|
||||||
ports.tools.generateCanvasImages.execute = async () => {
|
|
||||||
attempts++;
|
|
||||||
throw new ImageReferenceReadTimeoutError();
|
|
||||||
};
|
|
||||||
ports.native = async options => {
|
|
||||||
await options.execute("planCanvasTask", {}, "plan", options.signal!);
|
|
||||||
await options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "timeout", options.signal!);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
expect(await run(ports)).toBe("needs_review");
|
|
||||||
expect(attempts).toBe(1);
|
|
||||||
expect(snapshot.calls.find(call => call.id === "timeout")).toMatchObject({ confirmedNoResult: true, uncertain: false });
|
|
||||||
expect(snapshot.message).toContain("任务尚未提交,可以稍后重试");
|
|
||||||
expect(snapshot.message).toContain(ports.runId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("repairing a failed result permits original images as references without admitting another passed target", async () => {
|
test("repairing a failed result permits original images as references without admitting another passed target", async () => {
|
||||||
const { ports, run, snapshot } = fixture();
|
const { ports, run, snapshot } = fixture();
|
||||||
let submissions = 0;
|
let submissions = 0;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user