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">&times;</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">&times;</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', () => ({