Fix agent mode chat request sanitization
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-27 11:07:41 +08:00
parent 17dcfcd55d
commit d133af4cbe

View File

@ -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 });