attachment system polishing
frdel committed
Jul 9, 2025 at 23:03 UTC
82799a5b34bcec033ce9e87cab1bddef37f430a1
14 files changed
+633
-616
agent.py
+3
-3
@@ -746,7 +746,7 @@ class Agent:
746
# Fallback to local get_tool if MCP tool was not found or MCP lookup failed
747
if not tool:
748
tool = self.get_tool(
749
- name=tool_name, method=tool_method, args=tool_args, message=msg
749
+ name=tool_name, method=tool_method, args=tool_args, message=msg, loop_data=self.loop_data
750
)
751
752
if tool:
@@ -801,7 +801,7 @@ class Agent:
801
pass
802
803
def get_tool(
804
- self, name: str, method: str | None, args: dict, message: str, **kwargs
804
+ self, name: str, method: str | None, args: dict, message: str, loop_data: LoopData | None, **kwargs
805
):
806
from python.tools.unknown import Unknown
807
from python.helpers.tool import Tool
@@ -811,7 +811,7 @@ class Agent:
811
)
812
tool_class = classes[0] if classes else Unknown
813
return tool_class(
814
- agent=self, name=name, method=method, args=args, message=message, **kwargs
814
+ agent=self, name=name, method=method, args=args, message=message, loop_data=loop_data, **kwargs
815
)
816
817
async def call_extensions(self, folder: str, **kwargs) -> Any:
python/api/image_get.py
+135
-184
@@ -1,9 +1,13 @@
1
+import base64
2
import os
3
import re
4
from typing import override
5
from python.helpers.api import ApiHandler
6
from python.helpers import files
7
from flask import Request, Response, send_file
8
+from python.helpers import runtime
9
+import io
10
+from mimetypes import guess_type
11
12
13
class ImageGet(ApiHandler):
@@ -13,189 +17,136 @@ class ImageGet(ApiHandler):
17
return ["GET"]
18
19
async def process(self, input: dict, request: Request) -> dict | Response:
16
- # input data
17
- path = input.get("path", request.args.get("path", ""))
18
- metadata = input.get("metadata", request.args.get("metadata", "false")).lower() == "true"
19
-
20
- print(f"ImageGet: Processing path={path}, metadata={metadata}") # Debug
21
-
22
- if not path:
23
- raise ValueError("No path provided")
24
-
25
- # check if path is within base directory
26
- if not files.is_in_base_dir(path):
27
- raise ValueError("Path is outside of allowed directory")
28
-
29
- # get file extension and info
30
- file_ext = os.path.splitext(path)[1].lower()
31
- filename = os.path.basename(path)
32
-
33
- print(f"ImageGet: file_ext={file_ext}, filename={filename}") # Debug
34
-
35
- # list of allowed image extensions
36
- image_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]
37
-
38
- # If metadata is requested, return file information
39
- if metadata:
40
- return self._get_file_metadata(path, filename, file_ext, image_extensions)
41
-
42
- if file_ext in image_extensions:
43
- # Handle image files
44
- print(f"ImageGet: Handling as image file") # Debug
45
- if not os.path.exists(path):
46
- # If image doesn't exist, return default image icon
47
- return self._get_fallback_icon("image")
48
-
49
- # send actual image file with proper headers for device sync
20
+ # input data
21
+ path = input.get("path", request.args.get("path", ""))
22
+ metadata = (
23
+ input.get("metadata", request.args.get("metadata", "false")).lower()
24
+ == "true"
25
+ )
26
+
27
+ if not path:
28
+ raise ValueError("No path provided")
29
+
30
+ # check if path is within base directory
31
+ if not runtime.call_development_function(files.is_in_base_dir, path):
32
+ raise ValueError("Path is outside of allowed directory")
33
+
34
+ # get file extension and info
35
+ file_ext = os.path.splitext(path)[1].lower()
36
+ filename = os.path.basename(path)
37
+
38
+ # list of allowed image extensions
39
+ image_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]
40
+
41
+ # # If metadata is requested, return file information
42
+ # if metadata:
43
+ # return _get_file_metadata(path, filename, file_ext, image_extensions)
44
+
45
+ if file_ext in image_extensions:
46
+ # Handle image files
47
+ if not runtime.call_development_function(files.exists, path):
48
+ # If image doesn't exist, return default image icon
49
+ return _send_fallback_icon("image")
50
+
51
+ # send image file right away if dockerized, if development, read it from docker and send
52
+ if runtime.is_dockerized():
53
response = send_file(path)
51
- # Add cache headers for better device sync performance
52
- response.headers['Cache-Control'] = 'public, max-age=3600'
53
- response.headers['X-File-Type'] = 'image'
54
- response.headers['X-File-Name'] = filename
55
- return response
54
else:
57
- # Handle non-image files with fallback icons
58
- print(f"ImageGet: Handling as non-image file, getting icon") # Debug
59
- return self._get_file_type_icon(file_ext, filename)
60
-
61
- def _get_file_type_icon(self, file_ext, filename=None):
62
- """Return appropriate icon for file type"""
63
- print(f"_get_file_type_icon: file_ext={file_ext}, filename={filename}") # Debug
64
-
65
- # Map file extensions to icon names
66
- icon_mapping = {
67
- # Archive files
68
- '.zip': 'archive',
69
- '.rar': 'archive',
70
- '.7z': 'archive',
71
- '.tar': 'archive',
72
- '.gz': 'archive',
73
-
74
- # Document files
75
- '.pdf': 'document',
76
- '.doc': 'document',
77
- '.docx': 'document',
78
- '.txt': 'document',
79
- '.rtf': 'document',
80
- '.odt': 'document',
81
-
82
- # Code files
83
- '.py': 'code',
84
- '.js': 'code',
85
- '.html': 'code',
86
- '.css': 'code',
87
- '.json': 'code',
88
- '.xml': 'code',
89
- '.md': 'code',
90
- '.yml': 'code',
91
- '.yaml': 'code',
92
- '.sql': 'code',
93
- '.sh': 'code',
94
- '.bat': 'code',
95
-
96
- # Spreadsheet files
97
- '.xls': 'document',
98
- '.xlsx': 'document',
99
- '.csv': 'document',
100
-
101
- # Presentation files
102
- '.ppt': 'document',
103
- '.pptx': 'document',
104
- '.odp': 'document',
105
- }
106
-
107
- # Get icon name, default to 'file' if not found
108
- icon_name = icon_mapping.get(file_ext, 'file')
109
- print(f"_get_file_type_icon: icon_name={icon_name}") # Debug
110
-
111
- response = self._get_fallback_icon(icon_name)
112
-
113
- # Add headers for device sync
114
- if hasattr(response, 'headers'):
115
- response.headers['Cache-Control'] = 'public, max-age=86400' # Cache icons for 24 hours
116
- response.headers['X-File-Type'] = 'icon'
117
- response.headers['X-Icon-Type'] = icon_name
118
- if filename:
119
- response.headers['X-File-Name'] = filename
120
-
121
- return response
122
-
123
- def _get_file_metadata(self, path, filename, file_ext, image_extensions):
124
- """Return file metadata for device sync and UI enhancement"""
125
- metadata = {
126
- 'filename': filename,
127
- 'extension': file_ext,
128
- 'exists': os.path.exists(path),
129
- 'is_image': file_ext in image_extensions,
130
- 'file_type': 'image' if file_ext in image_extensions else self._get_file_category(file_ext),
131
- 'api_url': f'/image_get?path={path}',
132
- 'icon_type': self._get_icon_type(file_ext) if file_ext not in image_extensions else 'image'
133
- }
134
-
135
- # Add file size if file exists
136
- if metadata['exists']:
137
- try:
138
- metadata['size'] = os.path.getsize(path)
139
- metadata['size_human'] = self._format_file_size(metadata['size'])
140
- except OSError:
141
- metadata['size'] = 0
142
- metadata['size_human'] = 'Unknown'
143
-
144
- return metadata
145
-
146
- def _get_file_category(self, file_ext):
147
- """Get file category for metadata"""
148
- categories = {
149
- '.zip': 'archive', '.rar': 'archive', '.7z': 'archive', '.tar': 'archive', '.gz': 'archive',
150
- '.pdf': 'document', '.doc': 'document', '.docx': 'document', '.txt': 'document',
151
- '.py': 'code', '.js': 'code', '.html': 'code', '.css': 'code', '.json': 'code',
152
- '.xls': 'spreadsheet', '.xlsx': 'spreadsheet', '.csv': 'spreadsheet',
153
- '.ppt': 'presentation', '.pptx': 'presentation'
154
- }
155
- return categories.get(file_ext, 'file')
156
-
157
- def _get_icon_type(self, file_ext):
158
- """Get icon type for metadata (matches the icon mapping)"""
159
- icon_mapping = {
160
- '.zip': 'archive', '.rar': 'archive', '.7z': 'archive', '.tar': 'archive', '.gz': 'archive',
161
- '.pdf': 'document', '.doc': 'document', '.docx': 'document', '.txt': 'document', '.rtf': 'document', '.odt': 'document',
162
- '.py': 'code', '.js': 'code', '.html': 'code', '.css': 'code', '.json': 'code', '.xml': 'code', '.md': 'code',
163
- '.yml': 'code', '.yaml': 'code', '.sql': 'code', '.sh': 'code', '.bat': 'code',
164
- '.xls': 'document', '.xlsx': 'document', '.csv': 'document',
165
- '.ppt': 'document', '.pptx': 'document', '.odp': 'document'
166
- }
167
- return icon_mapping.get(file_ext, 'file')
168
-
169
- def _format_file_size(self, size_bytes):
170
- """Format file size in human readable format"""
171
- if size_bytes == 0:
172
- return "0 B"
173
- size_names = ["B", "KB", "MB", "GB", "TB"]
174
- import math
175
- i = int(math.floor(math.log(size_bytes, 1024)))
176
- p = math.pow(1024, i)
177
- s = round(size_bytes / p, 2)
178
- return f"{s} {size_names[i]}"
179
-
180
- def _get_fallback_icon(self, icon_name):
181
- """Return fallback icon from public directory"""
182
- print(f"_get_fallback_icon: icon_name={icon_name}") # Debug
183
-
184
- # Path to public icons
185
- icon_path = files.get_abs_path(f"webui/public/{icon_name}.svg")
186
- print(f"_get_fallback_icon: icon_path={icon_path}") # Debug
187
-
188
- # Check if specific icon exists, fallback to generic file icon
189
- if not os.path.exists(icon_path):
190
- print(f"_get_fallback_icon: Icon not found, falling back to file.svg") # Debug
191
- icon_path = files.get_abs_path("webui/public/file.svg")
192
-
193
- # Final fallback if file.svg doesn't exist
194
- if not os.path.exists(icon_path):
195
- print(f"_get_fallback_icon: ERROR - file.svg not found at {icon_path}") # Debug
196
- raise ValueError(f"Fallback icon not found: {icon_path}")
197
-
198
- print(f"_get_fallback_icon: Sending file {icon_path}") # Debug
199
- return send_file(icon_path, mimetype='image/svg+xml')
200
-
201
-
\ No newline at end of file
55
+ b64_content = await runtime.call_development_function(
56
+ files.read_file_base64, path
57
+ )
58
+ file_content = base64.b64decode(b64_content)
59
+ mime_type, _ = guess_type(filename)
60
+ if not mime_type:
61
+ mime_type = "application/octet-stream"
62
+ response = send_file(
63
+ io.BytesIO(file_content),
64
+ mimetype=mime_type,
65
+ as_attachment=False,
66
+ download_name=filename,
67
+ )
68
+
69
+ # Add cache headers for better device sync performance
70
+ response.headers["Cache-Control"] = "public, max-age=3600"
71
+ response.headers["X-File-Type"] = "image"
72
+ response.headers["X-File-Name"] = filename
73
+ return response
74
+ else:
75
+ # Handle non-image files with fallback icons
76
+ return _send_file_type_icon(file_ext, filename)
77
+
78
+
79
+def _send_file_type_icon(file_ext, filename=None):
80
+ """Return appropriate icon for file type"""
81
+
82
+ # Map file extensions to icon names
83
+ icon_mapping = {
84
+ # Archive files
85
+ ".zip": "archive",
86
+ ".rar": "archive",
87
+ ".7z": "archive",
88
+ ".tar": "archive",
89
+ ".gz": "archive",
90
+ # Document files
91
+ ".pdf": "document",
92
+ ".doc": "document",
93
+ ".docx": "document",
94
+ ".txt": "document",
95
+ ".rtf": "document",
96
+ ".odt": "document",
97
+ # Code files
98
+ ".py": "code",
99
+ ".js": "code",
100
+ ".html": "code",
101
+ ".css": "code",
102
+ ".json": "code",
103
+ ".xml": "code",
104
+ ".md": "code",
105
+ ".yml": "code",
106
+ ".yaml": "code",
107
+ ".sql": "code",
108
+ ".sh": "code",
109
+ ".bat": "code",
110
+ # Spreadsheet files
111
+ ".xls": "document",
112
+ ".xlsx": "document",
113
+ ".csv": "document",
114
+ # Presentation files
115
+ ".ppt": "document",
116
+ ".pptx": "document",
117
+ ".odp": "document",
118
+ }
119
+
120
+ # Get icon name, default to 'file' if not found
121
+ icon_name = icon_mapping.get(file_ext, "file")
122
+
123
+ response = _send_fallback_icon(icon_name)
124
+
125
+ # Add headers for device sync
126
+ if hasattr(response, "headers"):
127
+ response.headers["Cache-Control"] = (
128
+ "public, max-age=86400" # Cache icons for 24 hours
129
+ )
130
+ response.headers["X-File-Type"] = "icon"
131
+ response.headers["X-Icon-Type"] = icon_name
132
+ if filename:
133
+ response.headers["X-File-Name"] = filename
134
+
135
+ return response
136
+
137
+
138
+def _send_fallback_icon(icon_name):
139
+ """Return fallback icon from public directory"""
140
+
141
+ # Path to public icons
142
+ icon_path = files.get_abs_path(f"webui/public/{icon_name}.svg")
143
+
144
+ # Check if specific icon exists, fallback to generic file icon
145
+ if not os.path.exists(icon_path):
146
+ icon_path = files.get_abs_path("webui/public/file.svg")
147
+
148
+ # Final fallback if file.svg doesn't exist
149
+ if not os.path.exists(icon_path):
150
+ raise ValueError(f"Fallback icon not found: {icon_path}")
151
+
152
+ return send_file(icon_path, mimetype="image/svg+xml")
python/helpers/tool.py
+3
-2
@@ -1,7 +1,7 @@
1
from abc import abstractmethod
2
from dataclasses import dataclass
3
4
-from agent import Agent
4
+from agent import Agent, LoopData
5
from python.helpers.print_style import PrintStyle
6
from python.helpers.strings import sanitize_string
7
@@ -13,11 +13,12 @@ class Response:
13
14
class Tool:
15
16
- def __init__(self, agent: Agent, name: str, method: str | None, args: dict[str,str], message: str, **kwargs) -> None:
16
+ def __init__(self, agent: Agent, name: str, method: str | None, args: dict[str,str], message: str, loop_data: LoopData | None, **kwargs) -> None:
17
self.agent = agent
18
self.name = name
19
self.method = method
20
self.args = args
21
+ self.loop_data = loop_data
22
self.message = message
23
24
@abstractmethod
python/tools/response.py
+5
-1
@@ -12,4 +12,8 @@ class ResponseTool(Tool):
12
pass
13
14
async def after_execution(self, response, **kwargs):
15
- pass # do not add anything to the history or output
15
+ # do not add anything to the history or output
16
+
17
+ if self.loop_data and "log_item_response" in self.loop_data.params_temporary:
18
+ log = self.loop_data.params_temporary["log_item_response"]
19
+ log.update(finished=True) # mark the message as finished
webui/components/chat/attachments/attachmentsStore.js
+219
-106
@@ -1,4 +1,5 @@
1
import { createStore } from "/js/AlpineStore.js";
2
+import { fetchApi } from "/js/api.js";
3
4
const model = {
5
// State properties
@@ -48,10 +49,12 @@ const model = {
49
50
validateDuplicates(newAttachment) {
51
// Check if attachment already exists based on name and size
51
- const isDuplicate = this.attachments.some(existing =>
52
- existing.name === newAttachment.name &&
53
- existing.file && newAttachment.file &&
54
- existing.file.size === newAttachment.file.size
52
+ const isDuplicate = this.attachments.some(
53
+ (existing) =>
54
+ existing.name === newAttachment.name &&
55
+ existing.file &&
56
+ newAttachment.file &&
57
+ existing.file.size === newAttachment.file.size
58
);
59
return !isDuplicate;
60
},
@@ -71,80 +74,101 @@ const model = {
74
75
// Setup drag and drop event handlers
76
setupDragDropHandlers() {
74
- console.log('Setting up drag and drop handlers...');
77
+ console.log("Setting up drag and drop handlers...");
78
let dragCounter = 0;
79
80
// Prevent default drag behaviors
78
- ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
79
- document.addEventListener(eventName, (e) => {
80
- e.preventDefault();
81
- e.stopPropagation();
82
- }, false);
81
+ ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
82
+ document.addEventListener(
83
+ eventName,
84
+ (e) => {
85
+ e.preventDefault();
86
+ e.stopPropagation();
87
+ },
88
+ false
89
+ );
90
});
91
92
// Handle drag enter
86
- document.addEventListener('dragenter', (e) => {
87
- console.log('Drag enter detected');
88
- dragCounter++;
89
- if (dragCounter === 1) {
90
- console.log('Showing drag drop overlay');
91
- this.showDragDropOverlay();
92
- }
93
- }, false);
93
+ document.addEventListener(
94
+ "dragenter",
95
+ (e) => {
96
+ console.log("Drag enter detected");
97
+ dragCounter++;
98
+ if (dragCounter === 1) {
99
+ console.log("Showing drag drop overlay");
100
+ this.showDragDropOverlay();
101
+ }
102
+ },
103
+ false
104
+ );
105
106
// Handle drag leave
96
- document.addEventListener('dragleave', (e) => {
97
- dragCounter--;
98
- if (dragCounter === 0) {
99
- this.hideDragDropOverlay();
100
- }
101
- }, false);
107
+ document.addEventListener(
108
+ "dragleave",
109
+ (e) => {
110
+ dragCounter--;
111
+ if (dragCounter === 0) {
112
+ this.hideDragDropOverlay();
113
+ }
114
+ },
115
+ false
116
+ );
117
118
// Handle drop
104
- document.addEventListener('drop', (e) => {
105
- console.log('Drop detected with files:', e.dataTransfer.files.length);
106
- dragCounter = 0;
107
- this.hideDragDropOverlay();
108
-
109
- const files = e.dataTransfer.files;
110
- this.handleFiles(files);
111
- }, false);
119
+ document.addEventListener(
120
+ "drop",
121
+ (e) => {
122
+ console.log("Drop detected with files:", e.dataTransfer.files.length);
123
+ dragCounter = 0;
124
+ this.hideDragDropOverlay();
125
+
126
+ const files = e.dataTransfer.files;
127
+ this.handleFiles(files);
128
+ },
129
+ false
130
+ );
131
},
132
133
// Setup paste event handler for clipboard images
134
setupPasteHandler() {
116
- console.log('Setting up paste handler...');
117
- document.addEventListener('paste', (e) => {
118
- console.log('Paste event detected, target:', e.target.tagName);
119
-
135
+ console.log("Setting up paste handler...");
136
+ document.addEventListener("paste", (e) => {
137
+ console.log("Paste event detected, target:", e.target.tagName);
138
+
139
const items = e.clipboardData.items;
140
let imageFound = false;
122
- console.log('Checking clipboard items:', items.length);
123
-
141
+ console.log("Checking clipboard items:", items.length);
142
+
143
// First, check if there are any images in the clipboard
144
for (let i = 0; i < items.length; i++) {
145
const item = items[i];
127
- if (item.type.indexOf('image') !== -1) {
146
+ if (item.type.indexOf("image") !== -1) {
147
imageFound = true;
148
const blob = item.getAsFile();
149
if (blob) {
150
e.preventDefault(); // Prevent default paste behavior for images
151
this.handleClipboardImage(blob);
133
- console.log('Image detected in clipboard, processing...');
152
+ console.log("Image detected in clipboard, processing...");
153
}
154
break; // Only handle the first image found
155
}
156
}
157
158
// If no images found and we're in an input field, let normal text paste happen
140
- if (!imageFound && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) {
141
- console.log('No images in clipboard, allowing normal text paste in input field');
159
+ if (
160
+ !imageFound &&
161
+ (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
162
+ ) {
163
+ console.log(
164
+ "No images in clipboard, allowing normal text paste in input field"
165
+ );
166
return;
167
}
168
169
// If no images found and not in input field, do nothing
170
if (!imageFound) {
147
- console.log('No images in clipboard');
171
+ console.log("No images in clipboard");
172
}
173
});
174
},
@@ -155,16 +179,17 @@ const model = {
179
// Generate unique filename
180
const guid = this.generateGUID();
181
const filename = `clipboard-${guid}.png`;
158
-
182
+
183
// Create file object from blob
160
- const file = new File([blob], filename, { type: 'image/png' });
161
-
184
+ const file = new File([blob], filename, { type: "image/png" });
185
+
186
// Create attachment object
187
const attachment = {
188
file: file,
165
- type: 'image',
189
+ type: "image",
190
name: filename,
167
- extension: 'png'
191
+ extension: "png",
192
+ displayInfo: this.getAttachmentDisplayInfo(file),
193
};
194
195
// Read as data URL for preview
@@ -176,26 +201,28 @@ const model = {
201
reader.readAsDataURL(file);
202
203
// Show success feedback
179
- console.log('Clipboard image pasted successfully:', filename);
180
-
204
+ console.log("Clipboard image pasted successfully:", filename);
205
} catch (error) {
182
- console.error('Failed to handle clipboard image:', error);
206
+ console.error("Failed to handle clipboard image:", error);
207
}
208
},
209
210
// File handling logic (moved from index.js)
211
handleFiles(files) {
188
- console.log('handleFiles called with', files.length, 'files');
189
- Array.from(files).forEach(file => {
190
- console.log('Processing file:', file.name, file.type);
191
- const ext = file.name.split('.').pop().toLowerCase();
192
- const isImage = ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'webp'].includes(ext);
212
+ console.log("handleFiles called with", files.length, "files");
213
+ Array.from(files).forEach((file) => {
214
+ console.log("Processing file:", file.name, file.type);
215
+ const ext = file.name.split(".").pop().toLowerCase();
216
+ const isImage = ["jpg", "jpeg", "png", "bmp", "gif", "webp"].includes(
217
+ ext
218
+ );
219
220
const attachment = {
221
file: file,
196
- type: isImage ? 'image' : 'file',
222
+ type: isImage ? "image" : "file",
223
name: file.name,
198
- extension: ext
224
+ extension: ext,
225
+ displayInfo: this.getAttachmentDisplayInfo(file),
226
};
227
228
if (isImage) {
@@ -215,106 +242,194 @@ const model = {
242
243
// Get attachments for sending message
244
getAttachmentsForSending() {
218
- return this.attachments.map(attachment => {
219
- if (attachment.type === 'image') {
245
+ return this.attachments.map((attachment) => {
246
+ if (attachment.type === "image") {
247
return {
248
...attachment,
222
- url: URL.createObjectURL(attachment.file)
249
+ url: URL.createObjectURL(attachment.file),
250
};
251
} else {
252
return {
226
- ...attachment
253
+ ...attachment,
254
};
255
}
256
});
257
},
258
259
// Generate server-side API URL for file (for device sync)
233
- getServerFileUrl(filename) {
260
+ getServerImgUrl(filename) {
261
return `/image_get?path=/a0/tmp/uploads/${encodeURIComponent(filename)}`;
262
},
263
237
- // Get file metadata from server (for device sync and enhanced UI)
238
- async getFileMetadata(filename) {
239
- try {
240
- const response = await fetch(`/image_get?path=/a0/tmp/uploads/${encodeURIComponent(filename)}&metadata=true`);
241
- if (response.ok) {
242
- return await response.json();
243
- }
244
- return null;
245
- } catch (error) {
246
- console.error('Failed to get file metadata:', error);
247
- return null;
248
- }
264
+ getServerFileUrl(filename) {
265
+ return `/a0/tmp/uploads/${encodeURIComponent(filename)}`;
266
},
267
268
+ // // Get file metadata from server (for device sync and enhanced UI)
269
+ // async getFileMetadata(filename) {
270
+ // try {
271
+ // const response = await fetch(`/image_get?path=/a0/tmp/uploads/${encodeURIComponent(filename)}&metadata=true`);
272
+ // if (response.ok) {
273
+ // return await response.json();
274
+ // }
275
+ // return null;
276
+ // } catch (error) {
277
+ // console.error('Failed to get file metadata:', error);
278
+ // return null;
279
+ // }
280
+ // },
281
+
282
// Check if file is an image based on extension
283
isImageFile(filename) {
253
- const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
254
- const extension = filename.split('.').pop().toLowerCase();
284
+ const imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"];
285
+ const extension = filename.split(".").pop().toLowerCase();
286
return imageExtensions.includes(extension);
287
},
288
289
// Get attachment preview URL (server URL for persistence, blob URL for current session)
290
getAttachmentPreviewUrl(attachment) {
291
// If attachment has a name and we're dealing with a server-stored file
261
- if (typeof attachment === 'string') {
292
+ if (typeof attachment === "string") {
293
// attachment is just a filename (from loaded chat)
263
- return this.getServerFileUrl(attachment);
294
+ return this.getServerImgUrl(attachment);
295
} else if (attachment.name && attachment.file) {
296
// attachment is an object from current session
266
- if (attachment.type === 'image') {
297
+ if (attachment.type === "image") {
298
// For images, use blob URL for current session preview
299
return attachment.url || URL.createObjectURL(attachment.file);
300
} else {
301
// For non-image files, use server URL to get appropriate icon
271
- return this.getServerFileUrl(attachment.name);
302
+ return this.getServerImgUrl(attachment.name);
303
}
304
}
305
return null;
306
},
307
308
+ getFilePreviewUrl(filename) {
309
+ const extension = filename.split(".").pop().toLowerCase();
310
+ const types = {
311
+ // Archive files
312
+ zip: "archive",
313
+ rar: "archive",
314
+ "7z": "archive",
315
+ tar: "archive",
316
+ gz: "archive",
317
+ // Document files
318
+ pdf: "document",
319
+ doc: "document",
320
+ docx: "document",
321
+ txt: "document",
322
+ rtf: "document",
323
+ odt: "document",
324
+ // Code files
325
+ py: "code",
326
+ js: "code",
327
+ html: "code",
328
+ css: "code",
329
+ json: "code",
330
+ xml: "code",
331
+ md: "code",
332
+ yml: "code",
333
+ yaml: "code",
334
+ sql: "code",
335
+ sh: "code",
336
+ bat: "code",
337
+ // Spreadsheet files
338
+ xls: "document",
339
+ xlsx: "document",
340
+ csv: "document",
341
+ // Presentation files
342
+ ppt: "document",
343
+ pptx: "document",
344
+ odp: "document",
345
+ };
346
+ const type = types[extension] || "file";
347
+ return `/public/${type}.svg`;
348
+ },
349
+
350
// Enhanced method to get attachment display info for UI
351
getAttachmentDisplayInfo(attachment) {
279
- if (typeof attachment === 'string') {
352
+ if (typeof attachment === "string") {
353
// attachment is filename only (from persistent storage)
354
const filename = attachment;
282
- const extension = filename.split('.').pop();
355
+ const extension = filename.split(".").pop();
356
+ const isImage = this.isImageFile(filename);
357
+ const previewUrl = isImage
358
+ ? this.getServerImgUrl(filename)
359
+ : this.getFilePreviewUrl(filename);
360
+
361
return {
362
filename: filename,
363
extension: extension.toUpperCase(),
286
- isImage: this.isImageFile(filename),
287
- previewUrl: this.getServerFileUrl(filename),
364
+ isImage: isImage,
365
+ previewUrl: previewUrl,
366
clickHandler: () => {
367
if (this.isImageFile(filename)) {
290
- this.openImageModal(this.getServerFileUrl(filename), filename);
368
+ this.openImageModal(this.getServerImgUrl(filename), filename);
369
+ } else {
370
+ this.downloadAttachment(filename);
371
}
292
- }
372
+ },
373
};
374
} else {
375
// attachment is object (from current session)
376
+ const isImage = this.isImageFile(attachment.name);
377
+ const filename = attachment.name;
378
+ const extension = filename.split(".").pop() || "";
379
+ const previewUrl = isImage
380
+ ? this.getServerImgUrl(attachment.name)
381
+ : this.getFilePreviewUrl(attachment.name);
382
return {
297
- filename: attachment.name,
298
- extension: attachment.extension.toUpperCase(),
299
- isImage: attachment.type === 'image',
300
- previewUrl: this.getAttachmentPreviewUrl(attachment),
383
+ filename: filename,
384
+ extension: extension.toUpperCase(),
385
+ isImage: attachment.type === "image",
386
+ previewUrl: previewUrl,
387
clickHandler: () => {
302
- if (attachment.type === 'image') {
303
- const imageUrl = this.getAttachmentPreviewUrl(attachment);
388
+ if (attachment.type === "image") {
389
+ const imageUrl = this.getServerImgUrl(attachment.name);
390
this.openImageModal(imageUrl, attachment.name);
391
+ } else {
392
+ this.downloadAttachment(attachment.name);
393
}
306
- }
394
+ },
395
};
396
}
397
},
398
399
+ async downloadAttachment(filename) {
400
+ try {
401
+ const path = this.getServerFileUrl(filename);
402
+ const response = await fetchApi("/download_work_dir_file?path=" + path);
403
+
404
+ if (!response.ok) {
405
+ throw new Error("Network response was not ok");
406
+ }
407
+
408
+ const blob = await response.blob();
409
+
410
+ const link = document.createElement("a");
411
+ link.href = window.URL.createObjectURL(blob);
412
+ link.download = filename;
413
+ document.body.appendChild(link);
414
+ link.click();
415
+ document.body.removeChild(link);
416
+ window.URL.revokeObjectURL(link.href);
417
+ } catch (error) {
418
+ window.toastFetchError("Error downloading file", error);
419
+ alert("Error downloading file");
420
+ }
421
+ },
422
+
423
// Generate GUID for unique filenames
424
generateGUID() {
313
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
314
- const r = Math.random() * 16 | 0;
315
- const v = c == 'x' ? r : (r & 0x3 | 0x8);
316
- return v.toString(16);
317
- });
425
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
426
+ /[xy]/g,
427
+ function (c) {
428
+ const r = (Math.random() * 16) | 0;
429
+ const v = c == "x" ? r : (r & 0x3) | 0x8;
430
+ return v.toString(16);
431
+ }
432
+ );
433
},
434
435
// Image modal methods
@@ -324,10 +439,10 @@ const model = {
439
this.imageLoaded = false;
440
this.imageError = false;
441
this.zoomLevel = 1;
327
-
442
+
443
// Open the modal using the modals system
444
if (window.openModal) {
330
- window.openModal('chat/attachments/imageModal.html');
445
+ window.openModal("chat/attachments/imageModal.html");
446
}
447
},
448
@@ -356,15 +471,13 @@ const model = {
471
},
472
473
updateImageZoom() {
359
- const img = document.querySelector('.modal-image');
474
+ const img = document.querySelector(".modal-image");
475
if (img) {
476
img.style.transform = `scale(${this.zoomLevel})`;
477
}
363
- }
478
+ },
479
};
480
366
-console.log('Creating chatAttachments store...');
481
const store = createStore("chatAttachments", model);
368
-console.log('chatAttachments store created:', store);
482
370
-export { store };
\ No newline at end of file
483
+export { store };
webui/components/chat/attachments/dragDropOverlay.html
+14
-15
@@ -2,7 +2,7 @@
2
3
<head>
4
<title>Drag Drop Overlay</title>
5
-
5
+
6
<script type="module">
7
import { store } from "/components/chat/attachments/attachmentsStore.js";
8
</script>
@@ -10,20 +10,19 @@
10
11
<body>
12
<!-- Drag and Drop Overlay -->
13
- <div id="dragdrop-overlay"
14
- x-cloak
15
- x-show="$store.chatAttachments.dragDropOverlayVisible"
16
- x-transition:enter="transition ease-out duration-300"
17
- x-transition:enter-start="opacity-0"
18
- x-transition:enter-end="opacity-100"
19
- x-transition:leave="transition ease-in duration-300"
20
- x-transition:leave-start="opacity-100"
21
- x-transition:leave-end="opacity-0"
22
- class="dragdrop-overlay">
23
- <img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
24
- <div class="dragdrop-text">Drop files to attach them to your message</div>
25
- <div class="dragdrop-subtext"></div>
13
+ <div x-data>
14
+ <template x-if="$store.chatAttachments">
15
+ <div x-cloak x-show="$store.chatAttachments.dragDropOverlayVisible" id="dragdrop-overlay"
16
+ x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
17
+ x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-300"
18
+ x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="dragdrop-overlay">
19
+ <img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
20
+ <div class="dragdrop-text">Drop files to attach them to your message</div>
21
+ <div class="dragdrop-subtext"></div>
22
+ </div>
23
+ </template>
24
</div>
25
+
26
</body>
27
29
-</html>
\ No newline at end of file
28
+</html>
\ No newline at end of file
webui/components/chat/attachments/inputPreview.html
new
+29
@@ -0,0 +1,29 @@
1
+<script type="module">
2
+ import { store } from "/components/chat/attachments/attachmentsStore.js";
3
+</script>
4
+
5
+<div x-data>
6
+ <template x-if="$store.chatAttachments">
7
+
8
+ <div x-show="$store.chatAttachments.hasAttachments" class="preview-section">
9
+ <template x-for="(attachment, index) in $store.chatAttachments.attachments" :key="index">
10
+ <div class="attachment-item"
11
+ :class="{'image-type': attachment.type === 'image', 'file-type': attachment.type === 'file'}">
12
+ <template x-if="attachment.type === 'image'">
13
+ <img :src="attachment.url" class="attachment-preview" :alt="attachment.name" style="cursor: pointer;"
14
+ @click="$store.chatAttachments.openImageModal(attachment.url, attachment.name)">
15
+ </template>
16
+ <template x-if="attachment.type === 'file'">
17
+ <div>
18
+ <img :src="attachment.displayInfo.previewUrl" class="file-icon" :alt="attachment.extension">
19
+ <span class="file-title" x-text="attachment.name"></span>
20
+ </div>
21
+ </template>
22
+ <button @click="$store.chatAttachments.removeAttachment(index)"
23
+ class="remove-attachment">×</button>
24
+ </div>
25
+ </template>
26
+ </div>
27
+
28
+ </template>
29
+</div>
\ No newline at end of file
webui/index.css
+32
-32
@@ -106,6 +106,16 @@ body,
106
color: var(--color-text);
107
}
108
109
+img {
110
+ user-drag: none; /* Safari & old Chrome */
111
+ -webkit-user-drag: none;
112
+ user-select: none; /* Prevent selection for good measure */
113
+ -webkit-user-select: none;
114
+ -moz-user-select: none;
115
+ -ms-user-select: none;
116
+ pointer-events: auto; /* Allow clicks, just no drag */
117
+}
118
+
119
/* Layout */
120
.container {
121
display: -webkit-flex;
@@ -555,6 +565,12 @@ pre {
565
background-color: var(--color-input);
566
border-radius: 8px;
567
margin-bottom: var(--spacing-xs);
568
+ max-height: 10em;
569
+ overflow-y: auto;
570
+}
571
+
572
+.light-mode .preview-section {
573
+ background-color: none;
574
}
575
576
.preview-item {
@@ -910,7 +926,7 @@ pre {
926
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
927
gap: 12px;
928
padding: var(--spacing-sm);
913
- background-color: var(--color-input);
929
+ /* background-color: var(--color-input); */
930
border-radius: 8px;
931
max-width: 600px; /* Limits to ~5 columns at 120px each */
932
overflow: visible;
@@ -932,12 +948,21 @@ pre {
948
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
949
}
950
951
+.light-mode .attachment-item {
952
+ background-color: var(--color-panel-light);
953
+ border: 2px solid var(--color-border-light);
954
+}
955
+
956
.attachment-item:hover {
957
border-color: var(--color-primary);
958
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
959
transform: translateY(-2px);
960
}
961
962
+.light-mode .attachment-item:hover {
963
+ border-color: var(--color-primary-light);
964
+}
965
+
966
/* Image attachment styling */
967
.attachment-item.image-type {
968
padding: 0;
@@ -950,21 +975,6 @@ pre {
975
border-radius: 10px;
976
}
977
953
-.attachment-item.image-type .image-badge {
954
- position: absolute;
955
- bottom: 6px;
956
- left: 50%;
957
- transform: translateX(-50%);
958
- background: rgba(0, 0, 0, 0.7);
959
- color: white;
960
- padding: 2px 8px;
961
- border-radius: 12px;
962
- font-size: 0.65rem;
963
- text-transform: uppercase;
964
- backdrop-filter: blur(4px);
965
- -webkit-backdrop-filter: blur(4px);
966
-}
967
-
978
/* File attachment styling */
979
.attachment-item.file-type {
980
flex-direction: column;
@@ -994,16 +1004,6 @@ pre {
1004
margin-bottom: 4px;
1005
}
1006
997
-.attachment-item.file-type .file-extension {
998
- background: var(--color-primary);
999
- color: var(--color-text);
1000
- padding: 2px 6px;
1001
- border-radius: 4px;
1002
- font-size: 0.7rem;
1003
- text-transform: uppercase;
1004
- white-space: nowrap;
1005
-}
1006
-
1007
.attachment-preview {
1008
width: 100%;
1009
height: 100%;
@@ -1112,8 +1112,8 @@ pre {
1112
1113
.remove-attachment {
1114
position: absolute;
1115
- top: -6px;
1116
- right: -6px;
1115
+ top: 5px;
1116
+ right: 5px;
1117
background-color: var(--color-primary);
1118
color: white;
1119
border: none;
@@ -1296,11 +1296,11 @@ pre {
1296
background-color: var(--color-background);
1297
}
1298
1299
-.preview-item img {
1299
+.image-preview img {
1300
max-height: 100px;
1301
object-fit: cover;
1302
border-radius: 8px;
1303
- border: 1px solid var(--color-border-light);
1303
+ /* border: 1px solid var(--color-border-light); */
1304
}
1305
1306
.file-preview {
@@ -1544,13 +1544,13 @@ input:checked + .slider:before {
1544
padding: 1px 4px;
1545
}
1546
1547
- .remove-attachment,
1547
+ /* .remove-attachment,
1548
.remove-image {
1549
width: 20px;
1550
height: 20px;
1551
top: 2px;
1552
right: 2px;
1553
- }
1553
+ } */
1554
1555
.attachment-item.image-type .image-badge {
1556
bottom: 4px;
webui/index.html
+6
-23
@@ -102,7 +102,6 @@
102
<script type="module" src="js/scheduler.js"></script>
103
<script type="module" src="js/speech.js"></script>
104
<script type="module" src="js/history.js"></script>
105
- <script type="module" src="components/chat/attachments/attachmentsStore.js"></script>
105
<script type="module" src="index.js"></script>
106
107
<!-- Then load Alpine.js -->
@@ -128,7 +127,7 @@
127
<script type="text/javascript" src="js/settings.js"></script>
128
<script type="text/javascript" src="js/file_browser.js"></script>
129
<script type="text/javascript" src="js/modal.js"></script>
131
- <script type="text/javascript" src="js/tunnel.js"></script>
130
+ <script type="module" src="js/tunnel.js"></script>
131
</head>
132
133
<body class="dark-mode">
@@ -364,7 +363,7 @@
363
</div>
364
<div id="progress-bar-box" x-data="{ isSpeaking: false }" x-init="
365
// Watch the speech synthesis status to update isSpeaking
367
- setInterval(() => isSpeaking = window.speech.isSpeaking(), 100)
366
+ setInterval(() => isSpeaking = window.speech && window.speech.isSpeaking(), 1000)
367
">
368
<h4 id="progress-bar-h">
369
<span id="progress-bar-i">|></span><span id="progress-bar"></span>
@@ -377,27 +376,11 @@
376
paused: false
377
}">
378
380
- <!-- Preview section -->
381
- <div x-show="$store.chatAttachments.hasAttachments" class="preview-section">
382
- <template x-for="(attachment, index) in $store.chatAttachments.attachments" :key="index">
383
- <div class="preview-item" :class="{'image-preview': attachment.type === 'image'}">
384
- <template x-if="attachment.type === 'image'">
385
- <img :src="attachment.url" :alt="attachment.name"
386
- style="cursor: pointer;"
387
- @click="$store.chatAttachments.openImageModal(attachment.url, attachment.name)">
388
- </template>
389
- <template x-if="attachment.type === 'file'">
390
- <div class="file-preview">
391
- <span class="filename" x-text="attachment.name"></span>
392
- <span class="extension" x-text="attachment.extension.toUpperCase()"></span>
393
- </div>
394
- </template>
395
- <button @click="$store.chatAttachments.removeAttachment(index)"
396
- class="remove-attachment">×</button>
397
- </div>
398
- </template>
379
+ <!-- Attachment Preview section -->
380
+ <div>
381
+ <x-component path="/chat/attachments/inputPreview.html" />
382
</div>
400
-
383
+
384
<!-- Top row with input and buttons -->
385
<div class="input-row">
386
<!-- Attachment icon with tooltip -->
webui/index.js
+152
-126
@@ -2,6 +2,7 @@ import * as msgs from "./js/messages.js";
2
import { speech } from "./js/speech.js";
3
import * as api from "./js/api.js";
4
import * as css from "./js/css.js";
5
+import { store as attachmentsStore } from "./components/chat/attachments/attachmentsStore.js";
6
7
window.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
8
@@ -91,19 +92,19 @@ function setupSidebarToggle() {
92
document.addEventListener("DOMContentLoaded", setupSidebarToggle);
93
94
export async function sendMessage() {
94
- try {
95
- const message = chatInput.value.trim();
96
- const attachmentsStore = window.Alpine ? Alpine.store('chatAttachments') : null;
97
- const attachments = attachmentsStore ? attachmentsStore.attachments : [];
98
- const hasAttachments = attachmentsStore ? attachmentsStore.hasAttachments : false;
95
+ try {
96
+ const message = chatInput.value.trim();
97
+ // const attachmentsStore = attachmentsStore; //window.Alpine ? Alpine.store('chatAttachments') : null;
98
+ const attachments = attachmentsStore.attachments; // attachmentsStore ? attachmentsStore.attachments : [];
99
+ const hasAttachments = attachmentsStore.hasAttachments; // attachmentsStore ? attachmentsStore.hasAttachments : false;
100
101
if (message || hasAttachments) {
102
let response;
103
const messageId = generateGUID();
104
104
- // Include attachments in the user message
105
- if (hasAttachments) {
106
- const attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
105
+ // Include attachments in the user message
106
+ if (hasAttachments) {
107
+ const attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
108
109
// Render user message with attachments
110
setMessage(messageId, "user", "", message, false, {
@@ -155,16 +156,16 @@ export async function sendMessage() {
156
setContext(jsonResponse.context);
157
}
158
158
- // Clear input and attachments
159
- chatInput.value = '';
160
- if (attachmentsStore) {
161
- attachmentsStore.clearAttachments();
162
- }
163
- adjustTextareaHeight();
164
- }
165
- } catch (e) {
166
- toastFetchError("Error sending message", e)
159
+ // Clear input and attachments
160
+ chatInput.value = "";
161
+ // if (attachmentsStore) {
162
+ attachmentsStore.clearAttachments();
163
+ // }
164
+ adjustTextareaHeight();
165
}
166
+ } catch (e) {
167
+ toastFetchError("Error sending message", e);
168
+ }
169
}
170
171
function toastFetchError(text, error) {
@@ -309,16 +310,16 @@ function getConnectionStatus() {
310
}
311
312
function setConnectionStatus(connected) {
312
- connectionStatus = connected
313
- if (window.Alpine && timeDate) {
314
- const statusIconEl = timeDate.querySelector('.status-icon');
315
- if (statusIconEl) {
316
- const statusIcon = Alpine.$data(statusIconEl);
317
- if (statusIcon) {
318
- statusIcon.connected = connected;
319
- }
320
- }
313
+ connectionStatus = connected;
314
+ if (window.Alpine && timeDate) {
315
+ const statusIconEl = timeDate.querySelector(".status-icon");
316
+ if (statusIconEl) {
317
+ const statusIcon = Alpine.$data(statusIconEl);
318
+ if (statusIcon) {
319
+ statusIcon.connected = connected;
320
+ }
321
}
322
+ }
323
}
324
325
let lastLogVersion = 0;
@@ -372,60 +373,60 @@ async function poll() {
373
374
updateProgress(response.log_progress, response.log_progress_active);
375
375
- //set ui model vars from backend
376
- if (window.Alpine && inputSection) {
377
- const inputAD = Alpine.$data(inputSection);
378
- if (inputAD) {
379
- inputAD.paused = response.paused;
380
- }
381
- }
376
+ //set ui model vars from backend
377
+ if (window.Alpine && inputSection) {
378
+ const inputAD = Alpine.$data(inputSection);
379
+ if (inputAD) {
380
+ inputAD.paused = response.paused;
381
+ }
382
+ }
383
384
// Update status icon state
385
setConnectionStatus(true);
386
386
- // Update chats list and sort by created_at time (newer first)
387
- let chatsAD = null;
388
- let contexts = response.contexts || [];
389
- if (window.Alpine && chatsSection) {
390
- chatsAD = Alpine.$data(chatsSection);
391
- if (chatsAD) {
392
- chatsAD.contexts = contexts.sort((a, b) =>
393
- (b.created_at || 0) - (a.created_at || 0)
394
- );
395
- }
396
- }
387
+ // Update chats list and sort by created_at time (newer first)
388
+ let chatsAD = null;
389
+ let contexts = response.contexts || [];
390
+ if (window.Alpine && chatsSection) {
391
+ chatsAD = Alpine.$data(chatsSection);
392
+ if (chatsAD) {
393
+ chatsAD.contexts = contexts.sort(
394
+ (a, b) => (b.created_at || 0) - (a.created_at || 0)
395
+ );
396
+ }
397
+ }
398
+
399
+ // Update tasks list and sort by creation time (newer first)
400
+ const tasksSection = document.getElementById("tasks-section");
401
+ if (window.Alpine && tasksSection) {
402
+ const tasksAD = Alpine.$data(tasksSection);
403
+ if (tasksAD) {
404
+ let tasks = response.tasks || [];
405
+
406
+ // Always update tasks to ensure state changes are reflected
407
+ if (tasks.length > 0) {
408
+ // Sort the tasks by creation time
409
+ const sortedTasks = [...tasks].sort(
410
+ (a, b) => (b.created_at || 0) - (a.created_at || 0)
411
+ );
412
398
- // Update tasks list and sort by creation time (newer first)
399
- const tasksSection = document.getElementById('tasks-section');
400
- if (window.Alpine && tasksSection) {
401
- const tasksAD = Alpine.$data(tasksSection);
402
- if (tasksAD) {
403
- let tasks = response.tasks || [];
404
-
405
- // Always update tasks to ensure state changes are reflected
406
- if (tasks.length > 0) {
407
- // Sort the tasks by creation time
408
- const sortedTasks = [...tasks].sort((a, b) =>
409
- (b.created_at || 0) - (a.created_at || 0)
410
- );
411
-
412
- // Assign the sorted tasks to the Alpine data
413
- tasksAD.tasks = sortedTasks;
414
- } else {
415
- // Make sure to use a new empty array instance
416
- tasksAD.tasks = [];
417
- }
418
- }
413
+ // Assign the sorted tasks to the Alpine data
414
+ tasksAD.tasks = sortedTasks;
415
+ } else {
416
+ // Make sure to use a new empty array instance
417
+ tasksAD.tasks = [];
418
}
419
+ }
420
+ }
421
422
// Make sure the active context is properly selected in both lists
423
if (context) {
424
// Update selection in the active tab
425
const activeTab = localStorage.getItem("activeTab") || "chats";
426
426
- if (activeTab === 'chats' && chatsAD) {
427
- chatsAD.selected = context;
428
- localStorage.setItem('lastSelectedChat', context);
427
+ if (activeTab === "chats" && chatsAD) {
428
+ chatsAD.selected = context;
429
+ localStorage.setItem("lastSelectedChat", context);
430
431
// Check if this context exists in the chats list
432
const contextExists = contexts.some((ctx) => ctx.id === context);
@@ -451,26 +452,34 @@ async function poll() {
452
// Check if this context exists in the tasks list
453
const taskExists = response.tasks?.some((task) => task.id === context);
454
454
- // If it doesn't exist in the tasks list but we're in tasks tab, try to select the first task
455
- if (!taskExists && response.tasks?.length > 0) {
456
- const firstTaskId = response.tasks[0].id;
457
- setContext(firstTaskId);
458
- tasksAD.selected = firstTaskId;
459
- localStorage.setItem('lastSelectedTask', firstTaskId);
460
- }
461
- }
462
- } else if (response.tasks && response.tasks.length > 0 && localStorage.getItem('activeTab') === 'tasks') {
463
- // If we're in tasks tab with no selection but have tasks, select the first one
464
- const firstTaskId = response.tasks[0].id;
465
- setContext(firstTaskId);
466
- if (tasksSection) {
467
- const tasksAD = Alpine.$data(tasksSection);
468
- tasksAD.selected = firstTaskId;
469
- localStorage.setItem('lastSelectedTask', firstTaskId);
470
- }
471
- } else if (contexts.length > 0 && localStorage.getItem('activeTab') === 'chats' && chatsAD) {
472
- // If we're in chats tab with no selection but have chats, select the first one
473
- const firstChatId = contexts[0].id;
455
+ // If it doesn't exist in the tasks list but we're in tasks tab, try to select the first task
456
+ if (!taskExists && response.tasks?.length > 0) {
457
+ const firstTaskId = response.tasks[0].id;
458
+ setContext(firstTaskId);
459
+ tasksAD.selected = firstTaskId;
460
+ localStorage.setItem("lastSelectedTask", firstTaskId);
461
+ }
462
+ }
463
+ } else if (
464
+ response.tasks &&
465
+ response.tasks.length > 0 &&
466
+ localStorage.getItem("activeTab") === "tasks"
467
+ ) {
468
+ // If we're in tasks tab with no selection but have tasks, select the first one
469
+ const firstTaskId = response.tasks[0].id;
470
+ setContext(firstTaskId);
471
+ if (tasksSection) {
472
+ const tasksAD = Alpine.$data(tasksSection);
473
+ tasksAD.selected = firstTaskId;
474
+ localStorage.setItem("lastSelectedTask", firstTaskId);
475
+ }
476
+ } else if (
477
+ contexts.length > 0 &&
478
+ localStorage.getItem("activeTab") === "chats" &&
479
+ chatsAD
480
+ ) {
481
+ // If we're in chats tab with no selection but have chats, select the first one
482
+ const firstChatId = contexts[0].id;
483
484
// Only set context if we don't already have one to avoid duplicates
485
if (!context) {
@@ -500,12 +509,26 @@ function speakMessages(logs) {
509
// log.no, log.type, log.heading, log.content
510
for (let i = logs.length - 1; i >= 0; i--) {
511
const log = logs[i];
503
- if (log.type == "response") {
512
+ // finished response
513
+ if (log.type == "response" && log.kvps && log.kvps.finished) {
514
if (log.no > lastSpokenNo) {
515
lastSpokenNo = log.no;
516
speech.speak(log.content);
517
return;
518
}
519
+ // finished LLM headline, not response
520
+ } else if (
521
+ log.type == "agent" &&
522
+ log.kvps &&
523
+ log.kvps.headline &&
524
+ log.kvps.tool_args &&
525
+ log.kvps.tool_name != "response"
526
+ ) {
527
+ if (log.no > lastSpokenNo) {
528
+ lastSpokenNo = log.no;
529
+ speech.speak(log.kvps.headline);
530
+ return;
531
+ }
532
}
533
}
534
}
@@ -700,18 +723,18 @@ export const setContext = function (id) {
723
// Clear the chat history immediately to avoid showing stale content
724
chatHistory.innerHTML = "";
725
703
- // Update both selected states
704
- if (window.Alpine) {
705
- if (chatsSection) {
706
- const chatsAD = Alpine.$data(chatsSection);
707
- if (chatsAD) chatsAD.selected = id;
708
- }
709
- if (tasksSection) {
710
- const tasksAD = Alpine.$data(tasksSection);
711
- if (tasksAD) tasksAD.selected = id;
712
- }
726
+ // Update both selected states
727
+ if (window.Alpine) {
728
+ if (chatsSection) {
729
+ const chatsAD = Alpine.$data(chatsSection);
730
+ if (chatsAD) chatsAD.selected = id;
731
}
714
-}
732
+ if (tasksSection) {
733
+ const tasksAD = Alpine.$data(tasksSection);
734
+ if (tasksAD) tasksAD.selected = id;
735
+ }
736
+ }
737
+};
738
739
export const getContext = function () {
740
return context;
@@ -1020,13 +1043,13 @@ function hideToast() {
1043
}
1044
1045
function scrollChanged(isAtBottom) {
1023
- if (window.Alpine && autoScrollSwitch) {
1024
- const inputAS = Alpine.$data(autoScrollSwitch);
1025
- if (inputAS) {
1026
- inputAS.autoScroll = isAtBottom;
1027
- }
1046
+ if (window.Alpine && autoScrollSwitch) {
1047
+ const inputAS = Alpine.$data(autoScrollSwitch);
1048
+ if (inputAS) {
1049
+ inputAS.autoScroll = isAtBottom;
1050
}
1029
- // autoScrollSwitch.checked = isAtBottom
1051
+ }
1052
+ // autoScrollSwitch.checked = isAtBottom
1053
}
1054
1055
function updateAfterScroll() {
@@ -1077,16 +1100,17 @@ document.addEventListener("DOMContentLoaded", startPolling);
1100
// Drag and drop functionality has been moved to attachmentsStore.js
1101
1102
// Update handleFileUpload to use the attachments store
1080
-window.handleFileUpload = function(event) {
1081
- console.log('handleFileUpload called with files:', event.target.files.length);
1082
- const files = event.target.files;
1083
- if (window.Alpine && Alpine.store('chatAttachments')) {
1084
- console.log('Calling store handleFiles...');
1085
- Alpine.store('chatAttachments').handleFiles(files);
1086
- } else {
1087
- console.error('Alpine or chatAttachments store not found!');
1088
- }
1089
-}
1103
+window.handleFileUpload = function (event) {
1104
+ // console.log('handleFileUpload called with files:', event.target.files.length);
1105
+ const files = event.target.files;
1106
+ // if (window.Alpine && Alpine.store('chatAttachments')) {
1107
+ // console.log('Calling store handleFiles...');
1108
+ // Alpine.store('chatAttachments').handleFiles(files);
1109
+ // } else {
1110
+ // console.error('Alpine or chatAttachments store not found!');
1111
+ // }
1112
+ attachmentsStore.handleFiles(files);
1113
+};
1114
1115
// Setup event handlers once the DOM is fully loaded
1116
document.addEventListener("DOMContentLoaded", function () {
@@ -1145,9 +1169,9 @@ function activateTab(tabName) {
1169
chatsTab.classList.add("active");
1170
chatsSection.style.display = "";
1171
1148
- // Get the available contexts from Alpine.js data
1149
- const chatsAD = window.Alpine ? Alpine.$data(chatsSection) : null;
1150
- const availableContexts = chatsAD?.contexts || [];
1172
+ // Get the available contexts from Alpine.js data
1173
+ const chatsAD = window.Alpine ? Alpine.$data(chatsSection) : null;
1174
+ const availableContexts = chatsAD?.contexts || [];
1175
1176
// Restore previous chat selection
1177
const lastSelectedChat = localStorage.getItem("lastSelectedChat");
@@ -1169,9 +1193,9 @@ function activateTab(tabName) {
1193
tasksSection.style.display = "flex";
1194
tasksSection.style.flexDirection = "column";
1195
1172
- // Get the available tasks from Alpine.js data
1173
- const tasksAD = window.Alpine ? Alpine.$data(tasksSection) : null;
1174
- const availableTasks = tasksAD?.tasks || [];
1196
+ // Get the available tasks from Alpine.js data
1197
+ const tasksAD = window.Alpine ? Alpine.$data(tasksSection) : null;
1198
+ const availableTasks = tasksAD?.tasks || [];
1199
1200
// Restore previous task selection
1201
const lastSelectedTask = localStorage.getItem("lastSelectedTask");
@@ -1236,8 +1260,8 @@ function openTaskDetail(taskId) {
1260
return;
1261
}
1262
1239
- // Get the Alpine.js data for the modal
1240
- const modalData = window.Alpine ? Alpine.$data(modalEl) : null;
1263
+ // Get the Alpine.js data for the modal
1264
+ const modalData = window.Alpine ? Alpine.$data(modalEl) : null;
1265
1266
// Use a timeout to ensure the modal is fully rendered
1267
setTimeout(() => {
@@ -1255,8 +1279,10 @@ function openTaskDetail(taskId) {
1279
return;
1280
}
1281
1258
- // Get the Alpine.js data for the scheduler component
1259
- const schedulerData = window.Alpine ? Alpine.$data(schedulerComponent) : null;
1282
+ // Get the Alpine.js data for the scheduler component
1283
+ const schedulerData = window.Alpine
1284
+ ? Alpine.$data(schedulerComponent)
1285
+ : null;
1286
1287
// Show the task detail view for the specific task
1288
schedulerData.showTaskDetail(taskId);
webui/js/AlpineStore.js
+2
@@ -18,6 +18,8 @@ export function createStore(name, initialState) {
18
return true;
19
},
20
get(target, prop) {
21
+ const store = globalThis.Alpine?.store(name);
22
+ if (store) return store[prop];
23
return target[prop];
24
}
25
});
webui/js/messages.js
+8
-114
@@ -1,8 +1,9 @@
1
// copy button
2
import { openImageModal } from "./image_modal.js";
3
import { marked } from "../vendor/marked/marked.esm.js";
4
-import { store as messageResizeStore } from "/components/messages/resize/message-resize-store.js";
4
import { getAutoScroll } from "/index.js";
5
+import { store as _messageResizeStore } from "/components/messages/resize/message-resize-store.js"; // keep here, required in html
6
+import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
7
8
const chatHistory = document.getElementById("chat-history");
9
@@ -394,64 +395,9 @@ export function drawMessageUser(
395
const attachmentDiv = document.createElement("div");
396
attachmentDiv.classList.add("attachment-item");
397
397
- // Get attachment store for enhanced device sync support
398
- const attachmentStore = window.Alpine && window.Alpine.store('chatAttachments');
399
-
400
- // Helper function to generate server-side image URL (fallback if store not available)
401
- const getServerImageUrl = (filename) => {
402
- if (attachmentStore) {
403
- return attachmentStore.getServerFileUrl(filename);
404
- }
405
- return `/image_get?path=/a0/tmp/uploads/${encodeURIComponent(filename)}`;
406
- };
407
-
408
- // Helper function to check if file is an image (fallback if store not available)
409
- const isImageFile = (filename) => {
410
- if (attachmentStore) {
411
- return attachmentStore.isImageFile(filename);
412
- }
413
- const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
414
- const extension = filename.split('.').pop().toLowerCase();
415
- return imageExtensions.includes(extension);
416
- };
417
-
418
- // Use enhanced attachment store methods for better device sync
419
- let displayInfo;
420
- if (attachmentStore) {
421
- displayInfo = attachmentStore.getAttachmentDisplayInfo(attachment);
422
- } else {
423
- // Fallback for when store is not available
424
- if (typeof attachment === "string") {
425
- const filename = attachment;
426
- const extension = filename.split(".").pop();
427
- displayInfo = {
428
- filename: filename,
429
- extension: extension.toUpperCase(),
430
- isImage: isImageFile(filename),
431
- previewUrl: getServerImageUrl(filename),
432
- clickHandler: () => {
433
- if (isImageFile(filename) && window.Alpine && window.Alpine.store('chatAttachments')) {
434
- window.Alpine.store('chatAttachments').openImageModal(getServerImageUrl(filename), filename);
435
- }
436
- }
437
- };
438
- } else {
439
- displayInfo = {
440
- filename: attachment.name,
441
- extension: attachment.extension.toUpperCase(),
442
- isImage: attachment.type === 'image',
443
- previewUrl: attachment.url,
444
- clickHandler: () => {
445
- if (attachment.type === 'image' && window.Alpine && window.Alpine.store('chatAttachments')) {
446
- window.Alpine.store('chatAttachments').openImageModal(attachment.url, attachment.name);
447
- }
448
- }
449
- };
450
- }
451
- }
398
+ const displayInfo = attachmentsStore.getAttachmentDisplayInfo(attachment);
399
400
if (displayInfo.isImage) {
454
- // Render as image tile with bottom badge
401
attachmentDiv.classList.add("image-type");
402
403
const img = document.createElement("img");
@@ -459,14 +405,9 @@ export function drawMessageUser(
405
img.alt = displayInfo.filename;
406
img.classList.add("attachment-preview");
407
img.style.cursor = "pointer";
462
- img.addEventListener('click', displayInfo.clickHandler);
408
464
- const imageBadge = document.createElement("div");
465
- imageBadge.classList.add("image-badge");
466
- imageBadge.textContent = displayInfo.extension;
409
410
attachmentDiv.appendChild(img);
469
- attachmentDiv.appendChild(imageBadge);
411
} else {
412
// Render as file tile with title and icon
413
attachmentDiv.classList.add("file-type");
@@ -480,22 +421,15 @@ export function drawMessageUser(
421
attachmentDiv.appendChild(iconImg);
422
}
423
483
- // File title (filename without extension)
424
+ // File title
425
const fileTitle = document.createElement("div");
426
fileTitle.classList.add("file-title");
486
- const nameWithoutExt = displayInfo.filename.replace(/\.[^/.]+$/, "");
487
- fileTitle.textContent = nameWithoutExt;
488
-
489
- // File extension badge
490
- const fileExtension = document.createElement("div");
491
- fileExtension.classList.add("file-extension");
492
- fileExtension.textContent = displayInfo.extension;
493
-
427
+ fileTitle.textContent = displayInfo.filename;
428
+
429
attachmentDiv.appendChild(fileTitle);
495
- attachmentDiv.appendChild(fileExtension);
430
}
431
498
-
432
+ attachmentDiv.addEventListener('click', displayInfo.clickHandler);
433
434
attachmentsContainer.appendChild(attachmentDiv);
435
});
@@ -743,8 +677,6 @@ function drawKvps(container, kvps, latex) {
677
});
678
} else {
679
const pre = document.createElement("pre");
746
- // pre.classList.add("kvps-val");
747
- // if (row.classList.contains("msg-thoughts")) {
680
const span = document.createElement("span");
681
span.innerHTML = convertHTML(value);
682
pre.appendChild(span);
@@ -766,17 +698,7 @@ function drawKvps(container, kvps, latex) {
698
}
699
}
700
}
769
- // } else {
770
- // pre.textContent = value;
701
772
- // // Add click handler
773
- // pre.addEventListener("click", () => {
774
- // copyText(value, pre);
775
- // });
776
-
777
- // td.appendChild(pre);
778
- // addCopyButtonToElement(row);
779
- // }
702
}
703
container.appendChild(table);
704
}
@@ -896,32 +818,4 @@ function adjustMarkdownRender(element) {
818
el.parentNode.insertBefore(wrapper, el);
819
wrapper.appendChild(el);
820
});
899
-}
900
-
901
-// function convertPathsToLinksInHtml(htmlString) {
902
-// // 1. Parse the input safely
903
-// const wrapper = document.createElement("div");
904
-// wrapper.innerHTML = htmlString;
905
-
906
-// // 2. Depth-first walk
907
-// function walk(node) {
908
-// // Skip <script> and <style> blocks entirely
909
-// if (node.nodeName === "SCRIPT" || node.nodeName === "STYLE") return;
910
-
911
-// if (node.nodeType === Node.TEXT_NODE) {
912
-// const original = node.nodeValue;
913
-// const replaced = convertPathsToLinks(original);
914
-// if (replaced !== original) {
915
-// // Turn the replacement HTML string into real nodes
916
-// const frag = document.createRange().createContextualFragment(replaced);
917
-// node.replaceWith(frag);
918
-// }
919
-// } else {
920
-// // Recurse into children
921
-// for (const child of Array.from(node.childNodes)) walk(child);
922
-// }
923
-// }
924
-
925
-// walk(wrapper);
926
-// return wrapper.innerHTML;
927
-// }
821
+}
\ No newline at end of file
webui/js/speech.js
+21
-10
@@ -1,5 +1,6 @@
1
// import { pipeline, read_audio } from '../transformers@3.0.2.js';
2
import { updateChatInput, sendMessage } from "../index.js";
3
+import { fetchApi } from "./api.js";
4
5
const microphoneButton = document.getElementById("microphone-button");
6
let microphoneInput = null;
@@ -400,24 +401,34 @@ class Speech {
401
}
402
403
stripEmojis(str) {
403
- return str
404
- .replace(
405
- /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
406
- ""
407
- )
408
- .replace(/\s+/g, " ")
409
- .trim();
404
+ // Remove emojis
405
+ return str.replace(
406
+ /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
407
+ ""
408
+ );
409
+ }
410
+
411
+ normalizeWhitespace(str) {
412
+ // Replace multiple spaces with a single space while preserving newlines
413
+ str = str.split('\n').map(line => {
414
+ return line.replace(/\s+/g, ' ').trim();
415
+ }).join('\n');
416
+
417
+ return str.trim();
418
}
419
420
speak(text) {
413
- console.log("Speaking:", text);
421
+ console.log("Starting speak:", text);
422
// Stop any current utterance
423
this.stop();
424
417
- // Remove emojis and create a new utterance
418
- text = this.stripEmojis(text);
425
+ // Process text for speech synthesis
426
+ text = this.stripEmojis(text); // remove emojis
427
+ text = this.normalizeWhitespace(text); // normalize whitespace while preserving newlines
428
text = this.replaceURLs(text);
429
text = this.replaceGuids(text);
430
+
431
+ console.log("Speaking text:", text);
432
this.utterance = new SpeechSynthesisUtterance(text);
433
434
// Speak the new utterance
webui/js/tunnel.js
+4
@@ -1,3 +1,7 @@
1
+
2
+
3
+import { fetchApi } from "./api.js";
4
+
5
// Tunnel settings for the Settings modal
6
document.addEventListener('alpine:init', () => {
7
Alpine.data('tunnelSettings', () => ({