refactor: extract canvas chat session helpers

This commit is contained in:
Your Name 2026-05-27 10:11:21 +08:00
parent 36412dee83
commit a8f5dff6f8
2 changed files with 172 additions and 148 deletions

View File

@ -75,7 +75,7 @@ import {
getImageModelCreditDiscountLabel,
} from "@/lib/ai/image-model-pricing";
import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { ChatMessage } from "@/lib/types";
import type {
AgentPreset,
AgentWorkflowComposerContext,
@ -106,6 +106,25 @@ import {
type CanvasToolbarConfig,
type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config";
import {
clampChatRequestText,
deriveSessionTitleFromMessages,
extractUserTextPrompt,
findNearestUserPrompt,
getSessionRootStorageKey,
getSessionStorageKey,
isGenericCanvasPrompt,
isValidChatHttpUrl,
isValidChatUuid,
MAX_CHAT_AUTO_CONTEXT_LENGTH,
MAX_CHAT_HIDDEN_PROMPT_LENGTH,
MAX_CHAT_TEXT_PART_LENGTH,
normalizeChatAttachmentName,
normalizeChatMediaType,
type PickedObjectAttachment,
sanitizeChatAttachment,
toRawCanvasChatId,
} from "@/components/canvas/chat-panel/canvas-chat-session";
// 标签悬停预览信息
interface TagPreviewInfo {
@ -1365,64 +1384,6 @@ function getVideoImageLimit(
}
}
function extractUserTextPrompt(message: ChatMessage): string {
const parts = (message as any)?.parts || [];
const textFromParts = parts
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) =>
part.text
.replace(
/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g,
""
)
.trim()
)
.filter(Boolean)
.join("\n")
.trim();
if (textFromParts) return textFromParts;
const content = (message as any)?.content;
return typeof content === "string" ? content.trim() : "";
}
function findNearestUserPrompt(messages: ChatMessage[], assistantIndex: number): string {
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
const candidate = messages[i];
if (candidate?.role !== "user") continue;
const prompt = extractUserTextPrompt(candidate);
if (prompt) return prompt;
}
return "";
}
const GENERIC_CANVAS_PROMPT_SET = new Set([
"对话生成",
"图片生成",
"AI生成",
"智能生成",
"自动生成",
]);
function isGenericCanvasPrompt(prompt: unknown): boolean {
if (typeof prompt !== "string") return true;
const normalized = prompt.trim();
if (!normalized) return true;
return GENERIC_CANVAS_PROMPT_SET.has(normalized);
}
// 🆕 拾取物品的附件类型扩展
interface PickedObjectAttachment extends Attachment {
pickedObject?: {
objectName: string;
objectPosition: string;
clickX: number;
clickY: number;
boundingBox: { x: number; y: number; width: number; height: number };
};
}
type MessageVote = {
chatId: string;
messageId: string;
@ -1640,95 +1601,6 @@ 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}`;
}
function getSessionRootStorageKey(canvasId: string, scope: "canvas" | "agent"): string {
return `canvas-chat-root:${scope}:${canvasId}`;
}
function deriveSessionTitleFromMessages(messages: ChatMessage[]): string {
const firstUserMessage = messages.find((message) => message.role === "user");
if (!firstUserMessage) return "";
const prompt = extractUserTextPrompt(firstUserMessage).replace(/\s+/g, " ").trim();
if (!prompt) return "";
return prompt.length > SESSION_TITLE_MAX_LEN
? `${prompt.slice(0, SESSION_TITLE_MAX_LEN)}...`
: prompt;
}
function extractDetailFrameCount(text: string): number {
const normalized = text.replace(/\s+/g, "");
const matched = normalized.match(/(\d{1,2})(?:张|屏|页)/);

View File

@ -0,0 +1,152 @@
import type { Attachment, ChatMessage } from "@/lib/types";
export const SESSION_TITLE_MAX_LEN = 18;
export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
export const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000;
export const MAX_CHAT_AUTO_CONTEXT_LENGTH = 4000;
export 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",
]);
const GENERIC_CANVAS_PROMPT_SET = new Set([
"对话生成",
"图片生成",
"AI生成",
"智能生成",
"自动生成",
]);
export interface PickedObjectAttachment extends Attachment {
pickedObject?: {
objectName: string;
objectPosition: string;
clickX: number;
clickY: number;
boundingBox: { x: number; y: number; width: number; height: number };
};
}
export function toRawCanvasChatId(value: string): string {
return value.startsWith("canvas-") ? value.replace("canvas-", "") : value;
}
export function isValidChatUuid(value?: string | null): value is string {
return Boolean(value && CHAT_UUID_REGEX.test(String(value).trim()));
}
export 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;
}
}
export function clampChatRequestText(
value?: string | null,
maxLength = MAX_CHAT_TEXT_PART_LENGTH
): string {
return String(value || "").slice(0, maxLength);
}
export function normalizeChatAttachmentName(value?: string | null): string {
const normalized = String(value || "").trim() || "参考图";
return normalized.slice(0, MAX_CHAT_ATTACHMENT_NAME_LENGTH);
}
export 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";
}
export 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,
};
}
export function getSessionStorageKey(canvasId: string, scope: "canvas" | "agent"): string {
return `canvas-chat-sessions:${scope}:${canvasId}`;
}
export function getSessionRootStorageKey(canvasId: string, scope: "canvas" | "agent"): string {
return `canvas-chat-root:${scope}:${canvasId}`;
}
export function extractUserTextPrompt(message: ChatMessage): string {
const parts = (message as any)?.parts || [];
const textFromParts = parts
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) =>
part.text
.replace(
/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g,
""
)
.trim()
)
.filter(Boolean)
.join("\n")
.trim();
if (textFromParts) return textFromParts;
const content = (message as any)?.content;
return typeof content === "string" ? content.trim() : "";
}
export function findNearestUserPrompt(messages: ChatMessage[], assistantIndex: number): string {
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
const candidate = messages[i];
if (candidate?.role !== "user") continue;
const prompt = extractUserTextPrompt(candidate);
if (prompt) return prompt;
}
return "";
}
export function isGenericCanvasPrompt(prompt: unknown): boolean {
if (typeof prompt !== "string") return true;
const normalized = prompt.trim();
if (!normalized) return true;
return GENERIC_CANVAS_PROMPT_SET.has(normalized);
}
export function deriveSessionTitleFromMessages(messages: ChatMessage[]): string {
const firstUserMessage = messages.find((message) => message.role === "user");
if (!firstUserMessage) return "";
const prompt = extractUserTextPrompt(firstUserMessage).replace(/\s+/g, " ").trim();
if (!prompt) return "";
return prompt.length > SESSION_TITLE_MAX_LEN
? `${prompt.slice(0, SESSION_TITLE_MAX_LEN)}...`
: prompt;
}