| 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 }; |