-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconversationStore.ts
More file actions
182 lines (161 loc) · 5.26 KB
/
Copy pathconversationStore.ts
File metadata and controls
182 lines (161 loc) · 5.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
/**
* IndexedDB-backed persistent store for AI Assistant conversations.
*
* Mirrors :mod:`DataLab/datalab/aiassistant/conversation.py` (filesystem
* JSON files keyed by conversation id) but uses IndexedDB since browsers
* don't have a writable user-config directory.
*
* One object store keyed by conversation id, with a ``by-updated`` index
* for newest-first listing and capacity pruning. The system prompt is
* NEVER persisted — it lives in :class:`AIController` and is recomputed
* on load to pick up any prompt change between sessions.
*/
import { openDB, type DBSchema, type IDBPDatabase } from "idb";
import type { ChatMessage, TokenUsage } from "./types";
/** Lightweight metadata for the History dialog (no message payload). */
export interface ConversationInfo {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
}
/** Full persisted conversation. ``messages`` excludes the system prompt. */
export interface Conversation {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messages: ChatMessage[];
/** Cumulative token usage across the conversation. Optional for
* backward-compatibility with conversations saved before Phase 3. */
usage?: TokenUsage;
}
const DB_NAME = "datalab-web.aiassistant";
const DB_VERSION = 1;
const STORE = "conversations";
interface Schema extends DBSchema {
[STORE]: {
key: string;
value: Conversation;
indexes: { "by-updated": number };
};
}
const DEFAULT_MAX = 200;
let _maxConversations = DEFAULT_MAX;
/** Override the cap (used by tests; future user setting). */
export function setMaxConversations(max: number): void {
_maxConversations = Math.max(1, Math.floor(max));
}
export function getMaxConversations(): number {
return _maxConversations;
}
let _dbPromise: Promise<IDBPDatabase<Schema>> | null = null;
function getDb(): Promise<IDBPDatabase<Schema>> {
if (!_dbPromise) {
_dbPromise = openDB<Schema>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: "id" });
store.createIndex("by-updated", "updatedAt");
}
},
});
}
return _dbPromise;
}
/** Reset the in-memory connection (tests). */
export function _resetForTests(): void {
_dbPromise = null;
_maxConversations = DEFAULT_MAX;
}
/** Build a unique time-sortable conversation identifier. */
export function makeConversationId(): string {
const stamp = new Date()
.toISOString()
.replace(/[-:.TZ]/g, "")
.slice(0, 14);
const rnd = Math.random().toString(36).slice(2, 10);
return `${stamp}-${rnd}`;
}
/** Derive a short conversation title from its first user message. */
export function deriveTitle(text: string, maxLen = 60): string {
const collapsed = String(text).replace(/\s+/g, " ").trim();
if (!collapsed) return "(empty)";
if (collapsed.length <= maxLen) return collapsed;
return collapsed.slice(0, maxLen - 1) + "…";
}
/** Build an empty conversation with a fresh id. */
export function newConversation(): Conversation {
const now = Date.now();
return {
id: makeConversationId(),
title: "",
createdAt: now,
updatedAt: now,
messages: [],
};
}
/** Persist *conv* (insert-or-replace), then prune oldest entries. */
export async function saveConversation(conv: Conversation): Promise<void> {
const db = await getDb();
conv.updatedAt = Date.now();
await db.put(STORE, conv);
await _pruneIfNeeded(db);
}
async function _pruneIfNeeded(db: IDBPDatabase<Schema>): Promise<void> {
const count = await db.count(STORE);
const surplus = count - _maxConversations;
if (surplus <= 0) return;
// Oldest first via the by-updated index.
const all = await db.getAllFromIndex(STORE, "by-updated");
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
for (let i = 0; i < surplus; i += 1) {
await store.delete(all[i].id);
}
await tx.done;
}
/** Return conversation metadata sorted from most-recent to oldest. */
export async function listConversations(): Promise<ConversationInfo[]> {
const db = await getDb();
const all = await db.getAllFromIndex(STORE, "by-updated");
return all
.map((c) => ({
id: c.id,
title: c.title,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
messageCount: c.messages.length,
}))
.sort((a, b) => b.updatedAt - a.updatedAt);
}
export async function loadConversation(
id: string,
): Promise<Conversation | null> {
const db = await getDb();
const rec = await db.get(STORE, id);
return rec ?? null;
}
export async function deleteConversation(id: string): Promise<void> {
const db = await getDb();
await db.delete(STORE, id);
}
/** Rename a conversation in-place. Does NOT bump ``updatedAt`` so the
* history listing keeps its chronological order \u2014 a rename is metadata
* housekeeping, not new content. Silently no-ops when the conversation
* no longer exists. */
export async function renameConversation(
id: string,
title: string,
): Promise<void> {
const db = await getDb();
const rec = await db.get(STORE, id);
if (!rec) return;
rec.title = title;
await db.put(STORE, rec);
}
export async function clearConversations(): Promise<void> {
const db = await getDb();
await db.clear(STORE);
}