import { writable } from "svelte/store"; import { encryptMessage, decryptMessage, generateNonce, fromBase64, toBase64, exportKeyJWK, importECDHPrivateKeyJWK, importAESKeyJWK } from "./crypto"; export interface Message { role: "user" | "bot" | "assistant" | "system"; text: string; timestamp: number; device_name?: string; } export interface Conversation { id: string; title: string; updatedAt: number; messages: Message[]; } const DB_NAME = "LocalAILog"; const DB_VERSION = 2; const STORE_NAME = "conversations"; function initDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (e: any) => { const db = e.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: "id" }); } if (!db.objectStoreNames.contains("keys")) { db.createObjectStore("keys"); } }; }); } export async function getPrivateKey(): Promise { const db = await initDB(); return new Promise((resolve, reject) => { const tx = db.transaction("keys", "readonly"); const req = tx.objectStore("keys").get("privateKeyJwk"); req.onsuccess = async () => { if (req.result) { resolve(await importECDHPrivateKeyJWK(req.result)); } else { resolve(null); } }; req.onerror = () => reject(req.error); }); } export async function savePrivateKey(key: CryptoKey): Promise { const db = await initDB(); const jwk = await exportKeyJWK(key); return new Promise((resolve, reject) => { const tx = db.transaction("keys", "readwrite"); const req = tx.objectStore("keys").put(jwk, "privateKeyJwk"); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } async function getStorageKey(): Promise { const db = await initDB(); return new Promise((resolve, reject) => { const tx = db.transaction("keys", "readwrite"); const store = tx.objectStore("keys"); const req = store.get("storageKeyJwk"); req.onsuccess = async () => { if (req.result) { resolve(await importAESKeyJWK(req.result)); } else { const newKey = await window.crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); store.put(await exportKeyJWK(newKey), "storageKeyJwk"); resolve(newKey); } }; req.onerror = () => reject(req.error); }); } async function encryptConv(conv: Conversation): Promise { const key = await getStorageKey(); const nonce = generateNonce(); const encryptedText = await encryptMessage(JSON.stringify(conv.messages), key, nonce); return { ...conv, messages: encryptedText, // Stocké sous forme de string chiffrée nonce: toBase64(nonce) }; } async function decryptConv(data: any): Promise { if (Array.isArray(data.messages)) return data; // Déjà en clair (vieille version) const key = await getStorageKey(); const decryptedText = await decryptMessage(data.messages, key, fromBase64(data.nonce)); return { ...data, messages: JSON.parse(decryptedText) }; } export async function getRawConversations(): Promise { const db = await initDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = async () => { const results = []; for (const res of req.result) { results.push(await decryptConv(res)); } resolve(results); }; req.onerror = () => reject(req.error); }); } function createConversationStore() { const { subscribe, set, update } = writable({ conversations: [] as Omit[], currentConversationId: null as string | null, currentMessages: [] as Message[] }); const loadAllMetas = async () => { try { const db = await initDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const store = tx.objectStore(STORE_NAME); const req = store.getAll(); req.onsuccess = () => { const all = req.result as Conversation[]; // On ne garde que les metas pour la liste const metas = all.map(c => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })).sort((a, b) => b.updatedAt - a.updatedAt); update(s => ({ ...s, conversations: metas })); resolve(); }; req.onerror = () => reject(req.error); }); } catch(e) { console.error("IDB Meta Load Error", e); } }; const methods = { subscribe, init: async () => { await loadAllMetas(); // Charger la dernière conversation si elle existe let lastId: string | null = null; subscribe(s => { if(s.conversations.length > 0 && !lastId) lastId = s.conversations[0].id; })(); if (lastId) await methods.loadConversation(lastId); }, setTemporaryView: (id: string, title: string, messages: Message[]) => { update(s => ({ ...s, currentConversationId: id, currentMessages: messages })); }, createConversation: async () => { const id = crypto.randomUUID(); const newConv: Conversation = { id, title: "Nouvelle conversation", updatedAt: Date.now(), messages: [] }; try { const encrypted = await encryptConv(newConv); const db = await initDB(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); tx.objectStore(STORE_NAME).put(encrypted); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); await loadAllMetas(); update(s => ({ ...s, currentConversationId: id, currentMessages: [] })); } catch (e) { console.error("IDB Create Error", e); } }, setMessages: (messages: Message[]) => { update(s => ({ ...s, currentMessages: messages })); }, loadConversation: async (id: string) => { try { const db = await initDB(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const req = tx.objectStore(STORE_NAME).get(id); req.onsuccess = async () => { if (req.result) { const conv = await decryptConv(req.result); update(s => ({ ...s, currentConversationId: id, currentMessages: conv.messages })); } resolve(); }; req.onerror = () => reject(req.error); }); } catch (e) { console.error("IDB Load Error", e); } }, deleteConversation: async (id: string) => { try { const db = await initDB(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); const req = store.delete(id); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); await loadAllMetas(); update(s => { if (s.currentConversationId === id) { return { ...s, currentConversationId: null, currentMessages: [] }; } return s; }); } catch (e) { console.error("IDB Delete Error", e); } }, addMessage: async (role: "user" | "bot" | "assistant" | "system", text: string, device_name?: string) => { let currentId: string | null = null; let titleToSet: string | null = null; update(s => { currentId = s.currentConversationId; const messages = [...s.currentMessages, { role, text, timestamp: Date.now(), device_name }]; if (messages.length === 1 && role === "user") { titleToSet = text.substring(0, 30) + (text.length > 30 ? "..." : ""); } return { ...s, currentMessages: messages }; }); if (!currentId) { await methods.createConversation(); update(s => { currentId = s.currentConversationId; return s; }); if (!currentId) return; } try { const db = await initDB(); const convToUpdate = await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const req = tx.objectStore(STORE_NAME).get(currentId!); req.onsuccess = async () => resolve(await decryptConv(req.result)); req.onerror = () => reject(req.error); }); convToUpdate.messages.push({ role, text, timestamp: Date.now(), device_name }); convToUpdate.updatedAt = Date.now(); if (titleToSet) convToUpdate.title = titleToSet; const encrypted = await encryptConv(convToUpdate); await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); tx.objectStore(STORE_NAME).put(encrypted); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); await loadAllMetas(); } catch(e) { console.error("IDB Add Message Error", e); } }, updateLastMessage: async (text: string, saveToDB: boolean = false) => { let currentId: string | null = null; update(s => { currentId = s.currentConversationId; if (s.currentMessages.length > 0) { const newMessages = [...s.currentMessages]; newMessages[newMessages.length - 1].text = text; return { ...s, currentMessages: newMessages }; } return s; }); if (saveToDB && currentId) { try { const db = await initDB(); const convToUpdate = await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const req = tx.objectStore(STORE_NAME).get(currentId!); req.onsuccess = async () => resolve(await decryptConv(req.result)); req.onerror = () => reject(req.error); }); if (convToUpdate.messages.length > 0) { convToUpdate.messages[convToUpdate.messages.length - 1].text = text; convToUpdate.updatedAt = Date.now(); const encrypted = await encryptConv(convToUpdate); await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); tx.objectStore(STORE_NAME).put(encrypted); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); await loadAllMetas(); } } catch(e) { console.error("IDB Update Last Message Error", e); } } } }; return methods; } export const conversationStore = createConversationStore();