257 lines
7.7 KiB
TypeScript
257 lines
7.7 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 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;
|
|
}
|