main
js 476 lines 13.7 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import {
4 sendJsonData,
5 getContext,
6 setContext,
7 toastFetchError,
8 toast,
9 justToast,
10 getConnectionStatus,
11 } from "/index.js";
12 import { store as notificationStore } from "/components/notifications/notification-store.js";
13 import { store as sidebarStore } from "/components/sidebar/sidebar-store.js";
14 import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
15 import { store as syncStore } from "/components/sync/sync-store.js";
16 import { store as chatInputStore } from "/components/chat/input/input-store.js";
17
18 const model = {
19 contexts: [],
20 contextsJson: "",
21 selected: "",
22 selectedContext: null,
23 loggedIn: false,
24 expandedParents: {},
25 deletedContextIds: {},
26
27 // for convenience
28 getSelectedChatId() {
29 return this.selected;
30 },
31
32 getSelectedContext(){
33 return this.selectedContext;
34 },
35
36 init() {
37 this.loggedIn = Boolean(window.runtimeInfo && window.runtimeInfo.loggedIn);
38
39 // URL parameter takes priority (e.g. ?ctxid=abc from "open in new window")
40 const urlParams = new URL(window.location.href).searchParams;
41 const urlCtxId = urlParams.get("ctxid");
42 if (urlCtxId) {
43 const cleanUrl = new URL(window.location.href);
44 cleanUrl.searchParams.delete("ctxid");
45 window.history.replaceState({}, "", cleanUrl);
46 this.selectChat(urlCtxId);
47 return;
48 }
49
50 // Initialize from sessionStorage
51 const lastSelectedChat = sessionStorage.getItem("lastSelectedChat");
52 if (lastSelectedChat) {
53 this.selectChat(lastSelectedChat);
54 }
55 },
56
57 // Update contexts from sync snapshots
58 applyContexts(contextsList) {
59 const incomingContexts = Array.isArray(contextsList) ? contextsList : [];
60
61 // Sort by created_at time (newer first)
62 const nextContexts = incomingContexts
63 .filter((context) => !this.deletedContextIds[context?.id])
64 .sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
65 const contextsJson = JSON.stringify(nextContexts);
66 if (contextsJson !== this.contextsJson) {
67 this.contextsJson = contextsJson;
68 const sameRows =
69 nextContexts.length === this.contexts.length &&
70 nextContexts.every((context, index) => context?.id === this.contexts[index]?.id);
71
72 if (sameRows) {
73 nextContexts.forEach((context, index) => {
74 const current = this.contexts[index];
75 if (JSON.stringify(current) === JSON.stringify(context)) return;
76 Object.keys(current).forEach((key) => {
77 if (!(key in context)) delete current[key];
78 });
79 Object.assign(current, context);
80 });
81 } else {
82 this.contexts = nextContexts;
83 }
84 }
85
86 // Keep selectedContext in sync when the currently selected context's
87 // metadata changes (e.g. project activation/deactivation).
88 if (this.selected) {
89 const selectedId = this.selected;
90 const updated = this.contexts.find((ctx) => ctx.id === selectedId);
91 if (updated) {
92 if (this.selectedContext !== updated) this.selectedContext = updated;
93 if (updated.parent_context_id) {
94 if (!this.expandedParents[updated.parent_context_id]) {
95 this.expandedParents = {
96 ...this.expandedParents,
97 [updated.parent_context_id]: true,
98 };
99 }
100 } else if (
101 this.hasChildren(selectedId) &&
102 this.expandedParents[selectedId] === undefined
103 ) {
104 this.expandedParents = {
105 ...this.expandedParents,
106 [selectedId]: true,
107 };
108 }
109 }
110 }
111 },
112
113 topLevelContexts() {
114 return sidebarStore.sortRows(
115 "chat",
116 this.contexts.filter((ctx) => !ctx?.parent_context_id),
117 );
118 },
119
120 childContexts(parentId) {
121 return sidebarStore.sortRows(
122 "chat",
123 this.contexts.filter((ctx) => ctx?.parent_context_id === parentId),
124 );
125 },
126
127 hasChildren(parentId) {
128 return this.childContexts(parentId).length > 0;
129 },
130
131 isExpanded(parentId) {
132 return Boolean(this.expandedParents?.[parentId]);
133 },
134
135 toggleChildren(parentId) {
136 if (!parentId || !this.hasChildren(parentId)) return;
137 this.expandedParents = {
138 ...this.expandedParents,
139 [parentId]: !this.expandedParents?.[parentId],
140 };
141 },
142
143 displayName(context) {
144 if (!context) return "";
145 return context.parent_context_label || context.name || `Chat #${context.no}`;
146 },
147
148 // Select a chat
149 async selectChat(id) {
150 // The row may still have a queued click while Alpine removes it.
151 if (!id || this.deletedContextIds[id]) return;
152
153 const currentContext = getContext();
154 if (id === currentContext) {
155 this.setSelected(id);
156 return;
157 }
158
159 // Proceed with context selection
160 setContext(id);
161
162 // Update selection state (will also persist to localStorage)
163 this.setSelected(id);
164
165 // In push mode, context switching triggers a new `state_request` via setContext().
166 // Keep polling only as a degraded-mode fallback.
167 try {
168 const mode = typeof syncStore.mode === "string" ? syncStore.mode : null;
169 const shouldFallbackPoll = mode === "DEGRADED";
170 if (shouldFallbackPoll && typeof globalThis.poll === "function") {
171 globalThis.poll();
172 }
173 } catch (_e) {
174 // no-op
175 }
176 },
177
178 // Delete a chat
179 async killChat(id) {
180 if (!id) {
181 console.error("No chat ID provided for deletion");
182 return;
183 }
184 if (this.deletedContextIds[id]) return;
185
186 const removedContext = this.contexts.find((context) => context.id === id);
187 const deletingSelectedContext = this.selected === id || getContext() === id;
188
189 // Remove first, before selecting the fallback chat. Alpine batches both
190 // state changes into one render so the old row cannot remain above the new
191 // selection while the HTTP request is in flight.
192 this.deletedContextIds = { ...this.deletedContextIds, [id]: true };
193 this.contexts = this.contexts.filter((context) => context.id !== id);
194
195 try {
196 // Switch to another context if deleting current
197 if (deletingSelectedContext) {
198 await this.switchFromContext(id);
199 }
200
201 // Delete the chat on the server
202 await sendJsonData("/chat_remove", { context: id });
203
204 // Show success notification
205 justToast("Chat deleted successfully", "success", 1000, "chat-removal");
206 } catch (e) {
207 const deletedContextIds = { ...this.deletedContextIds };
208 delete deletedContextIds[id];
209 this.deletedContextIds = deletedContextIds;
210
211 // Roll back the optimistic row removal without disturbing any chat the
212 // user selected while the request was pending.
213 if (removedContext && !this.contexts.some((context) => context.id === id)) {
214 this.contexts = [...this.contexts, removedContext].sort(
215 (a, b) => (b.created_at || 0) - (a.created_at || 0),
216 );
217 }
218
219 console.error("Error deleting chat:", e);
220 toastFetchError("Error deleting chat", e);
221 }
222 },
223
224 // Switch from a context that's being deleted
225 async switchFromContext(id) {
226 // Find an alternate chat to switch to
227 let alternateChat = null;
228 for (let i = 0; i < this.contexts.length; i++) {
229 if (this.contexts[i].id !== id) {
230 alternateChat = this.contexts[i];
231 break;
232 }
233 }
234
235 if (alternateChat) {
236 await this.selectChat(alternateChat.id);
237 } else {
238 // If no other chats, create a new empty context
239 this.deselectChat();
240 //await this.newChat();
241 }
242 },
243
244 // Reset current chat
245 async resetChat(ctxid = null) {
246 try {
247 const context = ctxid || this.selected || getContext();
248 await sendJsonData("/chat_reset", {
249 context
250 });
251
252 // Increment reset counter
253 if (typeof globalThis.resetCounter === 'number') {
254 globalThis.resetCounter = globalThis.resetCounter + 1;
255 }
256 } catch (e) {
257 toastFetchError("Error resetting chat", e);
258 }
259 },
260
261 // Create new chat
262 async newChat() {
263 try {
264
265 // first create a new chat on the backend
266 const response = await sendJsonData("/chat_create", {
267 current_context: this.selected
268 });
269
270 if (response.ok) {
271 await this.selectChat(response.ctxid);
272 document.dispatchEvent(new CustomEvent("chat-created", { detail: { ctxid: response.ctxid } }));
273 return response.ctxid;
274 }
275
276 } catch (e) {
277 toastFetchError("Error creating new chat", e);
278 }
279 return null;
280 },
281
282 deselectChat(){
283 globalThis.deselectChat(); //TODO move here
284 },
285
286 // Smoothly scroll the chats list to top if present
287 _scrollChatsToTop() {
288 const listEl = document.querySelector('#chats-section .chats-config-list');
289 if (!listEl) return; // no-op if not in DOM
290 listEl.scrollTo({ top: 0, behavior: 'smooth' });
291 },
292
293 // Load chats from files
294 async loadChats() {
295 try {
296 const fileContents = await this.readJsonFiles();
297 const response = await sendJsonData("/chat_load", { chats: fileContents });
298
299 if (!response) {
300 toast("No response returned.", "error");
301 } else {
302 // Set context to first loaded chat
303 if (response.ctxids?.[0]) {
304 setContext(response.ctxids[0]);
305 }
306 toast("Chats loaded.", "success");
307 }
308 } catch (e) {
309 toastFetchError("Error loading chats", e);
310 }
311 },
312
313 // Save current chat
314 async saveChat() {
315 try {
316 const context = this.selected || getContext();
317 const response = await sendJsonData("/chat_export", { ctxid: context });
318
319 if (!response) {
320 toast("No response returned.", "error");
321 } else {
322 this.downloadFile(response.ctxid + ".json", response.content);
323 toast("Chat file downloaded.", "success");
324 }
325 } catch (e) {
326 toastFetchError("Error saving chat", e);
327 }
328 },
329
330 // Helper: read JSON files
331 readJsonFiles() {
332 return new Promise((resolve, reject) => {
333 const input = document.createElement("input");
334 input.type = "file";
335 input.accept = ".json";
336 input.multiple = true;
337
338 input.click();
339
340 input.onchange = async () => {
341 const files = input.files;
342 if (!files.length) {
343 resolve([]);
344 return;
345 }
346
347 const filePromises = Array.from(files).map((file) => {
348 return new Promise((fileResolve, fileReject) => {
349 const reader = new FileReader();
350 reader.onload = () => fileResolve(reader.result);
351 reader.onerror = fileReject;
352 reader.readAsText(file);
353 });
354 });
355
356 try {
357 const fileContents = await Promise.all(filePromises);
358 resolve(fileContents);
359 } catch (error) {
360 reject(error);
361 }
362 };
363 });
364 },
365
366 // Helper: download file
367 downloadFile(filename, content) {
368 const blob = new Blob([content], { type: "application/json" });
369 const link = document.createElement("a");
370 const url = URL.createObjectURL(blob);
371 link.href = url;
372 link.download = filename;
373 link.click();
374
375 setTimeout(() => {
376 URL.revokeObjectURL(url);
377 }, 0);
378 },
379
380 // Check if context exists
381 contains(contextId) {
382 return this.contexts.some((ctx) => ctx.id === contextId);
383 },
384
385 // Get first context ID
386 firstId() {
387 return this.contexts.length > 0 ? this.contexts[0].id : null;
388 },
389
390 // Set selected context
391 setSelected(contextId) {
392 this.selected = contextId || "";
393 this.selectedContext = this.contexts.find((ctx) => ctx.id === this.selected);
394 // if not found in contexts, try to find in tasks < not nice, will need refactor later
395 if(!this.selectedContext) this.selectedContext = tasksStore.tasks.find((ctx) => ctx.id === this.selected);
396 if (this.selected) {
397 sessionStorage.setItem("lastSelectedChat", this.selected);
398 } else {
399 sessionStorage.removeItem("lastSelectedChat");
400 }
401 },
402
403 // Restart the backend
404 async restart() {
405 // Check connection status (avoid spamming requests when already disconnected)
406 const connectionStatus = getConnectionStatus();
407 if (connectionStatus === false) {
408 await notificationStore.frontendError(
409 "Backend disconnected, cannot restart.",
410 "Restart Error",
411 );
412 return;
413 }
414
415 // Create a backend notification first so other tabs have a chance to show it
416 // before the process is replaced.
417 const notificationId = await notificationStore.info(
418 "Restarting...",
419 "System Restart",
420 "",
421 9999,
422 "restart",
423 );
424
425 // Best-effort: wait briefly for the notification to arrive via state sync so
426 // the initiating tab (and typically other tabs) renders the toast before restart.
427 if (notificationId) {
428 const deadline = Date.now() + 800;
429 while (Date.now() < deadline) {
430 try {
431
432 const stack = Array.isArray(notificationStore.toastStack) ? notificationStore.toastStack : null;
433 if (stack && stack.some((toast) => toast && toast.id === notificationId)) {
434 break;
435 }
436 } catch (_err) {
437 break;
438 }
439 await new Promise((resolve) => setTimeout(resolve, 25));
440 }
441 }
442
443 // The restart endpoint usually drops the connection as the process is replaced.
444 // Do not wait on /health - recovery is driven by WebSocket CSRF preflight + reconnect.
445 try {
446 await sendJsonData("/restart", {});
447 } catch (_e) {
448 // ignore
449 }
450 },
451
452 async logout() {
453 try {
454 await callJsonApi("/logout", {});
455 } catch (_e) {
456 // ignore
457 }
458
459 try {
460 sessionStorage.removeItem("lastSelectedChat");
461 sessionStorage.removeItem("lastSelectedTask");
462 } catch (_e) {
463 // ignore
464 }
465
466 try {
467 window.location.reload();
468 } catch (_e) {
469 // ignore
470 }
471 }
472 };
473
474 const store = createStore("chats", model);
475
476 export { store };