history/context/image modals components

Alessandro committed Oct 21, 2025 at 19:54 UTC 5bb89301f66336812f47d02fcfb85050b3e11ed9
15 files changed +690 -286
webui/components/chat/attachments/attachmentsStore.js
+3 -53
@@ -1,5 +1,6 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { fetchApi } from "/js/api.js";
3 +import { store as imageViewerStore } from "/components/modals/image-viewer-store.js";
4
5 const model = {
6 // State properties
@@ -7,13 +8,6 @@ const model = {
8 hasAttachments: false,
9 dragDropOverlayVisible: false,
10
10 - // Image modal properties
11 - currentImageUrl: null,
12 - currentImageName: null,
13 - imageLoaded: false,
14 - imageError: false,
15 - zoomLevel: 1,
16 -
11 async init() {
12 await this.initialize();
13 },
@@ -358,7 +352,7 @@ const model = {
352 previewUrl: previewUrl,
353 clickHandler: () => {
354 if (this.isImageFile(filename)) {
361 - this.openImageModal(this.getServerImgUrl(filename), filename);
355 + imageViewerStore.open(this.getServerImgUrl(filename), { name: filename });
356 } else {
357 this.downloadAttachment(filename);
358 }
@@ -380,7 +374,7 @@ const model = {
374 clickHandler: () => {
375 if (attachment.type === "image") {
376 const imageUrl = this.getServerImgUrl(attachment.name);
383 - this.openImageModal(imageUrl, attachment.name);
377 + imageViewerStore.open(imageUrl, { name: attachment.name });
378 } else {
379 this.downloadAttachment(attachment.name);
380 }
@@ -425,50 +419,6 @@ const model = {
419 );
420 },
421
428 - // Image modal methods
429 - openImageModal(imageUrl, imageName) {
430 - this.currentImageUrl = imageUrl;
431 - this.currentImageName = imageName;
432 - this.imageLoaded = false;
433 - this.imageError = false;
434 - this.zoomLevel = 1;
435 -
436 - // Open the modal using the modals system
437 - if (window.openModal) {
438 - window.openModal("chat/attachments/imageModal.html");
439 - }
440 - },
441 -
442 - closeImageModal() {
443 - this.currentImageUrl = null;
444 - this.currentImageName = null;
445 - this.imageLoaded = false;
446 - this.imageError = false;
447 - this.zoomLevel = 1;
448 - },
449 -
450 - // Zoom controls
451 - zoomIn() {
452 - this.zoomLevel = Math.min(this.zoomLevel * 1.2, 5); // Max 5x zoom
453 - this.updateImageZoom();
454 - },
455 -
456 - zoomOut() {
457 - this.zoomLevel = Math.max(this.zoomLevel / 1.2, 0.1); // Min 0.1x zoom
458 - this.updateImageZoom();
459 - },
460 -
461 - resetZoom() {
462 - this.zoomLevel = 1;
463 - this.updateImageZoom();
464 - },
465 -
466 - updateImageZoom() {
467 - const img = document.querySelector(".modal-image");
468 - if (img) {
469 - img.style.transform = `scale(${this.zoomLevel})`;
470 - }
471 - },
422 };
423
424 const store = createStore("chatAttachments", model);
webui/components/chat/attachments/inputPreview.html
+2 -1
@@ -1,5 +1,6 @@
1 <script type="module">
2 import { store } from "/components/chat/attachments/attachmentsStore.js";
3 + import { store as imageViewerStore } from "/components/modals/image-viewer-store.js";
4 </script>
5
6 <div x-data>
@@ -11,7 +12,7 @@
12 :class="{'image-type': attachment.type === 'image', 'file-type': attachment.type === 'file'}">
13 <template x-if="attachment.type === 'image'">
14 <img :src="attachment.url" class="attachment-preview" :alt="attachment.name" style="cursor: pointer;"
14 - @click="$store.chatAttachments.openImageModal(attachment.url, attachment.name)">
15 + @click="$store.imageViewer.open(attachment.url, { name: attachment.name })">
16 </template>
17 <template x-if="attachment.type === 'file'">
18 <div>
webui/components/chat/input/bottom-actions.html
+2 -2
@@ -40,14 +40,14 @@
40 <p>Files</p>
41 </button>
42
43 - <button class="text-button" id="history_inspect" @click="globalThis.openHistoryModal()">
43 + <button class="text-button" id="history_inspect" @click="$store.history.open()">
44 <svg xmlns="http://www.w3.org/2000/svg" viewBox="5 10 85 85">
45 <path fill="currentColor" d="m59.572,57.949c-.41,0-.826-.105-1.207-.325l-9.574-5.528c-.749-.432-1.21-1.231-1.21-2.095v-14.923c0-1.336,1.083-2.419,2.419-2.419s2.419,1.083,2.419,2.419v13.526l8.364,4.829c1.157.668,1.554,2.148.886,3.305-.448.776-1.261,1.21-2.097,1.21Zm30.427-7.947c0,10.684-4.161,20.728-11.716,28.283-6.593,6.59-15.325,10.69-24.59,11.544-1.223.113-2.448.169-3.669.169-7.492,0-14.878-2.102-21.22-6.068l-15.356,5.733c-.888.331-1.887.114-2.557-.556s-.887-1.669-.556-2.557l5.733-15.351c-4.613-7.377-6.704-16.165-5.899-24.891.854-9.266,4.954-17.998,11.544-24.588,7.555-7.555,17.6-11.716,28.285-11.716s20.73,4.161,28.285,11.716c7.555,7.555,11.716,17.599,11.716,28.283Zm-15.137-24.861c-13.71-13.71-36.018-13.71-49.728,0-11.846,11.846-13.682,30.526-4.365,44.417.434.647.53,1.464.257,2.194l-4.303,11.523,11.528-4.304c.274-.102.561-.153.846-.153.474,0,.944.139,1.348.41,13.888,9.315,32.568,7.479,44.417-4.365,13.707-13.708,13.706-36.014,0-49.723Zm-24.861-4.13c-15.989,0-28.996,13.006-28.996,28.992s13.008,28.992,28.996,28.992c1.336,0,2.419-1.083,2.419-2.419s-1.083-2.419-2.419-2.419c-13.32,0-24.157-10.835-24.157-24.153s10.837-24.153,24.157-24.153,24.153,10.835,24.153,24.153c0,1.336,1.083,2.419,2.419,2.419c0-15.986-13.006-28.992-28.992-28.992Zm25.041,33.531c-1.294.347-2.057,1.673-1.71,2.963.343,1.289,1.669,2.057,2.963,1.71,1.289-.343,2.053-1.669,1.71-2.963-.347-1.289-1.673-2.057-2.963-1.71Zm-2.03,6.328c-1.335,0-2.419,1.084-2.419,2.419s1.084,2.419,2.419,2.419,2.419-1.084,2.419-2.419-1.084-2.419-2.419-2.419Zm-3.598,5.587c-1.289-.347-2.615.416-2.963,1.71-.343,1.289.421,2.615,1.71,2.963,1.294.347,2.62-.421,2.963-1.71.347-1.294-.416-2.62-1.71-2.963Zm-4.919,4.462c-1.157-.667-2.638-.27-3.306.887-.667,1.157-.27,2.638.887,3.305,1.157.668,2.638.27,3.306-.887.667-1.157.27-2.638-.887-3.306Zm-9.327,3.04c-.946.946-.946,2.478,0,3.42.942.946,2.473.946,3.42,0,.946-.942.946-2.473,0-3.42-.946-.942-2.478-.946-3.42,0Z"></path>
46 </svg>
47 <p>History</p>
48 </button>
49
50 - <button class="text-button" id="ctx_window" @click="globalThis.openCtxWindowModal()">
50 + <button class="text-button" id="ctx_window" @click="$store.context.open()">
51 <svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="17 15 70 70" fill="currentColor">
52 <path d="m63 25c1.1016 0 2-0.89844 2-2s-0.89844-2-2-2h-26c-1.1016 0-2 0.89844-2 2s0.89844 2 2 2z"></path>
53 <path d="m63 79c1.1016 0 2-0.89844 2-2s-0.89844-2-2-2h-26c-1.1016 0-2 0.89844-2 2s0.89844 2 2 2z"></path>
webui/components/modals/context-store.js new
+132
@@ -0,0 +1,132 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +
3 +const model = {
4 + // State
5 + isLoading: false,
6 + contextData: null,
7 + tokenCount: 0,
8 + error: null,
9 + editor: null,
10 + closePromise: null,
11 +
12 + // Open Context Window modal
13 + async open() {
14 + if (this.isLoading) return; // Prevent double-open
15 +
16 + this.isLoading = true;
17 + this.error = null;
18 + this.contextData = null;
19 + this.tokenCount = 0;
20 +
21 + try {
22 + // Open modal FIRST (immediate UI feedback, but DON'T await)
23 + this.closePromise = window.openModal('modals/context.html');
24 +
25 + // Setup cleanup on modal close
26 + if (this.closePromise && typeof this.closePromise.then === 'function') {
27 + this.closePromise.then(() => {
28 + this.destroy();
29 + });
30 + }
31 +
32 + this.updateModalTitle(); // Set initial "loading" title
33 +
34 + // Fetch data from backend
35 + const contextId = window.getContext();
36 + const response = await window.sendJsonData('/ctx_window_get', {
37 + context: contextId,
38 + });
39 +
40 + // Update state with data
41 + this.contextData = response.content;
42 + this.tokenCount = response.tokens || 0;
43 + this.isLoading = false;
44 + this.updateModalTitle(); // Update with token count
45 +
46 + // Initialize ACE editor
47 + this.scheduleEditorInit();
48 +
49 + } catch (error) {
50 + console.error("Context fetch error:", error);
51 + this.error = error?.message || "Failed to load context window";
52 + this.isLoading = false;
53 + this.updateModalTitle(); // Show error in title
54 + }
55 + },
56 +
57 + scheduleEditorInit() {
58 + // Use double requestAnimationFrame to ensure DOM is ready
59 + window.requestAnimationFrame(() => {
60 + if (this.isLoading || this.error) return;
61 + window.requestAnimationFrame(() => this.initEditor());
62 + });
63 + },
64 +
65 + initEditor() {
66 + const container = document.getElementById("context-viewer-container");
67 + if (!container) {
68 + console.warn("Context container not found, deferring editor init");
69 + return;
70 + }
71 +
72 + // Destroy old instance if exists
73 + if (this.editor?.destroy) {
74 + this.editor.destroy();
75 + }
76 +
77 + // Check if ACE is available
78 + if (!window.ace?.edit) {
79 + console.error("ACE editor not available");
80 + this.error = "Editor library not loaded";
81 + return;
82 + }
83 +
84 + const editorInstance = window.ace.edit("context-viewer-container");
85 + if (!editorInstance) {
86 + console.error("Failed to create ACE editor instance");
87 + return;
88 + }
89 +
90 + this.editor = editorInstance;
91 +
92 + // Configure theme based on dark mode (legacy parity: != "false")
93 + const darkMode = window.localStorage?.getItem("darkMode");
94 + const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow";
95 +
96 + this.editor.setTheme(theme);
97 + this.editor.session.setMode("ace/mode/markdown");
98 + this.editor.setValue(this.contextData, -1); // -1 moves cursor to start
99 + this.editor.setReadOnly(true);
100 + this.editor.clearSelection();
101 + },
102 +
103 + updateModalTitle() {
104 + window.requestAnimationFrame(() => {
105 + const modalTitles = document.querySelectorAll(".modal.show .modal-title");
106 + if (!modalTitles.length) return;
107 +
108 + // Get the last (topmost) modal title
109 + const title = modalTitles[modalTitles.length - 1];
110 + if (!title) return;
111 +
112 + if (this.error) {
113 + title.textContent = "Context Window – Error";
114 + } else if (this.isLoading) {
115 + title.textContent = "Context Window (loading…)";
116 + } else {
117 + title.textContent = `Context Window ~${this.tokenCount} tokens`;
118 + }
119 + });
120 + },
121 +
122 + // Optional: cleanup method for lifecycle management
123 + destroy() {
124 + if (this.editor?.destroy) {
125 + this.editor.destroy();
126 + }
127 + this.editor = null;
128 + },
129 +};
130 +
131 +export const store = createStore("context", model);
132 +
webui/components/modals/context.html new
+107
@@ -0,0 +1,107 @@
1 +<html>
2 +<head>
3 + <title>Context Window</title>
4 + <script type="module">
5 + import { store } from "/components/modals/context-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.context">
11 + <div class="context-modal-root">
12 +
13 + <!-- Loading State -->
14 + <template x-if="$store.context.isLoading">
15 + <div class="loading-state">
16 + <div class="loading-spinner"></div>
17 + <p>Loading context window…</p>
18 + </div>
19 + </template>
20 +
21 + <!-- Error State -->
22 + <template x-if="$store.context.error">
23 + <div class="error-state">
24 + <p class="error-message" x-text="$store.context.error"></p>
25 + </div>
26 + </template>
27 +
28 + <!-- Content (ACE Editor) -->
29 + <template x-if="!$store.context.isLoading && !$store.context.error">
30 + <div id="context-viewer-container"></div>
31 + </template>
32 +
33 + </div>
34 + </template>
35 + </div>
36 +
37 + <style>
38 + .context-modal-root {
39 + display: flex;
40 + flex-direction: column;
41 + width: 100%;
42 + height: 100%;
43 + min-height: 400px;
44 + }
45 +
46 + .loading-state,
47 + .error-state {
48 + display: flex;
49 + flex-direction: column;
50 + align-items: center;
51 + justify-content: center;
52 + padding: var(--spacing-lg);
53 + min-height: 200px;
54 + }
55 +
56 + .loading-spinner {
57 + width: 40px;
58 + height: 40px;
59 + border: 3px solid var(--color-border);
60 + border-top: 3px solid var(--color-primary);
61 + border-radius: 50%;
62 + animation: spin 1s linear infinite;
63 + margin-bottom: var(--spacing-md);
64 + }
65 +
66 + @keyframes spin {
67 + 0% { transform: rotate(0deg); }
68 + 100% { transform: rotate(360deg); }
69 + }
70 +
71 + .loading-state p,
72 + .error-state p {
73 + color: var(--color-text-secondary);
74 + margin: 0;
75 + }
76 +
77 + .error-message {
78 + color: var(--color-error);
79 + font-weight: 500;
80 + }
81 +
82 + /* ACE Editor Container */
83 + #context-viewer-container {
84 + width: 100%;
85 + height: 71vh;
86 + border-radius: 0.4rem;
87 + overflow: auto;
88 + }
89 +
90 + #context-viewer-container::-webkit-scrollbar {
91 + width: 0;
92 + }
93 +
94 + /* ACE Editor Scrollbar */
95 + .ace_scrollbar-v {
96 + overflow-y: auto;
97 + }
98 +
99 + /* Viewer Styles */
100 + .context-modal-root {
101 + overflow: hidden;
102 + }
103 + </style>
104 +
105 +</body>
106 +</html>
107 +
webui/components/modals/history-store.js new
+132
@@ -0,0 +1,132 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +
3 +const model = {
4 + // State
5 + isLoading: false,
6 + historyData: null,
7 + tokenCount: 0,
8 + error: null,
9 + editor: null,
10 + closePromise: null,
11 +
12 + // Open History modal
13 + async open() {
14 + if (this.isLoading) return; // Prevent double-open
15 +
16 + this.isLoading = true;
17 + this.error = null;
18 + this.historyData = null;
19 + this.tokenCount = 0;
20 +
21 + try {
22 + // Open modal FIRST (immediate UI feedback, but DON'T await)
23 + this.closePromise = window.openModal('modals/history.html');
24 +
25 + // Setup cleanup on modal close
26 + if (this.closePromise && typeof this.closePromise.then === 'function') {
27 + this.closePromise.then(() => {
28 + this.destroy();
29 + });
30 + }
31 +
32 + this.updateModalTitle(); // Set initial "loading" title
33 +
34 + // Fetch data from backend
35 + const contextId = window.getContext();
36 + const response = await window.sendJsonData('/history_get', {
37 + context: contextId,
38 + });
39 +
40 + // Update state with data
41 + this.historyData = response.history;
42 + this.tokenCount = response.tokens || 0;
43 + this.isLoading = false;
44 + this.updateModalTitle(); // Update with token count
45 +
46 + // Initialize ACE editor
47 + this.scheduleEditorInit();
48 +
49 + } catch (error) {
50 + console.error("History fetch error:", error);
51 + this.error = error?.message || "Failed to load history";
52 + this.isLoading = false;
53 + this.updateModalTitle(); // Show error in title
54 + }
55 + },
56 +
57 + scheduleEditorInit() {
58 + // Use double requestAnimationFrame to ensure DOM is ready
59 + window.requestAnimationFrame(() => {
60 + if (this.isLoading || this.error) return;
61 + window.requestAnimationFrame(() => this.initEditor());
62 + });
63 + },
64 +
65 + initEditor() {
66 + const container = document.getElementById("history-viewer-container");
67 + if (!container) {
68 + console.warn("History container not found, deferring editor init");
69 + return;
70 + }
71 +
72 + // Destroy old instance if exists
73 + if (this.editor?.destroy) {
74 + this.editor.destroy();
75 + }
76 +
77 + // Check if ACE is available
78 + if (!window.ace?.edit) {
79 + console.error("ACE editor not available");
80 + this.error = "Editor library not loaded";
81 + return;
82 + }
83 +
84 + const editorInstance = window.ace.edit("history-viewer-container");
85 + if (!editorInstance) {
86 + console.error("Failed to create ACE editor instance");
87 + return;
88 + }
89 +
90 + this.editor = editorInstance;
91 +
92 + // Configure theme based on dark mode (legacy parity: != "false")
93 + const darkMode = window.localStorage?.getItem("darkMode");
94 + const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow";
95 +
96 + this.editor.setTheme(theme);
97 + this.editor.session.setMode("ace/mode/markdown");
98 + this.editor.setValue(this.historyData, -1); // -1 moves cursor to start
99 + this.editor.setReadOnly(true);
100 + this.editor.clearSelection();
101 + },
102 +
103 + updateModalTitle() {
104 + window.requestAnimationFrame(() => {
105 + const modalTitles = document.querySelectorAll(".modal.show .modal-title");
106 + if (!modalTitles.length) return;
107 +
108 + // Get the last (topmost) modal title
109 + const title = modalTitles[modalTitles.length - 1];
110 + if (!title) return;
111 +
112 + if (this.error) {
113 + title.textContent = "History – Error";
114 + } else if (this.isLoading) {
115 + title.textContent = "History (loading…)";
116 + } else {
117 + title.textContent = `History ~${this.tokenCount} tokens`;
118 + }
119 + });
120 + },
121 +
122 + // Optional: cleanup method for lifecycle management
123 + destroy() {
124 + if (this.editor?.destroy) {
125 + this.editor.destroy();
126 + }
127 + this.editor = null;
128 + },
129 +};
130 +
131 +export const store = createStore("history", model);
132 +
webui/components/modals/history.html new
+107
@@ -0,0 +1,107 @@
1 +<html>
2 +<head>
3 + <title>History</title>
4 + <script type="module">
5 + import { store } from "/components/modals/history-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.history">
11 + <div class="history-modal-root">
12 +
13 + <!-- Loading State -->
14 + <template x-if="$store.history.isLoading">
15 + <div class="loading-state">
16 + <div class="loading-spinner"></div>
17 + <p>Loading history…</p>
18 + </div>
19 + </template>
20 +
21 + <!-- Error State -->
22 + <template x-if="$store.history.error">
23 + <div class="error-state">
24 + <p class="error-message" x-text="$store.history.error"></p>
25 + </div>
26 + </template>
27 +
28 + <!-- Content (ACE Editor) -->
29 + <template x-if="!$store.history.isLoading && !$store.history.error">
30 + <div id="history-viewer-container"></div>
31 + </template>
32 +
33 + </div>
34 + </template>
35 + </div>
36 +
37 + <style>
38 + .history-modal-root {
39 + display: flex;
40 + flex-direction: column;
41 + width: 100%;
42 + height: 100%;
43 + min-height: 400px;
44 + }
45 +
46 + .loading-state,
47 + .error-state {
48 + display: flex;
49 + flex-direction: column;
50 + align-items: center;
51 + justify-content: center;
52 + padding: var(--spacing-lg);
53 + min-height: 200px;
54 + }
55 +
56 + .loading-spinner {
57 + width: 40px;
58 + height: 40px;
59 + border: 3px solid var(--color-border);
60 + border-top: 3px solid var(--color-primary);
61 + border-radius: 50%;
62 + animation: spin 1s linear infinite;
63 + margin-bottom: var(--spacing-md);
64 + }
65 +
66 + @keyframes spin {
67 + 0% { transform: rotate(0deg); }
68 + 100% { transform: rotate(360deg); }
69 + }
70 +
71 + .loading-state p,
72 + .error-state p {
73 + color: var(--color-text-secondary);
74 + margin: 0;
75 + }
76 +
77 + .error-message {
78 + color: var(--color-error);
79 + font-weight: 500;
80 + }
81 +
82 + /* ACE Editor Container */
83 + #history-viewer-container {
84 + width: 100%;
85 + height: 71vh;
86 + border-radius: 0.4rem;
87 + overflow: auto;
88 + }
89 +
90 + #history-viewer-container::-webkit-scrollbar {
91 + width: 0;
92 + }
93 +
94 + /* ACE Editor Scrollbar */
95 + .ace_scrollbar-v {
96 + overflow-y: auto;
97 + }
98 +
99 + /* Viewer Styles (legacy parity) */
100 + .history-modal-root {
101 + overflow: hidden;
102 + }
103 + </style>
104 +
105 +</body>
106 +</html>
107 +
webui/components/modals/image-viewer-store.js new
+188
@@ -0,0 +1,188 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +
3 +const model = {
4 + // State
5 + currentImageUrl: null,
6 + currentImageName: null,
7 + baseImageUrl: null,
8 + imageLoaded: false,
9 + imageError: false,
10 + zoomLevel: 1,
11 + refreshInterval: 0,
12 + activeIntervalId: null,
13 + closePromise: null,
14 +
15 + /**
16 + * Open image viewer modal
17 + * @param {string} imageUrl - URL of the image to display
18 + * @param {number|object} refreshOrOptions - Either:
19 + * - number: refresh interval in ms (legacy compat)
20 + * - object: { refreshInterval?: number, name?: string }
21 + */
22 + async open(imageUrl, refreshOrOptions) {
23 + // Parse options (backward compatibility)
24 + const options = typeof refreshOrOptions === 'number'
25 + ? { refreshInterval: refreshOrOptions, name: null }
26 + : refreshOrOptions || {};
27 +
28 + // Reset state
29 + this.baseImageUrl = imageUrl;
30 + this.refreshInterval = options.refreshInterval || 0;
31 + this.currentImageName = options.name || this.extractImageName(imageUrl);
32 + this.imageLoaded = false;
33 + this.imageError = false;
34 + this.zoomLevel = 1;
35 +
36 + // Add timestamp for cache-busting if refreshing
37 + this.currentImageUrl = this.refreshInterval > 0
38 + ? this.addTimestamp(imageUrl)
39 + : imageUrl;
40 +
41 + try {
42 + // Open modal and track close promise for cleanup
43 + this.closePromise = window.openModal('modals/image-viewer.html');
44 +
45 + // Setup cleanup on modal close
46 + if (this.closePromise && typeof this.closePromise.finally === 'function') {
47 + this.closePromise.finally(() => {
48 + this.stopRefresh();
49 + this.resetState();
50 + });
51 + }
52 +
53 + // Start refresh loop if needed
54 + if (this.refreshInterval > 0) {
55 + this.setupAutoRefresh();
56 + }
57 + } catch (error) {
58 + console.error("Image viewer error:", error);
59 + this.imageError = true;
60 + }
61 + },
62 +
63 + setupAutoRefresh() {
64 + // Clear any existing interval
65 + this.stopRefresh();
66 +
67 + this.activeIntervalId = setInterval(() => {
68 + if (!this.isModalVisible()) {
69 + this.stopRefresh();
70 + return;
71 + }
72 + this.preloadNextImage();
73 + }, this.refreshInterval);
74 + },
75 +
76 + async preloadNextImage() {
77 + const nextSrc = this.addTimestamp(this.baseImageUrl);
78 +
79 + // Create a promise that resolves when the image is loaded
80 + const preloadPromise = new Promise((resolve, reject) => {
81 + const tempImg = new Image();
82 + tempImg.onload = () => resolve(nextSrc);
83 + tempImg.onerror = reject;
84 + tempImg.src = nextSrc;
85 + });
86 +
87 + try {
88 + // Wait for preload to complete
89 + const loadedSrc = await preloadPromise;
90 +
91 + // Check if modal is still visible before updating
92 + if (this.isModalVisible()) {
93 + this.currentImageUrl = loadedSrc;
94 + this.imageLoaded = false; // Trigger reload animation
95 + }
96 + } catch (err) {
97 + console.error('Failed to preload image:', err);
98 + }
99 + },
100 +
101 + isModalVisible() {
102 + const container = document.querySelector('#image-viewer-wrapper');
103 + if (!container) return false;
104 +
105 + // Check if element or any parent is hidden
106 + let element = container;
107 + while (element) {
108 + const styles = window.getComputedStyle(element);
109 + if (styles.display === 'none' || styles.visibility === 'hidden') {
110 + return false;
111 + }
112 + element = element.parentElement;
113 + }
114 + return true;
115 + },
116 +
117 + stopRefresh() {
118 + if (this.activeIntervalId !== null) {
119 + clearInterval(this.activeIntervalId);
120 + this.activeIntervalId = null;
121 + }
122 + },
123 +
124 + resetState() {
125 + this.currentImageUrl = null;
126 + this.currentImageName = null;
127 + this.baseImageUrl = null;
128 + this.imageLoaded = false;
129 + this.imageError = false;
130 + this.zoomLevel = 1;
131 + this.refreshInterval = 0;
132 + },
133 +
134 + // Zoom controls
135 + zoomIn() {
136 + this.zoomLevel = Math.min(this.zoomLevel * 1.2, 5); // Max 5x zoom
137 + this.updateImageZoom();
138 + },
139 +
140 + zoomOut() {
141 + this.zoomLevel = Math.max(this.zoomLevel / 1.2, 0.1); // Min 0.1x zoom
142 + this.updateImageZoom();
143 + },
144 +
145 + resetZoom() {
146 + this.zoomLevel = 1;
147 + this.updateImageZoom();
148 + },
149 +
150 + updateImageZoom() {
151 + const img = document.querySelector(".modal-image");
152 + if (img) {
153 + img.style.transform = `scale(${this.zoomLevel})`;
154 + }
155 + },
156 +
157 + // Utility methods
158 + addTimestamp(url) {
159 + try {
160 + const urlObj = new URL(url, window.location.origin);
161 + urlObj.searchParams.set("t", Date.now().toString());
162 + return urlObj.toString();
163 + } catch (e) {
164 + // Fallback for invalid URLs
165 + const separator = url.includes('?') ? '&' : '?';
166 + return `${url}${separator}t=${Date.now()}`;
167 + }
168 + },
169 +
170 + extractImageName(url) {
171 + try {
172 + const urlObj = new URL(url, window.location.origin);
173 + const pathname = urlObj.pathname;
174 + return pathname.split("/").pop() || "Image";
175 + } catch (e) {
176 + return url.split("/").pop() || "Image";
177 + }
178 + },
179 +
180 + // Optional: cleanup on store destruction
181 + destroy() {
182 + this.stopRefresh();
183 + this.resetState();
184 + },
185 +};
186 +
187 +export const store = createStore("imageViewer", model);
188 +
webui/components/modals/image-viewer.html renamed
+15 -14
@@ -4,42 +4,42 @@
4 <title>Image Viewer</title>
5
6 <script type="module">
7 - import { store } from "/components/chat/attachments/attachmentsStore.js";
7 + import { store } from "/components/modals/image-viewer-store.js";
8 </script>
9 </head>
10
11 <body>
12 <div x-data>
13 - <template x-if="$store.chatAttachments">
14 - <div id="image-modal-content" class="image-modal-container">
13 + <template x-if="$store.imageViewer">
14 + <div id="image-viewer-wrapper" class="image-modal-container">
15 <!-- Image display area -->
16 <div class="image-display-wrapper">
17 <img
18 - x-show="$store.chatAttachments.currentImageUrl"
19 - :src="$store.chatAttachments.currentImageUrl"
20 - :alt="$store.chatAttachments.currentImageName || 'Image'"
18 + x-show="$store.imageViewer.currentImageUrl"
19 + :src="$store.imageViewer.currentImageUrl"
20 + :alt="$store.imageViewer.currentImageName || 'Image'"
21 class="modal-image"
22 - @load="$store.chatAttachments.imageLoaded = true"
23 - @error="$store.chatAttachments.imageError = true"
22 + @load="$store.imageViewer.imageLoaded = true"
23 + @error="$store.imageViewer.imageError = true"
24 />
25
26 <!-- Loading indicator -->
27 - <div x-show="!$store.chatAttachments.imageLoaded && !$store.chatAttachments.imageError" class="loading-indicator">
27 + <div x-show="!$store.imageViewer.imageLoaded && !$store.imageViewer.imageError" class="loading-indicator">
28 <div class="loading-spinner"></div>
29 <p>Loading image...</p>
30 </div>
31
32 <!-- Error indicator -->
33 - <div x-show="$store.chatAttachments.imageError" class="error-indicator">
33 + <div x-show="$store.imageViewer.imageError" class="error-indicator">
34 <p>Failed to load image</p>
35 </div>
36 </div>
37
38 <!-- Simple zoom controls -->
39 <div class="zoom-controls">
40 - <button @click="$store.chatAttachments.zoomOut()" class="zoom-btn" title="Zoom Out">−</button>
41 - <button @click="$store.chatAttachments.resetZoom()" class="zoom-btn" title="Reset">⌂</button>
42 - <button @click="$store.chatAttachments.zoomIn()" class="zoom-btn" title="Zoom In">+</button>
40 + <button @click="$store.imageViewer.zoomOut()" class="zoom-btn" title="Zoom Out">−</button>
41 + <button @click="$store.imageViewer.resetZoom()" class="zoom-btn" title="Reset">⌂</button>
42 + <button @click="$store.imageViewer.zoomIn()" class="zoom-btn" title="Zoom In">+</button>
43 </div>
44 </div>
45 </template>
@@ -145,4 +145,5 @@
145
146 </body>
147
148 -</html>
\ No newline at end of file
148 +</html>
149 +
webui/css/history.css deleted
-25
@@ -1,25 +0,0 @@
1 -/* History Styles */
2 -
3 -/* ACE Editor Scrollbar */
4 -.ace_scrollbar-v {
5 - overflow-y: auto;
6 - }
7 -
8 - /* JSON Viewer Container */
9 - #json-viewer-container {
10 - width: 100%;
11 - height: 71vh;
12 - border-radius: 0.4rem;
13 - overflow: auto;
14 - }
15 -
16 - #json-viewer-container::-webkit-scrollbar {
17 - width: 0;
18 - }
19 -
20 - /* Viewer Styles */
21 - .history-viewer {
22 - overflow: hidden;
23 - margin-bottom: 0.5rem;
24 - }
25 -
\ No newline at end of file
webui/index.html
-3
@@ -14,7 +14,6 @@
14 <link rel="stylesheet" href="css/modals.css">
15 <link rel="stylesheet" href="css/modals2.css">
16 <link rel="stylesheet" href="css/speech.css">
17 - <link rel="stylesheet" href="css/history.css">
17 <link rel="stylesheet" href="css/scheduler-datepicker.css">
18 <link rel="stylesheet" href="css/notification.css">
19
@@ -97,7 +96,6 @@
96
97 <!-- Load module scripts first -->
98 <script type="module" src="js/scheduler.js"></script>
100 - <script type="module" src="js/history.js"></script>
99
100 <script type="module" src="index.js"></script>
101
@@ -127,7 +125,6 @@
125 <!-- Non-module scripts after Alpine.js -->
126 <script type="text/javascript" src="js/settings.js"></script>
127 <script type="text/javascript" src="js/file_browser.js"></script>
130 - <script type="text/javascript" src="js/modal.js"></script>
128 <script type="module" src="js/tunnel.js"></script>
129 <script>
130 // Expose git info for sidebar component
webui/js/history.js deleted
-55
@@ -1,55 +0,0 @@
1 -import { getContext } from "../index.js";
2 -
3 -export async function openHistoryModal() {
4 - try {
5 - const hist = await window.sendJsonData("/history_get", { context: getContext() });
6 - // const data = JSON.stringify(hist.history, null, 4);
7 - const data = hist.history
8 - const size = hist.tokens
9 - await showEditorModal(data, "markdown", `History ~${size} tokens`, "Conversation history visible to the LLM. History is compressed to fit into the context window over time.");
10 - } catch (e) {
11 - window.toastFrontendError("Error fetching history: " + e.message, "Chat History Error");
12 - return
13 - }
14 -}
15 -
16 -export async function openCtxWindowModal() {
17 - try {
18 - const win = await window.sendJsonData("/ctx_window_get", { context: getContext() });
19 - const data = win.content
20 - const size = win.tokens
21 - await showEditorModal(data, "markdown", `Context window ~${size} tokens`, "Data passed to the LLM during last interaction. Contains system message, conversation history and RAG.");
22 - } catch (e) {
23 - window.toastFrontendError("Error fetching context: " + e.message, "Context Error");
24 - return
25 - }
26 -}
27 -
28 -async function showEditorModal(data, type = "json", title, description = "") {
29 - // Generate the HTML with JSON Viewer container
30 - const html = `<div id="json-viewer-container"></div>`;
31 -
32 - // Open the modal with the generated HTML
33 - await window.genericModalProxy.openModal(title, description, html, ["history-viewer"]);
34 -
35 - // Initialize the JSON Viewer after the modal is rendered
36 - const container = document.getElementById("json-viewer-container");
37 - if (container) {
38 - const editor = ace.edit("json-viewer-container");
39 -
40 - const dark = localStorage.getItem('darkMode')
41 - if (dark != "false") {
42 - editor.setTheme("ace/theme/github_dark");
43 - } else {
44 - editor.setTheme("ace/theme/tomorrow");
45 - }
46 -
47 - editor.session.setMode("ace/mode/" + type);
48 - editor.setValue(data);
49 - editor.clearSelection();
50 - // editor.session.$toggleFoldWidget(5, {})
51 - }
52 -}
53 -
54 -window.openHistoryModal = openHistoryModal;
55 -window.openCtxWindowModal = openCtxWindowModal;
webui/js/image_modal.js deleted
-87
@@ -1,87 +0,0 @@
1 -// Singleton interval ID for image refresh
2 -let activeIntervalId = null;
3 -
4 -export async function openImageModal(src, refreshInterval = 0) {
5 - try {
6 - let imgSrc = src;
7 -
8 - // Clear any existing refresh interval
9 - if (activeIntervalId !== null) {
10 - clearInterval(activeIntervalId);
11 - activeIntervalId = null;
12 - }
13 -
14 - if (refreshInterval > 0) {
15 - // Add or update timestamp to bypass cache
16 - const addTimestamp = (url) => {
17 - const urlObj = new URL(url, window.location.origin);
18 - urlObj.searchParams.set('t', Date.now());
19 - return urlObj.toString();
20 - };
21 -
22 - // Check if image viewer is still active
23 - const isImageViewerActive = () => {
24 - const container = document.querySelector('#image-viewer-container');
25 - if (!container) return false;
26 -
27 - // Check if element or any parent is hidden
28 - let element = container;
29 - while (element) {
30 - const style = window.getComputedStyle(element);
31 - if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
32 - return false;
33 - }
34 - element = element.parentElement;
35 - }
36 - return true;
37 - };
38 -
39 - // Preload next image before displaying
40 - const preloadAndUpdate = async (currentImg) => {
41 - const nextSrc = addTimestamp(src);
42 - // Create a promise that resolves when the image is loaded
43 - const preloadPromise = new Promise((resolve, reject) => {
44 - const tempImg = new Image();
45 - tempImg.onload = () => resolve(nextSrc);
46 - tempImg.onerror = reject;
47 - tempImg.src = nextSrc;
48 - });
49 -
50 - try {
51 - // Wait for preload to complete
52 - const loadedSrc = await preloadPromise;
53 - // Check if this interval is still the active one
54 - if (currentImg && isImageViewerActive()) {
55 - currentImg.src = loadedSrc;
56 - }
57 - } catch (err) {
58 - console.error('Failed to preload image:', err);
59 - }
60 - };
61 -
62 - imgSrc = addTimestamp(src);
63 -
64 - // Set up periodic refresh with preloading
65 - activeIntervalId = setInterval(() => {
66 - if (!isImageViewerActive()) {
67 - clearInterval(activeIntervalId);
68 - activeIntervalId = null;
69 - return;
70 - }
71 - const img = document.querySelector('.image-viewer-img');
72 - if (img) {
73 - preloadAndUpdate(img);
74 - }
75 - }, refreshInterval);
76 - }
77 -
78 - const html = `<div id="image-viewer-container"><img class="image-viewer-img" src="${imgSrc}" /></div>`;
79 - const fileName = src.split("/").pop();
80 -
81 - // Open the modal with the generated HTML
82 - await window.genericModalProxy.openModal(fileName, "", html);
83 - } catch (e) {
84 - window.toastFrontendError("Error fetching history: " + e.message, "Image History Error");
85 - return;
86 - }
87 -}
webui/js/messages.js
+2 -2
@@ -1,5 +1,5 @@
1 // message actions and components
2 -import { openImageModal } from "./image_modal.js";
2 +import { store as imageViewerStore } from "/components/modals/image-viewer-store.js";
3 import { marked } from "../vendor/marked/marked.esm.js";
4 import { store as _messageResizeStore } from "/components/messages/resize/message-resize-store.js"; // keep here, required in html
5 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
@@ -852,7 +852,7 @@ function drawKvpsIncremental(container, kvps, latex) {
852 // Add click handler and cursor change
853 imgElement.style.cursor = "pointer";
854 imgElement.addEventListener("click", () => {
855 - openImageModal(imgElement.src, 1000);
855 + imageViewerStore.open(imgElement.src, { refreshInterval: 1000 });
856 });
857 } else {
858 const pre = document.createElement("pre");
webui/js/modal.js deleted
-44
@@ -1,44 +0,0 @@
1 -// Full-Screen Input Modal logic moved to /components/modals/full-screen-store.js
2 -// Keep only other modals here.
3 -
4 -const genericModalProxy = {
5 - isOpen: false,
6 - isLoading: false,
7 - title: '',
8 - description: '',
9 - html: '',
10 -
11 - async openModal(title, description, html, contentClasses = []) {
12 - const modalEl = document.getElementById('genericModal');
13 - const modalContent = document.getElementById('viewer');
14 - const modalAD = Alpine.$data(modalEl);
15 -
16 - modalAD.isOpen = true;
17 - modalAD.title = title
18 - modalAD.description = description
19 - modalAD.html = html
20 -
21 - modalContent.className = 'modal-content';
22 - modalContent.classList.add(...contentClasses);
23 - },
24 -
25 - handleClose() {
26 - this.isOpen = false;
27 - }
28 -}
29 -
30 -// Wait for Alpine to be ready
31 -document.addEventListener('alpine:init', () => {
32 - Alpine.data('genericModalProxy', () => ({
33 - init() {
34 - Object.assign(this, genericModalProxy);
35 - // Ensure immediate file fetch when modal opens
36 - this.$watch('isOpen', async (value) => {
37 - // what now?
38 - });
39 - }
40 - }));
41 -});
42 -
43 -// Keep the global assignment for backward compatibility
44 -window.genericModalProxy = genericModalProxy;
\ No newline at end of file