diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 7ec1fbe0e..d243ecf34 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1340,11 +1340,75 @@ const DETAIL_GENERATION_MODE_OPTIONS: Array<{ ]; const SESSION_TITLE_MAX_LEN = 18; +const MAX_CHAT_TEXT_PART_LENGTH = 2000; +const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000; +const MAX_CHAT_AUTO_CONTEXT_LENGTH = 4000; +const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; +const CHAT_UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const ALLOWED_CHAT_MEDIA_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "image/jpg", + "image/bmp", + "image/svg+xml", +]); function toRawCanvasChatId(value: string): string { return value.startsWith("canvas-") ? value.replace("canvas-", "") : value; } +function isValidChatUuid(value?: string | null): value is string { + return Boolean(value && CHAT_UUID_REGEX.test(String(value).trim())); +} + +function isValidChatHttpUrl(value?: string | null): value is string { + if (!value) return false; + try { + const parsed = new URL(String(value).trim()); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function clampChatRequestText(value?: string | null, maxLength = MAX_CHAT_TEXT_PART_LENGTH): string { + return String(value || "").slice(0, maxLength); +} + +function normalizeChatAttachmentName(value?: string | null): string { + const normalized = String(value || "").trim() || "参考图"; + return normalized.slice(0, MAX_CHAT_ATTACHMENT_NAME_LENGTH); +} + +function normalizeChatMediaType(value?: string | null): string { + const normalized = String(value || "").trim().toLowerCase(); + if (normalized === "image/pjpeg") return "image/jpeg"; + if (normalized === "image/x-png") return "image/png"; + if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) { + return normalized; + } + return "image/jpeg"; +} + +function sanitizeChatAttachment( + attachment?: PickedObjectAttachment | null, +): PickedObjectAttachment | null { + if (!attachment || !isValidChatHttpUrl(attachment.url)) { + return null; + } + + return { + ...attachment, + url: attachment.url, + name: normalizeChatAttachmentName(attachment.name), + contentType: normalizeChatMediaType(attachment.contentType), + assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined, + }; +} + function getSessionStorageKey(canvasId: string, scope: "canvas" | "agent"): string { return `canvas-chat-sessions:${scope}:${canvasId}`; } @@ -4637,9 +4701,11 @@ export function CanvasChatPanel({ if (!launchPayload?.autoSend) return; if (!selectedModelId || status !== "ready") return; - const launchAttachments = launchPayload.attachments || []; + const launchAttachments = (launchPayload.attachments || []) + .map((attachment) => sanitizeChatAttachment(attachment as PickedObjectAttachment)) + .filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment)); const textContent = - launchPayload.prompt?.trim() || + clampChatRequestText(launchPayload.prompt?.trim() || "", MAX_CHAT_TEXT_PART_LENGTH) || (launchAttachments.length > 0 ? "请基于这些图片继续生成设计方案" : ""); if (!textContent && launchAttachments.length === 0) { @@ -4649,10 +4715,10 @@ export function CanvasChatPanel({ const parts: any[] = launchAttachments.map((attachment) => ({ type: "file" as const, - mediaType: attachment.contentType || "image/jpeg", - name: attachment.name || "参考图", + mediaType: normalizeChatMediaType(attachment.contentType), + name: normalizeChatAttachmentName(attachment.name), url: attachment.url, - assetId: attachment.assetId, + assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined, })); if (textContent) { @@ -5193,36 +5259,40 @@ export function CanvasChatPanel({ const imageNodes = nodes.filter( (node) => node.type === "image" && - typeof (node.data as any)?.src === "string" && - String((node.data as any).src).trim().length > 0, + isValidChatHttpUrl(String((node.data as any)?.src || "").trim()), ); if (imageNodes.length === 0) return undefined; const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode; const shouldIsolateDirectModeContext = effectiveComposerMode !== "agent"; - if (shouldIsolateDirectModeContext && selectedImageNodes.length === 0) { + const selectedImageNodesForContext = selectedImageNodes.filter((node) => + isValidChatHttpUrl(String((node.data as any)?.src || "").trim()) + ); + + if (shouldIsolateDirectModeContext && selectedImageNodesForContext.length === 0) { return undefined; } const MAX_CONTEXT_IMAGES = 12; - const selectedIds = new Set(selectedImageNodes.map((node) => node.id)); + const selectedContextNodes = selectedImageNodesForContext.slice(0, 20); + const selectedIds = new Set(selectedContextNodes.map((node) => node.id)); const prioritizedNodes = shouldIsolateDirectModeContext - ? selectedImageNodes - : selectedImageNodes.length > 0 + ? selectedContextNodes + : selectedContextNodes.length > 0 ? [ - ...selectedImageNodes, + ...selectedContextNodes, ...imageNodes.filter((node) => !selectedIds.has(node.id)), ] : imageNodes; const contextNodes = prioritizedNodes.slice(0, MAX_CONTEXT_IMAGES); const selectedAssetIds = Array.from( new Set( - selectedImageNodes + selectedContextNodes .map((node) => { const assetId = (node.data as any)?.assetId; - return typeof assetId === "string" && assetId.trim() ? assetId.trim() : null; + return isValidChatUuid(assetId) ? assetId.trim() : null; }) .filter((value): value is string => Boolean(value)), ), @@ -5230,17 +5300,16 @@ export function CanvasChatPanel({ return { source: "canvas" as const, - projectId: canvasId || undefined, - selectedNodeIds: selectedImageNodes.map((node) => node.id), + projectId: isValidChatUuid(canvasId) ? canvasId : undefined, + selectedNodeIds: selectedContextNodes.map((node) => node.id).slice(0, 20), selectedAssetIds, - focusPolicy: selectedImageNodes.length > 0 ? ("selected" as const) : ("auto" as const), + focusPolicy: selectedContextNodes.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 || ""), + assetId: isValidChatUuid((node.data as any)?.assetId) + ? String((node.data as any).assetId).trim() + : undefined, + url: String((node.data as any)?.src || "").trim(), title: typeof (node.data as any)?.title === "string" ? String((node.data as any).title).slice(0, 256) @@ -7477,7 +7546,9 @@ export function CanvasChatPanel({ // 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致) const parts: any[] = []; - let effectiveAttachments = [...attachments]; + let effectiveAttachments = attachments + .map((attachment) => sanitizeChatAttachment(attachment)) + .filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment)); if (hasAgentUploadSlots) { if (effectiveAttachments.length > agentRequiredUploadCount) { effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount); @@ -7521,10 +7592,10 @@ export function CanvasChatPanel({ const att = a as PickedObjectAttachment; parts.push({ type: "file" as const, - mediaType: a.contentType, - name: a.name, + mediaType: normalizeChatMediaType(a.contentType), + name: normalizeChatAttachmentName(a.name), url: a.url, - assetId: a.assetId, + assetId: isValidChatUuid(a.assetId) ? a.assetId : undefined, hasAnnotations: hasAnnotatedImages, // 🆕 标记图片是否有标注 pickedObject: att.pickedObject, // 🆕 包含物品信息 }); @@ -7573,6 +7644,7 @@ export function CanvasChatPanel({ if (!textContent && selectedSkillTag) { textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`; } + textContent = clampChatRequestText(textContent, MAX_CHAT_TEXT_PART_LENGTH); // 🆕 隐藏提示词(不在 UI 中展示) const hiddenSegments: string[] = []; @@ -7625,10 +7697,12 @@ export function CanvasChatPanel({ ); } const hiddenPrompt = hiddenSegments.join('\n\n').trim(); - hiddenPromptRef.current = hiddenPrompt || null; + hiddenPromptRef.current = + clampChatRequestText(hiddenPrompt, MAX_CHAT_HIDDEN_PROMPT_LENGTH) || null; const autoContext = buildAutoContext(); - autoContextRef.current = autoContext ? autoContext : null; + autoContextRef.current = + clampChatRequestText(autoContext, MAX_CHAT_AUTO_CONTEXT_LENGTH) || null; if (textContent) { parts.push({ type: "text" as const, text: textContent });