aegida-console / lib / chat / storage.ts
storage.ts
Raw
import {
  isModelId,
  type ChatMessage,
  type Conversation,
  type MessageRole,
  type MessageStatus,
} from "@/lib/chat/types";

const STORAGE_PREFIX = "ai-control-center:chats:";
const MAX_CONVERSATIONS = 50;
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const messageRoles = new Set<MessageRole>(["user", "assistant"]);
const messageStatuses = new Set<MessageStatus>(["streaming", "complete", "error", "stopped"]);

export function storageKey(userId: string): string {
  return `${STORAGE_PREFIX}${encodeURIComponent(userId)}`;
}

export function loadConversations(userId: string): Conversation[] {
  try {
    if (typeof window === "undefined") return [];

    const serialized = window.localStorage.getItem(storageKey(userId));
    if (serialized === null) return [];

    const value: unknown = JSON.parse(serialized);
    if (!Array.isArray(value) || !value.every(isConversation)) return [];

    return value.toSorted((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
  } catch {
    return [];
  }
}

export function saveConversations(userId: string, conversations: Conversation[]): void {
  try {
    if (typeof window === "undefined") return;

    const latestConversations = conversations
      .filter(isConversation)
      .toSorted((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
      .slice(0, MAX_CONVERSATIONS);

    window.localStorage.setItem(storageKey(userId), JSON.stringify(latestConversations));
  } catch {
    // Browser storage can be disabled or unavailable; persistence is best effort.
  }
}

function isConversation(value: unknown): value is Conversation {
  if (!isRecord(value)) return false;

  return (
    isNonEmptyString(value.id) &&
    typeof value.title === "string" &&
    isModelId(value.modelId) &&
    isIsoDate(value.updatedAt) &&
    Array.isArray(value.messages) &&
    value.messages.every(isChatMessage)
  );
}

function isChatMessage(value: unknown): value is ChatMessage {
  if (!isRecord(value)) return false;

  return (
    isNonEmptyString(value.id) &&
    isMessageRole(value.role) &&
    typeof value.content === "string" &&
    isIsoDate(value.createdAt) &&
    isMessageStatus(value.status)
  );
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isNonEmptyString(value: unknown): value is string {
  return typeof value === "string" && value.length > 0;
}

function isIsoDate(value: unknown): value is string {
  if (typeof value !== "string" || !ISO_TIMESTAMP.test(value)) return false;

  const date = new Date(value);
  return !Number.isNaN(date.getTime()) && date.toISOString() === value;
}

function isMessageRole(value: unknown): value is MessageRole {
  return typeof value === "string" && messageRoles.has(value as MessageRole);
}

function isMessageStatus(value: unknown): value is MessageStatus {
  return typeof value === "string" && messageStatuses.has(value as MessageStatus);
}