Spaces:
Runtime error
Runtime error
| 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<IDBDatabase> { | |
| 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<CryptoKey | null> { | |
| 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<void> { | |
| 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<CryptoKey> { | |
| 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<any> { | |
| 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<Conversation> { | |
| 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<Conversation[]> { | |
| 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<Conversation, "messages">[], | |
| currentConversationId: null as string | null, | |
| currentMessages: [] as Message[] | |
| }); | |
| const loadAllMetas = async () => { | |
| try { | |
| const db = await initDB(); | |
| return new Promise<void>((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<void>((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<void>((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<void>((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<Conversation>((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<void>((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<Conversation>((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<void>((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(); | |