feat: apply latest canvas editor and chat 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 16:24:23 +08:00
parent 889cf33b63
commit dc6162c93d
10 changed files with 2765 additions and 143 deletions

View File

@ -1258,6 +1258,78 @@ function extractOriginalImageUrlsFromText(text: string): string[] {
return extractImageUrlsFromText(text);
}
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 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 pushInput = (rawValue: unknown) => {
const normalized = normalizeImageInputRef(rawValue, requestOrigin);
if (!normalized) return;
if (!result.includes(normalized)) {
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") {
const parsedUrls = extractOriginalImageUrlsFromText(part.text);
for (const parsedUrl of parsedUrls) {
pushInput(parsedUrl);
}
}
}
return result;
}
function mimeTypeToExtension(mimeType: string) {
const normalized = mimeType.toLowerCase();
if (normalized.includes("png")) return "png";
@ -1695,7 +1767,7 @@ const inFlightChatRequests = new Map<string, number>();
const IN_FLIGHT_REQUEST_TTL_MS = 5 * 60 * 1000;
const DUPLICATE_REQUEST_WAIT_MS = 2 * 60 * 1000;
const CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS = 55_000;
const SMART_AUTO_GRAPH_TIMEOUT_MS = 35_000;
const SMART_AUTO_GRAPH_TIMEOUT_MS = 28_000;
const SMART_AUTO_FALLBACK_TIMEOUT_MS = 20_000;
const SMART_AUTO_IMAGE_SUMMARY_TIMEOUT_MS = 18_000;
const DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS = 12_000;
@ -1821,6 +1893,13 @@ export async function POST(request: Request) {
deepThinkingEnabled?: boolean;
} = requestBody;
const requestStartedAt = Date.now();
const requestOrigin = (() => {
try {
return new URL(request.url).origin;
} catch {
return "";
}
})();
const now = Date.now();
for (const [key, startedAt] of inFlightChatRequests.entries()) {
@ -1946,8 +2025,8 @@ export async function POST(request: Request) {
if (shouldRunAgentConversation) {
console.log("[Smart Auto Mode] 🚀 Starting intelligent analysis...");
let autoTextForFallback = "";
let autoHasImageForFallback = false;
let autoFromHistoryForFallback = false;
let autoCurrentImageUrlsForFallback: string[] = [];
try {
// 🆕 首先获取历史消息(在 Smart Auto Mode 内部单独获取,避免依赖后面的 messagesFromDb)
@ -1968,20 +2047,17 @@ export async function POST(request: Request) {
textContent
);
// @ts-ignore - 检查各种可能的图片类型
let hasImage = lastUserMessage.parts.some(
(p: any) => p.type === "file" || p.type === "image" || p.type === "image_url"
);
autoHasImageForFallback = hasImage;
// @ts-ignore - 提取图片URL
let imageUrls = lastUserMessage.parts
.filter((p: any) => p.type === "file" && p.url)
.map((p: any) => p.url as string);
const currentMessageImageInputs = collectImageInputsFromMessageParts(
lastUserMessage.parts as any[],
requestOrigin,
).slice(0, 4);
let imageUrls = currentMessageImageInputs;
let hasImage = imageUrls.length > 0;
autoCurrentImageUrlsForFallback = imageUrls.slice();
// 🆕 历史图片上下文感知:如果用户没有发送新图片,自动使用历史中最近的图片(支持多张)
// 逻辑:只要用户在对话中没有附上新图片,就默认继续处理之前的图片
if (!hasImage && smartAutoHistoryMessagesFromDb.length > 0) {
if (imageUrls.length === 0 && smartAutoHistoryMessagesFromDb.length > 0) {
console.log("[Smart Auto Mode] 🔍 No image in current message, auto-searching history...");
const explicitFollowLatestResult = /(上一张|上一轮|刚生成|最新生成|这个结果|该结果|按这个结果|继续在这张|基于这张结果|延续上一次结果)/i.test(
@ -2067,8 +2143,8 @@ export async function POST(request: Request) {
if (smartAutoHistoryImages.length > 0) {
hasImage = true;
imageUrls = smartAutoHistoryImages;
autoHasImageForFallback = true;
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");
@ -2287,6 +2363,15 @@ export async function POST(request: Request) {
}
try {
const { analyzeIntent } = await import("@/lib/ai/smart-analyzer");
const rescueImageUrls =
autoCurrentImageUrlsForFallback.length > 0
? autoCurrentImageUrlsForFallback
: smartAutoHistoryImages;
const rescueHasImage = rescueImageUrls.length > 0;
const rescueFromHistory =
rescueHasImage &&
autoCurrentImageUrlsForFallback.length === 0 &&
smartAutoHistoryImages.length > 0;
const rescueTimeoutMs = getBudgetedTimeout({
startedAt: requestStartedAt,
preferredMs: SMART_AUTO_FALLBACK_TIMEOUT_MS,
@ -2296,9 +2381,9 @@ export async function POST(request: Request) {
const rescueAnalysis = await withOperationTimeout(
analyzeIntent({
text: autoTextForFallback,
hasImage: autoHasImageForFallback || smartAutoHistoryImages.length > 0,
imageUrls: smartAutoHistoryImages,
imageFromHistory: autoFromHistoryForFallback || smartAutoHistoryImages.length > 0,
hasImage: rescueHasImage,
imageUrls: rescueImageUrls,
imageFromHistory: rescueFromHistory || autoFromHistoryForFallback,
historyMessages: [],
context: undefined,
preferredModelId: preferredAgentModelId,
@ -2314,29 +2399,35 @@ export async function POST(request: Request) {
}
} catch (rescueError) {
console.error("[Smart Auto Mode] Rescue analysis failed:", rescueError);
try {
const { fallbackKeywordAnalysis } = await import("@/lib/ai/smart-analyzer");
const keywordFallback = fallbackKeywordAnalysis(
autoTextForFallback,
autoCurrentImageUrlsForFallback.length > 0 || smartAutoHistoryImages.length > 0,
);
if (keywordFallback.conversationalReply?.trim()) {
smartAutoFriendlyMessage = keywordFallback.conversationalReply.trim();
console.warn("[Smart Auto Mode] Applied keyword fallback after rescue failure");
}
} catch (keywordFallbackError) {
console.warn(
"[Smart Auto Mode] Keyword fallback after rescue failure also failed:",
keywordFallbackError,
);
}
}
const allModels = await getEnabledAiModelsWithProvider();
// @ts-ignore - 检查各种可能的图片类型
const hasImage = autoHasImageForFallback || message.parts.some(
(p: any) => p.type === "file" || p.type === "image" || p.type === "image_url"
);
const hasImage =
autoCurrentImageUrlsForFallback.length > 0 || smartAutoHistoryImages.length > 0;
let fallbackVisualSummary: string | undefined;
if (isAutoTimeout && hasImage) {
try {
const extractCurrentImageUrls = (parts: any[]) =>
parts
.map((part: any) => {
if (!part) return null;
if (part.type === "file") return part.url || part.data || null;
if (part.type === "image") return part.url || part.image || null;
if (part.type === "image_url") return part.image_url?.url || null;
return null;
})
.filter((url: any): url is string => typeof url === "string" && /^https?:\/\//i.test(url));
const currentImageUrls = extractCurrentImageUrls(message.parts as any[]);
const currentImageUrls =
autoCurrentImageUrlsForFallback.length > 0
? autoCurrentImageUrlsForFallback
: collectImageInputsFromMessageParts(message.parts as any[], requestOrigin);
const summaryImageUrls =
currentImageUrls.length > 0 ? currentImageUrls : smartAutoHistoryImages;

View File

@ -188,7 +188,8 @@ export function AngleEditDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-4xl p-0 gap-0 overflow-hidden border border-border shadow-2xl bg-background sm:rounded-[20px]"
overlayClassName="bg-black"
className="max-w-4xl p-0 gap-0 overflow-hidden border border-border shadow-2xl !bg-white dark:!bg-zinc-950 sm:rounded-[20px]"
onPointerDownCapture={stopDialogEventCapture}
onPointerMoveCapture={stopDialogEventCapture}
onPointerUpCapture={stopDialogEventCapture}
@ -214,7 +215,7 @@ export function AngleEditDialog({
}}
>
{/* 紧凑标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center justify-between border-b border-border bg-white px-6 py-4 dark:bg-zinc-950">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Move3D className="h-4 w-4 text-primary" />
@ -223,9 +224,9 @@ export function AngleEditDialog({
</div>
</div>
<div className="flex h-[550px]">
<div className="flex h-[550px] bg-white dark:bg-zinc-950">
{/* 左侧:3D 控制区 (固定宽度 340px) */}
<div className="w-[340px] flex flex-col border-r border-border bg-background">
<div className="flex w-[340px] flex-col border-r border-border bg-white dark:bg-zinc-950">
{/* 上部内容区域 - 可伸缩 */}
<div className="flex-1 flex flex-col p-4 overflow-hidden">
{/* QwenCameraWidget - 包含 Canvas 和 3个角度选择器 */}
@ -258,7 +259,7 @@ export function AngleEditDialog({
</div>
{/* 底部按钮区域 - 包含重置和生成 */}
<div className="p-4 border-t bg-background flex-shrink-0 flex gap-2">
<div className="flex shrink-0 gap-2 border-t bg-white p-4 dark:bg-zinc-950">
<Button
variant="outline"
size="lg"
@ -292,7 +293,7 @@ export function AngleEditDialog({
</div>
{/* 右侧:预览区 (自适应) */}
<div className="flex-1 flex flex-col bg-background p-4">
<div className="flex flex-1 flex-col bg-white p-4 dark:bg-zinc-950">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-4">
<TabsList className="h-8">
@ -317,7 +318,7 @@ export function AngleEditDialog({
)}
</div>
<div className="flex-1 relative rounded-lg border bg-background overflow-hidden shadow-inner">
<div className="relative flex-1 overflow-hidden rounded-lg border bg-white shadow-inner dark:bg-zinc-950">
{/* 原图 Tab */}
<TabsContent value="original" className="absolute inset-0 m-0 h-full w-full p-0">
<div className="w-full h-full flex items-center justify-center p-4">

View File

@ -1613,16 +1613,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
connections: [node.id],
});
addPendingChatAttachment({
url: finalUrl,
name: title,
contentType: "image/jpeg",
});
toast.success("尺寸调整结果已发送到对话");
toast.success("尺寸调整结果已发送到画布");
},
[
addNode,
addPendingChatAttachment,
data.src,
displayTitle,
node.id,
@ -2506,7 +2500,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<ImageAnnotator
imageUrl={data.src}
initialResizeMode
confirmButtonText="发送到对话"
confirmButtonText="发送到画布"
onConfirm={handleResizeEditorComplete}
onCancel={() => setResizeAnnotatorOpen(false)}
open={resizeAnnotatorOpen}

View File

@ -243,7 +243,7 @@ export function ImageAnnotator({
}, [mounted, originalImageUrl]);
// 尺寸调整相关状态
const [isResizeMode, setIsResizeMode] = useState(false);
const [isResizeMode, setIsResizeMode] = useState(initialResizeMode);
const [resizeCanvasWidth, setResizeCanvasWidth] = useState<number>(1920);
const [resizeCanvasHeight, setResizeCanvasHeight] = useState<number>(1080);
const [resizeBackgroundColor, setResizeBackgroundColor] = useState<string>("#FFFFFF");
@ -391,6 +391,7 @@ export function ImageAnnotator({
if (!imageUrl) return; // 如果没有初始图片,不执行加载逻辑
setIsLoading(true);
setIsResizeMode(initialResizeMode);
setOriginalImageUrl(imageUrl);
setProcessedImageUrl(null);
setProcessedImageBase64(null);
@ -400,9 +401,8 @@ export function ImageAnnotator({
img.onload = () => {
setImage(img);
setIsLoading(false);
// 如果指定了初始进入尺寸调整模式,自动进入
if (initialResizeMode && img) {
setIsResizeMode(true);
if (img) {
setIsResizeMode(initialResizeMode);
setResizeCanvasWidth(img.width);
setResizeCanvasHeight(img.height);
setSelectedAspectRatio(null);
@ -1408,7 +1408,7 @@ export function ImageAnnotator({
)}
{/* Floating Toolbar - Top (Tools & Actions) */}
{!showSceneFusion && (
{!showSceneFusion && !isResizeMode && (
<div className="absolute left-1/2 top-[72px] z-20 -translate-x-1/2 pointer-events-none w-full max-w-[90vw] flex justify-center">
<div className="pointer-events-auto flex items-center gap-1 rounded-[30px] border border-black/10 bg-white/95 p-2 shadow-xl backdrop-blur-xl dark:bg-zinc-900/95 dark:border-white/10 overflow-x-auto max-w-full scrollbar-hide">
@ -1681,68 +1681,6 @@ export function ImageAnnotator({
</Tooltip>
</>
)}
{/* Resize Mode specific tools */}
{isResizeMode && (
<>
<div className="flex items-center gap-2 px-2">
{aspectRatios.map((ratio) => (
<Button
key={ratio.value}
variant={selectedAspectRatio === ratio.value ? "secondary" : "ghost"}
size="sm"
onClick={() => handleAspectRatioSelect(ratio.value === selectedAspectRatio ? null : ratio.value)}
className="h-8 text-xs"
>
{ratio.label}
</Button>
))}
</div>
<div className="mx-1 h-7 w-px bg-black/10 dark:bg-white/10" />
<div className="flex items-center gap-2 px-2">
<Input
type="number"
value={resizeCanvasWidth}
onChange={(e) => handleWidthChange(Number.parseInt(e.target.value) || 1)}
className="h-8 w-20 text-xs"
/>
<span className="text-zinc-400">x</span>
<Input
type="number"
value={resizeCanvasHeight}
onChange={(e) => handleHeightChange(Number.parseInt(e.target.value) || 1)}
className="h-8 w-20 text-xs"
/>
</div>
<div className="mx-1 h-7 w-px bg-black/10 dark:bg-white/10" />
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleResizeZoomOut}
>
<ZoomOut className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>缩小</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleResizeZoomIn}
>
<ZoomIn className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>放大</TooltipContent>
</Tooltip>
</>
)}
</TooltipProvider>
</div>
</div>
@ -1762,7 +1700,10 @@ export function ImageAnnotator({
<div className="absolute inset-0 pattern-grid-lg opacity-[0.03] pointer-events-none" />
{/* Main Canvas / Image Area */}
<div className="relative z-10 flex items-center justify-center w-full h-full p-8 pb-24">
<div className={cn(
"relative z-10 flex items-center justify-center w-full h-full p-8",
isResizeMode ? "pb-36" : "pb-24"
)}>
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-3">
<div className="flex items-center gap-1">
@ -1798,7 +1739,7 @@ export function ImageAnnotator({
</div>
) : isResizeMode ? (
/* Resize Mode View */
<div className="flex gap-4 h-full w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center gap-5">
{/* Source Image */}
<div className="flex flex-col border border-border/40 rounded-xl bg-white/50 dark:bg-zinc-900/50 p-2 overflow-hidden shadow-sm h-full max-h-[80vh] flex-1">
<div className="mb-2 text-xs font-medium text-muted-foreground text-center bg-black/5 dark:bg-white/5 py-1 rounded">原图</div>
@ -1917,8 +1858,107 @@ export function ImageAnnotator({
)}
</div>
{/* Floating Toolbar - Bottom (Confirm/Cancel) */}
{!showSceneFusion && (
{!showSceneFusion && isResizeMode && (
<div className="absolute bottom-8 left-1/2 z-20 w-[min(92vw,1120px)] -translate-x-1/2 pointer-events-none">
<div className="pointer-events-auto flex items-center justify-between gap-3 overflow-x-auto rounded-[28px] border border-border/50 bg-background/95 px-3 py-2 shadow-2xl backdrop-blur-xl scrollbar-hide">
<div className="flex min-w-max items-center gap-2">
<div className="flex items-center gap-1 rounded-[22px] bg-muted/35 px-1 py-1">
{aspectRatios.map((ratio) => (
<Button
key={ratio.value}
variant={selectedAspectRatio === ratio.value ? "secondary" : "ghost"}
size="sm"
onClick={() =>
handleAspectRatioSelect(
ratio.value === selectedAspectRatio ? null : ratio.value
)
}
className={cn(
"h-8 rounded-[18px] px-3 text-xs font-medium",
selectedAspectRatio === ratio.value && "shadow-sm"
)}
>
{ratio.label}
</Button>
))}
</div>
<div className="h-7 w-px shrink-0 bg-border/60" />
<div className="flex items-center gap-2 rounded-[20px] bg-muted/30 px-2 py-1">
<Input
type="number"
value={resizeCanvasWidth}
onChange={(e) => handleWidthChange(Number.parseInt(e.target.value) || 1)}
className="h-8 w-24 border-0 bg-transparent px-2 text-center text-sm shadow-none focus-visible:ring-0"
/>
<span className="text-sm text-muted-foreground">×</span>
<Input
type="number"
value={resizeCanvasHeight}
onChange={(e) => handleHeightChange(Number.parseInt(e.target.value) || 1)}
className="h-8 w-24 border-0 bg-transparent px-2 text-center text-sm shadow-none focus-visible:ring-0"
/>
</div>
<div className="h-7 w-px shrink-0 bg-border/60" />
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleResizeZoomOut}
>
<ZoomOut className="h-4.5 w-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>缩小</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
onClick={handleResizeZoomIn}
>
<ZoomIn className="h-4.5 w-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>放大</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="ghost"
onClick={onCancel}
className="rounded-[18px] px-4 text-muted-foreground hover:text-foreground"
>
取消
</Button>
<Button
onClick={handleResizeConfirm}
disabled={isResizing}
className="rounded-[18px] px-5 bg-primary text-primary-foreground hover:bg-primary/90"
>
{isResizing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
处理中...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
{confirmButtonText}
</>
)}
</Button>
</div>
</div>
</div>
)}
{!showSceneFusion && !isResizeMode && (
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20 pointer-events-none">
<div className="pointer-events-auto flex items-center gap-2 p-1.5 bg-background/95 backdrop-blur-xl border border-border/50 shadow-2xl rounded-[24px]">
<Button
@ -1930,21 +1970,11 @@ export function ImageAnnotator({
</Button>
<div className="h-4 w-px bg-border/50" />
<Button
onClick={isResizeMode ? handleResizeConfirm : handleConfirm}
disabled={isResizing}
onClick={handleConfirm}
className="rounded-xl px-6 bg-primary text-primary-foreground hover:bg-primary/90"
>
{isResizing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
处理中...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
{confirmButtonText}
</>
)}
<Check className="mr-2 h-4 w-4" />
{confirmButtonText}
</Button>
</div>
</div>

View File

@ -51,6 +51,7 @@ interface SceneFusionViewProps {
initialProductImage?: string | null;
initialProductName?: string;
onBatchAddToCanvas?: (imageUrls: string[]) => void | Promise<void>;
onBack?: () => void;
}
const aspectRatioOptions = [

View File

@ -32,10 +32,12 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = forwardRef<
ComponentRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
overlayClassName?: string;
}
>(({ className, children, overlayClassName, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -249,7 +249,7 @@ const ANALYZER_MAX_IMAGE_COUNT = 2;
const ANALYZER_IMAGE_MAX_SIDE = 500;
const ANALYZER_TIMEOUT_MS = getEnvInt(
"AGENT_ANALYZER_TIMEOUT_MS",
18000,
12000,
8000,
120000,
);
@ -289,6 +289,10 @@ function isHttpUrl(value: string) {
return /^https?:\/\//i.test(value);
}
function isDataImageUrl(value: string) {
return /^data:image\//i.test(value);
}
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@ -346,16 +350,21 @@ async function toAnalyzerDataUrl(imageUrl: string): Promise<string | null> {
}
async function buildAnalyzerVisionImages(imageUrls?: string[]) {
const urls = (imageUrls || []).filter((url) => isHttpUrl(url)).slice(0, ANALYZER_MAX_IMAGE_COUNT);
if (urls.length === 0) return [] as Array<{ type: "image"; image: string }>;
const inputs = (imageUrls || [])
.map((url) => String(url || "").trim())
.filter((url) => isHttpUrl(url) || isDataImageUrl(url))
.slice(0, ANALYZER_MAX_IMAGE_COUNT);
if (inputs.length === 0) return [] as Array<{ type: "image"; image: string }>;
const convertedResults = await Promise.all(urls.map((url) => toAnalyzerDataUrl(url)));
const convertedResults = await Promise.all(
inputs.map((input) => (isDataImageUrl(input) ? Promise.resolve(input) : toAnalyzerDataUrl(input))),
);
return urls.map((originalUrl, index) => {
return inputs.map((originalInput, index) => {
const inlineDataUrl = convertedResults[index];
return {
type: "image" as const,
image: inlineDataUrl || originalUrl,
image: inlineDataUrl || originalInput,
};
});
}
@ -398,7 +407,8 @@ async function buildAnalyzerVisionImagesByMode(params: {
preferRemoteUrls: boolean;
}) {
const urls = (params.imageUrls || [])
.filter((url) => isHttpUrl(url))
.map((url) => String(url || "").trim())
.filter((url) => isHttpUrl(url) || isDataImageUrl(url))
.slice(0, ANALYZER_MAX_IMAGE_COUNT);
if (urls.length === 0) return [] as Array<{ type: "image"; image: string }>;
if (params.preferRemoteUrls) {

File diff suppressed because one or more lines are too long