Luminaria / frontend /lib /chat-storage.ts
senlinyy's picture
feat: persistent chat
697c853
Raw
History Blame Contribute Delete
5.12 kB
export type ChatMessage = {
role: "user" | "assistant";
content: string;
reasoning?: string;
sources?: string[];
status?: string;
};
export type ChatConversation = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
personaId: string;
temperature: number;
messages: ChatMessage[];
};
type ChatStore = {
activeId: string | null;
conversations: ChatConversation[];
};
type ChatPreferences = {
personaId?: string;
temperature?: number;
};
const STORAGE_KEY = "iamearth.chat.v1";
const PREFERENCES_KEY = "iamearth.chat.preferences.v1";
const MAX_CONVERSATIONS = 20;
const MAX_MESSAGES_PER_CONVERSATION = 80;
const MAX_MESSAGE_CHARS = 12000;
const emptyStore: ChatStore = {
activeId: null,
conversations: [],
};
function now() {
return new Date().toISOString();
}
function id() {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `chat-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function isBrowser() {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function sanitizeMessage(message: ChatMessage): ChatMessage {
return {
...message,
content: message.content.slice(0, MAX_MESSAGE_CHARS),
reasoning: message.reasoning?.slice(0, MAX_MESSAGE_CHARS),
};
}
function sanitizeConversation(conversation: ChatConversation): ChatConversation {
return {
...conversation,
messages: conversation.messages
.slice(-MAX_MESSAGES_PER_CONVERSATION)
.map(sanitizeMessage),
};
}
function sorted(conversations: ChatConversation[]) {
return [...conversations].sort(
(a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)
);
}
export function titleFromMessages(messages: ChatMessage[]) {
const firstUser = messages.find((message) => message.role === "user")?.content.trim();
if (!firstUser) return "New chat";
return firstUser.length > 48 ? `${firstUser.slice(0, 45)}...` : firstUser;
}
export function createConversation(personaId: string, temperature: number): ChatConversation {
const timestamp = now();
return {
id: id(),
title: "New chat",
createdAt: timestamp,
updatedAt: timestamp,
personaId,
temperature,
messages: [],
};
}
export function loadChatStore(): ChatStore {
if (!isBrowser()) return emptyStore;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return emptyStore;
const parsed = JSON.parse(raw) as Partial<ChatStore>;
if (!Array.isArray(parsed.conversations)) return emptyStore;
const conversations = sorted(
parsed.conversations
.filter((conversation): conversation is ChatConversation => {
return Boolean(
conversation &&
typeof conversation.id === "string" &&
typeof conversation.title === "string" &&
Array.isArray(conversation.messages)
);
})
.map(sanitizeConversation)
).slice(0, MAX_CONVERSATIONS);
const activeId =
parsed.activeId === null
? null
: conversations.find((conversation) => conversation.id === parsed.activeId)?.id ??
conversations[0]?.id ??
null;
return { activeId, conversations };
} catch {
return emptyStore;
}
}
export function saveChatStore(store: ChatStore) {
if (!isBrowser()) return;
try {
const conversations = sorted(store.conversations)
.map(sanitizeConversation)
.slice(0, MAX_CONVERSATIONS);
const activeId =
store.activeId === null
? null
: conversations.find((conversation) => conversation.id === store.activeId)?.id ??
conversations[0]?.id ??
null;
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ activeId, conversations })
);
} catch {
// Storage can fail in private browsing or when the quota is full.
}
}
export function loadChatPreferences(): ChatPreferences {
if (!isBrowser()) return {};
try {
const raw = window.localStorage.getItem(PREFERENCES_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Partial<ChatPreferences>;
return {
personaId: typeof parsed.personaId === "string" ? parsed.personaId : undefined,
temperature: typeof parsed.temperature === "number" ? parsed.temperature : undefined,
};
} catch {
return {};
}
}
export function saveChatPreferences(preferences: ChatPreferences) {
if (!isBrowser()) return;
try {
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
} catch {
// Storage can fail in private browsing or when the quota is full.
}
}
export function upsertConversation(
conversations: ChatConversation[],
conversation: ChatConversation
) {
return sorted([
sanitizeConversation(conversation),
...conversations.filter((item) => item.id !== conversation.id),
]).slice(0, MAX_CONVERSATIONS);
}
export function removeConversation(conversations: ChatConversation[], idToRemove: string) {
return conversations.filter((conversation) => conversation.id !== idToRemove);
}