feature: work_dir file manager
Implemented the file browser for work_dir, we need to: - move endpoints away from run_ui.py - make the "Up" (parent dir) button work Extra: - Now when under 768px in width, you can touch outside of the sidebar to collapse it.
Alessandro committed
Nov 21, 2024 at 10:58 UTC
9c968ba1cff3bcbde20a1484e4e647b520f762a8
17 files changed
+1232
-77
python/helpers/file_browser.py
new
+170
@@ -0,0 +1,170 @@
1
+import os
2
+from pathlib import Path
3
+from typing import Dict, List, Tuple, Optional, Any
4
+from werkzeug.utils import secure_filename
5
+from datetime import datetime
6
+
7
+class FileBrowser:
8
+ ALLOWED_EXTENSIONS = {
9
+ 'image': {'jpg', 'jpeg', 'png', 'bmp'},
10
+ 'code': {'py', 'js', 'sh', 'html', 'css'},
11
+ 'document': {'md', 'pdf', 'txt', 'csv', 'json'}
12
+ }
13
+
14
+ MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
15
+
16
+ def __init__(self, base_dir: str):
17
+ self.base_dir = Path(base_dir).resolve()
18
+
19
+ def _check_file_size(self, file) -> bool:
20
+ try:
21
+ file.seek(0, os.SEEK_END)
22
+ size = file.tell()
23
+ file.seek(0)
24
+ return size <= self.MAX_FILE_SIZE
25
+ except (AttributeError, IOError):
26
+ return False
27
+
28
+ def save_files(self, files: List, current_path: str = "") -> Tuple[List[str], List[str]]:
29
+ """Save uploaded files and return successful and failed filenames"""
30
+ successful = []
31
+ failed = []
32
+
33
+ try:
34
+ # Resolve the target directory path
35
+ target_dir = (self.base_dir / current_path).resolve()
36
+ if not str(target_dir).startswith(str(self.base_dir)):
37
+ raise ValueError("Invalid target directory")
38
+
39
+ os.makedirs(target_dir, exist_ok=True)
40
+
41
+ for file in files:
42
+ try:
43
+ if file and self._is_allowed_file(file.filename, file):
44
+ filename = secure_filename(file.filename)
45
+ file_path = target_dir / filename
46
+
47
+ file.save(str(file_path))
48
+ successful.append(filename)
49
+ else:
50
+ failed.append(file.filename)
51
+ except Exception as e:
52
+ print(f"Error saving file {file.filename}: {e}")
53
+ failed.append(file.filename)
54
+
55
+ return successful, failed
56
+
57
+ except Exception as e:
58
+ print(f"Error in save_files: {e}")
59
+ return successful, failed
60
+
61
+ def delete_file(self, file_path: str) -> bool:
62
+ """Delete a file or empty directory"""
63
+ try:
64
+ # Resolve the full path while preventing directory traversal
65
+ full_path = (self.base_dir / file_path).resolve()
66
+ if not str(full_path).startswith(str(self.base_dir)):
67
+ raise ValueError("Invalid path")
68
+
69
+ if os.path.exists(full_path):
70
+ if os.path.isfile(full_path):
71
+ os.remove(full_path)
72
+ elif os.path.isdir(full_path) and not os.listdir(full_path):
73
+ os.rmdir(full_path)
74
+ else:
75
+ raise ValueError("Can only delete files or empty directories")
76
+ return True
77
+
78
+ return False
79
+
80
+ except Exception as e:
81
+ print(f"Error deleting {file_path}: {e}")
82
+ return False
83
+
84
+ def _is_allowed_file(self, filename: str, file) -> bool:
85
+ if not filename:
86
+ return False
87
+ ext = self._get_file_extension(filename)
88
+ all_allowed = set().union(*self.ALLOWED_EXTENSIONS.values())
89
+ if ext not in all_allowed:
90
+ return False
91
+
92
+ return True # Allow the file if it passes the checks
93
+
94
+ def _get_file_extension(self, filename: str) -> str:
95
+ return filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
96
+
97
+ def get_files(self, current_path: str = "") -> Dict:
98
+ try:
99
+ # Resolve the full path while preventing directory traversal
100
+ full_path = (self.base_dir / current_path).resolve()
101
+ if not str(full_path).startswith(str(self.base_dir)):
102
+ raise ValueError("Invalid path")
103
+
104
+ files = []
105
+ folders = []
106
+
107
+ # List all entries in the current directory
108
+ for entry in os.scandir(full_path):
109
+ entry_data: Dict[str, Any] = {
110
+ "name": entry.name,
111
+ "path": str(Path(entry.path).relative_to(self.base_dir)),
112
+ "modified": datetime.fromtimestamp(entry.stat().st_mtime).isoformat()
113
+ }
114
+
115
+ if entry.is_file():
116
+ entry_data.update({
117
+ "type": self._get_file_type(entry.name),
118
+ "size": entry.stat().st_size,
119
+ "is_dir": False
120
+ })
121
+ files.append(entry_data)
122
+ else:
123
+ entry_data.update({
124
+ "type": "folder",
125
+ "size": 0, # Directories show as 0 bytes
126
+ "is_dir": True
127
+ })
128
+ folders.append(entry_data)
129
+
130
+ # Combine folders and files, folders first
131
+ all_entries = folders + files
132
+
133
+ # Get parent directory path if not at root
134
+ parent_path = ""
135
+ if current_path:
136
+ parent = (Path(current_path).parent)
137
+ parent_path = str(parent) if parent != Path(".") else ""
138
+
139
+ return {
140
+ "entries": all_entries,
141
+ "current_path": current_path,
142
+ "parent_path": parent_path
143
+ }
144
+
145
+ except Exception as e:
146
+ print(f"Error reading directory: {e}")
147
+ return {"entries": [], "current_path": "", "parent_path": ""}
148
+
149
+ def get_file_path(self, file_path: str) -> Optional[Path]:
150
+ """Get full file path if it exists and is within base_dir"""
151
+ try:
152
+ full_path = (self.base_dir / file_path).resolve()
153
+ if not str(full_path).startswith(str(self.base_dir)):
154
+ raise ValueError("Invalid path")
155
+
156
+ if os.path.isfile(full_path):
157
+ return full_path
158
+ return None
159
+
160
+ except Exception as e:
161
+ print(f"Error accessing file {file_path}: {e}")
162
+ return None
163
+
164
+ def _get_file_type(self, filename: str) -> str:
165
+ ext = self._get_file_extension(filename)
166
+ for file_type, extensions in self.ALLOWED_EXTENSIONS.items():
167
+ if ext in extensions:
168
+ return file_type
169
+ return 'unknown'
170
+
\ No newline at end of file
run_ui.py
+132
-16
@@ -4,7 +4,7 @@ import os
4
from pathlib import Path
5
import threading
6
import uuid
7
-from flask import Flask, request, jsonify, Response
7
+from flask import Flask, request, jsonify, Response, send_file
8
from flask_basicauth import BasicAuth
9
from agent import AgentContext
10
from initialize import initialize
@@ -16,6 +16,7 @@ from python.helpers import persist_chat, settings, whisper, rfc, runtime, dotenv
16
import base64
17
from werkzeug.utils import secure_filename
18
from python.helpers.cloudflare_tunnel import CloudflareTunnel
19
+from python.helpers.file_browser import FileBrowser
20
21
22
# initialize the internal Flask server
@@ -76,7 +77,7 @@ async def upload_file():
77
78
for file in files:
79
if file and allowed_file(file.filename): # Check file type
79
- filename = secure_filename(file.filename)
80
+ filename = secure_filename(file.filename) # type: ignore
81
file.save(os.path.join(UPLOAD_FOLDER, filename))
82
saved_filenames.append(filename)
83
@@ -103,7 +104,7 @@ async def import_knowledge():
104
105
for file in files:
106
if file:
106
- filename = secure_filename(file.filename)
107
+ filename = secure_filename(file.filename) # type: ignore
108
file.save(os.path.join(KNOWLEDGE_FOLDER, filename))
109
saved_filenames.append(filename)
110
@@ -112,22 +113,135 @@ async def import_knowledge():
113
)
114
115
115
-@app.route("/work_dir", methods=["GET"]) # Correct route
116
+@app.route("/getWorkDirFiles", methods=["GET"])
117
@requires_auth
117
-async def browse_work_dir():
118
- work_dir = os.path.join(os.getcwd(), "work_dir")
118
+async def get_work_dir_files():
119
try:
120
- files = [
121
- f for f in os.listdir(work_dir) if os.path.isfile(os.path.join(work_dir, f))
122
- ]
123
- return jsonify({"ok": True, "files": files})
124
- except FileNotFoundError:
125
- return jsonify({"ok": False, "message": "work_dir not found"}), 404
120
+ current_path = request.args.get('path', '')
121
+ work_dir = files.get_abs_path("work_dir")
122
+ browser = FileBrowser(work_dir)
123
+ result = browser.get_files(current_path)
124
+
125
+ response = {
126
+ "ok": True,
127
+ "data": result
128
+ }
129
+
130
except Exception as e:
127
- return (
128
- jsonify({"ok": False, "message": f"Error browsing work_dir: {str(e)}"}),
129
- 500,
130
- )
131
+ response = {
132
+ "ok": False,
133
+ "message": str(e)
134
+ }
135
+ PrintStyle.error(str(e))
136
+
137
+ return jsonify(response)
138
+
139
+
140
+@app.route("/uploadWorkDirFiles", methods=["POST"])
141
+@requires_auth
142
+async def upload_work_dir_files():
143
+ try:
144
+ if "files[]" not in request.files:
145
+ return jsonify({"ok": False, "message": "No files uploaded"}), 400
146
+
147
+ current_path = request.form.get('path', '')
148
+ uploaded_files = request.files.getlist("files[]")
149
+
150
+ work_dir = files.get_abs_path("work_dir")
151
+ browser = FileBrowser(work_dir)
152
+
153
+ successful, failed = browser.save_files(uploaded_files, current_path)
154
+
155
+ if not successful and failed:
156
+ return jsonify({
157
+ "ok": False,
158
+ "message": "All uploads failed",
159
+ "failed": failed
160
+ }), 400
161
+
162
+ result = browser.get_files(current_path)
163
+
164
+ response = {
165
+ "ok": True,
166
+ "message": "Files uploaded successfully" if not failed else "Some files failed to upload",
167
+ "data": result,
168
+ "successful": successful,
169
+ "failed": failed
170
+ }
171
+
172
+ except Exception as e:
173
+ response = {
174
+ "ok": False,
175
+ "message": str(e)
176
+ }
177
+ PrintStyle.error(str(e))
178
+
179
+ return jsonify(response)
180
+
181
+
182
+@app.route("/downloadWorkDirFile", methods=["GET"])
183
+@requires_auth
184
+async def download_work_dir_file():
185
+ try:
186
+ file_path = request.args.get('path', '')
187
+ if not file_path:
188
+ raise ValueError("No file path provided")
189
+
190
+ work_dir = files.get_abs_path("work_dir")
191
+ browser = FileBrowser(work_dir)
192
+
193
+ full_path = browser.get_file_path(file_path)
194
+ if full_path:
195
+ return send_file(
196
+ full_path,
197
+ as_attachment=True,
198
+ download_name=os.path.basename(file_path)
199
+ )
200
+
201
+ return jsonify({
202
+ "ok": False,
203
+ "message": "File not found"
204
+ }), 404
205
+
206
+ except Exception as e:
207
+ return jsonify({
208
+ "ok": False,
209
+ "message": str(e)
210
+ }), 500
211
+
212
+
213
+@app.route("/deleteWorkDirFile", methods=["POST"])
214
+@requires_auth
215
+async def delete_work_dir_file():
216
+ try:
217
+ data = request.get_json()
218
+ file_path = data.get('path', '')
219
+ current_path = data.get('currentPath', '')
220
+
221
+ work_dir = files.get_abs_path("work_dir")
222
+ browser = FileBrowser(work_dir)
223
+
224
+ if browser.delete_file(file_path):
225
+ # Get updated file list
226
+ result = browser.get_files(current_path)
227
+ response = {
228
+ "ok": True,
229
+ "data": result
230
+ }
231
+ else:
232
+ response = {
233
+ "ok": False,
234
+ "message": "File not found or could not be deleted"
235
+ }
236
+
237
+ except Exception as e:
238
+ response = {
239
+ "ok": False,
240
+ "message": str(e)
241
+ }
242
+ PrintStyle.error(str(e))
243
+
244
+ return jsonify(response)
245
246
247
# handle default address, load index
@@ -174,6 +288,8 @@ async def handle_message(sync: bool):
288
if attachments:
289
os.makedirs(upload_folder, exist_ok=True)
290
for attachment in attachments:
291
+ if attachment.filename is None:
292
+ continue
293
filename = secure_filename(attachment.filename)
294
save_path = files.get_abs_path(upload_folder, filename)
295
attachment.save(save_path)
webui/file_browser.css
new
+363
@@ -0,0 +1,363 @@
1
+/* Work Directory Modal Styles */
2
+.modal-overlay {
3
+ position: fixed;
4
+ top: 0;
5
+ left: 0;
6
+ right: 0;
7
+ bottom: 0;
8
+ background-color: rgba(0, 0, 0, 0.75);
9
+ display: flex;
10
+ justify-content: center;
11
+ align-items: center;
12
+ z-index: 2002;
13
+}
14
+
15
+.modal-container {
16
+ background-color: var(--color-panel);
17
+ border-radius: 12px;
18
+ width: 90%;
19
+ max-width: 800px;
20
+ max-height: 80vh;
21
+ overflow: hidden;
22
+ box-shadow: 0 4px 23px 0 rgba(0, 0, 0, 0.2);
23
+}
24
+
25
+.modal-header {
26
+ padding: 1rem 2rem;
27
+ border-bottom: 1px solid var(--color-border);
28
+ display: flex;
29
+ justify-content: space-between;
30
+ align-items: center;
31
+}
32
+
33
+.modal-subheader {
34
+ padding: 0.7rem 1.5rem;
35
+ display: inline;
36
+ justify-content: space-between;
37
+ align-items: center;
38
+}
39
+
40
+.modal-title {
41
+ font-size: 1.25rem;
42
+ font-weight: 500;
43
+ color: var(--color-text);
44
+}
45
+
46
+.modal-close {
47
+ background: none;
48
+ font-size: xx-large;
49
+ border: none;
50
+ color: var(--color-text);
51
+ opacity: 0.7;
52
+ cursor: pointer;
53
+ padding: 0.5rem;
54
+ transition: opacity 0.2s;
55
+}
56
+
57
+.modal-close:hover {
58
+ opacity: 1;
59
+}
60
+
61
+.modal-content {
62
+ padding: 0.5rem 1.5rem 0rem 1.5rem;
63
+ overflow-y: auto;
64
+ max-height: calc(80vh - 4rem);
65
+}
66
+
67
+.modal-footer {
68
+ padding: var(--spacing-md);
69
+ border-top: 1px solid var(--color-border);
70
+ display: flex;
71
+ justify-content: flex-end;
72
+ background: var(--color-background);
73
+}
74
+
75
+h2 {
76
+ color: var(--color-primary);
77
+}
78
+
79
+/* File Browser Styles */
80
+.files-list {
81
+ width: 100%;
82
+ border-radius: 4px;
83
+ overflow: hidden;
84
+}
85
+
86
+.file-header {
87
+ display: grid;
88
+ grid-template-columns: 2fr 0.6fr 1.0fr 80px;
89
+ background: var(--secondary-bg);
90
+ padding: 8px 0;
91
+ font-weight: bold;
92
+ border-bottom: 1px solid var(--border-color);
93
+}
94
+
95
+.file-cell {
96
+ padding: 4px;
97
+ cursor: pointer;
98
+}
99
+
100
+.file-item {
101
+ display: grid;
102
+ grid-template-columns: 2fr 0.6fr 1.0fr 80px;
103
+ align-items: center;
104
+ padding: 8px 0;
105
+ border-top: 1px solid var(--color-border);
106
+ transition: background-color 0.2s;
107
+ white-space: nowrap;
108
+ overflow: hidden;
109
+}
110
+
111
+.file-item:hover {
112
+ background-color: var(--hover-bg);
113
+ cursor: pointer;
114
+}
115
+
116
+.file-icon {
117
+ font-size: var(--font-size-sm);
118
+ width: 1.8rem;
119
+ height: 1.8rem;
120
+ vertical-align: middle;
121
+ margin-right: 1rem;
122
+}
123
+
124
+.file-name {
125
+ display: flex;
126
+ align-items: center;
127
+ font-weight: 500;
128
+ overflow: hidden;
129
+ margin-right: var(--spacing-sm);
130
+}
131
+
132
+.file-name > span {
133
+ white-space: nowrap;
134
+ overflow: hidden;
135
+ text-overflow: ellipsis;
136
+}
137
+
138
+.file-size {
139
+ color: var(--text-secondary);
140
+}
141
+
142
+.file-date {
143
+ color: var(--text-secondary);
144
+}
145
+
146
+.no-files {
147
+ padding: 32px;
148
+ text-align: center;
149
+ color: var(--text-secondary);
150
+}
151
+
152
+/* Light mode adjustments */
153
+.light-mode .modal-container {
154
+ background-color: var(--color-panel-light);
155
+}
156
+
157
+.light-mode .file-item {
158
+ background-color: var(--color-background-light);
159
+}
160
+
161
+.light-mode .file-item:hover {
162
+ background-color: var(--color-secondary-light);
163
+}
164
+
165
+/* Path Navigator */
166
+
167
+.path-navigator {
168
+ padding: 8px 0;
169
+ display: flex;
170
+ align-items: center;
171
+ gap: 24px;
172
+ background-color: var(--color-message-bg);
173
+ padding: 0.70rem var(--spacing-sm) 0.7rem var(--spacing-sm);
174
+ margin-bottom: 0.3rem;
175
+ border: 1px solid var(--color-border);
176
+ border-radius: 8px;
177
+}
178
+
179
+.nav-button {
180
+ padding: 4px 12px;
181
+ border: 1px solid var(--color-border);
182
+ border-radius: 4px;
183
+ background: var(--color-background);
184
+ color: var(--color-text);
185
+ cursor: pointer;
186
+}
187
+
188
+.nav-button:hover {
189
+ background: var(--hover-bg);
190
+}
191
+
192
+.nav-button.back-button {
193
+ background-color: var(--color-secondary);
194
+ color: var(--color-text);
195
+}
196
+
197
+.nav-button.back-button:hover {
198
+ background-color: var(--color-secondary-dark);
199
+}
200
+
201
+.current-path {
202
+ color: var(--color-text);
203
+ font-family: monospace;
204
+}
205
+
206
+/* Update file-item for folders */
207
+.file-item[data-is-dir="true"] {
208
+ cursor: pointer;
209
+}
210
+
211
+.file-item[data-is-dir="true"]:hover {
212
+ background-color: var(--hover-bg);
213
+}
214
+
215
+/* Button Section */
216
+
217
+.upload-button {
218
+display: inline-block;
219
+padding: 8px 16px;
220
+background-color: var(--color-primary);
221
+color: white;
222
+border-radius: 4px;
223
+cursor: pointer;
224
+transition: background-color 0.2s;
225
+}
226
+
227
+.btn-upload {
228
+ background: #3270e2;
229
+ color: white;
230
+ display: flex;
231
+ transition: background 0.3s ease-in-out;
232
+ margin: 0 auto;
233
+ text-wrap: wrap;
234
+ gap: 0.5rem;
235
+ align-items:center;
236
+}
237
+
238
+ .btn-upload > svg {
239
+ width: 20px;
240
+ }
241
+
242
+.upload-button:hover {
243
+background-color: var(--color-primary-dark);
244
+}
245
+
246
+/* Delete Button */
247
+
248
+.delete-button {
249
+ background: none;
250
+ border: none;
251
+ color: var(--color-primary);
252
+ cursor: pointer;
253
+ width: 32px;
254
+ padding: 4px 8px;
255
+ border-radius: 4px;
256
+ transition: opacity 0.2s, background-color 0.2s;}
257
+
258
+.delete-button:hover {
259
+ color: #ff7878
260
+}
261
+
262
+.delete-button:active {
263
+ opacity: 0.6;
264
+}
265
+
266
+.file-actions {
267
+ display: flex;
268
+ gap: var(--spacing-xs);
269
+ transition: opacity 0.2s;
270
+ }
271
+
272
+.action-button {
273
+ background: none;
274
+ border: none;
275
+ cursor: pointer;
276
+ width: 32px;
277
+ padding: 6px 8px;
278
+ border-radius: 4px;
279
+ transition: background-color 0.2s;
280
+}
281
+
282
+.download-button {
283
+ color: var(--color-primary);
284
+}
285
+
286
+.download-button:hover {
287
+ background-color: var(--color-primary-light);
288
+}
289
+
290
+.button-section {
291
+ gap: 1rem;
292
+}
293
+
294
+@media (max-width: 768px) {
295
+ .file-header {
296
+ display: grid;
297
+ grid-template-columns: 1fr 0.5fr 80px;
298
+ background: var(--secondary-bg);
299
+ padding: 8px 0;
300
+ font-weight: bold;
301
+ border-bottom: 1px solid var(--border-color);
302
+ }
303
+ .file-item {
304
+ display: grid;
305
+ grid-template-columns: 1fr 0.5fr 80px;
306
+ align-items: center;
307
+ padding: 8px 0;
308
+ border-top: 1px solid var(--color-border);
309
+ transition: background-color 0.2s;
310
+ }
311
+ .file-cell-date {
312
+ display: none;
313
+ }
314
+ .file-date {
315
+ display: none;
316
+ }
317
+}
318
+
319
+@media (max-width: 540px) {
320
+ .file-header {
321
+ display: grid;
322
+ grid-template-columns: 1fr 80px;
323
+ background: var(--secondary-bg);
324
+ padding: 8px 0;
325
+ font-weight: bold;
326
+ border-bottom: 1px solid var(--border-color);
327
+ }
328
+ .file-item {
329
+ display: grid;
330
+ grid-template-columns: 1fr 80px;
331
+ align-items: center;
332
+ padding: 8px 0;
333
+ border-top: 1px solid var(--color-border);
334
+ transition: background-color 0.2s;
335
+ }
336
+ .file-cell-size {
337
+ display: none;
338
+ }
339
+ .file-size {
340
+ display: none;
341
+ }
342
+ .file-cell-date {
343
+ display: none;
344
+ }
345
+ .file-date {
346
+ display: none;
347
+ }
348
+
349
+ #buttons-container {
350
+ max-height: 50px;
351
+ }
352
+
353
+ .btn-upload {
354
+ margin: 0 auto;
355
+ text-wrap: wrap;
356
+ gap: 0.5rem;
357
+ align-items:center;
358
+ }
359
+
360
+ .btn-upload > svg {
361
+ width: 20px;
362
+ }
363
+}
\ No newline at end of file
webui/file_browser.js
new
+249
@@ -0,0 +1,249 @@
1
+const fileBrowserModalProxy = {
2
+ isOpen: false,
3
+ isLoading: false,
4
+
5
+ browser: {
6
+ title: "Work Directory Browser",
7
+ currentPath: "",
8
+ entries: [],
9
+ parentPath: "",
10
+ sortBy: "name",
11
+ sortDirection: "asc"
12
+ },
13
+
14
+ // Initialize navigation history
15
+ history: [],
16
+
17
+ async openModal() {
18
+ const modalEl = document.getElementById('fileBrowserModal');
19
+ const modalAD = Alpine.$data(modalEl);
20
+
21
+ modalAD.isOpen = true;
22
+ modalAD.isLoading = true;
23
+ modalAD.history = []; // reset history when opening modal
24
+
25
+ // Initialize currentPath to root if it's empty
26
+ if (!modalAD.browser.currentPath) {
27
+ modalAD.browser.currentPath = "";
28
+ }
29
+
30
+ await modalAD.fetchFiles(modalAD.browser.currentPath);
31
+ },
32
+
33
+ isArchive(filename) {
34
+ const archiveExts = ['zip', 'tar', 'gz', 'rar', '7z'];
35
+ const ext = filename.split('.').pop().toLowerCase();
36
+ return archiveExts.includes(ext);
37
+ },
38
+
39
+ async fetchFiles(path = "") {
40
+ this.isLoading = true;
41
+ try {
42
+ const response = await fetch(`/getWorkDirFiles?path=${encodeURIComponent(path)}`);
43
+ const data = await response.json();
44
+
45
+ if (data.ok) {
46
+ this.browser.entries = data.data.entries;
47
+ this.browser.currentPath = data.data.current_path;
48
+ this.browser.parentPath = data.data.parent_path;
49
+ } else {
50
+ console.error('Error fetching files:', data.message);
51
+ this.browser.entries = [];
52
+ }
53
+ } catch (error) {
54
+ console.error('Error fetching files:', error);
55
+ this.browser.entries = [];
56
+ } finally {
57
+ this.isLoading = false;
58
+ }
59
+ },
60
+
61
+ async navigateToFolder(path) {
62
+ // Push current path to history before navigating
63
+ if (this.browser.currentPath !== path) {
64
+ this.history.push(this.browser.currentPath);
65
+ }
66
+ await this.fetchFiles(path);
67
+ },
68
+
69
+ async navigateUp() {
70
+ if (this.browser.parentPath !== "") {
71
+ // Push current path to history before navigating up
72
+ this.history.push(this.browser.currentPath);
73
+ await this.fetchFiles(this.browser.parentPath);
74
+ }
75
+ },
76
+
77
+ sortFiles(entries) {
78
+ return [...entries].sort((a, b) => {
79
+ // Folders always come first
80
+ if (a.is_dir !== b.is_dir) {
81
+ return a.is_dir ? -1 : 1;
82
+ }
83
+
84
+ const direction = this.browser.sortDirection === 'asc' ? 1 : -1;
85
+ switch (this.browser.sortBy) {
86
+ case 'name':
87
+ return direction * a.name.localeCompare(b.name);
88
+ case 'size':
89
+ return direction * (a.size - b.size);
90
+ case 'date':
91
+ return direction * (new Date(a.modified) - new Date(b.modified));
92
+ default:
93
+ return 0;
94
+ }
95
+ });
96
+ },
97
+
98
+ toggleSort(column) {
99
+ if (this.browser.sortBy === column) {
100
+ this.browser.sortDirection = this.browser.sortDirection === 'asc' ? 'desc' : 'asc';
101
+ } else {
102
+ this.browser.sortBy = column;
103
+ this.browser.sortDirection = 'asc';
104
+ }
105
+ },
106
+
107
+ async deleteFile(file) {
108
+ if (!confirm(`Are you sure you want to delete ${file.name}?`)) {
109
+ return;
110
+ }
111
+
112
+ try {
113
+ const response = await fetch('/deleteWorkDirFile', {
114
+ method: 'POST',
115
+ headers: {
116
+ 'Content-Type': 'application/json',
117
+ },
118
+ body: JSON.stringify({
119
+ path: file.path,
120
+ currentPath: this.browser.currentPath
121
+ })
122
+ });
123
+
124
+ const data = await response.json();
125
+ if (data.ok) {
126
+ this.browser.entries = this.browser.entries.filter(entry => entry.path !== file.path);
127
+ alert('File deleted successfully.');
128
+ } else {
129
+ alert(`Error deleting file: ${data.message}`);
130
+ }
131
+ } catch (error) {
132
+ console.error('Error deleting file:', error);
133
+ alert('Error deleting file');
134
+ }
135
+ },
136
+
137
+ handleFileUpload(event) {
138
+ const files = event.target.files;
139
+ if (!files.length) return;
140
+
141
+ const formData = new FormData();
142
+ formData.append('path', this.browser.currentPath);
143
+
144
+ for (let i = 0; i < files.length; i++) {
145
+ const ext = files[i].name.split('.').pop().toLowerCase();
146
+ if (!['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) {
147
+ if (files[i].size > 100 * 1024 * 1024) { // 100MB
148
+ alert(`File ${files[i].name} exceeds the maximum allowed size of 100MB.`);
149
+ continue;
150
+ }
151
+ }
152
+ formData.append('files[]', files[i]);
153
+ }
154
+
155
+ // Proceed with upload after validation
156
+ fetch('/uploadWorkDirFiles', {
157
+ method: 'POST',
158
+ body: formData
159
+ })
160
+ .then(response => response.json())
161
+ .then(data => {
162
+ if (data.ok) {
163
+ // Update the file list with new data
164
+ this.browser.entries = data.data.entries.map(entry => ({
165
+ ...entry,
166
+ uploadStatus: data.failed.includes(entry.name) ? 'failed' : 'success'
167
+ }));
168
+ this.browser.currentPath = data.data.current_path;
169
+ this.browser.parentPath = data.data.parent_path;
170
+
171
+ // Show success message
172
+ if (data.failed && data.failed.length > 0) {
173
+ const failedFiles = data.failed.map(file => `${file.name}: ${file.error}`).join('\n');
174
+ alert(`Some files failed to upload:\n${failedFiles}`);
175
+ }
176
+ } else {
177
+ alert(data.message);
178
+ }
179
+ })
180
+ .catch(error => {
181
+ console.error('Error uploading files:', error);
182
+ alert('Error uploading files');
183
+ });
184
+ },
185
+
186
+ downloadFile(file) {
187
+ if (file.is_dir) return;
188
+
189
+ const downloadUrl = `/downloadWorkDirFile?path=${encodeURIComponent(file.path)}`;
190
+
191
+ fetch(downloadUrl)
192
+
193
+ .then(response => {
194
+ if (!response.ok) {
195
+ throw new Error('Network response was not ok');
196
+ }
197
+ return response.blob();
198
+ })
199
+ .then(blob => {
200
+ const link = document.createElement('a');
201
+ link.href = window.URL.createObjectURL(blob);
202
+ link.download = file.name;
203
+ document.body.appendChild(link);
204
+ link.click();
205
+ document.body.removeChild(link);
206
+ window.URL.revokeObjectURL(link.href);
207
+ })
208
+ .catch(error => {
209
+ console.error('Error downloading file:', error);
210
+ alert('Error downloading file');
211
+ });
212
+ },
213
+
214
+ // Helper Functions
215
+ formatFileSize(size) {
216
+ if (size === 0) return '0 Bytes';
217
+ const k = 1024;
218
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
219
+ const i = Math.floor(Math.log(size) / Math.log(k));
220
+ return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
221
+ },
222
+
223
+ formatDate(dateString) {
224
+ const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
225
+ return new Date(dateString).toLocaleDateString(undefined, options);
226
+ },
227
+
228
+ handleClose() {
229
+ this.isOpen = false;
230
+ }
231
+};
232
+
233
+// Wait for Alpine to be ready
234
+document.addEventListener('alpine:init', () => {
235
+ Alpine.data('fileBrowserModalProxy', () => ({
236
+ init() {
237
+ Object.assign(this, fileBrowserModalProxy);
238
+ // Ensure immediate file fetch when modal opens
239
+ this.$watch('isOpen', async (value) => {
240
+ if (value) {
241
+ await this.fetchFiles(this.browser.currentPath);
242
+ }
243
+ });
244
+ }
245
+ }));
246
+});
247
+
248
+// Keep the global assignment for backward compatibility
249
+window.fileBrowserModalProxy = fileBrowserModalProxy;
webui/index.css
+20
-2
@@ -684,7 +684,7 @@ font-size: var(--font-size-small)
684
-webkit-font-optical-sizing: auto;
685
font-size: 0.875rem;
686
max-height: 7rem;
687
- min-height: 2.7rem;
687
+ min-height: 2.8rem;
688
padding: var(--spacing-xs) var(--spacing-sm);
689
overflow-y: auto;
690
scroll-behavior: smooth;
@@ -722,6 +722,7 @@ font-size: var(--font-size-small)
722
#chat-input:focus {
723
outline: 0.05rem solid rgba(155, 155, 155, 0.3);
724
font-size: 0.955rem;
725
+ padding-top: 0.58rem;
726
background-color: var(--color-input-focus);
727
}
728
@@ -1033,7 +1034,7 @@ font-size: var(--font-size-small)
1034
flex-grow: 1;
1035
min-height: 2.7rem;
1036
padding: var(--spacing-sm) var(--spacing-sm);
1036
- padding-top: 0.70rem;
1037
+ padding-top: 0.65rem;
1038
border: 1px solid var(--color-border);
1039
border-radius: 8px;
1040
resize: none;
@@ -1540,6 +1541,23 @@ input:checked + .slider:before {
1541
}
1542
}
1543
1544
+.sidebar-overlay {
1545
+ display: none;
1546
+ position: fixed;
1547
+ top: 0;
1548
+ left: 0;
1549
+ right: 0;
1550
+ bottom: 0;
1551
+ background-color: rgba(0, 0, 0, 0);
1552
+ opacity: 0;
1553
+ z-index: 999;
1554
+}
1555
+
1556
+.sidebar-overlay.visible {
1557
+ display: block;
1558
+}
1559
+
1560
+
1561
@media (max-width: 768px) {
1562
#left-panel {
1563
position: fixed;
webui/index.html
+111
-17
@@ -8,6 +8,7 @@
8
<link rel="stylesheet" href="index.css">
9
<link rel="stylesheet" href="toast.css">
10
<link rel="stylesheet" href="settings.css">
11
+ <link rel="stylesheet" href="file_browser.css">
12
<link rel="stylesheet" href="speech.css">
13
14
<script>
@@ -16,24 +17,27 @@
17
}
18
</script>
19
20
+ <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
21
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
22
+
23
<!-- KaTeX CSS -->
24
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css" crossorigin="anonymous">
25
26
<!-- KaTeX javascript -->
27
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js" crossorigin="anonymous"></script>
24
- <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js"
25
- crossorigin="anonymous"></script>
28
+ <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
29
27
- <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
28
- <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
30
+
31
<script type="module" src="index.js"></script>
32
<script type="text/javascript" src="settings.js"></script>
33
+ <script type="text/javascript" src="file_browser.js"></script>
34
<script type="module" src="speech.js"></script>
35
36
</head>
37
38
<body>
39
<div class="container">
40
+ <div id="sidebar-overlay" class="sidebar-overlay hidden"></div>
41
<div class="icons-section" id="hide-button" x-data="{ connected: true }">
42
<!--Sidebar-->
43
<!-- Sidebar Toggle Button -->
@@ -307,7 +311,7 @@
311
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5">
312
</path>
313
</svg>Import knowledge</button>
310
- <button class="text-button" @click="workDirModalProxy.openModal()">
314
+ <button class="text-button" id="work_dir_browser" @click="fileBrowserModalProxy.openModal()">
315
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.37 92.59">
316
<path
317
d="m5.72,11.5l-3.93,8.73h119.77s-3.96-8.73-3.96-8.73h-60.03c-1.59,0-2.88-1.29-2.88-2.88V1.75H13.72v6.87c0,1.59-1.29,2.88-2.88,2.88h-5.12Z"
@@ -340,10 +344,13 @@
344
</div>
345
<div id="settingsModal" x-data="settingsModalProxy">
346
<template x-teleport="body">
343
- <div x-show="isOpen" class="modal-overlay" @click.self="handleCancel()" x-transition>
347
+ <div x-show="isOpen" class="modal-overlay" @click.self="handleCancel()" @keydown.escape.window="handleClose()" x-transition>
348
<div class="modal-container">
345
- <div class="modal-header">
349
+ <div class="modal-header" id="settings-title">
350
<h2 x-text="settings.title"></h2>
351
+ <button class="modal-close" @click="handleCancel()">×</button>
352
+ </div>
353
+ <div id="settings-sections">
354
<!-- Dynamically generated navigation -->
355
<nav>
356
<ul>
@@ -450,26 +457,113 @@
457
</div>
458
</template>
459
</div>
460
+
461
+ <!-- work_dir Browser Modal -->
462
454
- <div id="workDirModal" x-data="workDirModalProxy">
463
+ <div id="fileBrowserModal" x-data="fileBrowserModalProxy">
464
<template x-teleport="body">
456
- <div x-show="isOpen" class="modal-overlay" @click.self="close()" x-transition>
465
+ <div x-show="isOpen" class="modal-overlay" @click.self="handleClose()" @keydown.escape.window="handleClose()" x-transition>
466
<div class="modal-container">
467
<div class="modal-header">
459
- <h2>Work Directory</h2>
468
+ <h2 class="modal-title" x-text="browser.title"></h2>
469
+ <button class="modal-close" @click="handleClose()">×</button>
470
</div>
471
<div class="modal-content">
462
- <ul>
463
- <template x-for="file in files" :key="file">
464
- <li x-text="file"></li>
465
- </template>
466
- </ul>
472
+ <div x-show="isLoading" class="loading-spinner">
473
+ Loading...
474
+ </div>
475
+ <div x-show="!isLoading">
476
+ <div class="path-navigator">
477
+ <!-- Up Button -->
478
+ <button class="text-button back-button"
479
+ @click="navigateUp()"
480
+ aria-label="Navigate Up">
481
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
482
+ <path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
483
+ </svg>
484
+ Up
485
+ </button>
486
+
487
+ <span class="current-path" x-text="browser.currentPath || 'Current Path: /'"></span>
488
+ </div>
489
+
490
+ <div class="files-list">
491
+ <!-- Header -->
492
+ <div class="file-header">
493
+ <div class="file-cell" @click="toggleSort('name')">
494
+ Name
495
+ <span x-show="browser.sortBy === 'name'"
496
+ x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
497
+ </span>
498
+ </div>
499
+ <div class="file-cell-size" @click="toggleSort('size')">
500
+ Size
501
+ <span x-show="browser.sortBy === 'size'"
502
+ x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
503
+ </span>
504
+ </div>
505
+ <div class="file-cell-date" @click="toggleSort('date')">
506
+ Modified
507
+ <span x-show="browser.sortBy === 'date'"
508
+ x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
509
+ </span>
510
+ </div>
511
+ </div>
512
+
513
+ <!-- File List -->
514
+ <template x-if="browser.entries.length">
515
+ <template x-for="file in sortFiles(browser.entries)" :key="file.path">
516
+ <div class="file-item" :data-is-dir="file.is_dir">
517
+ <div class="file-name" @click="file.is_dir ? navigateToFolder(file.path) : downloadFile(file)">
518
+ <img :src="'/public/' + (file.type === 'unknown' ? 'file' : (isArchive(file.name) ? 'archive' : file.type)) + '.svg'" class="file-icon" :alt="file.type">
519
+ <span x-text="file.name"></span>
520
+ </div>
521
+ <div class="file-size" x-text="formatFileSize(file.size)"></div>
522
+ <div class="file-date" x-text="formatDate(file.modified)"></div>
523
+
524
+ <div class="file-actions">
525
+ <button class="action-button download-button" @click.stop="downloadFile(file)" x-show="!file.is_dir">
526
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.5 19.5">
527
+ <path d="m.75,14.25v2.25c0,1.24,1.01,2.25,2.25,2.25h13.5c1.24,0,2.25-1.01,2.25-2.25v-2.25m-4.5-4.5l-4.5,4.5m0,0l-4.5-4.5m4.5,4.5V.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
528
+ </svg>
529
+ </button>
530
+ <button class="delete-button" @click.stop="deleteFile(file)" x-show="!file.is_dir || (file.is_dir && file.size === 0)">
531
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15.03 22.53" fill="currentColor">
532
+ <path d="m14.55,7.82H4.68L14.09,3.19c.83-.41,1.17-1.42.77-2.25-.41-.83-1.42-1.17-2.25-.77l-3.16,1.55-.15-.31c-.22-.44-.59-.76-1.05-.92-.46-.16-.96-.13-1.39.09l-2.08,1.02c-.9.44-1.28,1.54-.83,2.44l.15.31-3.16,1.55c-.83.41-1.17,1.42-.77,2.25.29.59.89.94,1.51.94.25,0,.5-.06.74-.17l.38-.19s.09.03.14.03h11.14v11.43c0,.76-.62,1.38-1.38,1.38h-.46v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-2.39v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-2.39v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-.46c-.76,0-1.38-.62-1.38-1.38v-9.9c0-.26-.21-.47-.47-.47s-.47.21-.47.47v9.9c0,1.28,1.04,2.32,2.32,2.32h8.55c1.28,0,2.32-1.04,2.32-2.32v-11.91c0-.26-.21-.47-.47-.47ZM5.19,2.46l2.08-1.02c.12-.06.25-.09.39-.09.09,0,.19.02.28.05.22.08.4.23.5.44l.15.31-.19.09-3.46,1.7-.15-.31c-.21-.43-.03-.96.4-1.17Zm-3.19,5.62c-.36.18-.8.03-.98-.33-.18-.36-.03-.8.33-.98l5.8-2.85,2.72-1.34,3.16-1.55c.1-.05.21-.07.32-.07.27,0,.53.15.66.41.09.17.1.37.04.56-.06.18-.19.33-.37.42L2,8.08Z" stroke-width="0"></path>
533
+ </svg>
534
+ </button>
535
+ </div>
536
+ </div>
537
+ </template>
538
+ </template>
539
+
540
+ <!-- Empty State -->
541
+ <template x-if="!browser.entries.length">
542
+ <div class="no-files">
543
+ No files found
544
+ </div>
545
+ </template>
546
+ </div>
547
+ </div>
548
</div>
549
<div class="modal-footer">
469
- <button @click="close()">Close</button>
550
+ <div id="buttons-container">
551
+ <label class="btn btn-upload"><svg xmlns="http://www.w3.org/2000/svg"
552
+ fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
553
+ <path stroke-linecap="round" stroke-linejoin="round"
554
+ d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5">
555
+ </path>
556
+ </svg>
557
+ Upload Files
558
+ <input type="file" multiple=""
559
+ accept="all"
560
+ @change="handleFileUpload"
561
+ style="display: none;">
562
+ </label>
563
+ <button class="btn btn-cancel" @click="handleClose()">Close Browser</button>
564
+ </div>
565
</div>
566
</div>
472
- </div>
567
</template>
568
</div>
569
webui/index.js
+23
-42
@@ -23,24 +23,44 @@ function isMobile() {
23
return window.innerWidth <= 768;
24
}
25
26
-function toggleSidebar() {
27
- leftPanel.classList.toggle('hidden');
28
- rightPanel.classList.toggle('expanded');
26
+function toggleSidebar(show) {
27
+ const overlay = document.getElementById('sidebar-overlay');
28
+ if (typeof show === 'boolean') {
29
+ leftPanel.classList.toggle('hidden', !show);
30
+ rightPanel.classList.toggle('expanded', !show);
31
+ overlay.classList.toggle('visible', show);
32
+ } else {
33
+ leftPanel.classList.toggle('hidden');
34
+ rightPanel.classList.toggle('expanded');
35
+ overlay.classList.toggle('visible', !leftPanel.classList.contains('hidden'));
36
+ }
37
}
38
39
function handleResize() {
40
+ const overlay = document.getElementById('sidebar-overlay');
41
if (isMobile()) {
42
leftPanel.classList.add('hidden');
43
rightPanel.classList.add('expanded');
44
+ overlay.classList.remove('visible');
45
} else {
46
leftPanel.classList.remove('hidden');
47
rightPanel.classList.remove('expanded');
48
+ overlay.classList.remove('visible');
49
}
50
}
51
52
window.addEventListener('load', handleResize);
53
window.addEventListener('resize', handleResize);
54
55
+document.addEventListener('DOMContentLoaded', () => {
56
+ const overlay = document.getElementById('sidebar-overlay');
57
+ overlay.addEventListener('click', () => {
58
+ if (isMobile()) {
59
+ toggleSidebar(false);
60
+ }
61
+ });
62
+});
63
+
64
function setupSidebarToggle() {
65
const leftPanel = document.getElementById('left-panel');
66
const rightPanel = document.getElementById('right-panel');
@@ -249,45 +269,6 @@ window.loadKnowledge = async function () {
269
}
270
271
252
-const workDirModalProxy = {
253
- isOpen: false,
254
- files: [],
255
-
256
- async openModal() { // Define openModal
257
- // Inside openModal, call the existing open method:
258
- await this.open(); // Or directly include the fetching logic here
259
- },
260
-
261
- async open() {
262
- const response = await sendJsonData('/work_dir');
263
- if (response.ok) {
264
- this.files = response.files;
265
- this.isOpen = true;
266
- } else {
267
- toast(response.message, 'error');
268
- }
269
- },
270
-
271
- close() {
272
- this.isOpen = false;
273
- }
274
-};
275
-
276
-// Make the proxy available globally
277
-window.workDirModalProxy = workDirModalProxy;
278
-
279
-// Ensure correct setup for Alpine.js x-data.
280
-window.workDirModal = function () {
281
- return workDirModalProxy; // Returns the proxy object for the Work Dir modal
282
-}
283
-
284
-
285
-document.addEventListener('alpine:init', () => {
286
- // Make workDirModalProxy available as an Alpine component/store
287
- Alpine.data('workDirModal', workDirModal);
288
-});
289
-
290
-
272
function adjustTextareaHeight() {
273
chatInput.style.height = 'auto';
274
chatInput.style.height = (chatInput.scrollHeight) + 'px';
webui/public/archive.svg
new
+24
@@ -0,0 +1,24 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 22.5 23" style="enable-background:new 0 0 22.5 23;" xml:space="preserve">
5
+<style type="text/css">
6
+ .st0{fill:#6495ED;}
7
+</style>
8
+<g>
9
+ <path class="st0" d="M19.6,5.8H16c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5c-0.3,0-0.5,0.2-0.5,0.5v2.6
10
+ c0,0.9,0.7,1.6,1.6,1.6h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H4c-0.3,0-0.5-0.2-0.5-0.5V1.6C3.4,1.3,3.7,1,4,1h6.3v1h1V1h4l4,3.5
11
+ c0.2,0.2,0.5,0.2,0.7,0c0.2-0.2,0.2-0.5,0-0.7l-4.2-3.7C15.7,0,15.6,0,15.5,0H4C3.1,0,2.4,0.7,2.4,1.6v19.9C2.4,22.3,3.1,23,4,23
12
+ h14.6c0.9,0,1.6-0.7,1.6-1.6v-5.2V6.3C20.2,6,19.9,5.8,19.6,5.8z"/>
13
+ <rect x="11.3" y="2.1" class="st0" width="1" height="1"/>
14
+ <rect x="10.2" y="3.1" class="st0" width="1" height="1"/>
15
+ <rect x="11.3" y="4.2" class="st0" width="1" height="1"/>
16
+ <rect x="10.2" y="5.2" class="st0" width="1" height="1"/>
17
+ <rect x="11.3" y="6.3" class="st0" width="1" height="1"/>
18
+ <rect x="10.2" y="7.3" class="st0" width="1" height="1"/>
19
+ <rect x="11.3" y="8.4" class="st0" width="1" height="1"/>
20
+ <rect x="10.2" y="9.4" class="st0" width="1" height="1"/>
21
+ <rect x="11.3" y="10.5" class="st0" width="1" height="1"/>
22
+ <path class="st0" d="M10.2,14.6c0,0.6,0.5,1,1,1c0.6,0,1-0.5,1-1v-2.1h-2.1V14.6z"/>
23
+</g>
24
+</svg>
webui/public/code.svg
new
+20
@@ -0,0 +1,20 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 22.5 23" style="enable-background:new 0 0 22.5 23;" xml:space="preserve">
5
+<style type="text/css">
6
+ .st0{fill:#F2B700;}
7
+</style>
8
+<g>
9
+ <path class="st0" d="M19.6,5.8H16c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5c-0.3,0-0.5,0.2-0.5,0.5v2.6
10
+ c0,0.9,0.7,1.6,1.6,1.6h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H4c-0.3,0-0.5-0.2-0.5-0.5V1.6C3.4,1.3,3.7,1,4,1h11.3l4,3.5
11
+ c0.2,0.2,0.5,0.2,0.7,0c0.2-0.2,0.2-0.5,0-0.7l-4.2-3.7C15.7,0,15.6,0,15.5,0H4C3.1,0,2.4,0.7,2.4,1.6v19.9C2.4,22.3,3.1,23,4,23
12
+ h14.6c0.9,0,1.6-0.7,1.6-1.6v-5.2V6.3C20.2,6,19.9,5.8,19.6,5.8z"/>
13
+ <path class="st0" d="M11.8,11.3l-2.1,5.2c-0.1,0.3,0,0.6,0.3,0.7c0.1,0,0.1,0,0.2,0c0.2,0,0.4-0.1,0.5-0.3l2.1-5.2
14
+ c0.1-0.3,0-0.6-0.3-0.7C12.2,10.9,11.9,11,11.8,11.3z"/>
15
+ <path class="st0" d="M14.1,17.1c0.1,0.1,0.2,0.1,0.3,0.1c0.2,0,0.3-0.1,0.4-0.2l2.1-2.6c0.2-0.2,0.2-0.5,0-0.7l-2.1-2.6
16
+ c-0.2-0.2-0.5-0.3-0.7-0.1c-0.2,0.2-0.3,0.5-0.1,0.7l1.8,2.3L14,16.4C13.8,16.6,13.9,17,14.1,17.1z"/>
17
+ <path class="st0" d="M8.5,11.1c-0.2-0.2-0.6-0.1-0.7,0.1l-2.1,2.6c-0.2,0.2-0.2,0.5,0,0.7l2.1,2.6c0.1,0.1,0.3,0.2,0.4,0.2
18
+ c0.1,0,0.2,0,0.3-0.1c0.2-0.2,0.3-0.5,0.1-0.7l-1.8-2.3l1.8-2.3C8.7,11.6,8.7,11.3,8.5,11.1z"/>
19
+</g>
20
+</svg>
webui/public/deletefile.svg
new
+13
@@ -0,0 +1,13 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
5
+<path d="M18.2,8.8H8.4l9.4-4.6c0.8-0.4,1.2-1.4,0.8-2.2c-0.4-0.8-1.4-1.2-2.2-0.8l-3.2,1.6L13,2.4c-0.2-0.4-0.6-0.8-1-0.9
6
+ c-0.5-0.2-1-0.1-1.4,0.1l-2.1,1C7.5,3,7.2,4.1,7.6,5l0.2,0.3L4.6,6.9C3.8,7.3,3.4,8.3,3.9,9.1c0.3,0.6,0.9,0.9,1.5,0.9
7
+ c0.2,0,0.5-0.1,0.7-0.2l0.4-0.2c0,0,0.1,0,0.1,0h11.1v11.4c0,0.8-0.6,1.4-1.4,1.4h-0.5V11.3c0-0.3-0.2-0.5-0.5-0.5S15,11,15,11.3
8
+ v11.3h-2.4V11.3c0-0.3-0.2-0.5-0.5-0.5c-0.3,0-0.5,0.2-0.5,0.5v11.3H9.2V11.3c0-0.3-0.2-0.5-0.5-0.5c-0.3,0-0.5,0.2-0.5,0.5v11.3
9
+ H7.8c-0.8,0-1.4-0.6-1.4-1.4v-9.9c0-0.3-0.2-0.5-0.5-0.5S5.5,11,5.5,11.3v9.9c0,1.3,1,2.3,2.3,2.3h8.5c1.3,0,2.3-1,2.3-2.3V9.3
10
+ C18.7,9,18.5,8.8,18.2,8.8z M8.9,3.4l2.1-1c0.1-0.1,0.3-0.1,0.4-0.1c0.1,0,0.2,0,0.3,0c0.2,0.1,0.4,0.2,0.5,0.4l0.2,0.3l-0.2,0.1
11
+ L8.6,4.9L8.5,4.6C8.3,4.2,8.4,3.6,8.9,3.4z M5.7,9.1c-0.4,0.2-0.8,0-1-0.3c-0.2-0.4,0-0.8,0.3-1l5.8-2.9l2.7-1.3L16.7,2
12
+ c0.1-0.1,0.2-0.1,0.3-0.1c0.3,0,0.5,0.1,0.7,0.4c0.1,0.2,0.1,0.4,0,0.6c-0.1,0.2-0.2,0.3-0.4,0.4L5.7,9.1z"/>
13
+</svg>
webui/public/document.svg
new
+29
@@ -0,0 +1,29 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 22.5 23" style="enable-background:new 0 0 22.5 23;" xml:space="preserve">
5
+<style type="text/css">
6
+ .st0{fill:#A0A0A0;}
7
+</style>
8
+<g>
9
+ <path class="st0" d="M19.6,5.8H16c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5s-0.5,0.2-0.5,0.5v2.6c0,0.9,0.7,1.6,1.6,1.6
10
+ h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H4c-0.3,0-0.5-0.2-0.5-0.5V1.6C3.4,1.3,3.7,1,4,1h11.3l4,3.5c0.2,0.2,0.5,0.2,0.7,0
11
+ s0.2-0.5,0-0.7l-4.2-3.7C15.7,0,15.6,0,15.5,0H4C3.1,0,2.4,0.7,2.4,1.6v19.9C2.4,22.3,3.1,23,4,23h14.6c0.9,0,1.6-0.7,1.6-1.6v-5.2
12
+ V6.3C20.2,6,19.9,5.8,19.6,5.8z"/>
13
+ <path class="st0" d="M16.5,16.7c0-0.3-0.2-0.5-0.5-0.5H6c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h10C16.3,17.2,16.5,17,16.5,16.7z"
14
+ />
15
+ <path class="st0" d="M6,8.9h5.2c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6c-0.3,0-0.5,0.2-0.5,0.5S5.8,8.9,6,8.9z"/>
16
+ <path class="st0" d="M16.5,9.9h-6.3c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h6.3c0.3,0,0.5-0.2,0.5-0.5C17,10.2,16.8,9.9,16.5,9.9z
17
+ "/>
18
+ <path class="st0" d="M16.5,7.8h-3.1c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h3.1c0.3,0,0.5-0.2,0.5-0.5C17,8.1,16.8,7.8,16.5,7.8z"
19
+ />
20
+ <path class="st0" d="M6,13.1h4.7c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6c-0.3,0-0.5,0.2-0.5,0.5C5.5,12.8,5.8,13.1,6,13.1z"/>
21
+ <path class="st0" d="M6,15.2h2.1c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6c-0.3,0-0.5,0.2-0.5,0.5C5.5,14.9,5.8,15.2,6,15.2z"/>
22
+ <path class="st0" d="M6,11h2.1c0.3,0,0.5-0.2,0.5-0.5S8.4,10,8.1,10H6c-0.3,0-0.5,0.2-0.5,0.5C5.5,10.7,5.8,11,6,11z"/>
23
+ <path class="st0" d="M17,12.5c0-0.3-0.2-0.5-0.5-0.5h-3.7c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h3.7C16.8,13.1,17,12.8,17,12.5z"
24
+ />
25
+ <path class="st0" d="M9.7,14.6c0,0.3,0.2,0.5,0.5,0.5h4.2c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5h-4.2C9.9,14.1,9.7,14.3,9.7,14.6
26
+ z"/>
27
+ <path class="st0" d="M6,18.3c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h5.2c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6z"/>
28
+</g>
29
+</svg>
webui/public/downloadfile.svg
new
+11
@@ -0,0 +1,11 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
5
+<style type="text/css">
6
+ .st0{fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;}
7
+</style>
8
+<g>
9
+ <path class="st0" d="M3,16.5v2.2C3,20,4,21,5.2,21h13.5c1.2,0,2.2-1,2.2-2.2v-2.2 M16.5,12L12,16.5 M12,16.5L7.5,12 M12,16.5V3"/>
10
+</g>
11
+</svg>
webui/public/file.svg
new
+14
@@ -0,0 +1,14 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 19.3 23" style="enable-background:new 0 0 19.3 23;" xml:space="preserve">
5
+<style type="text/css">
6
+ .st0{fill:#A0A0A0;}
7
+</style>
8
+<g>
9
+ <path class="st0" d="M18,5.8h-3.6c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5s-0.5,0.2-0.5,0.5v2.6c0,0.9,0.7,1.6,1.6,1.6
10
+ h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H2.4c-0.3,0-0.5-0.2-0.5-0.5V1.6C1.8,1.3,2.1,1,2.4,1h11.3l4,3.5c0.2,0.2,0.5,0.2,0.7,0
11
+ s0.2-0.5,0-0.7l-4.2-3.7C14.1,0,14,0,13.9,0H2.4C1.5,0,0.8,0.7,0.8,1.6v19.9c0,0.8,0.7,1.5,1.6,1.5H17c0.9,0,1.6-0.7,1.6-1.6v-5.2
12
+ V6.3C18.6,6,18.3,5.8,18,5.8z"/>
13
+</g>
14
+</svg>
webui/public/folder.svg
new
+13
@@ -0,0 +1,13 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 23 16.3" style="enable-background:new 0 0 23 16.3;" xml:space="preserve">
5
+<style type="text/css">
6
+ .st0{fill:#A0A0A0;}
7
+</style>
8
+<path class="st0" d="M21.9,5.3h-0.7V3.8c0-0.6-0.5-1.1-1.1-1.1h-8.9l-1.3-2C9.6,0.2,9.3,0,8.8,0H3C2.4,0,1.9,0.5,1.9,1.2v4.2H1.1
9
+ c-0.3,0-0.6,0.1-0.9,0.4C0.1,5.9,0,6.3,0,6.6l1,8.8c0.1,0.6,0.6,1,1.1,1h18.6c0.6,0,1.1-0.4,1.1-1l1-8.8c0-0.3-0.1-0.7-0.3-0.9
10
+ C22.5,5.4,22.2,5.3,21.9,5.3L21.9,5.3z M2.8,1.1C2.8,1,2.9,1,3,1h5.8C8.9,1,8.9,1,9,1.1l1.4,2.3c0.1,0.1,0.2,0.2,0.4,0.2H20
11
+ c0.1,0,0.2,0.1,0.2,0.2v1.5H2.8V1.1z M21,15.2c0,0.1-0.1,0.1-0.2,0.1H2.2c-0.1,0-0.2-0.1-0.2-0.1L1,6.5c0-0.1,0-0.1,0-0.1
12
+ s0.1-0.1,0.1-0.1h20.7c0.1,0,0.1,0,0.1,0.1s0,0.1,0,0.1L21,15.2L21,15.2z"/>
13
+</svg>
webui/public/image.svg
new
+20
@@ -0,0 +1,20 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 22.5 23" style="enable-background:new 0 0 22.5 23;" xml:space="preserve">
5
+<style type="text/css">
6
+ .st0{fill:#00DD7F;}
7
+</style>
8
+<g>
9
+ <path class="st0" d="M19.6,5.8H16c-0.3,0-0.5-0.2-0.5-0.5V2.6c0-0.3-0.2-0.5-0.5-0.5s-0.5,0.2-0.5,0.5v2.6c0,0.9,0.7,1.6,1.6,1.6
10
+ h3.1v9.4v5.2c0,0.3-0.2,0.5-0.5,0.5H4c-0.3,0-0.5-0.2-0.5-0.5V1.6C3.4,1.3,3.7,1,4,1h11.3l4,3.5c0.2,0.2,0.5,0.2,0.7,0
11
+ s0.2-0.5,0-0.7l-4.2-3.7C15.7,0,15.6,0,15.5,0H4C3.1,0,2.4,0.7,2.4,1.6v19.9C2.4,22.3,3.1,23,4,23h14.6c0.9,0,1.6-0.7,1.6-1.6v-5.2
12
+ V6.3C20.2,6,19.9,5.8,19.6,5.8z"/>
13
+ <path class="st0" d="M7.8,14.8c0.1-0.2,0.5-0.3,0.6,0L9.8,17c0.1,0.2,0.3,0.2,0.5,0.3c0.2,0,0.4-0.1,0.4-0.3l2.4-4.3
14
+ c0.1-0.2,0.5-0.2,0.6,0l2.8,5.5c0.1,0.3,0.4,0.4,0.7,0.2c0.3-0.1,0.4-0.4,0.2-0.7l-2.8-5.5c-0.2-0.5-0.7-0.8-1.2-0.8
15
+ s-1,0.3-1.2,0.7l-1.9,3.5l-0.9-1.4c-0.2-0.4-0.7-0.7-1.2-0.7s-1,0.3-1.2,0.8l-1.7,3.4C5.1,18,5,18.3,5,18.6c0,1,0.8,1.8,1.8,1.8H17
16
+ c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5H6.8C6.4,19.3,6,19,6,18.6c0-0.1,0-0.2,0.1-0.3L7.8,14.8z"/>
17
+ <path class="st0" d="M10.2,9.4c0-1.2-0.9-2.1-2.1-2.1S6,8.3,6,9.4s0.9,2.1,2.1,2.1S10.2,10.6,10.2,9.4z M7.1,9.4c0-0.6,0.5-1,1-1
18
+ c0.6,0,1,0.5,1,1c0,0.6-0.5,1-1,1C7.6,10.5,7.1,10,7.1,9.4z"/>
19
+</g>
20
+</svg>
webui/public/settings.svg
new
+2
@@ -0,0 +1,2 @@
1
+<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
2
+<svg fill="#000000" width="800px" height="800px" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M25 34c-5 0-9-4-9-9s4-9 9-9 9 4 9 9-4 9-9 9zm0-16c-3.9 0-7 3.1-7 7s3.1 7 7 7 7-3.1 7-7-3.1-7-7-7z"/><path d="M27.7 44h-5.4l-1.5-4.6c-1-.3-2-.7-2.9-1.2l-4.4 2.2-3.8-3.8 2.2-4.4c-.5-.9-.9-1.9-1.2-2.9L6 27.7v-5.4l4.6-1.5c.3-1 .7-2 1.2-2.9l-2.2-4.4 3.8-3.8 4.4 2.2c.9-.5 1.9-.9 2.9-1.2L22.3 6h5.4l1.5 4.6c1 .3 2 .7 2.9 1.2l4.4-2.2 3.8 3.8-2.2 4.4c.5.9.9 1.9 1.2 2.9l4.6 1.5v5.4l-4.6 1.5c-.3 1-.7 2-1.2 2.9l2.2 4.4-3.8 3.8-4.4-2.2c-.9.5-1.9.9-2.9 1.2L27.7 44zm-4-2h2.6l1.4-4.3.5-.1c1.2-.3 2.3-.8 3.4-1.4l.5-.3 4 2 1.8-1.8-2-4 .3-.5c.6-1 1.1-2.2 1.4-3.4l.1-.5 4.3-1.4v-2.6l-4.3-1.4-.1-.5c-.3-1.2-.8-2.3-1.4-3.4l-.3-.5 2-4-1.8-1.8-4 2-.5-.3c-1.1-.6-2.2-1.1-3.4-1.4l-.5-.1L26.3 8h-2.6l-1.4 4.3-.5.1c-1.2.3-2.3.8-3.4 1.4l-.5.3-4-2-1.8 1.8 2 4-.3.5c-.6 1-1.1 2.2-1.4 3.4l-.1.5L8 23.7v2.6l4.3 1.4.1.5c.3 1.2.8 2.3 1.4 3.4l.3.5-2 4 1.8 1.8 4-2 .5.3c1.1.6 2.2 1.1 3.4 1.4l.5.1 1.4 4.3z"/></svg>
\ No newline at end of file
webui/settings.css
+18
@@ -32,6 +32,7 @@ select {
32
}
33
34
.modal-header h2 {
35
+ color: var(--color-primary);
36
font-size: 1.25rem;
37
margin: 0;
38
}
@@ -41,6 +42,10 @@ select {
42
line-height: 2em;
43
}
44
45
+#settings-title {
46
+ padding: 0.5rem 2rem 0.5rem 2rem;
47
+}
48
+
49
.modal-content {
50
padding: var(--spacing-sm);
51
overflow-y: auto;
@@ -240,6 +245,10 @@ input[type="range"] {
245
transition: background 0.3s ease-in-out;
246
}
247
248
+.btn-ok > svg {
249
+ max-width: 20px;
250
+}
251
+
252
.btn-ok:hover {
253
background: #3265c0;
254
}
@@ -311,6 +320,15 @@ nav ul li a:hover {
320
text-decoration: underline;
321
}
322
323
+#settings-sections {
324
+ background-color: var(--color-secondary);
325
+ padding: 0.3rem 2rem;
326
+ line-height: 1.6rem;
327
+ font-size: var(--font-size-normal);
328
+ border-bottom: 1px solid var(--color-border);
329
+}
330
+
331
+
332
@media (max-width: 768px) {
333
.modal-header {
334
padding: var(--spacing-sm);