172 lines · 5.3 KB · TypeScript
RawDownload
import type {  AssistantBlock,  ChatMessage,  ChatMessageAttachment,} from "@/features/agent/messages/types";import { isRecord } from "@/lib/guards"; type TranscriptStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">; export const TRANSCRIPT_CACHE_PREFIX = "local-studio.agent.transcript.v2.";const LEGACY_TRANSCRIPT_CACHE_KEY = "local-studio.agent.transcripts.v1"; const MAX_MESSAGES_PER_SESSION = 200;const MAX_CHARS_PER_SESSION = 512 * 1024;const MAX_SESSIONS = 24;const MAX_BLOCK_TEXT = 16 * 1024; export type CachedTranscript = {  version: 2;  updatedAt: number;  title?: string;  messages: ChatMessage[];}; function defaultStorage(): TranscriptStorage | null {  return typeof window === "undefined" ? null : window.localStorage;} function sessionKey(piSessionId: string): string {  return `${TRANSCRIPT_CACHE_PREFIX}${piSessionId}`;} function truncateText(text: string | undefined): string | undefined {  if (typeof text !== "string" || text.length <= MAX_BLOCK_TEXT) return text;  return `${text.slice(0, MAX_BLOCK_TEXT)}\n…[truncated]`;} function sanitizeBlock(block: AssistantBlock): AssistantBlock {  if (block.kind === "tool") {    return {      ...block,      text: truncateText(block.text) ?? "",      ...(block.argsText !== undefined ? { argsText: truncateText(block.argsText) } : {}),      ...(block.resultText !== undefined ? { resultText: truncateText(block.resultText) } : {}),    };  }  return { ...block, text: truncateText(block.text) ?? "" };} function stripAttachmentBody(attachment: ChatMessageAttachment): ChatMessageAttachment {  return {    id: attachment.id,    name: attachment.name,    type: attachment.type,    size: attachment.size,    mode: attachment.mode,    content: "",    ...(attachment.path ? { path: attachment.path } : {}),    ...(attachment.previewKind ? { previewKind: attachment.previewKind } : {}),  };} function sanitizeMessage(message: ChatMessage): ChatMessage {  const clean: ChatMessage = {    id: message.id,    role: message.role,    text: truncateText(message.text) ?? "",  };  if (message.timestamp) clean.timestamp = message.timestamp;  if (message.skills?.length) clean.skills = message.skills;  if (message.blocks?.length) clean.blocks = message.blocks.map(sanitizeBlock);  if (message.attachments?.length) clean.attachments = message.attachments.map(stripAttachmentBody);  return clean;} export function boundMessagesForCache(messages: ChatMessage[]): ChatMessage[] {  let kept = messages.slice(-MAX_MESSAGES_PER_SESSION).map(sanitizeMessage);  while (kept.length > 1 && JSON.stringify(kept).length > MAX_CHARS_PER_SESSION) {    kept = kept.slice(1);  }  return kept;} function parseCachedTranscript(raw: string | null): CachedTranscript | null {  if (!raw) return null;  try {    const parsed: unknown = JSON.parse(raw);    if (!isRecord(parsed) || parsed.version !== 2 || !Array.isArray(parsed.messages)) return null;    return parsed as unknown as CachedTranscript;  } catch {    return null;  }} function cacheKeys(storage: TranscriptStorage): string[] {  const local = storage as Partial<Storage>;  if (typeof local.length !== "number" || typeof local.key !== "function") return [];  const keys: string[] = [];  for (let index = 0; index < local.length; index += 1) {    const key = local.key(index);    if (key?.startsWith(TRANSCRIPT_CACHE_PREFIX)) keys.push(key);  }  return keys;} function evictStaleSessions(storage: TranscriptStorage, keepKey: string): void {  const keys = cacheKeys(storage);  if (keys.length <= MAX_SESSIONS) return;  const dated = keys    .filter((key) => key !== keepKey)    .map((key) => {      const entry = parseCachedTranscript(storage.getItem(key));      return { key, updatedAt: entry?.updatedAt ?? 0 };    })    .sort((a, b) => a.updatedAt - b.updatedAt);  for (const { key } of dated.slice(0, keys.length - MAX_SESSIONS)) {    storage.removeItem(key);  }} export function purgeLegacyTranscriptCache(  storage: TranscriptStorage | null = defaultStorage(),): void {  try {    storage?.removeItem(LEGACY_TRANSCRIPT_CACHE_KEY);  } catch {    return;  }} export function readTranscriptSnapshot(  piSessionId: string | null | undefined,  storage: TranscriptStorage | null = defaultStorage(),): ChatMessage[] | null {  if (!storage || !piSessionId) return null;  try {    const entry = parseCachedTranscript(storage.getItem(sessionKey(piSessionId)));    return entry && entry.messages.length > 0 ? entry.messages : null;  } catch {    return null;  }} export function writeTranscriptSnapshot(  piSessionId: string | null | undefined,  messages: ChatMessage[],  title: string | undefined,  storage: TranscriptStorage | null = defaultStorage(),  now: number = Date.now(),): void {  if (!storage || !piSessionId || messages.length === 0) return;  const key = sessionKey(piSessionId);  const entry: CachedTranscript = {    version: 2,    updatedAt: now,    ...(title ? { title } : {}),    messages: boundMessagesForCache(messages),  };  try {    storage.setItem(key, JSON.stringify(entry));    evictStaleSessions(storage, key);  } catch {    try {      for (const stale of cacheKeys(storage)) {        if (stale !== key) storage.removeItem(stale);      }      storage.setItem(key, JSON.stringify(entry));    } catch {      return;    }  }}