feature: attachments preview and sending (file, code, imgs)
Alessandro committed
Nov 12, 2024 at 15:02 UTC
a57f0c11988b06efdc6b8da8dad44969996d4b92
8 files changed
+649
-329
python/extensions/message_loop_prompts/_30_include_attachments.py
+35
-48
@@ -1,53 +1,40 @@
1
+# python/extensions/monologue_start/include_attachments.py
2
from python.helpers.extension import Extension
3
+from python.helpers.attachment_manager import AttachmentManager
4
from agent import Agent, LoopData
5
import os
4
-import io
5
-import base64
6
-from PIL import Image
6
7
class IncludeAttachments(Extension):
9
- async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10
- # Check if there are attachments in agent data
11
- attachments = self.agent.get_data('attachments') or []
12
- if attachments:
13
- loop_data.attachments = [] # Initialize attachments list for loop_data
14
-
15
- # For each attachment, compress and encode the image
16
- for attachment_path in attachments:
17
- if os.path.exists(attachment_path):
18
- # Prepare the base64-encoded image
19
- compressed_image_base64 = self.compress_and_encode_image(attachment_path)
20
- if compressed_image_base64:
21
- # Append the image data to loop_data.attachments
22
- loop_data.attachments.append(f"<image>{compressed_image_base64}</image>")
23
-
24
- # Clear attachments from agent data
25
- self.agent.set_data('attachments', [])
26
-
27
- def compress_and_encode_image(self, image_path: str) -> str:
28
- try:
29
- # Open an image file
30
- with Image.open(image_path) as img:
31
- # Convert image to RGB if it's in RGBA mode
32
- if img.mode in ('RGBA', 'P'):
33
- img = img.convert('RGB')
34
-
35
- # Resize the image to a reasonable size
36
- max_dimension = 800 # You can adjust this value
37
- img.thumbnail((max_dimension, max_dimension))
38
-
39
- # Compress the image
40
- buffered = io.BytesIO()
41
- # Save as JPEG to ensure compression; you can adjust quality
42
- img.save(buffered, format="JPEG", quality=70, optimize=True)
43
- compressed_image = buffered.getvalue()
44
-
45
- # Encode the compressed image in base64
46
- return base64.b64encode(compressed_image).decode('utf-8')
47
- except Exception as e:
48
- print(f"Error compressing and encoding image {image_path}: {e}")
49
- return ""
50
-
51
- def estimate_token_count(self, message: str) -> int:
52
- # Simple estimation: assume 4 characters per token
53
- return len(message) // 4
8
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
9
+ attachments = self.agent.get_data('attachments') or []
10
+ if attachments:
11
+ loop_data.attachments = []
12
+ file_manager = AttachmentManager(os.path.join(os.getcwd(), 'work_dir'))
13
+
14
+ for attachment in attachments:
15
+ if os.path.exists(attachment):
16
+ filename = os.path.basename(attachment)
17
+ file_type = file_manager.get_file_type(filename)
18
+
19
+ attachment_html = f'<div class="attachment-item attachment-{file_type}">'
20
+ if file_type == 'image':
21
+ preview = file_manager.generate_image_preview(attachment)
22
+ if preview:
23
+ attachment_html += f'<img src="data:image/jpeg;base64,{preview}" alt="{filename}" class="attachment-preview"/>'
24
+ else:
25
+ # Add placeholder for non-image files
26
+ attachment_html += f'<div class="attachment-placeholder">{file_type.upper()}</div>'
27
+
28
+ # Add filename and extension badge
29
+ ext = file_manager.get_file_extension(filename)
30
+ attachment_html += f'''
31
+ <div class="attachment-info">
32
+ <span class="attachment-name">{filename}</span>
33
+ <span class="attachment-badge">{ext}</span>
34
+ </div>
35
+ </div>'''
36
+
37
+ loop_data.attachments.append(attachment_html)
38
+
39
+ # Clear attachments after processing
40
+ self.agent.set_data('attachments', [])
\ No newline at end of file
python/helpers/attachment_manager.py
new
+91
@@ -0,0 +1,91 @@
1
+import os
2
+import io
3
+import base64
4
+from PIL import Image
5
+from typing import Dict, List, Optional, Tuple
6
+from werkzeug.utils import secure_filename
7
+
8
+class AttachmentManager:
9
+ ALLOWED_EXTENSIONS = {
10
+ 'image': {'jpg', 'jpeg', 'png', 'bmp'},
11
+ 'code': {'py', 'js', 'sh', 'html', 'css'},
12
+ 'document': {'md', 'pdf', 'txt', 'csv', 'json'}
13
+ }
14
+
15
+ def __init__(self, work_dir: str):
16
+ self.work_dir = work_dir
17
+ os.makedirs(work_dir, exist_ok=True)
18
+
19
+ def is_allowed_file(self, filename: str) -> bool:
20
+ ext = self.get_file_extension(filename)
21
+ all_allowed = set().union(*self.ALLOWED_EXTENSIONS.values())
22
+ return ext in all_allowed
23
+
24
+ def get_file_type(self, filename: str) -> str:
25
+ ext = self.get_file_extension(filename)
26
+ for file_type, extensions in self.ALLOWED_EXTENSIONS.items():
27
+ if ext in extensions:
28
+ return file_type
29
+ return 'unknown'
30
+
31
+ @staticmethod
32
+ def get_file_extension(filename: str) -> str:
33
+ return filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
34
+
35
+ def validate_mime_type(self, file) -> bool:
36
+ try:
37
+ mime_type = file.content_type
38
+ return mime_type.split('/')[0] in ['image', 'text', 'application']
39
+ except AttributeError:
40
+ return False
41
+
42
+ def save_file(self, file, filename: str) -> Tuple[str, Dict]:
43
+ """Save file and return path and metadata"""
44
+ try:
45
+ filename = secure_filename(filename)
46
+ if not filename:
47
+ raise ValueError("Invalid filename")
48
+
49
+ file_path = os.path.join(self.work_dir, filename)
50
+
51
+ file_type = self.get_file_type(filename)
52
+ metadata = {
53
+ 'filename': filename,
54
+ 'type': file_type,
55
+ 'extension': self.get_file_extension(filename),
56
+ 'preview': None
57
+ }
58
+
59
+ # Save file
60
+ file.save(file_path)
61
+
62
+ # Generate preview for images
63
+ if file_type == 'image':
64
+ metadata['preview'] = self.generate_image_preview(file_path)
65
+
66
+ return file_path, metadata
67
+
68
+ except Exception as e:
69
+ print(f"Error saving file {filename}: {e}")
70
+ return None, {} # type: ignore
71
+
72
+ def generate_image_preview(self, image_path: str, max_size: int = 800) -> Optional[str]:
73
+ try:
74
+ with Image.open(image_path) as img:
75
+ # Convert image if needed
76
+ if img.mode in ('RGBA', 'P'):
77
+ img = img.convert('RGB')
78
+
79
+ # Resize for preview
80
+ img.thumbnail((max_size, max_size))
81
+
82
+ # Save to buffer
83
+ buffer = io.BytesIO()
84
+ img.save(buffer, format="JPEG", quality=70, optimize=True)
85
+
86
+ # Convert to base64
87
+ return base64.b64encode(buffer.getvalue()).decode('utf-8')
88
+ except Exception as e:
89
+ print(f"Error generating preview for {image_path}: {e}")
90
+ return None
91
+
\ No newline at end of file
python/helpers/log.py
+155
-154
@@ -4,165 +4,166 @@ from typing import Any, Literal, Optional, Dict
4
import uuid
5
from collections import OrderedDict # Import OrderedDict
6
7
-
7
Type = Literal[
9
- "agent",
10
- "code_exe",
11
- "error",
12
- "hint",
13
- "info",
14
- "progress",
15
- "response",
16
- "tool",
17
- "user",
18
- "util",
19
- "warning",
8
+ "agent",
9
+ "code_exe",
10
+ "error",
11
+ "hint",
12
+ "info",
13
+ "progress",
14
+ "response",
15
+ "tool",
16
+ "user",
17
+ "util",
18
+ "warning",
19
]
20
22
-
21
@dataclass
22
class LogItem:
25
- log: "Log"
26
- no: int
27
- type: str
28
- heading: str
29
- content: str
30
- temp: bool
31
- kvps: Optional[OrderedDict] = None # Use OrderedDict for kvps
32
- guid: str = ""
33
-
34
- def __post_init__(self):
35
- self.guid = self.log.guid
36
-
37
- def update(
38
- self,
39
- type: Type | None = None,
40
- heading: str | None = None,
41
- content: str | None = None,
42
- kvps: dict | None = None,
43
- temp: bool | None = None,
44
- **kwargs,
45
- ):
46
- if self.guid == self.log.guid:
47
- self.log.update_item(
48
- self.no,
49
- type=type,
50
- heading=heading,
51
- content=content,
52
- kvps=kvps,
53
- temp=temp,
54
- **kwargs,
55
- )
56
-
57
- def stream(self, heading: str | None = None, content: str | None = None, **kwargs):
58
- if heading is not None:
59
- self.update(heading=self.heading + heading)
60
- if content is not None:
61
- self.update(content=self.content + content)
62
-
63
- for k, v in kwargs.items():
64
- prev = self.kvps.get(k, "") if self.kvps else ""
65
- self.update(**{k: prev + v})
66
-
67
- def output(self):
68
- return {
69
- "no": self.no,
70
- "type": self.type,
71
- "heading": self.heading,
72
- "content": self.content,
73
- "temp": self.temp,
74
- "kvps": self.kvps,
75
- }
76
-
23
+ log: "Log"
24
+ no: int
25
+ type: str
26
+ heading: str
27
+ content: str
28
+ temp: bool
29
+ kvps: Optional[OrderedDict] = None # Use OrderedDict for kvps
30
+ id: Optional[str] = None # Add id field
31
+ guid: str = ""
32
+
33
+ def __post_init__(self):
34
+ self.guid = self.log.guid
35
+
36
+ def update(
37
+ self,
38
+ type: Type | None = None,
39
+ heading: str | None = None,
40
+ content: str | None = None,
41
+ kvps: dict | None = None,
42
+ temp: bool | None = None,
43
+ **kwargs,
44
+ ):
45
+ if self.guid == self.log.guid:
46
+ self.log.update_item(
47
+ self.no,
48
+ type=type,
49
+ heading=heading,
50
+ content=content,
51
+ kvps=kvps,
52
+ temp=temp,
53
+ **kwargs,
54
+ )
55
+
56
+ def stream(self, heading: str | None = None, content: str | None = None, **kwargs):
57
+ if heading is not None:
58
+ self.update(heading=self.heading + heading)
59
+ if content is not None:
60
+ self.update(content=self.content + content)
61
+
62
+ for k, v in kwargs.items():
63
+ prev = self.kvps.get(k, "") if self.kvps else ""
64
+ self.update(**{k: prev + v})
65
+
66
+ def output(self):
67
+ return {
68
+ "no": self.no,
69
+ "id": self.id, # Include id in output
70
+ "type": self.type,
71
+ "heading": self.heading,
72
+ "content": self.content,
73
+ "temp": self.temp,
74
+ "kvps": self.kvps,
75
+ }
76
77
class Log:
78
80
- def __init__(self):
81
- self.guid: str = str(uuid.uuid4())
82
- self.updates: list[int] = []
83
- self.logs: list[LogItem] = []
84
- self.progress = ""
85
- self.progress_no = 0
86
-
87
- def log(
88
- self,
89
- type: Type,
90
- heading: str | None = None,
91
- content: str | None = None,
92
- kvps: dict | None = None,
93
- temp: bool | None = None,
94
- ) -> LogItem:
95
- # Use OrderedDict if kvps is provided
96
- if kvps is not None:
97
- kvps = OrderedDict(kvps)
98
- item = LogItem(
99
- log=self,
100
- no=len(self.logs),
101
- type=type,
102
- heading=heading or "",
103
- content=content or "",
104
- kvps=kvps,
105
- temp=temp or False,
106
- )
107
- self.logs.append(item)
108
- self.updates += [item.no]
109
- if heading and item.no >= self.progress_no:
110
- self.progress = heading
111
- self.progress_no = item.no
112
- return item
113
-
114
- def update_item(
115
- self,
116
- no: int,
117
- type: str | None = None,
118
- heading: str | None = None,
119
- content: str | None = None,
120
- kvps: dict | None = None,
121
- temp: bool | None = None,
122
- **kwargs,
123
- ):
124
- item = self.logs[no]
125
- if type is not None:
126
- item.type = type
127
- if heading is not None:
128
- item.heading = heading
129
- if no >= self.progress_no:
130
- self.progress = heading
131
- self.progress_no = no
132
- if content is not None:
133
- item.content = content
134
- if kvps is not None:
135
- item.kvps = OrderedDict(kvps) # Use OrderedDict to keep the order
136
-
137
- if temp is not None:
138
- item.temp = temp
139
-
140
- if kwargs:
141
- if item.kvps is None:
142
- item.kvps = OrderedDict() # Ensure kvps is an OrderedDict
143
- for k, v in kwargs.items():
144
- item.kvps[k] = v
145
-
146
- self.updates += [item.no]
147
-
148
- def output(self, start=None, end=None):
149
- if start is None:
150
- start = 0
151
- if end is None:
152
- end = len(self.updates)
153
-
154
- out = []
155
- seen = set()
156
- for update in self.updates[start:end]:
157
- if update not in seen:
158
- out.append(self.logs[update].output())
159
- seen.add(update)
160
-
161
- return out
162
-
163
- def reset(self):
164
- self.guid = str(uuid.uuid4())
165
- self.updates = []
166
- self.logs = []
167
- self.progress = ""
168
- self.progress_no = 0
79
+ def __init__(self):
80
+ self.guid: str = str(uuid.uuid4())
81
+ self.updates: list[int] = []
82
+ self.logs: list[LogItem] = []
83
+ self.progress = ""
84
+ self.progress_no = 0
85
+
86
+ def log(
87
+ self,
88
+ type: Type,
89
+ heading: str | None = None,
90
+ content: str | None = None,
91
+ kvps: dict | None = None,
92
+ temp: bool | None = None,
93
+ id: Optional[str] = None, # Add id parameter
94
+ ) -> LogItem:
95
+ # Use OrderedDict if kvps is provided
96
+ if kvps is not None:
97
+ kvps = OrderedDict(kvps)
98
+ item = LogItem(
99
+ log=self,
100
+ no=len(self.logs),
101
+ type=type,
102
+ heading=heading or "",
103
+ content=content or "",
104
+ kvps=kvps,
105
+ temp=temp or False,
106
+ id=id, # Pass id to LogItem
107
+ )
108
+ self.logs.append(item)
109
+ self.updates += [item.no]
110
+ if heading and item.no >= self.progress_no:
111
+ self.progress = heading
112
+ self.progress_no = item.no
113
+ return item
114
+
115
+ def update_item(
116
+ self,
117
+ no: int,
118
+ type: str | None = None,
119
+ heading: str | None = None,
120
+ content: str | None = None,
121
+ kvps: dict | None = None,
122
+ temp: bool | None = None,
123
+ **kwargs,
124
+ ):
125
+ item = self.logs[no]
126
+ if type is not None:
127
+ item.type = type
128
+ if heading is not None:
129
+ item.heading = heading
130
+ if no >= self.progress_no:
131
+ self.progress = heading
132
+ self.progress_no = no
133
+ if content is not None:
134
+ item.content = content
135
+ if kvps is not None:
136
+ item.kvps = OrderedDict(kvps) # Use OrderedDict to keep the order
137
+
138
+ if temp is not None:
139
+ item.temp = temp
140
+
141
+ if kwargs:
142
+ if item.kvps is None:
143
+ item.kvps = OrderedDict() # Ensure kvps is an OrderedDict
144
+ for k, v in kwargs.items():
145
+ item.kvps[k] = v
146
+
147
+ self.updates += [item.no]
148
+
149
+ def output(self, start=None, end=None):
150
+ if start is None:
151
+ start = 0
152
+ if end is None:
153
+ end = len(self.updates)
154
+
155
+ out = []
156
+ seen = set()
157
+ for update in self.updates[start:end]:
158
+ if update not in seen:
159
+ out.append(self.logs[update].output())
160
+ seen.add(update)
161
+
162
+ return out
163
+
164
+ def reset(self):
165
+ self.guid = str(uuid.uuid4())
166
+ self.updates = []
167
+ self.logs = []
168
+ self.progress = ""
169
+ self.progress_no = 0
\ No newline at end of file
run_ui.py
+12
-2
@@ -225,13 +225,13 @@ async def handle_message_async():
225
async def handle_msg_sync():
226
return await handle_message(True)
227
228
-
228
async def handle_message(sync: bool):
229
try:
230
# Handle both JSON and multipart/form-data
231
if request.content_type.startswith('multipart/form-data'):
232
text = request.form.get('text', '')
233
ctxid = request.form.get('context', '')
234
+ message_id = request.form.get('message_id', None)
235
attachments = request.files.getlist('attachments')
236
attachment_paths = []
237
@@ -249,6 +249,7 @@ async def handle_message(sync: bool):
249
input_data = request.get_json()
250
text = input_data.get('text', '')
251
ctxid = input_data.get('context', '')
252
+ message_id = input_data.get('message_id', None)
253
attachment_paths = []
254
255
# Now process the message
@@ -260,12 +261,21 @@ async def handle_message(sync: bool):
261
# Store attachments in agent data
262
context.agent0.set_data('attachments', attachment_paths)
263
264
+ # Prepare attachment filenames for logging
265
+ attachment_filenames = [os.path.basename(path) for path in attachment_paths] if attachment_paths else []
266
+
267
# Print to console and log
268
PrintStyle(
269
background_color="#6C3483", font_color="white", bold=True, padding=True
270
).print(f"User message:")
271
PrintStyle(font_color="white", padding=False).print(f"> {message}")
268
- context.log.log(type="user", heading="User message", content=message)
272
+ if attachment_filenames:
273
+ PrintStyle(font_color="white", padding=False).print("Attachments:")
274
+ for filename in attachment_filenames:
275
+ PrintStyle(font_color="white", padding=False).print(f"- {filename}")
276
+
277
+ # Log the message with message_id and attachments
278
+ context.log.log(type="user", heading="User message", content=message, kvps={'attachments': attachment_filenames}, id=message_id)
279
280
if sync:
281
context.communicate(message)
webui/index.css
+190
-12
@@ -516,6 +516,14 @@ pre {
516
text-align: end;
517
}
518
519
+.message-user > div {
520
+padding-top: var(--spacing-xs);
521
+font-family: 'Roboto Mono', monospace;
522
+font-optical-sizing: auto;
523
+-webkit-font-optical-sizing: auto;
524
+font-size: var(--font-size-small)
525
+}
526
+
527
.message-ai {
528
border-bottom-left-radius: var(--spacing-xs);
529
}
@@ -785,7 +793,6 @@ pre {
793
align-items: center;
794
gap: var(--spacing-xs);
795
}
788
-
796
/* Attachment icon */
797
.attachment-wrapper {
798
position: relative;
@@ -796,7 +803,7 @@ pre {
803
cursor: pointer;
804
color: var(--color-text);
805
opacity: 0.7;
799
- transition: opacity 0.2s;
806
+ transition: opacity 0.2s ease;
807
display: flex;
808
align-items: center;
809
}
@@ -809,24 +816,195 @@ pre {
816
opacity: 0.5;
817
}
818
819
+/* Message attachments styles */
820
.attachments-container {
813
- margin-top: 10px;
814
- padding: 10px;
815
- border-radius: 5px;
821
+ margin-top: 0.5em;
822
+ display: flex;
823
+ flex-direction: column;
824
+ gap: 0.5em;
825
+}
826
+
827
+.attachment-item {
828
+ display: flex;
829
+ align-items: center;
830
+ gap: 1em;
831
+ background: var(--color-background);
832
+ padding: 0.5em;
833
+ border-radius: 4px;
834
+ transition: background-color 0.2s ease;
835
+}
836
+
837
+.attachment-item:hover {
838
+ background: var(--color-secondary-dark);
839
+}
840
+
841
+.attachment-item.file-type {
842
+ background: var(--color-background);
843
}
844
818
-.message-attachment {
819
- max-width: 100%;
820
- max-height: 400px;
821
- margin: 5px 0;
822
- border-radius: 5px;
845
+.attachment-item:hover {
846
+ background: var(--color-secondary-dark);
847
+}
848
+
849
+.attachment-preview {
850
+ max-width: 100px;
851
+ max-height: 100px;
852
+ border-radius: 4px;
853
object-fit: contain;
854
}
855
856
+.attachment-image .attachment-preview {
857
+ margin-right: 8px;
858
+}
859
+
860
+.attachment-info,
861
+.file-info {
862
+ display: flex;
863
+ align-items: center;
864
+ gap: 8px;
865
+}
866
+
867
+.file-info {
868
+ display: flex;
869
+ align-items: center;
870
+ gap: 0.5em;
871
+}
872
+
873
+.attachment-name,
874
+.filename,
875
+.file-name {
876
+ font-size: 0.9em;
877
+ color: var(--color-text);
878
+ word-break: break-word;
879
+}
880
+
881
+.attachment-ext,
882
+.extension,
883
+.file-ext {
884
+ background: var(--color-primary);
885
+ color: var(--color-text);
886
+ padding: 2px 6px;
887
+ border-radius: 4px;
888
+ font-size: 0.8em;
889
+ text-transform: uppercase;
890
+ white-space: nowrap;
891
+}
892
+
893
+/* Preview section styles */
894
+.preview-section {
895
+ display: flex;
896
+ flex-wrap: wrap;
897
+ gap: 8px;
898
+ margin-bottom: 10px;
899
+ padding: var(--spacing-xs);
900
+}
901
+
902
+.preview-item {
903
+ position: relative;
904
+ background: var(--color-secondary);
905
+ border-radius: 8px;
906
+ padding: 8px;
907
+ max-width: 200px;
908
+ display: flex;
909
+ align-items: center;
910
+ gap: 8px;
911
+ transition: background-color 0.2s ease;
912
+}
913
+
914
+.preview-item:hover {
915
+ background: var(--color-secondary-dark);
916
+}
917
+
918
+.preview-item.image-preview img {
919
+ max-height: 100px;
920
+ object-fit: cover;
921
+ border-radius: 4px;
922
+}
923
+
924
+.image-wrapper {
925
+ width: 100px;
926
+ height: 100px;
927
+ display: flex;
928
+ align-items: center;
929
+ justify-content: center;
930
+}
931
+
932
+.file-preview {
933
+ display: flex;
934
+ align-items: center;
935
+ gap: 0.5em;
936
+}
937
+
938
+.extension {
939
+ background: var(--color-primary);
940
+ color: var(--color-text);
941
+ padding: 2px 6px;
942
+ border-radius: 4px;
943
+ font-size: 0.8em;
944
+ text-transform: uppercase;
945
+}
946
+
947
+.file-preview:hover {
948
+ background: var(--color-secondary-dark);
949
+}
950
+
951
+.remove-attachment {
952
+ position: absolute;
953
+ top: -6px;
954
+ right: -6px;
955
+ background-color: var(--color-primary);
956
+ /* opacity: 0.6; */
957
+ color: white;
958
+ border: none;
959
+ border-radius: 50%;
960
+ width: 20px;
961
+ height: 20px;
962
+ cursor: pointer;
963
+ display: flex;
964
+ align-items: center;
965
+ justify-content: center;
966
+ transition: background-color 0.2s ease, transform 0.1s ease;
967
+ z-index: 1;
968
+}
969
+
970
+.remove-attachment:hover {
971
+ background-color: var(--color-accent);
972
+ transform: scale(1.1);
973
+}
974
+
975
+.remove-attachment:active {
976
+ transform: scale(0.9);
977
+}
978
+
979
+/* File type specific styles
980
+.extension[data-type="image"] {
981
+ background: #4CAF50;
982
+}
983
+
984
+.extension[data-type="code"] {
985
+ background: #2196F3;
986
+}
987
+
988
+.extension[data-type="document"] {
989
+ background: #FF9800;
990
+}
991
+
992
+*/
993
+
994
+/* Error handling */
995
.image-error {
827
- border: 1px solid #ff0000;
996
+ border: 1px solid var(--color-error);
997
padding: 10px;
829
- color: #ff0000;
998
+ color: var(--color-error);
999
+ border-radius: 4px;
1000
+ font-size: 0.9em;
1001
+ display: flex;
1002
+ align-items: center;
1003
+ gap: 8px;
1004
+}
1005
+
1006
+.image-error::before {
1007
+ content: "⚠️";
1008
}
1009
1010
/* Text input */
webui/index.html
+55
-26
@@ -195,34 +195,63 @@
195
alert('Maximum 4 attachments allowed');
196
return;
197
}
198
-
198
+
199
Array.from(files).forEach(file => {
200
- if (file.type.startsWith('image/')) {
201
- const reader = new FileReader();
202
- reader.onload = e => {
200
+ const ext = file.name.split('.').pop().toLowerCase();
201
+ const allowedExts = new Set(['jpg', 'jpeg', 'png', 'bmp', 'md', 'py', 'js', 'sh',
202
+ 'html', 'css', 'pdf', 'txt', 'csv', 'json']);
203
+
204
+ if (allowedExts.has(ext)) {
205
+ const isImage = ['jpg', 'jpeg', 'png', 'bmp'].includes(ext);
206
+
207
+ if (isImage) {
208
+ // Handle image preview
209
+ const reader = new FileReader();
210
+ reader.onload = e => {
211
+ this.attachments.push({
212
+ file: file,
213
+ url: e.target.result,
214
+ type: 'image',
215
+ name: file.name,
216
+ extension: ext
217
+ });
218
+ this.hasAttachments = true;
219
+ };
220
+ reader.readAsDataURL(file);
221
+ } else {
222
+ // Handle other file types
223
this.attachments.push({
224
file: file,
205
- url: e.target.result
225
+ type: 'file',
226
+ name: file.name,
227
+ extension: ext
228
});
229
this.hasAttachments = true;
208
- };
209
- reader.readAsDataURL(file);
230
+ }
231
}
232
});
233
}
234
}">
214
-
215
- <!-- Image preview section -->
216
- <div x-show="hasAttachments" class="image-preview-section">
217
- <template x-for="(image, index) in attachments" :key="index">
218
- <div class="preview-item">
219
- <img :src="image.url" alt="Preview">
220
- <button @click="attachments.splice(index, 1); hasAttachments = attachments.length > 0"
221
- class="remove-image">×</button>
235
+
236
+ <!-- Preview section -->
237
+ <div x-show="hasAttachments" class="preview-section">
238
+ <template x-for="(attachment, index) in attachments" :key="index">
239
+ <div class="preview-item" :class="{'image-preview': attachment.type === 'image'}">
240
+ <template x-if="attachment.type === 'image'">
241
+ <img :src="attachment.url" :alt="attachment.name">
242
+ </template>
243
+ <template x-if="attachment.type === 'file'">
244
+ <div class="file-preview">
245
+ <span class="filename" x-text="attachment.name"></span>
246
+ <span class="extension" x-text="attachment.extension.toUpperCase()"></span>
247
+ </div>
248
+ </template>
249
+ <button @click="attachments.splice(index, 1); hasAttachments = attachments.length > 0"
250
+ class="remove-attachment">×</button>
251
</div>
252
</template>
253
</div>
225
-
254
+
255
<!-- Top row with input and buttons -->
256
<div class="input-row">
257
<!-- Attachment icon with tooltip -->
@@ -235,20 +264,20 @@
264
d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
265
</svg>
266
</label>
238
- <input type="file" id="file-input" accept="png, jpg, jpeg, txt, pdf, csv, html, json, md"
239
- multiple style="display: none" @change="handleFileUpload($event)">
267
+ <input type="file" id="file-input" accept=".png, .jpg, .jpeg, .txt, .pdf, .csv, .html, .json, .md, .py, .js, .sh, .css" multiple style="display: none"
268
+ @change="handleFileUpload($event)">
269
241
- <div x-show="showTooltip" class="tooltip">
242
- Limit: 4 attachments per message
243
- </div>
270
+ <div x-show="showTooltip" class="tooltip">
271
+ Limit: 4 attachments per message
272
</div>
273
+ </div>
274
246
- <!-- Text input -->
247
- <textarea id="chat-input" placeholder="Type your message here..." rows="1"></textarea>
275
+ <!-- Text input -->
276
+ <textarea id="chat-input" placeholder="Type your message here..." rows="1"></textarea>
277
249
- <div id="chat-buttons-wrapper">
250
- <!-- Send button -->
251
- <button class="chat-button" id="send-button" aria-label="Send message">
278
+ <div id="chat-buttons-wrapper">
279
+ <!-- Send button -->
280
+ <button class="chat-button" id="send-button" aria-label="Send message">
281
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
282
<path d="M25 20 L75 50 L25 80" fill="none" stroke="currentColor" stroke-width="15">
283
</path>
webui/index.js
+25
-40
@@ -1,4 +1,4 @@
1
-import * as msgs from "./messages.js"
1
+import * as msgs from "./messages.js";
2
import { speech } from "./speech.js";
3
4
const leftPanel = document.getElementById('left-panel');
@@ -10,16 +10,12 @@ const sendButton = document.getElementById('send-button');
10
const inputSection = document.getElementById('input-section');
11
const statusSection = document.getElementById('status-section');
12
const chatsSection = document.getElementById('chats-section');
13
-const scrollbarThumb = document.querySelector('#chat-history::-webkit-scrollbar-thumb');
13
const progressBar = document.getElementById('progress-bar');
14
const autoScrollSwitch = document.getElementById('auto-scroll-switch');
15
17
-
16
let autoScroll = true;
17
let context = "";
18
21
-
22
-
19
// Initialize the toggle button
20
setupSidebarToggle();
21
@@ -42,7 +38,6 @@ function handleResize() {
38
}
39
}
40
45
-// Run on startup and window resize
41
window.addEventListener('load', handleResize);
42
window.addEventListener('resize', handleResize);
43
@@ -57,10 +52,8 @@ function setupSidebarToggle() {
52
setTimeout(setupSidebarToggle, 100);
53
}
54
}
60
-// Make sure to call this function
55
document.addEventListener('DOMContentLoaded', setupSidebarToggle);
56
63
-// index.js
57
export async function sendMessage() {
58
try {
59
const message = chatInput.value.trim();
@@ -72,14 +65,22 @@ export async function sendMessage() {
65
let response;
66
const messageId = generateGUID();
67
75
- // Only render immediately for attachments
68
+ // Include attachments in the user message
69
if (hasAttachments) {
77
- const attachmentsWithUrls = attachments.map(attachment => ({
78
- ...attachment,
79
- url: URL.createObjectURL(attachment.file)
80
- }));
70
+ const attachmentsWithUrls = attachments.map(attachment => {
71
+ if (attachment.type === 'image') {
72
+ return {
73
+ ...attachment,
74
+ url: URL.createObjectURL(attachment.file)
75
+ };
76
+ } else {
77
+ return {
78
+ ...attachment
79
+ };
80
+ }
81
+ });
82
82
- // Only render if there's text content or it's an image-only message
83
+ // Render user message with attachments
84
setMessage(messageId, 'user', '', message, false, {
85
attachments: attachmentsWithUrls
86
});
@@ -98,7 +99,7 @@ export async function sendMessage() {
99
body: formData
100
});
101
} else {
101
- // For text-only messages, let polling handle the rendering
102
+ // For text-only messages
103
const data = {
104
text: message,
105
context,
@@ -186,12 +187,17 @@ function updateUserTime() {
187
updateUserTime();
188
setInterval(updateUserTime, 1000);
189
190
+
191
function setMessage(id, type, heading, content, temp, kvps = null) {
192
// Search for the existing message container by id
193
let messageContainer = document.getElementById(`message-${id}`);
194
195
if (messageContainer) {
194
- // Clear the existing container's content if found
196
+ // Don't re-render user messages
197
+ if (type === 'user') {
198
+ return; // Skip re-rendering
199
+ }
200
+ // For other types, update the message
201
messageContainer.innerHTML = '';
202
} else {
203
// Create a new container if not found
@@ -199,8 +205,7 @@ function setMessage(id, type, heading, content, temp, kvps = null) {
205
messageContainer = document.createElement('div');
206
messageContainer.id = `message-${id}`;
207
messageContainer.classList.add('message-container', `${sender}-container`);
202
- if (temp) messageContainer.classList.add("message-temp")
203
-
208
+ if (temp) messageContainer.classList.add("message-temp");
209
}
210
211
const handler = msgs.getHandler(type);
@@ -215,27 +220,6 @@ function setMessage(id, type, heading, content, temp, kvps = null) {
220
}
221
222
218
-async function handleFileUpload(event) {
219
- const files = event.target.files;
220
- const formData = new FormData();
221
- for (let i = 0; i < files.length; i++) {
222
- formData.append('file', files[i]);
223
- }
224
-
225
- const response = await fetch('/upload', {
226
- method: 'POST',
227
- body: formData,
228
- });
229
-
230
- const data = await response.json();
231
- if (!data.ok) {
232
- toast(data.message, "error");
233
- } else {
234
- toast("Files uploaded: " + data.filenames.join(", "), "success");
235
- }
236
-}
237
-
238
-
223
window.loadKnowledge = async function () {
224
const input = document.createElement('input');
225
input.type = 'file';
@@ -356,7 +340,8 @@ async function poll() {
340
if (lastLogVersion != response.log_version) {
341
updated = true
342
for (const log of response.logs) {
359
- setMessage(log.no, log.type, log.heading, log.content, log.temp, log.kvps);
343
+ const messageId = log.id || log.no; // Use log.id if available
344
+ setMessage(messageId, log.type, log.heading, log.content, log.temp, log.kvps);
345
}
346
afterMessagesUpdate(response.logs)
347
}
webui/messages.js
+86
-47
@@ -30,7 +30,6 @@ export function getHandler(type) {
30
31
// draw a message with a specific type
32
export function _drawMessage(messageContainer, heading, content, temp, followUp, kvps = null, messageClasses = [], contentClasses = []) {
33
-
33
const messageDiv = document.createElement('div');
34
messageDiv.classList.add('message', ...messageClasses);
35
@@ -42,35 +41,37 @@ export function _drawMessage(messageContainer, heading, content, temp, followUp,
41
42
drawKvps(messageDiv, kvps);
43
45
- const preElement = document.createElement('pre');
46
- preElement.classList.add("msg-content", ...contentClasses);
47
- preElement.style.whiteSpace = 'pre-wrap';
48
- preElement.style.wordBreak = 'break-word';
44
+ if (content && content.trim().length > 0) {
45
+ const preElement = document.createElement('pre');
46
+ preElement.classList.add("msg-content", ...contentClasses);
47
+ preElement.style.whiteSpace = 'pre-wrap';
48
+ preElement.style.wordBreak = 'break-word';
49
+
50
+ const spanElement = document.createElement('span');
51
+ spanElement.innerHTML = content;
52
+ preElement.appendChild(spanElement);
53
+ messageDiv.appendChild(preElement);
54
+
55
+ // Render LaTeX math within the span
56
+ if (window.renderMathInElement) {
57
+ renderMathInElement(spanElement, {
58
+ delimiters: [
59
+ { left: "$", right: "$", display: true },
60
+ { left: "\\$", right: "\\$", display: true },
61
+ { left: "$", right: "$", display: false },
62
+ { left: "\\$", right: "\\$", display: false }
63
+ ],
64
+ throwOnError: false
65
+ });
66
+ }
67
+ }
68
50
- // Wrap content in a <span> to allow HTML parsing
51
- const spanElement = document.createElement('span');
52
- spanElement.innerHTML = content; // Use innerHTML instead of textContent
53
- preElement.appendChild(spanElement);
54
- messageDiv.appendChild(preElement);
69
messageContainer.appendChild(messageDiv);
70
71
if (followUp) {
72
messageContainer.classList.add("message-followup");
73
}
74
61
- // Render LaTeX math within the span
62
- if (window.renderMathInElement) {
63
- renderMathInElement(spanElement, {
64
- delimiters: [
65
- { left: "$", right: "$", display: true },
66
- { left: "\\$", right: "\\$", display: true },
67
- { left: "$", right: "$", display: false },
68
- { left: "\\$", right: "\\$", display: false }
69
- ],
70
- throwOnError: false // Prevent KaTeX from throwing errors
71
- });
72
- }
73
-
75
return messageDiv;
76
}
77
@@ -102,41 +103,79 @@ export function drawMessageDelegation(messageContainer, id, type, heading, conte
103
}
104
105
export function drawMessageUser(messageContainer, id, type, heading, content, temp, kvps = null) {
105
- const hasContent = content && content.trim().length > 0;
106
- const hasAttachments = kvps && kvps.attachments && kvps.attachments.length > 0;
106
+ const messageDiv = document.createElement('div');
107
+ messageDiv.classList.add('message', 'message-user');
108
108
- // Only create message container if there's content or it's the initial message
109
- if (hasContent || !messageContainer.hasChildNodes()) {
110
- // Create the message with user heading and content
111
- _drawMessage(messageContainer, "User message", content, temp, false, null, ['message-user'], []);
109
+ const headingElement = document.createElement('h4');
110
+ headingElement.textContent = "User message";
111
+ messageDiv.appendChild(headingElement);
112
+
113
+ if (content && content.trim().length > 0) {
114
+ const textDiv = document.createElement('div');
115
+ textDiv.classList.add('message-text');
116
+ textDiv.textContent = content;
117
+ messageDiv.appendChild(textDiv);
118
}
119
114
- // Render image attachments for user messages
115
- if (hasAttachments) {
120
+ // Handle attachments
121
+ if (kvps && kvps.attachments && kvps.attachments.length > 0) {
122
const attachmentsContainer = document.createElement('div');
123
attachmentsContainer.classList.add('attachments-container');
124
119
- // Add "Attachments" heading if there's text content
120
- if (hasContent) {
121
- const attachmentsHeading = document.createElement('h4');
122
- attachmentsHeading.textContent = "Attachments";
123
- attachmentsContainer.appendChild(attachmentsHeading);
124
- }
125
-
125
kvps.attachments.forEach(attachment => {
127
- const image = document.createElement('img');
128
- if (attachment.url) {
129
- image.src = attachment.url;
130
- } else if (attachment.data) {
131
- image.src = `data:image/jpeg;base64,${attachment.data}`;
126
+ const attachmentDiv = document.createElement('div');
127
+ attachmentDiv.classList.add('attachment-item');
128
+
129
+ if (typeof attachment === 'string') {
130
+ // attachment is filename
131
+ const filename = attachment;
132
+ const extension = filename.split('.').pop().toUpperCase();
133
+
134
+ attachmentDiv.classList.add('file-type');
135
+ attachmentDiv.innerHTML = `
136
+ <div class="file-preview">
137
+ <span class="filename">${filename}</span>
138
+ <span class="extension">${extension}</span>
139
+ </div>
140
+ `;
141
+ } else if (attachment.type === 'image') {
142
+ // Existing logic for images
143
+ const imgWrapper = document.createElement('div');
144
+ imgWrapper.classList.add('image-wrapper');
145
+
146
+ const img = document.createElement('img');
147
+ img.src = attachment.url;
148
+ img.alt = attachment.name;
149
+ img.classList.add('attachment-preview');
150
+
151
+ const fileInfo = document.createElement('div');
152
+ fileInfo.classList.add('file-info');
153
+ fileInfo.innerHTML = `
154
+ <span class="filename">${attachment.name}</span>
155
+ <span class="extension">${attachment.extension.toUpperCase()}</span>
156
+ `;
157
+
158
+ imgWrapper.appendChild(img);
159
+ attachmentDiv.appendChild(imgWrapper);
160
+ attachmentDiv.appendChild(fileInfo);
161
+ } else {
162
+ // Existing logic for non-image files
163
+ attachmentDiv.classList.add('file-type');
164
+ attachmentDiv.innerHTML = `
165
+ <div class="file-preview">
166
+ <span class="filename">${attachment.name}</span>
167
+ <span class="extension">${attachment.extension.toUpperCase()}</span>
168
+ </div>
169
+ `;
170
}
133
- image.alt = 'Attachment';
134
- image.classList.add('message-attachment');
135
- attachmentsContainer.appendChild(image);
171
+
172
+ attachmentsContainer.appendChild(attachmentDiv);
173
});
174
138
- messageContainer.appendChild(attachmentsContainer);
175
+ messageDiv.appendChild(attachmentsContainer);
176
}
177
+
178
+ messageContainer.appendChild(messageDiv);
179
}
180
181
export function drawMessageTool(messageContainer, id, type, heading, content, temp, kvps = null) {