neural-communia-core / src /lib /conversationStore.ts
Romain Khanoyan
Initial commit
6c46b3f
Raw
History Blame Contribute Delete
11.2 kB
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();