import type { Attachment, ChatMessage } from "@/lib/types"; import { isOpenClawChatFileAttachment } from "@/lib/openclaw-chat/media-attachments"; 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", "video/mp4", "video/quicktime", "video/webm", "video/x-matroska", "application/zip", "application/x-zip-compressed", ]); 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 interface CanvasChatSessionRecord { id: string; title: string; mode: string; createdAt: number; updatedAt: number; clawdSkillTagId?: string | null; clawdSkillTagPromptApplied?: boolean; } export interface ClawdChatHistoryRecord { id: string; title: string; createdAt: string | Date; updatedAt: string | Date; messageCount: 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; } if (isOpenClawChatFileAttachment("", normalized)) { return normalized; } return "image/jpeg"; } export function sanitizeChatAttachment( attachment?: PickedObjectAttachment | null ): PickedObjectAttachment | null { if (!attachment || !isValidChatHttpUrl(attachment.url)) { return null; } const width = Number(attachment.width); const height = Number(attachment.height); const safeWidth = Number.isFinite(width) && width > 0 ? Math.round(width) : undefined; const safeHeight = Number.isFinite(height) && height > 0 ? Math.round(height) : undefined; return { ...attachment, url: attachment.url, name: normalizeChatAttachmentName(attachment.name), contentType: normalizeChatMediaType(attachment.contentType), assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined, width: safeWidth, height: safeHeight, }; } 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}`; } function parseHistoryTimestamp(value: string | Date) { const timestamp = value instanceof Date ? value.getTime() : Date.parse(value); return Number.isFinite(timestamp) ? timestamp : 0; } export function mergeClawdHistoryIntoCanvasSessions< T extends CanvasChatSessionRecord, >(sessions: T[], history: ClawdChatHistoryRecord[]): T[] { if (history.length === 0) return sessions; const nextSessions = [...sessions]; const indexById = new Map( nextSessions.map((session, index) => [session.id, index]) ); let changed = false; for (const item of history) { const rawId = toRawCanvasChatId(String(item.id || "").trim()); if (!isValidChatUuid(rawId)) continue; const id = `canvas-${rawId}`; const existingIndex = indexById.get(id); const historyCreatedAt = parseHistoryTimestamp(item.createdAt); const historyUpdatedAt = parseHistoryTimestamp(item.updatedAt) || historyCreatedAt; const historyTitle = String(item.title || "").trim(); if (existingIndex === undefined) { const created = { id, title: historyTitle || "Claw 对话", mode: "clawd", createdAt: historyCreatedAt || historyUpdatedAt, updatedAt: historyUpdatedAt || historyCreatedAt, } as T; indexById.set(id, nextSessions.length); nextSessions.push(created); changed = true; continue; } const existing = nextSessions[existingIndex]; const nextTitle = historyTitle || existing.title || "Claw 对话"; const nextCreatedAt = Number(existing.createdAt) || historyCreatedAt || historyUpdatedAt; const nextUpdatedAt = Math.max( Number(existing.updatedAt) || 0, historyUpdatedAt ); if ( existing.title === nextTitle && existing.mode === "clawd" && existing.createdAt === nextCreatedAt && existing.updatedAt === nextUpdatedAt ) { continue; } nextSessions[existingIndex] = { ...existing, title: nextTitle, mode: "clawd", createdAt: nextCreatedAt, updatedAt: nextUpdatedAt, }; changed = true; } if (!changed) return sessions; return nextSessions.sort((a, b) => b.updatedAt - a.updatedAt); } 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; }