feat: sync canvas resize flow and latest local updates
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-06 17:00:35 +08:00
parent dc6162c93d
commit 5f849bf0b7
13 changed files with 1642 additions and 177 deletions

View File

@ -49,10 +49,19 @@ import {
getAiProviderById,
checkUserImageQuota,
consumeUserPoints,
createMessageTargetResolution,
getConversationWorkingStateByChatId,
upsertConversationWorkingState,
} from "@/lib/db/queries";
import { db } from "@/lib/db";
import type { DBMessage } from "@/lib/db/schema";
import { generatedImage, message as messageTable } from "@/lib/db/schema";
import { resolveChatMediaInput } from "@/lib/ai/context-memory/media-input";
import { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution";
import type {
ResolvedChatMediaInput,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
import { ChatSDKError } from "@/lib/errors";
import { extractImageUrlsFromText } from "@/lib/image-url";
import { uploadFile } from "@/lib/storage/service";
@ -1626,6 +1635,7 @@ async function saveGeneratedImages(
const matches = [...imageMarkdown.matchAll(imageRegex)];
let savedImageCount = 0;
const savedOriginalUrls: string[] = [];
for (const match of matches) {
const altText = match[1] || "";
@ -1644,12 +1654,30 @@ async function saveGeneratedImages(
modelId,
});
savedImageCount += 1;
if (!savedOriginalUrls.includes(originalUrl)) {
savedOriginalUrls.push(originalUrl);
}
console.log(`[Gallery] Saved image: ${originalUrl.substring(0, 80)}...`);
} catch (error) {
console.error("[Gallery] Failed to save image record:", error);
}
}
if (savedOriginalUrls.length > 0) {
try {
await upsertConversationWorkingState({
userId,
chatId,
currentFocusImageUrls: savedOriginalUrls,
lastGeneratedImageUrls: savedOriginalUrls,
lastResolvedTargetImageUrls: savedOriginalUrls,
lastMessageId: messageId,
});
} catch (memoryError) {
console.warn("[Gallery] Failed to update working state with generated images:", memoryError);
}
}
if (savedImageCount <= 0) return;
try {
@ -1882,6 +1910,7 @@ export async function POST(request: Request) {
selectedVisibilityType,
hiddenPrompt,
autoContext,
contextPayload,
deepThinkingEnabled,
}: {
id: string;
@ -1890,6 +1919,7 @@ export async function POST(request: Request) {
selectedVisibilityType: VisibilityType;
hiddenPrompt?: string;
autoContext?: string;
contextPayload?: PostRequestBody["contextPayload"];
deepThinkingEnabled?: boolean;
} = requestBody;
const requestStartedAt = Date.now();
@ -1900,6 +1930,12 @@ export async function POST(request: Request) {
return "";
}
})();
const requestTextContent = message.parts
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" ")
.trim();
const autoContextText = typeof autoContext === "string" ? autoContext.trim() : "";
const now = Date.now();
for (const [key, startedAt] of inFlightChatRequests.entries()) {
@ -1941,6 +1977,11 @@ export async function POST(request: Request) {
let smartAutoHistoryImages: string[] = []; // 从历史对话中提取的图片URL(支持多张)
let smartAutoNeedsClarification = false; // 是否需要先追问用户
let smartAutoClarificationQuestion: string | undefined;
let requestResolvedMediaInput: ResolvedChatMediaInput | null = null;
let requestTargetResolution: ResolvedTargetAssets | null = null;
let requestWorkingState:
| Awaited<ReturnType<typeof getConversationWorkingStateByChatId>>
| null = null;
let preferredAgentModelId: string | undefined;
const deepThinkingOn = deepThinkingEnabled !== false;
let shouldRunAgentConversation = finalModelId === "auto";
@ -2032,126 +2073,62 @@ export async function POST(request: Request) {
// 🆕 首先获取历史消息(在 Smart Auto Mode 内部单独获取,避免依赖后面的 messagesFromDb)
const smartAutoHistoryMessagesFromDb = await getMessagesByChatId({ id });
console.log("[Smart Auto Mode] Loaded", smartAutoHistoryMessagesFromDb.length, "messages from DB");
// 1. 提取用户消息内容
const lastUserMessage = message;
const textContent = lastUserMessage.parts
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join(" ");
autoTextForFallback = textContent;
const autoContextText = typeof autoContext === "string" ? autoContext.trim() : "";
const hasSelectedImageHint = /用户已选中\s*\d+\s*张图片/.test(autoContextText);
const hasExplicitTargetHint = /第[一二三四五六七八九十\d]+张|左|右|上|下|中间|选中/.test(textContent);
const hasActionableEditIntent = /改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按照|按|保留|统一|色差|背景|清晰|风格/.test(
textContent
);
const currentMessageImageInputs = collectImageInputsFromMessageParts(
lastUserMessage.parts as any[],
requestOrigin,
).slice(0, 4);
let imageUrls = currentMessageImageInputs;
let hasImage = imageUrls.length > 0;
autoCurrentImageUrlsForFallback = imageUrls.slice();
// 🆕 历史图片上下文感知:如果用户没有发送新图片,自动使用历史中最近的图片(支持多张)
// 逻辑:只要用户在对话中没有附上新图片,就默认继续处理之前的图片
if (imageUrls.length === 0 && smartAutoHistoryMessagesFromDb.length > 0) {
console.log("[Smart Auto Mode] 🔍 No image in current message, auto-searching history...");
const explicitFollowLatestResult = /(上一张|上一轮|刚生成|最新生成|这个结果|该结果|按这个结果|继续在这张|基于这张结果|延续上一次结果)/i.test(
textContent
);
const explicitUseOriginalUpload = /(原图|上传图|参考图|最初那张|第一张图|素材图)/i.test(
textContent
);
const latestAssistantImages: string[] = [];
const latestUserImages: string[] = [];
const pushUniqueUrl = (bucket: string[], maybeUrl?: string) => {
if (!maybeUrl || typeof maybeUrl !== "string") return;
const normalized = maybeUrl.trim();
if (!/^https?:\/\//i.test(normalized)) return;
if (!bucket.includes(normalized)) {
bucket.push(normalized);
}
};
// 从历史消息中倒序查找最近的“AI生成图”和“用户上传图”
for (let i = smartAutoHistoryMessagesFromDb.length - 1; i >= 0; i--) {
const msg = smartAutoHistoryMessagesFromDb[i];
const parts = msg.parts as any[];
if (!parts) continue;
if (msg.role === "assistant" && latestAssistantImages.length === 0) {
for (const part of parts) {
if (part.type === "text" && part.text) {
const parsedImageUrls = extractOriginalImageUrlsFromText(part.text);
for (const parsedUrl of parsedImageUrls) {
pushUniqueUrl(latestAssistantImages, parsedUrl);
}
}
}
}
if (msg.role === "user" && latestUserImages.length === 0) {
for (const part of parts) {
if ((part.type === "file" || part.type === "image") && part.url) {
pushUniqueUrl(latestUserImages, part.url);
}
if (part.type === "image_url" && part.image_url?.url) {
pushUniqueUrl(latestUserImages, part.image_url.url);
}
if (part.type === "text" && part.text) {
const parsedImageUrls = extractOriginalImageUrlsFromText(part.text);
for (const parsedUrl of parsedImageUrls) {
pushUniqueUrl(latestUserImages, parsedUrl);
}
}
}
}
if (latestAssistantImages.length > 0 && latestUserImages.length > 0) {
break;
}
}
if (explicitUseOriginalUpload && latestUserImages.length > 0) {
smartAutoHistoryImages = latestUserImages.slice(0, 4);
console.log(
"[Smart Auto Mode] 🖼️ Using latest user-uploaded image(s) by explicit instruction:",
smartAutoHistoryImages.length
);
} else if (latestAssistantImages.length > 0) {
smartAutoHistoryImages = latestAssistantImages.slice(0, 4);
console.log(
explicitFollowLatestResult
? "[Smart Auto Mode] 🖼️ Using latest generated image(s) by explicit instruction:"
: "[Smart Auto Mode] 🖼️ Using latest generated image(s) for continuous edit:",
smartAutoHistoryImages.length
);
} else if (latestUserImages.length > 0) {
smartAutoHistoryImages = latestUserImages.slice(0, 4);
console.log(
"[Smart Auto Mode] 🖼️ Fallback to latest user-uploaded image(s):",
smartAutoHistoryImages.length
);
}
if (smartAutoHistoryImages.length > 0) {
hasImage = true;
imageUrls = smartAutoHistoryImages;
autoFromHistoryForFallback = true;
autoCurrentImageUrlsForFallback = imageUrls.slice();
console.log("[Smart Auto Mode] ✅ Auto-using", smartAutoHistoryImages.length, "history image(s) for context continuity");
} else {
console.log("[Smart Auto Mode] ℹ️ No image found in history, proceeding as pure text");
}
autoTextForFallback = requestTextContent;
if (!requestWorkingState) {
requestWorkingState = await getConversationWorkingStateByChatId({
chatId: id,
});
}
if (!requestResolvedMediaInput) {
requestResolvedMediaInput = resolveChatMediaInput({
messageParts: message.parts as any[],
historyMessages: smartAutoHistoryMessagesFromDb,
contextPayload: contextPayload || undefined,
requestOrigin,
maxImages: 4,
});
}
if (!requestTargetResolution) {
requestTargetResolution = resolveTargetAssets({
text: requestTextContent,
mediaInput: requestResolvedMediaInput,
contextPayload: contextPayload || undefined,
workingState: requestWorkingState || undefined,
});
}
console.log("[Smart Auto Mode] Input:", { textContent: textContent.substring(0, 100), hasImage, imageCount: imageUrls.length, fromHistory: smartAutoHistoryImages.length > 0 });
const hasSelectedImageHint =
Boolean(contextPayload?.selectedNodeIds?.length) ||
/用户已选中\s*\d+\s*张图片/.test(autoContextText);
const hasExplicitTargetHint =
requestTargetResolution.resolutionMode === "text_reference" ||
/第[一二三四五六七八九十\d]+张|左|右|上|下|中间|选中/.test(requestTextContent);
const hasActionableEditIntent = /改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按照|按|保留|统一|色差|背景|清晰|风格/.test(
requestTextContent
);
const currentMessageImageInputs = requestResolvedMediaInput.currentMessageImageUrls.slice(0, 4);
let imageUrls =
requestTargetResolution.targetImageUrls.length > 0
? requestTargetResolution.targetImageUrls.slice(0, 4)
: requestResolvedMediaInput.resolvedImageUrls.slice(0, 4);
let hasImage = imageUrls.length > 0;
autoCurrentImageUrlsForFallback =
currentMessageImageInputs.length > 0 ? currentMessageImageInputs.slice() : imageUrls.slice();
smartAutoHistoryImages =
currentMessageImageInputs.length === 0 ? imageUrls.slice(0, 4) : [];
autoFromHistoryForFallback =
currentMessageImageInputs.length === 0 && imageUrls.length > 0;
console.log("[Smart Auto Mode] Input:", {
textContent: requestTextContent.substring(0, 100),
hasImage,
imageCount: imageUrls.length,
fromHistory: smartAutoHistoryImages.length > 0,
resolutionMode: requestTargetResolution.resolutionMode,
resolutionConfidence: requestTargetResolution.confidence,
});
// 🆕 从历史消息中提取对话文本(用于上下文理解)
const historyMessages: { role: "user" | "assistant"; content: string }[] = [];
@ -2186,7 +2163,7 @@ export async function POST(request: Request) {
}
const autoGraphResult = await withOperationTimeout(
runAutoAgentGraph({
text: textContent,
text: requestTextContent,
hasImage,
imageUrls,
imageFromHistory: smartAutoHistoryImages.length > 0,
@ -2218,8 +2195,8 @@ export async function POST(request: Request) {
) {
smartAutoNeedsClarification = false;
smartAutoClarificationQuestion = undefined;
if (!smartAutoOptimizedPrompt && textContent.trim()) {
smartAutoOptimizedPrompt = textContent.trim();
if (!smartAutoOptimizedPrompt && requestTextContent.trim()) {
smartAutoOptimizedPrompt = requestTextContent.trim();
}
if (
!smartAutoFriendlyMessage ||
@ -2280,7 +2257,7 @@ export async function POST(request: Request) {
if (autoGraphResult.optimizedPrompt) {
smartAutoOptimizedPrompt = autoGraphResult.optimizedPrompt;
console.log("[Smart Auto Mode] ✨ Prompt optimized (LLM):", {
original: textContent.substring(0, 50),
original: requestTextContent.substring(0, 50),
optimized: autoGraphResult.optimizedPrompt.substring(0, 100),
});
}
@ -2311,7 +2288,7 @@ export async function POST(request: Request) {
smartAutoVideoPlan = undefined;
smartAutoRecommendedVideoParams = undefined;
if (smartAutoPromptFromVideoPlan) {
smartAutoOptimizedPrompt = textContent.trim() || undefined;
smartAutoOptimizedPrompt = requestTextContent.trim() || undefined;
smartAutoPromptFromVideoPlan = false;
}
smartAutoFriendlyMessage =
@ -2821,6 +2798,36 @@ export async function POST(request: Request) {
messagesFromDb = await getMessagesByChatId({ id });
}
if (!requestWorkingState) {
requestWorkingState = await getConversationWorkingStateByChatId({
chatId: id,
});
}
if (!requestResolvedMediaInput) {
requestResolvedMediaInput = resolveChatMediaInput({
messageParts: message.parts as any[],
historyMessages: messagesFromDb,
contextPayload: contextPayload || undefined,
requestOrigin,
maxImages: 4,
});
}
if (!requestTargetResolution) {
requestTargetResolution = resolveTargetAssets({
text: requestTextContent,
mediaInput: requestResolvedMediaInput,
contextPayload: contextPayload || undefined,
workingState: requestWorkingState || undefined,
});
}
if (
requestResolvedMediaInput.currentMessageImageUrls.length === 0 &&
smartAutoHistoryImages.length === 0 &&
requestTargetResolution.targetImageUrls.length > 0
) {
smartAutoHistoryImages = requestTargetResolution.targetImageUrls.slice(0, 4);
}
const uiMessages = [...convertToUIMessages(messagesFromDb), message];
const hiddenPromptText = typeof hiddenPrompt === "string" ? hiddenPrompt.trim() : "";
const hiddenPromptPrefix = hiddenPromptText ? `${hiddenPromptText}\n\n` : "";
@ -2874,6 +2881,76 @@ export async function POST(request: Request) {
return createSseResponse(duplicateStream);
}
if (requestResolvedMediaInput && requestTargetResolution) {
try {
await createMessageTargetResolution({
userId: session.user.id,
chatId: id,
messageId: message.id,
projectId: contextPayload?.projectId || null,
rawText: requestTextContent,
uploadedAssetIds: requestResolvedMediaInput.uploadedAssetIds,
uploadedImageUrls: requestResolvedMediaInput.currentMessageImageUrls,
selectedAssetIds: requestResolvedMediaInput.selectedAssetIds,
selectedImageUrls: requestResolvedMediaInput.selectedImageUrls,
candidateAssetIds: requestResolvedMediaInput.candidateAssetIds,
candidateImageUrls: requestResolvedMediaInput.imageRefs.map((ref) => ref.url),
resolvedAssetIds: requestTargetResolution.targetAssetIds,
resolvedImageUrls: requestTargetResolution.targetImageUrls,
resolutionMode: requestTargetResolution.resolutionMode,
confidence: requestTargetResolution.confidence,
needsClarification: requestTargetResolution.needsClarification,
reasoning: {
reason: requestTargetResolution.reason,
fromHistory: requestTargetResolution.fromHistory,
focusPolicy: contextPayload?.focusPolicy || null,
contextSource: contextPayload?.source || null,
},
});
await upsertConversationWorkingState({
userId: session.user.id,
chatId: id,
projectId: contextPayload?.projectId || null,
currentIntent:
smartAutoDetectedIntent ||
(requestResolvedMediaInput.hasImage
? "image_edit"
: requestBody.videoParams
? "video_gen"
: null),
currentFocusAssetIds: requestTargetResolution.targetAssetIds,
currentFocusImageUrls: requestTargetResolution.targetImageUrls,
selectedAssetIds: requestResolvedMediaInput.selectedAssetIds,
selectedImageUrls: requestResolvedMediaInput.selectedImageUrls,
lastUploadedAssetIds: requestResolvedMediaInput.uploadedAssetIds,
lastUploadedImageUrls: requestResolvedMediaInput.currentMessageImageUrls,
lastReferencedAssetIds: requestTargetResolution.candidateAssetIds,
lastReferencedImageUrls: requestTargetResolution.candidateImageUrls,
lastResolvedTargetAssetIds: requestTargetResolution.targetAssetIds,
lastResolvedTargetImageUrls: requestTargetResolution.targetImageUrls,
activeCanvasNodeIds: contextPayload?.selectedNodeIds || [],
lastCanvasSnapshot:
Array.isArray(contextPayload?.canvasImageNodes) && contextPayload?.canvasImageNodes.length > 0
? {
source: contextPayload?.source || "canvas",
selectedNodeIds: contextPayload?.selectedNodeIds || [],
canvasImageNodes: contextPayload?.canvasImageNodes,
}
: null,
lastTurnSummary: {
text: requestTextContent.slice(0, 500),
resolutionMode: requestTargetResolution.resolutionMode,
confidence: requestTargetResolution.confidence,
},
lastMessageId: message.id,
incrementTurnCount: true,
});
} catch (memoryError) {
console.warn("[Chat API] Failed to persist context-memory state:", memoryError);
}
}
const streamId = generateUUID();
await createStreamId({ streamId, chatId: id });

View File

@ -43,6 +43,33 @@ const imageParamsSchema = z.object({
n: z.number().min(1).max(10).optional(), // 兼容 n 参数
}).optional();
const contextPayloadSchema = z
.object({
source: z.enum(["chat", "canvas", "realtime-canvas"]).optional(),
projectId: z.string().uuid().optional(),
selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(),
selectedAssetIds: z.array(z.string().uuid()).max(20).optional(),
focusPolicy: z.enum(["auto", "selected", "latest_result", "original"]).optional(),
canvasImageNodes: z
.array(
z.object({
nodeId: z.string().min(1).max(128),
assetId: z.string().uuid().optional(),
url: z.string().url(),
title: z.string().max(256).optional(),
prompt: z.string().max(2000).optional(),
x: z.number(),
y: z.number(),
width: z.number().optional(),
height: z.number().optional(),
isSelected: z.boolean().optional(),
}),
)
.max(20)
.optional(),
})
.optional();
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
message: z.object({
@ -58,6 +85,7 @@ export const postRequestBodySchema = z.object({
deepThinkingEnabled: z.boolean().optional(),
videoParams: videoParamsSchema, // 视频参数
imageParams: imageParamsSchema, // 🆕 图片生成参数
contextPayload: contextPayloadSchema,
});
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;

View File

@ -3177,10 +3177,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
id: rawId,
modelId: currentModelIdRef.current,
imageParams: imageParamsRef.current,
contextPayload: buildContextPayload(),
lastMessage: request.messages.at(-1),
});
const hiddenPrompt = hiddenPromptRef.current || undefined;
const autoContext = autoContextRef.current || undefined;
const contextPayload = buildContextPayload();
hiddenPromptRef.current = null;
autoContextRef.current = null;
return {
@ -3197,6 +3199,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
imageParams: isVideoModelRef.current ? undefined : imageParamsRef.current || undefined,
hiddenPrompt,
autoContext,
contextPayload,
...request.body,
},
};
@ -3951,6 +3954,67 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return lines.join("\n");
}, [nodes, selectedImageNodes]);
const buildContextPayload = useCallback(() => {
const imageNodes = nodes.filter(
(node) =>
node.type === "image" &&
typeof (node.data as any)?.src === "string" &&
String((node.data as any).src).trim().length > 0,
);
if (imageNodes.length === 0) return undefined;
const MAX_CONTEXT_IMAGES = 12;
const selectedIds = new Set(selectedImageNodes.map((node) => node.id));
const prioritizedNodes =
selectedImageNodes.length > 0
? [
...selectedImageNodes,
...imageNodes.filter((node) => !selectedIds.has(node.id)),
]
: imageNodes;
const contextNodes = prioritizedNodes.slice(0, MAX_CONTEXT_IMAGES);
const selectedAssetIds = Array.from(
new Set(
selectedImageNodes
.map((node) => {
const assetId = (node.data as any)?.assetId;
return typeof assetId === "string" && assetId.trim() ? assetId.trim() : null;
})
.filter((value): value is string => Boolean(value)),
),
);
return {
source: "canvas" as const,
projectId: rawChatId || undefined,
selectedNodeIds: selectedImageNodes.map((node) => node.id),
selectedAssetIds,
focusPolicy: selectedImageNodes.length > 0 ? ("selected" as const) : ("auto" as const),
canvasImageNodes: contextNodes.map((node) => ({
nodeId: node.id,
assetId:
typeof (node.data as any)?.assetId === "string" && (node.data as any).assetId.trim()
? String((node.data as any).assetId).trim()
: undefined,
url: String((node.data as any)?.src || ""),
title:
typeof (node.data as any)?.title === "string"
? String((node.data as any).title).slice(0, 256)
: undefined,
prompt:
typeof (node.data as any)?.prompt === "string"
? String((node.data as any).prompt).slice(0, 2000)
: undefined,
x: Number(node.position.x || 0),
y: Number(node.position.y || 0),
width: Number(node.size?.width || 0) || undefined,
height: Number(node.size?.height || 0) || undefined,
isSelected: selectedIds.has(node.id),
})),
};
}, [nodes, rawChatId, selectedImageNodes]);
const updateAttachmentAtVideoSlot = useCallback(
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
const slotCount = Math.max(videoFrameSlots.length, 2);
@ -6012,6 +6076,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
sendMessage,
clearSelection,
buildAutoContext,
buildContextPayload,
isVideoModel,
videoParams,
videoFrameSlots.length,

View File

@ -2498,6 +2498,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{resizeAnnotatorOpen && data.src && (
<ImageAnnotator
key={`resize-${node.id}-${data.src}`}
imageUrl={data.src}
initialResizeMode
confirmButtonText="发送到画布"

View File

@ -108,10 +108,80 @@ export function ImageAnnotator({
const [mounted, setMounted] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const objectUrlRef = useRef<string | null>(null);
const toCanvasLoadUrl = useCallback((url: string) => {
return buildImageProxyUrl(url);
}, []);
const revokeObjectUrl = useCallback(() => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
}, []);
const createImageElement = useCallback((src: string, useCrossOrigin = true) => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const img = new window.Image();
if (useCrossOrigin) {
img.crossOrigin = "anonymous";
}
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`图片加载失败: ${src}`));
img.src = src;
});
}, []);
const loadImageFromSource = useCallback(async (sourceUrl: string) => {
const normalizedSource = String(sourceUrl || "").trim();
if (!normalizedSource) {
throw new Error("图片地址为空");
}
const directUrl = normalizedSource;
const proxiedUrl = toCanvasLoadUrl(normalizedSource);
const candidateUrls = Array.from(
new Set([proxiedUrl, directUrl].filter((item): item is string => Boolean(item)))
);
for (const candidateUrl of candidateUrls) {
try {
const shouldUseCrossOrigin = !candidateUrl.startsWith("data:") && !candidateUrl.startsWith("blob:");
const imageElement = await createImageElement(candidateUrl, shouldUseCrossOrigin);
revokeObjectUrl();
return imageElement;
} catch {
continue;
}
}
for (const candidateUrl of candidateUrls) {
try {
const response = await fetch(candidateUrl, { cache: "force-cache" });
if (!response.ok) {
continue;
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const imageElement = await createImageElement(objectUrl, false);
revokeObjectUrl();
objectUrlRef.current = objectUrl;
return imageElement;
} catch {
continue;
}
}
throw new Error("图片加载失败");
}, [createImageElement, revokeObjectUrl, toCanvasLoadUrl]);
useEffect(() => {
return () => {
revokeObjectUrl();
};
}, [revokeObjectUrl]);
// Handle open change
const handleOpenChange = (newOpen: boolean) => {
onOpenChange?.(newOpen);
@ -160,18 +230,10 @@ export function ImageAnnotator({
setProcessType(null);
setActions([]);
setCurrentAction(null);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
toast.error("图片加载失败");
};
img.src = toCanvasLoadUrl(imageUrl);
const img = await loadImageFromSource(imageUrl);
setImage(img);
setIsLoading(false);
toast.success("图片上传成功");
} catch (error) {
console.error("上传图片失败:", error);
@ -179,7 +241,7 @@ export function ImageAnnotator({
setIsLoading(false);
}
}
}, []);
}, [loadImageFromSource]);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
@ -215,18 +277,10 @@ export function ImageAnnotator({
setProcessType(null);
setActions([]);
setCurrentAction(null);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
toast.error("图片加载失败");
};
img.src = toCanvasLoadUrl(imageUrl);
const img = await loadImageFromSource(imageUrl);
setImage(img);
setIsLoading(false);
toast.success("图片上传成功");
} catch (error) {
console.error("上传图片失败:", error);
@ -240,7 +294,7 @@ export function ImageAnnotator({
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [mounted, originalImageUrl]);
}, [loadImageFromSource, mounted, originalImageUrl]);
// 尺寸调整相关状态
const [isResizeMode, setIsResizeMode] = useState(initialResizeMode);
@ -362,19 +416,10 @@ export function ImageAnnotator({
setProcessType(null);
setActions([]);
setCurrentAction(null);
// 触发图片加载逻辑
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
toast.error("图片加载失败");
};
img.src = toCanvasLoadUrl(imageUrl);
const img = await loadImageFromSource(imageUrl);
setImage(img);
setIsLoading(false);
toast.success("图片上传成功");
} catch (error) {
console.error("上传图片失败:", error);
@ -390,29 +435,41 @@ export function ImageAnnotator({
useEffect(() => {
if (!imageUrl) return; // 如果没有初始图片,不执行加载逻辑
setIsLoading(true);
setIsResizeMode(initialResizeMode);
setOriginalImageUrl(imageUrl);
setProcessedImageUrl(null);
setProcessedImageBase64(null);
setProcessType(null);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
if (img) {
let cancelled = false;
const initializeImage = async () => {
try {
setIsLoading(true);
setIsResizeMode(initialResizeMode);
setOriginalImageUrl(imageUrl);
setProcessedImageUrl(null);
setProcessedImageBase64(null);
setProcessType(null);
const img = await loadImageFromSource(imageUrl);
if (cancelled) return;
setImage(img);
setIsLoading(false);
setIsResizeMode(initialResizeMode);
setResizeCanvasWidth(img.width);
setResizeCanvasHeight(img.height);
setSelectedAspectRatio(null);
} catch (error) {
if (cancelled) return;
console.error("初始化图片加载失败:", error);
setImage(null);
setIsLoading(false);
toast.error("当前图片加载失败,请重试");
}
};
img.onerror = () => {
setIsLoading(false);
initializeImage();
return () => {
cancelled = true;
};
img.src = toCanvasLoadUrl(imageUrl);
}, [imageUrl, initialResizeMode, toCanvasLoadUrl]);
}, [imageUrl, initialResizeMode, loadImageFromSource]);
// 初始化 canvas - 适配弹窗大小,固定编辑器大小,图片缩小显示
useEffect(() => {

View File

@ -0,0 +1,309 @@
import "server-only";
import { extractImageUrlsFromText, inferOriginalImageUrl } from "@/lib/image-url";
import type { DBMessage } from "@/lib/db/schema";
import type {
CanvasImageNodeContext,
ResolveChatMediaInputParams,
ResolvedChatMediaInput,
ResolvedImageRef,
} from "@/lib/ai/context-memory/types";
function normalizeMediaUrl(url: string): string {
const raw = String(url || "").trim();
if (!raw) return "";
if (/^data:image\/[a-z0-9+.-]+;base64,/i.test(raw)) {
return raw.replace(/\s+/g, "");
}
try {
const parsed = new URL(raw);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase();
} catch {
return raw
.split("#")[0]
.split("?")[0]
.replace(/\/+$/, "")
.toLowerCase();
}
}
function normalizeImageInputRef(rawValue: unknown, requestOrigin?: string): string | null {
if (typeof rawValue !== "string") return null;
const trimmed = rawValue.trim();
if (!trimmed) return null;
if (/^data:image\/[a-z0-9+.-]+;base64,/i.test(trimmed)) {
return trimmed.replace(/\s+/g, "");
}
if (/^https?:\/\//i.test(trimmed)) {
return inferOriginalImageUrl(trimmed);
}
if (requestOrigin && trimmed.startsWith("/")) {
try {
return new URL(trimmed, requestOrigin).toString();
} catch {
return null;
}
}
return null;
}
function collectImageInputsFromMessageParts(
parts: Array<Record<string, any>> | undefined,
requestOrigin?: string,
): string[] {
if (!Array.isArray(parts) || parts.length === 0) return [];
const result: string[] = [];
const seen = new Set<string>();
const pushInput = (rawValue: unknown) => {
const normalized = normalizeImageInputRef(rawValue, requestOrigin);
if (!normalized) return;
const key = normalizeMediaUrl(normalized);
if (!key || seen.has(key)) return;
seen.add(key);
result.push(normalized);
};
for (const part of parts) {
if (!part) continue;
if (part.type === "file") {
pushInput(part.url);
pushInput(part.data);
continue;
}
if (part.type === "image") {
pushInput(part.url);
pushInput(part.image);
pushInput(part.data);
continue;
}
if (part.type === "image_url") {
pushInput(part.image_url?.url);
pushInput(part.url);
continue;
}
if (part.type === "text" && typeof part.text === "string") {
for (const parsedUrl of extractImageUrlsFromText(part.text)) {
pushInput(parsedUrl);
}
}
}
return result;
}
function dedupeRefs(refs: ResolvedImageRef[], maxImages: number): ResolvedImageRef[] {
const seen = new Set<string>();
const result: ResolvedImageRef[] = [];
for (const ref of refs) {
const key = ref.normalizedUrl || normalizeMediaUrl(ref.url);
if (!key || seen.has(key)) continue;
seen.add(key);
result.push({ ...ref, normalizedUrl: key });
if (result.length >= maxImages) break;
}
return result;
}
function toCanvasImageRefs(nodes: CanvasImageNodeContext[] | undefined, maxImages: number) {
if (!Array.isArray(nodes) || nodes.length === 0) return [] as ResolvedImageRef[];
return dedupeRefs(
nodes
.filter((node) => typeof node?.url === "string" && node.url.trim())
.map((node) => ({
url: inferOriginalImageUrl(node.url),
normalizedUrl: normalizeMediaUrl(inferOriginalImageUrl(node.url)),
assetId: typeof node.assetId === "string" ? node.assetId.trim() || undefined : undefined,
nodeId: node.nodeId,
title: node.title,
prompt: node.prompt,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
isSelected: Boolean(node.isSelected),
source: node.isSelected ? "canvas_selection" : "canvas_context",
role: node.isSelected ? "selected" : "reference",
})),
maxImages,
);
}
function extractHistoryImageRefs(params: {
historyMessages: DBMessage[];
requestOrigin?: string;
maxImages: number;
}) {
const latestAssistant: ResolvedImageRef[] = [];
const latestUser: ResolvedImageRef[] = [];
const seenAssistant = new Set<string>();
const seenUser = new Set<string>();
const pushRef = (
bucket: ResolvedImageRef[],
seen: Set<string>,
url: string,
options: Pick<ResolvedImageRef, "source" | "role" | "messageId">,
) => {
const normalizedUrl = normalizeMediaUrl(url);
if (!normalizedUrl || seen.has(normalizedUrl) || bucket.length >= params.maxImages) return;
seen.add(normalizedUrl);
bucket.push({
url,
normalizedUrl,
source: options.source,
role: options.role,
messageId: options.messageId,
});
};
for (let index = params.historyMessages.length - 1; index >= 0; index -= 1) {
const historyMessage = params.historyMessages[index];
const parts = historyMessage.parts as Array<Record<string, any>> | undefined;
if (!Array.isArray(parts) || parts.length === 0) continue;
const urls = collectImageInputsFromMessageParts(parts, params.requestOrigin);
if (!urls.length) continue;
if (historyMessage.role === "assistant" && latestAssistant.length < params.maxImages) {
for (const url of urls) {
pushRef(latestAssistant, seenAssistant, url, {
source: "history_assistant_result",
role: "history_result",
messageId: historyMessage.id,
});
}
}
if (historyMessage.role === "user" && latestUser.length < params.maxImages) {
for (const url of urls) {
pushRef(latestUser, seenUser, url, {
source: "history_user_upload",
role: "reference",
messageId: historyMessage.id,
});
}
}
if (latestAssistant.length >= params.maxImages && latestUser.length >= params.maxImages) {
break;
}
}
return {
latestAssistant,
latestUser,
};
}
export function resolveChatMediaInput(
params: ResolveChatMediaInputParams,
): ResolvedChatMediaInput {
const maxImages = Math.max(1, Math.min(12, Math.round(params.maxImages || 4)));
const currentMessageImageRefs = dedupeRefs(
collectImageInputsFromMessageParts(params.messageParts, params.requestOrigin).map((url) => ({
url,
normalizedUrl: normalizeMediaUrl(url),
source: "current_message",
role: "upload",
})),
maxImages,
);
const canvasImageRefs = toCanvasImageRefs(params.contextPayload?.canvasImageNodes, maxImages);
const selectedNodeIds = new Set(params.contextPayload?.selectedNodeIds || []);
const selectedImageRefs = dedupeRefs(
canvasImageRefs.filter((ref) => ref.isSelected || (ref.nodeId && selectedNodeIds.has(ref.nodeId))),
maxImages,
);
const history = extractHistoryImageRefs({
historyMessages: params.historyMessages,
requestOrigin: params.requestOrigin,
maxImages,
});
const resolvedImageRefs =
currentMessageImageRefs.length > 0
? currentMessageImageRefs
: selectedImageRefs.length > 0
? selectedImageRefs
: history.latestAssistant.length > 0
? history.latestAssistant
: history.latestUser;
const imageRefs = dedupeRefs(
[
...currentMessageImageRefs,
...selectedImageRefs,
...canvasImageRefs,
...history.latestAssistant,
...history.latestUser,
],
maxImages * 4,
);
const selectedAssetIds = Array.from(
new Set(
selectedImageRefs
.map((ref) => ref.assetId)
.filter((value): value is string => typeof value === "string" && value.trim().length > 0),
),
);
const uploadedAssetIds = Array.from(
new Set(
currentMessageImageRefs
.map((ref) => ref.assetId)
.filter((value): value is string => typeof value === "string" && value.trim().length > 0),
),
);
const candidateAssetIds = Array.from(
new Set(
imageRefs
.map((ref) => ref.assetId)
.filter((value): value is string => typeof value === "string" && value.trim().length > 0),
),
);
const source =
currentMessageImageRefs.length > 0
? "current_message"
: selectedImageRefs.length > 0
? "canvas_selection"
: resolvedImageRefs.length > 0
? "history"
: "none";
return {
imageRefs,
currentMessageImageRefs,
selectedImageRefs,
canvasImageRefs,
historyUserImageRefs: history.latestUser,
historyAssistantImageRefs: history.latestAssistant,
resolvedImageRefs,
currentMessageImageUrls: currentMessageImageRefs.map((ref) => ref.url),
selectedImageUrls: selectedImageRefs.map((ref) => ref.url),
historyUserImageUrls: history.latestUser.map((ref) => ref.url),
historyAssistantImageUrls: history.latestAssistant.map((ref) => ref.url),
resolvedImageUrls: resolvedImageRefs.map((ref) => ref.url),
selectedAssetIds,
candidateAssetIds,
uploadedAssetIds,
hasImage: resolvedImageRefs.length > 0,
source,
fromHistory: source === "history" || source === "canvas_selection",
};
}

View File

@ -0,0 +1,378 @@
import "server-only";
import type {
ResolveTargetAssetsParams,
ResolvedImageRef,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
const ORIGINAL_IMAGE_REGEX =
/(原图|上传图|参考图|最初那张|第一张图|素材图|源图|原始图片|原始那张)/i;
const LATEST_RESULT_REGEX =
/(上一张|上一轮|刚生成|最新生成|这个结果|该结果|按这个结果|继续在这张|基于这张结果|延续上一次结果|上一版|刚才那张结果)/i;
const SELECTION_REGEX = /(选中的|当前这张|我选的|这几张|这些图|选中图)/i;
const ACTIONABLE_EDIT_REGEX =
/(改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按照|按|保留|统一|色差|背景|清晰|风格|亮一点|暗一点|白底|透明底)/i;
function normalizeStringArray(values?: string[] | null): string[] {
if (!Array.isArray(values)) return [];
return Array.from(
new Set(
values
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean),
),
);
}
function chineseNumberToInt(value: string): number | null {
const normalized = value.trim();
if (!normalized) return null;
if (/^\d+$/.test(normalized)) return Number.parseInt(normalized, 10);
const map: Record<string, number> = {
一: 1,
二: 2,
两: 2,
三: 3,
四: 4,
五: 5,
六: 6,
七: 7,
八: 8,
九: 9,
十: 10,
};
if (normalized === "十") return 10;
if (normalized.length === 1) return map[normalized] ?? null;
if (normalized.startsWith("十")) {
return 10 + (map[normalized.slice(1)] ?? 0);
}
if (normalized.endsWith("十")) {
return (map[normalized.slice(0, 1)] ?? 0) * 10;
}
if (normalized.includes("十")) {
const [head, tail] = normalized.split("十");
return (map[head] ?? 0) * 10 + (map[tail] ?? 0);
}
return null;
}
function sortRefsForCanvasOrdering(refs: ResolvedImageRef[]) {
return [...refs].sort((left, right) => {
const leftY = Number(left.y ?? 0);
const rightY = Number(right.y ?? 0);
if (Math.abs(leftY - rightY) > 12) {
return leftY - rightY;
}
return Number(left.x ?? 0) - Number(right.x ?? 0);
});
}
function getPositionLabel(ref: ResolvedImageRef, refs: ResolvedImageRef[]): string {
if (refs.length <= 1) return "中间";
const xs = refs.map((item) => Number(item.x ?? 0));
const ys = refs.map((item) => Number(item.y ?? 0));
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
const relX = (Number(ref.x ?? 0) - minX) / spanX;
const relY = (Number(ref.y ?? 0) - minY) / spanY;
const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中";
const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中";
if (horizontal === "中" && vertical === "中") return "中间";
return `${vertical}${horizontal}`;
}
function pickByOrdinal(text: string, refs: ResolvedImageRef[]): ResolvedImageRef[] {
const matched = text.match(/第\s*([一二三四五六七八九十两\d]+)\s*(?:张|个|幅|图)/i);
const numeric = matched ? chineseNumberToInt(matched[1]) : null;
if (!numeric || numeric <= 0) return [];
const ordered = sortRefsForCanvasOrdering(refs);
return ordered[numeric - 1] ? [ordered[numeric - 1]] : [];
}
function pickBySpatialHint(text: string, refs: ResolvedImageRef[]): ResolvedImageRef[] {
if (!refs.length) return [];
const normalizedText = text.replace(/\s+/g, "");
const hasLeft = normalizedText.includes("左");
const hasRight = normalizedText.includes("右");
const hasTop = normalizedText.includes("上");
const hasBottom = normalizedText.includes("下");
const hasCenter = normalizedText.includes("中间") || normalizedText.includes("中");
if (!(hasLeft || hasRight || hasTop || hasBottom || hasCenter)) return [];
const withLabels = refs.map((ref) => ({
ref,
label: getPositionLabel(ref, refs),
}));
return withLabels
.filter(({ label }) => {
if (hasLeft && !label.includes("左")) return false;
if (hasRight && !label.includes("右")) return false;
if (hasTop && !label.includes("上")) return false;
if (hasBottom && !label.includes("下")) return false;
if (hasCenter && !(label.includes("中") || label === "中间")) return false;
return true;
})
.map(({ ref }) => ref);
}
function buildResolvedTargetAssets(params: {
targetRefs: ResolvedImageRef[];
candidateRefs: ResolvedImageRef[];
resolutionMode: ResolvedTargetAssets["resolutionMode"];
confidence: number;
needsClarification: boolean;
reason: string;
fromHistory: boolean;
}): ResolvedTargetAssets {
const targetRefs = params.targetRefs;
const candidateRefs = params.candidateRefs;
return {
targetRefs,
candidateRefs,
targetImageUrls: targetRefs.map((ref) => ref.url),
targetAssetIds: normalizeStringArray(targetRefs.map((ref) => ref.assetId || "")),
candidateImageUrls: candidateRefs.map((ref) => ref.url),
candidateAssetIds: normalizeStringArray(candidateRefs.map((ref) => ref.assetId || "")),
resolutionMode: params.resolutionMode,
confidence: Math.max(0, Math.min(100, Math.round(params.confidence))),
needsClarification: params.needsClarification,
reason: params.reason,
fromHistory: params.fromHistory,
};
}
export function resolveTargetAssets(
params: ResolveTargetAssetsParams,
): ResolvedTargetAssets {
const text = String(params.text || "").trim();
const mediaInput = params.mediaInput;
const canvasRefs = mediaInput.canvasImageRefs;
const selectedRefs = mediaInput.selectedImageRefs;
const currentRefs = mediaInput.currentMessageImageRefs;
const assistantRefs = mediaInput.historyAssistantImageRefs;
const userRefs = mediaInput.historyUserImageRefs;
const candidateRefs = mediaInput.imageRefs;
const workingState = params.workingState;
const focusPolicy = params.contextPayload?.focusPolicy || "auto";
if (focusPolicy === "original" && userRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: userRefs,
candidateRefs,
resolutionMode: "text_reference",
confidence: 96,
needsClarification: false,
reason: "前端上下文指定优先使用原图",
fromHistory: true,
});
}
if (focusPolicy === "latest_result" && assistantRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: assistantRefs,
candidateRefs,
resolutionMode: "latest_result",
confidence: 96,
needsClarification: false,
reason: "前端上下文指定优先使用上一轮结果",
fromHistory: true,
});
}
if (focusPolicy === "selected" && selectedRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: selectedRefs,
candidateRefs,
resolutionMode: "explicit_selection",
confidence: 97,
needsClarification: false,
reason: "前端已明确选中目标图片",
fromHistory: true,
});
}
if (ORIGINAL_IMAGE_REGEX.test(text) && userRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: userRefs,
candidateRefs,
resolutionMode: "text_reference",
confidence: 94,
needsClarification: false,
reason: "用户文本明确指向原图/上传图",
fromHistory: true,
});
}
if (LATEST_RESULT_REGEX.test(text) && assistantRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: assistantRefs,
candidateRefs,
resolutionMode: "latest_result",
confidence: 94,
needsClarification: false,
reason: "用户文本明确指向上一轮结果",
fromHistory: true,
});
}
const ordinalRefs = pickByOrdinal(text, canvasRefs);
if (ordinalRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: ordinalRefs,
candidateRefs,
resolutionMode: "text_reference",
confidence: 88,
needsClarification: false,
reason: "根据用户文本中的序号定位到画布图片",
fromHistory: true,
});
}
const spatialRefs = pickBySpatialHint(text, canvasRefs);
if (spatialRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: spatialRefs,
candidateRefs,
resolutionMode: "text_reference",
confidence: spatialRefs.length === 1 ? 90 : 76,
needsClarification: false,
reason: "根据用户文本中的方位指向定位到画布图片",
fromHistory: true,
});
}
if (currentRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: currentRefs,
candidateRefs,
resolutionMode: "current_upload",
confidence: 93,
needsClarification: false,
reason: "本轮消息包含图片输入,默认优先处理当前上传图片",
fromHistory: false,
});
}
if ((SELECTION_REGEX.test(text) || focusPolicy === "selected") && selectedRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: selectedRefs,
candidateRefs,
resolutionMode: "explicit_selection",
confidence: 92,
needsClarification: false,
reason: "用户文本或上下文明确要求使用当前选中的图片",
fromHistory: true,
});
}
if (selectedRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: selectedRefs,
candidateRefs,
resolutionMode: "explicit_selection",
confidence: 86,
needsClarification: false,
reason: "当前存在画布选中图片,默认优先继续处理选中目标",
fromHistory: true,
});
}
const workingStateResolvedUrls = normalizeStringArray(
workingState?.lastResolvedTargetImageUrls,
);
if (workingStateResolvedUrls.length > 0) {
const refs = workingStateResolvedUrls.map((url) => ({
url,
normalizedUrl: url,
source: "working_memory" as const,
role: "working_memory" as const,
}));
return buildResolvedTargetAssets({
targetRefs: refs,
candidateRefs,
resolutionMode: "working_memory",
confidence: 82,
needsClarification: false,
reason: "使用会话工作记忆中的上一轮已解析目标图",
fromHistory: true,
});
}
const workingStateGeneratedUrls = normalizeStringArray(workingState?.lastGeneratedImageUrls);
if (workingStateGeneratedUrls.length > 0) {
const refs = workingStateGeneratedUrls.map((url) => ({
url,
normalizedUrl: url,
source: "working_memory" as const,
role: "working_memory" as const,
}));
return buildResolvedTargetAssets({
targetRefs: refs,
candidateRefs,
resolutionMode: "working_memory",
confidence: 78,
needsClarification: false,
reason: "使用会话工作记忆中的最近生成结果",
fromHistory: true,
});
}
if (assistantRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: assistantRefs,
candidateRefs,
resolutionMode: "latest_result",
confidence: 74,
needsClarification: false,
reason: "未显式指定时,默认续接最近一轮生成结果",
fromHistory: true,
});
}
if (userRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: userRefs,
candidateRefs,
resolutionMode: "working_memory",
confidence: 68,
needsClarification: false,
reason: "回退使用最近一次用户上传图片",
fromHistory: true,
});
}
if (ACTIONABLE_EDIT_REGEX.test(text)) {
return buildResolvedTargetAssets({
targetRefs: [],
candidateRefs,
resolutionMode: "clarification",
confidence: 0,
needsClarification: true,
reason: "用户表达了编辑意图,但当前没有可用图片目标",
fromHistory: false,
});
}
return buildResolvedTargetAssets({
targetRefs: [],
candidateRefs,
resolutionMode: "none",
confidence: 0,
needsClarification: false,
reason: "当前请求没有可定位的图片目标",
fromHistory: false,
});
}

View File

@ -0,0 +1,119 @@
import type { DBMessage } from "@/lib/db/schema";
export interface CanvasImageNodeContext {
nodeId: string;
assetId?: string;
url: string;
title?: string;
prompt?: string;
x: number;
y: number;
width?: number;
height?: number;
isSelected?: boolean;
}
export interface ContextPayload {
source?: "chat" | "canvas" | "realtime-canvas";
projectId?: string;
selectedNodeIds?: string[];
selectedAssetIds?: string[];
focusPolicy?: "auto" | "selected" | "latest_result" | "original";
canvasImageNodes?: CanvasImageNodeContext[];
}
export interface ResolvedImageRef {
url: string;
normalizedUrl: string;
assetId?: string;
nodeId?: string;
title?: string;
prompt?: string;
x?: number;
y?: number;
width?: number;
height?: number;
isSelected?: boolean;
source:
| "current_message"
| "canvas_selection"
| "canvas_context"
| "history_user_upload"
| "history_assistant_result"
| "working_memory";
role: "upload" | "selected" | "reference" | "history_result" | "working_memory";
messageId?: string;
}
export interface ResolvedChatMediaInput {
imageRefs: ResolvedImageRef[];
currentMessageImageRefs: ResolvedImageRef[];
selectedImageRefs: ResolvedImageRef[];
canvasImageRefs: ResolvedImageRef[];
historyUserImageRefs: ResolvedImageRef[];
historyAssistantImageRefs: ResolvedImageRef[];
resolvedImageRefs: ResolvedImageRef[];
currentMessageImageUrls: string[];
selectedImageUrls: string[];
historyUserImageUrls: string[];
historyAssistantImageUrls: string[];
resolvedImageUrls: string[];
selectedAssetIds: string[];
candidateAssetIds: string[];
uploadedAssetIds: string[];
hasImage: boolean;
source: "current_message" | "canvas_selection" | "history" | "none";
fromHistory: boolean;
}
export interface WorkingStateImageMemory {
currentFocusAssetIds?: string[] | null;
currentFocusImageUrls?: string[] | null;
selectedAssetIds?: string[] | null;
selectedImageUrls?: string[] | null;
lastUploadedAssetIds?: string[] | null;
lastUploadedImageUrls?: string[] | null;
lastGeneratedAssetIds?: string[] | null;
lastGeneratedImageUrls?: string[] | null;
lastReferencedAssetIds?: string[] | null;
lastReferencedImageUrls?: string[] | null;
lastResolvedTargetAssetIds?: string[] | null;
lastResolvedTargetImageUrls?: string[] | null;
activeCanvasNodeIds?: string[] | null;
}
export interface ResolvedTargetAssets {
targetRefs: ResolvedImageRef[];
candidateRefs: ResolvedImageRef[];
targetImageUrls: string[];
targetAssetIds: string[];
candidateImageUrls: string[];
candidateAssetIds: string[];
resolutionMode:
| "explicit_selection"
| "current_upload"
| "text_reference"
| "working_memory"
| "latest_result"
| "clarification"
| "none";
confidence: number;
needsClarification: boolean;
reason: string;
fromHistory: boolean;
}
export interface ResolveChatMediaInputParams {
messageParts: Array<Record<string, any>> | undefined;
historyMessages: DBMessage[];
contextPayload?: ContextPayload;
requestOrigin?: string;
maxImages?: number;
}
export interface ResolveTargetAssetsParams {
text: string;
mediaInput: ResolvedChatMediaInput;
contextPayload?: ContextPayload;
workingState?: WorkingStateImageMemory | null;
}

View File

@ -0,0 +1,101 @@
CREATE TABLE IF NOT EXISTS "ConversationWorkingState" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" uuid NOT NULL,
"chatId" uuid NOT NULL,
"projectId" uuid,
"currentIntent" varchar(64),
"currentSubIntent" varchar(64),
"currentFocusAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"currentFocusImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"selectedAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"selectedImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"lastUploadedAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"lastUploadedImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"lastGeneratedAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"lastGeneratedImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"lastReferencedAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"lastReferencedImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"lastResolvedTargetAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"lastResolvedTargetImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"activeCanvasNodeIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"lastCanvasSnapshot" jsonb,
"lastTurnSummary" jsonb,
"lastMessageId" uuid,
"turnCount" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "ConversationWorkingState_chatId_unique" UNIQUE("chatId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "MessageTargetResolution" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" uuid NOT NULL,
"chatId" uuid NOT NULL,
"messageId" uuid,
"projectId" uuid,
"rawText" text NOT NULL,
"uploadedAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"uploadedImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"selectedAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"selectedImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"candidateAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"candidateImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"resolvedAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"resolvedImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"resolutionMode" varchar(64) NOT NULL,
"confidence" integer DEFAULT 0 NOT NULL,
"needsClarification" boolean DEFAULT false NOT NULL,
"reasoning" jsonb,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ConversationWorkingState" ADD CONSTRAINT "ConversationWorkingState_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ConversationWorkingState" ADD CONSTRAINT "ConversationWorkingState_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ConversationWorkingState" ADD CONSTRAINT "ConversationWorkingState_projectId_CanvasProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."CanvasProject"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "MessageTargetResolution" ADD CONSTRAINT "MessageTargetResolution_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "MessageTargetResolution" ADD CONSTRAINT "MessageTargetResolution_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "MessageTargetResolution" ADD CONSTRAINT "MessageTargetResolution_messageId_Message_v2_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message_v2"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "MessageTargetResolution" ADD CONSTRAINT "MessageTargetResolution_projectId_CanvasProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."CanvasProject"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "ConversationWorkingState_user_updated_idx"
ON "ConversationWorkingState"("userId", "updatedAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "MessageTargetResolution_chat_created_idx"
ON "MessageTargetResolution"("chatId", "createdAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "MessageTargetResolution_message_idx"
ON "MessageTargetResolution"("messageId");

View File

@ -253,6 +253,13 @@
"when": 1772766180000,
"tag": "0049_add_model3d_assets",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1772788800000,
"tag": "0050_add_context_memory_tables",
"breakpoints": true
}
]
}

View File

@ -52,6 +52,10 @@ import {
pointsLedger,
tutorialVideos,
messageDeprecated,
conversationWorkingState,
type ConversationWorkingState,
messageTargetResolution,
type MessageTargetResolution,
} from "./schema";
import { generateHashedPassword } from "./utils";
import {
@ -666,6 +670,245 @@ export async function updateChatLastContextById({
}
}
function normalizeStringArray(value?: string[] | null): string[] {
if (!Array.isArray(value)) return [];
const deduped = new Set<string>();
for (const item of value) {
if (typeof item !== "string") continue;
const trimmed = item.trim();
if (!trimmed) continue;
deduped.add(trimmed);
}
return Array.from(deduped);
}
export async function getConversationWorkingStateByChatId({
chatId,
}: {
chatId: string;
}): Promise<ConversationWorkingState | null> {
try {
const rows = await db
.select()
.from(conversationWorkingState)
.where(eq(conversationWorkingState.chatId, chatId))
.limit(1);
return rows[0] ?? null;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get conversation working state"
);
}
}
export async function upsertConversationWorkingState(params: {
userId: string;
chatId: string;
projectId?: string | null;
currentIntent?: string | null;
currentSubIntent?: string | null;
currentFocusAssetIds?: string[] | null;
currentFocusImageUrls?: string[] | null;
selectedAssetIds?: string[] | null;
selectedImageUrls?: string[] | null;
lastUploadedAssetIds?: string[] | null;
lastUploadedImageUrls?: string[] | null;
lastGeneratedAssetIds?: string[] | null;
lastGeneratedImageUrls?: string[] | null;
lastReferencedAssetIds?: string[] | null;
lastReferencedImageUrls?: string[] | null;
lastResolvedTargetAssetIds?: string[] | null;
lastResolvedTargetImageUrls?: string[] | null;
activeCanvasNodeIds?: string[] | null;
lastCanvasSnapshot?: any;
lastTurnSummary?: any;
lastMessageId?: string | null;
incrementTurnCount?: boolean;
}): Promise<ConversationWorkingState | null> {
try {
const existing = await getConversationWorkingStateByChatId({
chatId: params.chatId,
});
const now = new Date();
const values = {
userId: params.userId,
chatId: params.chatId,
projectId:
params.projectId !== undefined ? params.projectId : existing?.projectId ?? null,
currentIntent:
params.currentIntent !== undefined
? params.currentIntent
: existing?.currentIntent ?? null,
currentSubIntent:
params.currentSubIntent !== undefined
? params.currentSubIntent
: existing?.currentSubIntent ?? null,
currentFocusAssetIds:
params.currentFocusAssetIds !== undefined
? normalizeStringArray(params.currentFocusAssetIds)
: normalizeStringArray(existing?.currentFocusAssetIds),
currentFocusImageUrls:
params.currentFocusImageUrls !== undefined
? normalizeStringArray(params.currentFocusImageUrls)
: normalizeStringArray(existing?.currentFocusImageUrls),
selectedAssetIds:
params.selectedAssetIds !== undefined
? normalizeStringArray(params.selectedAssetIds)
: normalizeStringArray(existing?.selectedAssetIds),
selectedImageUrls:
params.selectedImageUrls !== undefined
? normalizeStringArray(params.selectedImageUrls)
: normalizeStringArray(existing?.selectedImageUrls),
lastUploadedAssetIds:
params.lastUploadedAssetIds !== undefined
? normalizeStringArray(params.lastUploadedAssetIds)
: normalizeStringArray(existing?.lastUploadedAssetIds),
lastUploadedImageUrls:
params.lastUploadedImageUrls !== undefined
? normalizeStringArray(params.lastUploadedImageUrls)
: normalizeStringArray(existing?.lastUploadedImageUrls),
lastGeneratedAssetIds:
params.lastGeneratedAssetIds !== undefined
? normalizeStringArray(params.lastGeneratedAssetIds)
: normalizeStringArray(existing?.lastGeneratedAssetIds),
lastGeneratedImageUrls:
params.lastGeneratedImageUrls !== undefined
? normalizeStringArray(params.lastGeneratedImageUrls)
: normalizeStringArray(existing?.lastGeneratedImageUrls),
lastReferencedAssetIds:
params.lastReferencedAssetIds !== undefined
? normalizeStringArray(params.lastReferencedAssetIds)
: normalizeStringArray(existing?.lastReferencedAssetIds),
lastReferencedImageUrls:
params.lastReferencedImageUrls !== undefined
? normalizeStringArray(params.lastReferencedImageUrls)
: normalizeStringArray(existing?.lastReferencedImageUrls),
lastResolvedTargetAssetIds:
params.lastResolvedTargetAssetIds !== undefined
? normalizeStringArray(params.lastResolvedTargetAssetIds)
: normalizeStringArray(existing?.lastResolvedTargetAssetIds),
lastResolvedTargetImageUrls:
params.lastResolvedTargetImageUrls !== undefined
? normalizeStringArray(params.lastResolvedTargetImageUrls)
: normalizeStringArray(existing?.lastResolvedTargetImageUrls),
activeCanvasNodeIds:
params.activeCanvasNodeIds !== undefined
? normalizeStringArray(params.activeCanvasNodeIds)
: normalizeStringArray(existing?.activeCanvasNodeIds),
lastCanvasSnapshot:
params.lastCanvasSnapshot !== undefined
? params.lastCanvasSnapshot
: existing?.lastCanvasSnapshot ?? null,
lastTurnSummary:
params.lastTurnSummary !== undefined
? params.lastTurnSummary
: existing?.lastTurnSummary ?? null,
lastMessageId:
params.lastMessageId !== undefined
? params.lastMessageId
: existing?.lastMessageId ?? null,
turnCount:
(existing?.turnCount ?? 0) + (params.incrementTurnCount ? 1 : 0),
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const [result] = await db
.insert(conversationWorkingState)
.values(values)
.onConflictDoUpdate({
target: conversationWorkingState.chatId,
set: {
userId: values.userId,
projectId: values.projectId,
currentIntent: values.currentIntent,
currentSubIntent: values.currentSubIntent,
currentFocusAssetIds: values.currentFocusAssetIds,
currentFocusImageUrls: values.currentFocusImageUrls,
selectedAssetIds: values.selectedAssetIds,
selectedImageUrls: values.selectedImageUrls,
lastUploadedAssetIds: values.lastUploadedAssetIds,
lastUploadedImageUrls: values.lastUploadedImageUrls,
lastGeneratedAssetIds: values.lastGeneratedAssetIds,
lastGeneratedImageUrls: values.lastGeneratedImageUrls,
lastReferencedAssetIds: values.lastReferencedAssetIds,
lastReferencedImageUrls: values.lastReferencedImageUrls,
lastResolvedTargetAssetIds: values.lastResolvedTargetAssetIds,
lastResolvedTargetImageUrls: values.lastResolvedTargetImageUrls,
activeCanvasNodeIds: values.activeCanvasNodeIds,
lastCanvasSnapshot: values.lastCanvasSnapshot,
lastTurnSummary: values.lastTurnSummary,
lastMessageId: values.lastMessageId,
turnCount: values.turnCount,
updatedAt: values.updatedAt,
},
})
.returning();
return result ?? null;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to upsert conversation working state"
);
}
}
export async function createMessageTargetResolution(params: {
userId: string;
chatId: string;
messageId?: string | null;
projectId?: string | null;
rawText: string;
uploadedAssetIds?: string[] | null;
uploadedImageUrls?: string[] | null;
selectedAssetIds?: string[] | null;
selectedImageUrls?: string[] | null;
candidateAssetIds?: string[] | null;
candidateImageUrls?: string[] | null;
resolvedAssetIds?: string[] | null;
resolvedImageUrls?: string[] | null;
resolutionMode: string;
confidence?: number | null;
needsClarification?: boolean;
reasoning?: any;
}): Promise<MessageTargetResolution | null> {
try {
const [result] = await db
.insert(messageTargetResolution)
.values({
userId: params.userId,
chatId: params.chatId,
messageId: params.messageId ?? null,
projectId: params.projectId ?? null,
rawText: params.rawText,
uploadedAssetIds: normalizeStringArray(params.uploadedAssetIds),
uploadedImageUrls: normalizeStringArray(params.uploadedImageUrls),
selectedAssetIds: normalizeStringArray(params.selectedAssetIds),
selectedImageUrls: normalizeStringArray(params.selectedImageUrls),
candidateAssetIds: normalizeStringArray(params.candidateAssetIds),
candidateImageUrls: normalizeStringArray(params.candidateImageUrls),
resolvedAssetIds: normalizeStringArray(params.resolvedAssetIds),
resolvedImageUrls: normalizeStringArray(params.resolvedImageUrls),
resolutionMode: params.resolutionMode,
confidence: Math.max(0, Math.min(100, Math.round(params.confidence ?? 0))),
needsClarification: Boolean(params.needsClarification),
reasoning: params.reasoning ?? null,
createdAt: new Date(),
})
.returning();
return result ?? null;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to create message target resolution"
);
}
}
export async function getMessageCountByUserId({
id,
differenceInHours,

View File

@ -665,3 +665,80 @@ export const videoStoryboardShotTask = pgTable("VideoStoryboardShotTask", {
});
export type VideoStoryboardShotTask = InferSelectModel<typeof videoStoryboardShotTask>;
// 会话工作记忆表(用于连续对话中的图片/资产目标定位)
export const conversationWorkingState = pgTable("ConversationWorkingState", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
chatId: uuid("chatId")
.notNull()
.references(() => chat.id, { onDelete: "cascade" })
.unique(),
projectId: uuid("projectId").references(() => canvasProject.id, {
onDelete: "set null",
}),
currentIntent: varchar("currentIntent", { length: 64 }),
currentSubIntent: varchar("currentSubIntent", { length: 64 }),
currentFocusAssetIds: jsonb("currentFocusAssetIds").$type<string[]>().notNull().default([]),
currentFocusImageUrls: jsonb("currentFocusImageUrls").$type<string[]>().notNull().default([]),
selectedAssetIds: jsonb("selectedAssetIds").$type<string[]>().notNull().default([]),
selectedImageUrls: jsonb("selectedImageUrls").$type<string[]>().notNull().default([]),
lastUploadedAssetIds: jsonb("lastUploadedAssetIds").$type<string[]>().notNull().default([]),
lastUploadedImageUrls: jsonb("lastUploadedImageUrls").$type<string[]>().notNull().default([]),
lastGeneratedAssetIds: jsonb("lastGeneratedAssetIds").$type<string[]>().notNull().default([]),
lastGeneratedImageUrls: jsonb("lastGeneratedImageUrls").$type<string[]>().notNull().default([]),
lastReferencedAssetIds: jsonb("lastReferencedAssetIds").$type<string[]>().notNull().default([]),
lastReferencedImageUrls: jsonb("lastReferencedImageUrls").$type<string[]>().notNull().default([]),
lastResolvedTargetAssetIds: jsonb("lastResolvedTargetAssetIds")
.$type<string[]>()
.notNull()
.default([]),
lastResolvedTargetImageUrls: jsonb("lastResolvedTargetImageUrls")
.$type<string[]>()
.notNull()
.default([]),
activeCanvasNodeIds: jsonb("activeCanvasNodeIds").$type<string[]>().notNull().default([]),
lastCanvasSnapshot: jsonb("lastCanvasSnapshot"),
lastTurnSummary: jsonb("lastTurnSummary"),
lastMessageId: uuid("lastMessageId"),
turnCount: integer("turnCount").notNull().default(0),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type ConversationWorkingState = InferSelectModel<typeof conversationWorkingState>;
// 每轮消息的目标资产/图片定位审计表
export const messageTargetResolution = pgTable("MessageTargetResolution", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
chatId: uuid("chatId")
.notNull()
.references(() => chat.id, { onDelete: "cascade" }),
messageId: uuid("messageId").references(() => message.id, {
onDelete: "set null",
}),
projectId: uuid("projectId").references(() => canvasProject.id, {
onDelete: "set null",
}),
rawText: text("rawText").notNull(),
uploadedAssetIds: jsonb("uploadedAssetIds").$type<string[]>().notNull().default([]),
uploadedImageUrls: jsonb("uploadedImageUrls").$type<string[]>().notNull().default([]),
selectedAssetIds: jsonb("selectedAssetIds").$type<string[]>().notNull().default([]),
selectedImageUrls: jsonb("selectedImageUrls").$type<string[]>().notNull().default([]),
candidateAssetIds: jsonb("candidateAssetIds").$type<string[]>().notNull().default([]),
candidateImageUrls: jsonb("candidateImageUrls").$type<string[]>().notNull().default([]),
resolvedAssetIds: jsonb("resolvedAssetIds").$type<string[]>().notNull().default([]),
resolvedImageUrls: jsonb("resolvedImageUrls").$type<string[]>().notNull().default([]),
resolutionMode: varchar("resolutionMode", { length: 64 }).notNull(),
confidence: integer("confidence").notNull().default(0),
needsClarification: boolean("needsClarification").notNull().default(false),
reasoning: jsonb("reasoning"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
});
export type MessageTargetResolution = InferSelectModel<typeof messageTargetResolution>;

View File

@ -41,6 +41,7 @@ export interface ImageTextEditItem {
// 图片节点数据
export interface ImageNodeData {
src: string;
assetId?: string;
title?: string;
note?: string;
prompt?: string;
@ -59,6 +60,7 @@ export interface ImageNodeData {
// 视频节点数据
export interface VideoNodeData {
src: string;
assetId?: string;
thumbnail?: string;
prompt?: string;
model?: string;
@ -91,6 +93,7 @@ export interface SceneFusionNodeData {
// 3D 模型节点数据
export interface Model3DNodeData {
src: string;
assetId?: string;
previewImage?: string;
prompt?: string;
modelVersion?: string;