feat: apply latest canvas editor and chat updates
This commit is contained in:
parent
889cf33b63
commit
dc6162c93d
@ -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;
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -51,6 +51,7 @@ interface SceneFusionViewProps {
|
||||
initialProductImage?: string | null;
|
||||
initialProductName?: string;
|
||||
onBatchAddToCanvas?: (imageUrls: string[]) => void | Promise<void>;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const aspectRatioOptions = [
|
||||
|
||||
@ -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(
|
||||
|
||||
1487
docs/上下文长期记忆-图片目标定位-资产图谱表结构设计.md
Normal file
1487
docs/上下文长期记忆-图片目标定位-资产图谱表结构设计.md
Normal file
File diff suppressed because it is too large
Load Diff
1006
docs/智能分析链路重构方案-2026-03-06.md
Normal file
1006
docs/智能分析链路重构方案-2026-03-06.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
Loading…
x
Reference in New Issue
Block a user