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