ai-chatbot/components/canvas/chat-panel/canvas-chat-session.ts

294 lines
8.9 KiB
TypeScript

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 CANVAS_CHAT_QUERY_PARAM = "chat";
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 normalizeCanvasChatSessionId(value?: string | null) {
const raw = String(value || "").trim();
if (!raw) return null;
const rawUuid = raw.startsWith("canvas-") ? raw.slice("canvas-".length) : raw;
return isValidChatUuid(rawUuid) ? `canvas-${rawUuid}` : null;
}
export function replaceCanvasChatSessionHistory(params: {
currentUrl: string;
history: Pick<History, "replaceState">;
sessionId: string;
}) {
const normalized = normalizeCanvasChatSessionId(params.sessionId);
if (!normalized) return null;
const url = new URL(params.currentUrl);
url.searchParams.set(CANVAS_CHAT_QUERY_PARAM, normalized);
// Reusing Next.js' existing history state bypasses its URL synchronization.
params.history.replaceState(null, "", url);
return normalized;
}
export function resolveCanvasChatSessionNavigationTarget(params: {
activeSessionId: string;
browserSessionId: string | null;
rootSessionId: string;
routerSessionId: string | null;
}) {
if (params.browserSessionId !== params.routerSessionId) return null;
const targetSessionId = params.routerSessionId || params.rootSessionId;
return targetSessionId !== params.activeSessionId ? targetSessionId : null;
}
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;
}