Merge pull request #249 from 3clyp50/development
feature: work_dir file manager
170
python/helpers/file_browser.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from werkzeug.utils import secure_filename
|
||||
from datetime import datetime
|
||||
|
||||
class FileBrowser:
|
||||
ALLOWED_EXTENSIONS = {
|
||||
'image': {'jpg', 'jpeg', 'png', 'bmp'},
|
||||
'code': {'py', 'js', 'sh', 'html', 'css'},
|
||||
'document': {'md', 'pdf', 'txt', 'csv', 'json'}
|
||||
}
|
||||
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
|
||||
def __init__(self, base_dir: str):
|
||||
self.base_dir = Path(base_dir).resolve()
|
||||
|
||||
def _check_file_size(self, file) -> bool:
|
||||
try:
|
||||
file.seek(0, os.SEEK_END)
|
||||
size = file.tell()
|
||||
file.seek(0)
|
||||
return size <= self.MAX_FILE_SIZE
|
||||
except (AttributeError, IOError):
|
||||
return False
|
||||
|
||||
def save_files(self, files: List, current_path: str = "") -> Tuple[List[str], List[str]]:
|
||||
"""Save uploaded files and return successful and failed filenames"""
|
||||
successful = []
|
||||
failed = []
|
||||
|
||||
try:
|
||||
# Resolve the target directory path
|
||||
target_dir = (self.base_dir / current_path).resolve()
|
||||
if not str(target_dir).startswith(str(self.base_dir)):
|
||||
raise ValueError("Invalid target directory")
|
||||
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
if file and self._is_allowed_file(file.filename, file):
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = target_dir / filename
|
||||
|
||||
file.save(str(file_path))
|
||||
successful.append(filename)
|
||||
else:
|
||||
failed.append(file.filename)
|
||||
except Exception as e:
|
||||
print(f"Error saving file {file.filename}: {e}")
|
||||
failed.append(file.filename)
|
||||
|
||||
return successful, failed
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in save_files: {e}")
|
||||
return successful, failed
|
||||
|
||||
def delete_file(self, file_path: str) -> bool:
|
||||
"""Delete a file or empty directory"""
|
||||
try:
|
||||
# Resolve the full path while preventing directory traversal
|
||||
full_path = (self.base_dir / file_path).resolve()
|
||||
if not str(full_path).startswith(str(self.base_dir)):
|
||||
raise ValueError("Invalid path")
|
||||
|
||||
if os.path.exists(full_path):
|
||||
if os.path.isfile(full_path):
|
||||
os.remove(full_path)
|
||||
elif os.path.isdir(full_path) and not os.listdir(full_path):
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
raise ValueError("Can only delete files or empty directories")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error deleting {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def _is_allowed_file(self, filename: str, file) -> bool:
|
||||
if not filename:
|
||||
return False
|
||||
ext = self._get_file_extension(filename)
|
||||
all_allowed = set().union(*self.ALLOWED_EXTENSIONS.values())
|
||||
if ext not in all_allowed:
|
||||
return False
|
||||
|
||||
return True # Allow the file if it passes the checks
|
||||
|
||||
def _get_file_extension(self, filename: str) -> str:
|
||||
return filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
def get_files(self, current_path: str = "") -> Dict:
|
||||
try:
|
||||
# Resolve the full path while preventing directory traversal
|
||||
full_path = (self.base_dir / current_path).resolve()
|
||||
if not str(full_path).startswith(str(self.base_dir)):
|
||||
raise ValueError("Invalid path")
|
||||
|
||||
files = []
|
||||
folders = []
|
||||
|
||||
# List all entries in the current directory
|
||||
for entry in os.scandir(full_path):
|
||||
entry_data: Dict[str, Any] = {
|
||||
"name": entry.name,
|
||||
"path": str(Path(entry.path).relative_to(self.base_dir)),
|
||||
"modified": datetime.fromtimestamp(entry.stat().st_mtime).isoformat()
|
||||
}
|
||||
|
||||
if entry.is_file():
|
||||
entry_data.update({
|
||||
"type": self._get_file_type(entry.name),
|
||||
"size": entry.stat().st_size,
|
||||
"is_dir": False
|
||||
})
|
||||
files.append(entry_data)
|
||||
else:
|
||||
entry_data.update({
|
||||
"type": "folder",
|
||||
"size": 0, # Directories show as 0 bytes
|
||||
"is_dir": True
|
||||
})
|
||||
folders.append(entry_data)
|
||||
|
||||
# Combine folders and files, folders first
|
||||
all_entries = folders + files
|
||||
|
||||
# Get parent directory path if not at root
|
||||
parent_path = ""
|
||||
if current_path:
|
||||
parent = (Path(current_path).parent)
|
||||
parent_path = str(parent) if parent != Path(".") else ""
|
||||
|
||||
return {
|
||||
"entries": all_entries,
|
||||
"current_path": current_path,
|
||||
"parent_path": parent_path
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading directory: {e}")
|
||||
return {"entries": [], "current_path": "", "parent_path": ""}
|
||||
|
||||
def get_file_path(self, file_path: str) -> Optional[Path]:
|
||||
"""Get full file path if it exists and is within base_dir"""
|
||||
try:
|
||||
full_path = (self.base_dir / file_path).resolve()
|
||||
if not str(full_path).startswith(str(self.base_dir)):
|
||||
raise ValueError("Invalid path")
|
||||
|
||||
if os.path.isfile(full_path):
|
||||
return full_path
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error accessing file {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def _get_file_type(self, filename: str) -> str:
|
||||
ext = self._get_file_extension(filename)
|
||||
for file_type, extensions in self.ALLOWED_EXTENSIONS.items():
|
||||
if ext in extensions:
|
||||
return file_type
|
||||
return 'unknown'
|
||||
|
||||
148
run_ui.py
|
|
@ -4,7 +4,7 @@ import os
|
|||
from pathlib import Path
|
||||
import threading
|
||||
import uuid
|
||||
from flask import Flask, request, jsonify, Response
|
||||
from flask import Flask, request, jsonify, Response, send_file
|
||||
from flask_basicauth import BasicAuth
|
||||
from agent import AgentContext
|
||||
from initialize import initialize
|
||||
|
|
@ -16,6 +16,7 @@ from python.helpers import persist_chat, settings, whisper, rfc, runtime, dotenv
|
|||
import base64
|
||||
from werkzeug.utils import secure_filename
|
||||
from python.helpers.cloudflare_tunnel import CloudflareTunnel
|
||||
from python.helpers.file_browser import FileBrowser
|
||||
|
||||
|
||||
# initialize the internal Flask server
|
||||
|
|
@ -76,7 +77,7 @@ async def upload_file():
|
|||
|
||||
for file in files:
|
||||
if file and allowed_file(file.filename): # Check file type
|
||||
filename = secure_filename(file.filename)
|
||||
filename = secure_filename(file.filename) # type: ignore
|
||||
file.save(os.path.join(UPLOAD_FOLDER, filename))
|
||||
saved_filenames.append(filename)
|
||||
|
||||
|
|
@ -103,7 +104,7 @@ async def import_knowledge():
|
|||
|
||||
for file in files:
|
||||
if file:
|
||||
filename = secure_filename(file.filename)
|
||||
filename = secure_filename(file.filename) # type: ignore
|
||||
file.save(os.path.join(KNOWLEDGE_FOLDER, filename))
|
||||
saved_filenames.append(filename)
|
||||
|
||||
|
|
@ -112,22 +113,135 @@ async def import_knowledge():
|
|||
)
|
||||
|
||||
|
||||
@app.route("/work_dir", methods=["GET"]) # Correct route
|
||||
@app.route("/getWorkDirFiles", methods=["GET"])
|
||||
@requires_auth
|
||||
async def browse_work_dir():
|
||||
work_dir = os.path.join(os.getcwd(), "work_dir")
|
||||
async def get_work_dir_files():
|
||||
try:
|
||||
files = [
|
||||
f for f in os.listdir(work_dir) if os.path.isfile(os.path.join(work_dir, f))
|
||||
]
|
||||
return jsonify({"ok": True, "files": files})
|
||||
except FileNotFoundError:
|
||||
return jsonify({"ok": False, "message": "work_dir not found"}), 404
|
||||
current_path = request.args.get('path', '')
|
||||
work_dir = files.get_abs_path("work_dir")
|
||||
browser = FileBrowser(work_dir)
|
||||
result = browser.get_files(current_path)
|
||||
|
||||
response = {
|
||||
"ok": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return (
|
||||
jsonify({"ok": False, "message": f"Error browsing work_dir: {str(e)}"}),
|
||||
500,
|
||||
)
|
||||
response = {
|
||||
"ok": False,
|
||||
"message": str(e)
|
||||
}
|
||||
PrintStyle.error(str(e))
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@app.route("/uploadWorkDirFiles", methods=["POST"])
|
||||
@requires_auth
|
||||
async def upload_work_dir_files():
|
||||
try:
|
||||
if "files[]" not in request.files:
|
||||
return jsonify({"ok": False, "message": "No files uploaded"}), 400
|
||||
|
||||
current_path = request.form.get('path', '')
|
||||
uploaded_files = request.files.getlist("files[]")
|
||||
|
||||
work_dir = files.get_abs_path("work_dir")
|
||||
browser = FileBrowser(work_dir)
|
||||
|
||||
successful, failed = browser.save_files(uploaded_files, current_path)
|
||||
|
||||
if not successful and failed:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"message": "All uploads failed",
|
||||
"failed": failed
|
||||
}), 400
|
||||
|
||||
result = browser.get_files(current_path)
|
||||
|
||||
response = {
|
||||
"ok": True,
|
||||
"message": "Files uploaded successfully" if not failed else "Some files failed to upload",
|
||||
"data": result,
|
||||
"successful": successful,
|
||||
"failed": failed
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
response = {
|
||||
"ok": False,
|
||||
"message": str(e)
|
||||
}
|
||||
PrintStyle.error(str(e))
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@app.route("/downloadWorkDirFile", methods=["GET"])
|
||||
@requires_auth
|
||||
async def download_work_dir_file():
|
||||
try:
|
||||
file_path = request.args.get('path', '')
|
||||
if not file_path:
|
||||
raise ValueError("No file path provided")
|
||||
|
||||
work_dir = files.get_abs_path("work_dir")
|
||||
browser = FileBrowser(work_dir)
|
||||
|
||||
full_path = browser.get_file_path(file_path)
|
||||
if full_path:
|
||||
return send_file(
|
||||
full_path,
|
||||
as_attachment=True,
|
||||
download_name=os.path.basename(file_path)
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"message": "File not found"
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route("/deleteWorkDirFile", methods=["POST"])
|
||||
@requires_auth
|
||||
async def delete_work_dir_file():
|
||||
try:
|
||||
data = request.get_json()
|
||||
file_path = data.get('path', '')
|
||||
current_path = data.get('currentPath', '')
|
||||
|
||||
work_dir = files.get_abs_path("work_dir")
|
||||
browser = FileBrowser(work_dir)
|
||||
|
||||
if browser.delete_file(file_path):
|
||||
# Get updated file list
|
||||
result = browser.get_files(current_path)
|
||||
response = {
|
||||
"ok": True,
|
||||
"data": result
|
||||
}
|
||||
else:
|
||||
response = {
|
||||
"ok": False,
|
||||
"message": "File not found or could not be deleted"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
response = {
|
||||
"ok": False,
|
||||
"message": str(e)
|
||||
}
|
||||
PrintStyle.error(str(e))
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
# handle default address, load index
|
||||
|
|
@ -174,6 +288,8 @@ async def handle_message(sync: bool):
|
|||
if attachments:
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
for attachment in attachments:
|
||||
if attachment.filename is None:
|
||||
continue
|
||||
filename = secure_filename(attachment.filename)
|
||||
save_path = files.get_abs_path(upload_folder, filename)
|
||||
attachment.save(save_path)
|
||||
|
|
|
|||
363
webui/file_browser.css
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/* Work Directory Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2002;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background-color: var(--color-panel);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 23px 0 rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-subheader {
|
||||
padding: 0.7rem 1.5rem;
|
||||
display: inline;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
font-size: xx-large;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 0.5rem 1.5rem 0rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 4rem);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* File Browser Styles */
|
||||
.files-list {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 0.6fr 1.0fr 80px;
|
||||
background: var(--secondary-bg);
|
||||
padding: 8px 0;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.file-cell {
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 0.6fr 1.0fr 80px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background-color: var(--hover-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: var(--font-size-sm);
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
vertical-align: middle;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.file-name > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.file-date {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-files {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Light mode adjustments */
|
||||
.light-mode .modal-container {
|
||||
background-color: var(--color-panel-light);
|
||||
}
|
||||
|
||||
.light-mode .file-item {
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
|
||||
.light-mode .file-item:hover {
|
||||
background-color: var(--color-secondary-light);
|
||||
}
|
||||
|
||||
/* Path Navigator */
|
||||
|
||||
.path-navigator {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
background-color: var(--color-message-bg);
|
||||
padding: 0.70rem var(--spacing-sm) 0.7rem var(--spacing-sm);
|
||||
margin-bottom: 0.3rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.nav-button.back-button {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-button.back-button:hover {
|
||||
background-color: var(--color-secondary-dark);
|
||||
}
|
||||
|
||||
.current-path {
|
||||
color: var(--color-text);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Update file-item for folders */
|
||||
.file-item[data-is-dir="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-item[data-is-dir="true"]:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* Button Section */
|
||||
|
||||
.upload-button {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
background: #3270e2;
|
||||
color: white;
|
||||
display: flex;
|
||||
transition: background 0.3s ease-in-out;
|
||||
margin: 0 auto;
|
||||
text-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items:center;
|
||||
}
|
||||
|
||||
.btn-upload > svg {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.upload-button:hover {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* Delete Button */
|
||||
|
||||
.delete-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.2s, background-color 0.2s;}
|
||||
|
||||
.delete-button:hover {
|
||||
color: #ff7878
|
||||
}
|
||||
|
||||
.delete-button:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.button-section {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.file-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 0.5fr 80px;
|
||||
background: var(--secondary-bg);
|
||||
padding: 8px 0;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.file-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 0.5fr 80px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.file-cell-date {
|
||||
display: none;
|
||||
}
|
||||
.file-date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.file-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px;
|
||||
background: var(--secondary-bg);
|
||||
padding: 8px 0;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.file-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.file-cell-size {
|
||||
display: none;
|
||||
}
|
||||
.file-size {
|
||||
display: none;
|
||||
}
|
||||
.file-cell-date {
|
||||
display: none;
|
||||
}
|
||||
.file-date {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#buttons-container {
|
||||
max-height: 50px;
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
margin: 0 auto;
|
||||
text-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items:center;
|
||||
}
|
||||
|
||||
.btn-upload > svg {
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
249
webui/file_browser.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
const fileBrowserModalProxy = {
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
|
||||
browser: {
|
||||
title: "Work Directory Browser",
|
||||
currentPath: "",
|
||||
entries: [],
|
||||
parentPath: "",
|
||||
sortBy: "name",
|
||||
sortDirection: "asc"
|
||||
},
|
||||
|
||||
// Initialize navigation history
|
||||
history: [],
|
||||
|
||||
async openModal() {
|
||||
const modalEl = document.getElementById('fileBrowserModal');
|
||||
const modalAD = Alpine.$data(modalEl);
|
||||
|
||||
modalAD.isOpen = true;
|
||||
modalAD.isLoading = true;
|
||||
modalAD.history = []; // reset history when opening modal
|
||||
|
||||
// Initialize currentPath to root if it's empty
|
||||
if (!modalAD.browser.currentPath) {
|
||||
modalAD.browser.currentPath = "";
|
||||
}
|
||||
|
||||
await modalAD.fetchFiles(modalAD.browser.currentPath);
|
||||
},
|
||||
|
||||
isArchive(filename) {
|
||||
const archiveExts = ['zip', 'tar', 'gz', 'rar', '7z'];
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
return archiveExts.includes(ext);
|
||||
},
|
||||
|
||||
async fetchFiles(path = "") {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await fetch(`/getWorkDirFiles?path=${encodeURIComponent(path)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
this.browser.entries = data.data.entries;
|
||||
this.browser.currentPath = data.data.current_path;
|
||||
this.browser.parentPath = data.data.parent_path;
|
||||
} else {
|
||||
console.error('Error fetching files:', data.message);
|
||||
this.browser.entries = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching files:', error);
|
||||
this.browser.entries = [];
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async navigateToFolder(path) {
|
||||
// Push current path to history before navigating
|
||||
if (this.browser.currentPath !== path) {
|
||||
this.history.push(this.browser.currentPath);
|
||||
}
|
||||
await this.fetchFiles(path);
|
||||
},
|
||||
|
||||
async navigateUp() {
|
||||
if (this.browser.parentPath !== "") {
|
||||
// Push current path to history before navigating up
|
||||
this.history.push(this.browser.currentPath);
|
||||
await this.fetchFiles(this.browser.parentPath);
|
||||
}
|
||||
},
|
||||
|
||||
sortFiles(entries) {
|
||||
return [...entries].sort((a, b) => {
|
||||
// Folders always come first
|
||||
if (a.is_dir !== b.is_dir) {
|
||||
return a.is_dir ? -1 : 1;
|
||||
}
|
||||
|
||||
const direction = this.browser.sortDirection === 'asc' ? 1 : -1;
|
||||
switch (this.browser.sortBy) {
|
||||
case 'name':
|
||||
return direction * a.name.localeCompare(b.name);
|
||||
case 'size':
|
||||
return direction * (a.size - b.size);
|
||||
case 'date':
|
||||
return direction * (new Date(a.modified) - new Date(b.modified));
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
toggleSort(column) {
|
||||
if (this.browser.sortBy === column) {
|
||||
this.browser.sortDirection = this.browser.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.browser.sortBy = column;
|
||||
this.browser.sortDirection = 'asc';
|
||||
}
|
||||
},
|
||||
|
||||
async deleteFile(file) {
|
||||
if (!confirm(`Are you sure you want to delete ${file.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/deleteWorkDirFile', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: file.path,
|
||||
currentPath: this.browser.currentPath
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
this.browser.entries = this.browser.entries.filter(entry => entry.path !== file.path);
|
||||
alert('File deleted successfully.');
|
||||
} else {
|
||||
alert(`Error deleting file: ${data.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
alert('Error deleting file');
|
||||
}
|
||||
},
|
||||
|
||||
handleFileUpload(event) {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('path', this.browser.currentPath);
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const ext = files[i].name.split('.').pop().toLowerCase();
|
||||
if (!['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) {
|
||||
if (files[i].size > 100 * 1024 * 1024) { // 100MB
|
||||
alert(`File ${files[i].name} exceeds the maximum allowed size of 100MB.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
formData.append('files[]', files[i]);
|
||||
}
|
||||
|
||||
// Proceed with upload after validation
|
||||
fetch('/uploadWorkDirFiles', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
// Update the file list with new data
|
||||
this.browser.entries = data.data.entries.map(entry => ({
|
||||
...entry,
|
||||
uploadStatus: data.failed.includes(entry.name) ? 'failed' : 'success'
|
||||
}));
|
||||
this.browser.currentPath = data.data.current_path;
|
||||
this.browser.parentPath = data.data.parent_path;
|
||||
|
||||
// Show success message
|
||||
if (data.failed && data.failed.length > 0) {
|
||||
const failedFiles = data.failed.map(file => `${file.name}: ${file.error}`).join('\n');
|
||||
alert(`Some files failed to upload:\n${failedFiles}`);
|
||||
}
|
||||
} else {
|
||||
alert(data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error uploading files:', error);
|
||||
alert('Error uploading files');
|
||||
});
|
||||
},
|
||||
|
||||
downloadFile(file) {
|
||||
if (file.is_dir) return;
|
||||
|
||||
const downloadUrl = `/downloadWorkDirFile?path=${encodeURIComponent(file.path)}`;
|
||||
|
||||
fetch(downloadUrl)
|
||||
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const link = document.createElement('a');
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error downloading file:', error);
|
||||
alert('Error downloading file');
|
||||
});
|
||||
},
|
||||
|
||||
// Helper Functions
|
||||
formatFileSize(size) {
|
||||
if (size === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(size) / Math.log(k));
|
||||
return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
|
||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
||||
},
|
||||
|
||||
handleClose() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for Alpine to be ready
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('fileBrowserModalProxy', () => ({
|
||||
init() {
|
||||
Object.assign(this, fileBrowserModalProxy);
|
||||
// Ensure immediate file fetch when modal opens
|
||||
this.$watch('isOpen', async (value) => {
|
||||
if (value) {
|
||||
await this.fetchFiles(this.browser.currentPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
// Keep the global assignment for backward compatibility
|
||||
window.fileBrowserModalProxy = fileBrowserModalProxy;
|
||||
|
|
@ -684,7 +684,7 @@ font-size: var(--font-size-small)
|
|||
-webkit-font-optical-sizing: auto;
|
||||
font-size: 0.875rem;
|
||||
max-height: 7rem;
|
||||
min-height: 2.7rem;
|
||||
min-height: 2.8rem;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
|
|
@ -722,6 +722,7 @@ font-size: var(--font-size-small)
|
|||
#chat-input:focus {
|
||||
outline: 0.05rem solid rgba(155, 155, 155, 0.3);
|
||||
font-size: 0.955rem;
|
||||
padding-top: 0.58rem;
|
||||
background-color: var(--color-input-focus);
|
||||
}
|
||||
|
||||
|
|
@ -1033,7 +1034,7 @@ font-size: var(--font-size-small)
|
|||
flex-grow: 1;
|
||||
min-height: 2.7rem;
|
||||
padding: var(--spacing-sm) var(--spacing-sm);
|
||||
padding-top: 0.70rem;
|
||||
padding-top: 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
resize: none;
|
||||
|
|
@ -1540,6 +1541,23 @@ input:checked + .slider:before {
|
|||
}
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
opacity: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.sidebar-overlay.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#left-panel {
|
||||
position: fixed;
|
||||
|
|
|
|||
128
webui/index.html
|
|
@ -8,6 +8,7 @@
|
|||
<link rel="stylesheet" href="index.css">
|
||||
<link rel="stylesheet" href="toast.css">
|
||||
<link rel="stylesheet" href="settings.css">
|
||||
<link rel="stylesheet" href="file_browser.css">
|
||||
<link rel="stylesheet" href="speech.css">
|
||||
|
||||
<script>
|
||||
|
|
@ -16,24 +17,27 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- KaTeX CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css" crossorigin="anonymous">
|
||||
|
||||
<!-- KaTeX javascript -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js" crossorigin="anonymous"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
|
||||
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<script type="module" src="index.js"></script>
|
||||
<script type="text/javascript" src="settings.js"></script>
|
||||
<script type="text/javascript" src="file_browser.js"></script>
|
||||
<script type="module" src="speech.js"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="sidebar-overlay" class="sidebar-overlay hidden"></div>
|
||||
<div class="icons-section" id="hide-button" x-data="{ connected: true }">
|
||||
<!--Sidebar-->
|
||||
<!-- Sidebar Toggle Button -->
|
||||
|
|
@ -307,7 +311,7 @@
|
|||
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">
|
||||
</path>
|
||||
</svg>Import knowledge</button>
|
||||
<button class="text-button" @click="workDirModalProxy.openModal()">
|
||||
<button class="text-button" id="work_dir_browser" @click="fileBrowserModalProxy.openModal()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.37 92.59">
|
||||
<path
|
||||
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 @@
|
|||
</div>
|
||||
<div id="settingsModal" x-data="settingsModalProxy">
|
||||
<template x-teleport="body">
|
||||
<div x-show="isOpen" class="modal-overlay" @click.self="handleCancel()" x-transition>
|
||||
<div x-show="isOpen" class="modal-overlay" @click.self="handleCancel()" @keydown.escape.window="handleClose()" x-transition>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<div class="modal-header" id="settings-title">
|
||||
<h2 x-text="settings.title"></h2>
|
||||
<button class="modal-close" @click="handleCancel()">×</button>
|
||||
</div>
|
||||
<div id="settings-sections">
|
||||
<!-- Dynamically generated navigation -->
|
||||
<nav>
|
||||
<ul>
|
||||
|
|
@ -450,26 +457,113 @@
|
|||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- work_dir Browser Modal -->
|
||||
|
||||
<div id="workDirModal" x-data="workDirModalProxy">
|
||||
<div id="fileBrowserModal" x-data="fileBrowserModalProxy">
|
||||
<template x-teleport="body">
|
||||
<div x-show="isOpen" class="modal-overlay" @click.self="close()" x-transition>
|
||||
<div x-show="isOpen" class="modal-overlay" @click.self="handleClose()" @keydown.escape.window="handleClose()" x-transition>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2>Work Directory</h2>
|
||||
<h2 class="modal-title" x-text="browser.title"></h2>
|
||||
<button class="modal-close" @click="handleClose()">×</button>
|
||||
</div>
|
||||
<div class="modal-content">
|
||||
<ul>
|
||||
<template x-for="file in files" :key="file">
|
||||
<li x-text="file"></li>
|
||||
</template>
|
||||
</ul>
|
||||
<div x-show="isLoading" class="loading-spinner">
|
||||
Loading...
|
||||
</div>
|
||||
<div x-show="!isLoading">
|
||||
<div class="path-navigator">
|
||||
<!-- Up Button -->
|
||||
<button class="text-button back-button"
|
||||
@click="navigateUp()"
|
||||
aria-label="Navigate Up">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
|
||||
<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>
|
||||
</svg>
|
||||
Up
|
||||
</button>
|
||||
|
||||
<span class="current-path" x-text="browser.currentPath || 'Current Path: /'"></span>
|
||||
</div>
|
||||
|
||||
<div class="files-list">
|
||||
<!-- Header -->
|
||||
<div class="file-header">
|
||||
<div class="file-cell" @click="toggleSort('name')">
|
||||
Name
|
||||
<span x-show="browser.sortBy === 'name'"
|
||||
x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
|
||||
</span>
|
||||
</div>
|
||||
<div class="file-cell-size" @click="toggleSort('size')">
|
||||
Size
|
||||
<span x-show="browser.sortBy === 'size'"
|
||||
x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
|
||||
</span>
|
||||
</div>
|
||||
<div class="file-cell-date" @click="toggleSort('date')">
|
||||
Modified
|
||||
<span x-show="browser.sortBy === 'date'"
|
||||
x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File List -->
|
||||
<template x-if="browser.entries.length">
|
||||
<template x-for="file in sortFiles(browser.entries)" :key="file.path">
|
||||
<div class="file-item" :data-is-dir="file.is_dir">
|
||||
<div class="file-name" @click="file.is_dir ? navigateToFolder(file.path) : downloadFile(file)">
|
||||
<img :src="'/public/' + (file.type === 'unknown' ? 'file' : (isArchive(file.name) ? 'archive' : file.type)) + '.svg'" class="file-icon" :alt="file.type">
|
||||
<span x-text="file.name"></span>
|
||||
</div>
|
||||
<div class="file-size" x-text="formatFileSize(file.size)"></div>
|
||||
<div class="file-date" x-text="formatDate(file.modified)"></div>
|
||||
|
||||
<div class="file-actions">
|
||||
<button class="action-button download-button" @click.stop="downloadFile(file)" x-show="!file.is_dir">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.5 19.5">
|
||||
<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>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="delete-button" @click.stop="deleteFile(file)" x-show="!file.is_dir || (file.is_dir && file.size === 0)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15.03 22.53" fill="currentColor">
|
||||
<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>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template x-if="!browser.entries.length">
|
||||
<div class="no-files">
|
||||
No files found
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button @click="close()">Close</button>
|
||||
<div id="buttons-container">
|
||||
<label class="btn btn-upload"><svg xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
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">
|
||||
</path>
|
||||
</svg>
|
||||
Upload Files
|
||||
<input type="file" multiple=""
|
||||
accept="all"
|
||||
@change="handleFileUpload"
|
||||
style="display: none;">
|
||||
</label>
|
||||
<button class="btn btn-cancel" @click="handleClose()">Close Browser</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,24 +23,44 @@ function isMobile() {
|
|||
return window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
leftPanel.classList.toggle('hidden');
|
||||
rightPanel.classList.toggle('expanded');
|
||||
function toggleSidebar(show) {
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
if (typeof show === 'boolean') {
|
||||
leftPanel.classList.toggle('hidden', !show);
|
||||
rightPanel.classList.toggle('expanded', !show);
|
||||
overlay.classList.toggle('visible', show);
|
||||
} else {
|
||||
leftPanel.classList.toggle('hidden');
|
||||
rightPanel.classList.toggle('expanded');
|
||||
overlay.classList.toggle('visible', !leftPanel.classList.contains('hidden'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
if (isMobile()) {
|
||||
leftPanel.classList.add('hidden');
|
||||
rightPanel.classList.add('expanded');
|
||||
overlay.classList.remove('visible');
|
||||
} else {
|
||||
leftPanel.classList.remove('hidden');
|
||||
rightPanel.classList.remove('expanded');
|
||||
overlay.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('load', handleResize);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
overlay.addEventListener('click', () => {
|
||||
if (isMobile()) {
|
||||
toggleSidebar(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function setupSidebarToggle() {
|
||||
const leftPanel = document.getElementById('left-panel');
|
||||
const rightPanel = document.getElementById('right-panel');
|
||||
|
|
@ -249,45 +269,6 @@ window.loadKnowledge = async function () {
|
|||
}
|
||||
|
||||
|
||||
const workDirModalProxy = {
|
||||
isOpen: false,
|
||||
files: [],
|
||||
|
||||
async openModal() { // Define openModal
|
||||
// Inside openModal, call the existing open method:
|
||||
await this.open(); // Or directly include the fetching logic here
|
||||
},
|
||||
|
||||
async open() {
|
||||
const response = await sendJsonData('/work_dir');
|
||||
if (response.ok) {
|
||||
this.files = response.files;
|
||||
this.isOpen = true;
|
||||
} else {
|
||||
toast(response.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Make the proxy available globally
|
||||
window.workDirModalProxy = workDirModalProxy;
|
||||
|
||||
// Ensure correct setup for Alpine.js x-data.
|
||||
window.workDirModal = function () {
|
||||
return workDirModalProxy; // Returns the proxy object for the Work Dir modal
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Make workDirModalProxy available as an Alpine component/store
|
||||
Alpine.data('workDirModal', workDirModal);
|
||||
});
|
||||
|
||||
|
||||
function adjustTextareaHeight() {
|
||||
chatInput.style.height = 'auto';
|
||||
chatInput.style.height = (chatInput.scrollHeight) + 'px';
|
||||
|
|
|
|||
1
webui/public/archive.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 22.5 23"><style>.st0{fill:#6495ed}</style><path d="M19.6 5.8H16c-.3 0-.5-.2-.5-.5V2.6c0-.3-.2-.5-.5-.5s-.5.2-.5.5v2.6c0 .9.7 1.6 1.6 1.6h3.1v14.6c0 .3-.2.5-.5.5H4c-.3 0-.5-.2-.5-.5V1.6c-.1-.3.2-.6.5-.6h6.3v1h1V1h4l4 3.5c.2.2.5.2.7 0s.2-.5 0-.7L15.8.1c-.1-.1-.2-.1-.3-.1H4c-.9 0-1.6.7-1.6 1.6v19.9c0 .8.7 1.5 1.6 1.5h14.6c.9 0 1.6-.7 1.6-1.6V6.3c0-.3-.3-.5-.6-.5" class="st0"/><path d="M11.3 2.1h1v1h-1zM10.2 3.1h1v1h-1zM11.3 4.2h1v1h-1zM10.2 5.2h1v1h-1zM11.3 6.3h1v1h-1zM10.2 7.3h1v1h-1zM11.3 8.4h1v1h-1zM10.2 9.4h1v1h-1zM11.3 10.5h1v1h-1zM10.2 14.6c0 .6.5 1 1 1 .6 0 1-.5 1-1v-2.1h-2.1v2.1z" class="st0"/></svg>
|
||||
|
After Width: | Height: | Size: 725 B |
1
webui/public/code.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 22.5 23"><style>.st0{fill:#f2b700}</style><path d="M19.6 5.8H16c-.3 0-.5-.2-.5-.5V2.6c0-.3-.2-.5-.5-.5s-.5.2-.5.5v2.6c0 .9.7 1.6 1.6 1.6h3.1v14.6c0 .3-.2.5-.5.5H4c-.3 0-.5-.2-.5-.5V1.6c-.1-.3.2-.6.5-.6h11.3l4 3.5c.2.2.5.2.7 0s.2-.5 0-.7L15.8.1c-.1-.1-.2-.1-.3-.1H4c-.9 0-1.6.7-1.6 1.6v19.9c0 .8.7 1.5 1.6 1.5h14.6c.9 0 1.6-.7 1.6-1.6V6.3c0-.3-.3-.5-.6-.5" class="st0"/><path d="m11.8 11.3-2.1 5.2c-.1.3 0 .6.3.7h.2c.2 0 .4-.1.5-.3l2.1-5.2c.1-.3 0-.6-.3-.7s-.6 0-.7.3M14.1 17.1c.1.1.2.1.3.1.2 0 .3-.1.4-.2l2.1-2.6c.2-.2.2-.5 0-.7l-2.1-2.6c-.2-.2-.5-.3-.7-.1s-.3.5-.1.7l1.8 2.3-1.8 2.4c-.2.2-.1.6.1.7M8.5 11.1c-.2-.2-.6-.1-.7.1l-2.1 2.6c-.2.2-.2.5 0 .7l2.1 2.6c.1.1.3.2.4.2s.2 0 .3-.1c.2-.2.3-.5.1-.7l-1.8-2.3 1.8-2.3c.1-.3.1-.6-.1-.8" class="st0"/></svg>
|
||||
|
After Width: | Height: | Size: 866 B |
1
webui/public/deletefile.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 24 24"><path d="M18.2 8.8H8.4l9.4-4.6c.8-.4 1.2-1.4.8-2.2s-1.4-1.2-2.2-.8l-3.2 1.6-.2-.4c-.2-.4-.6-.8-1-.9-.5-.2-1-.1-1.4.1l-2.1 1C7.5 3 7.2 4.1 7.6 5l.2.3-3.2 1.6c-.8.4-1.2 1.4-.7 2.2.3.6.9.9 1.5.9.2 0 .5-.1.7-.2l.4-.2h11.2V21c0 .8-.6 1.4-1.4 1.4h-.5V11.3c0-.3-.2-.5-.5-.5s-.3.2-.3.5v11.3h-2.4V11.3c0-.3-.2-.5-.5-.5s-.5.2-.5.5v11.3H9.2V11.3c0-.3-.2-.5-.5-.5s-.5.2-.5.5v11.3h-.4c-.8 0-1.4-.6-1.4-1.4v-9.9c0-.3-.2-.5-.5-.5s-.4.2-.4.5v9.9c0 1.3 1 2.3 2.3 2.3h8.5c1.3 0 2.3-1 2.3-2.3V9.3c.1-.3-.1-.5-.4-.5M8.9 3.4l2.1-1c.1-.1.3-.1.4-.1h.3c.2.1.4.2.5.4l.2.3-.2.1-3.6 1.8-.1-.3c-.2-.4-.1-1 .4-1.2M5.7 9.1c-.4.2-.8 0-1-.3-.2-.4 0-.8.3-1l5.8-2.9 2.7-1.3L16.7 2c.1-.1.2-.1.3-.1.3 0 .5.1.7.4q.15.3 0 .6c-.1.2-.2.3-.4.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 793 B |
1
webui/public/document.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 22.5 23"><style>.st0{fill:#a0a0a0}</style><path d="M19.6 5.8H16c-.3 0-.5-.2-.5-.5V2.6c0-.3-.2-.5-.5-.5s-.5.2-.5.5v2.6c0 .9.7 1.6 1.6 1.6h3.1v14.6c0 .3-.2.5-.5.5H4c-.3 0-.5-.2-.5-.5V1.6c-.1-.3.2-.6.5-.6h11.3l4 3.5c.2.2.5.2.7 0s.2-.5 0-.7L15.8.1c-.1-.1-.2-.1-.3-.1H4c-.9 0-1.6.7-1.6 1.6v19.9c0 .8.7 1.5 1.6 1.5h14.6c.9 0 1.6-.7 1.6-1.6V6.3c0-.3-.3-.5-.6-.5" class="st0"/><path d="M16.5 16.7c0-.3-.2-.5-.5-.5H6c-.3 0-.5.2-.5.5s.2.5.5.5h10c.3 0 .5-.2.5-.5M6 8.9h5.2c.3 0 .5-.2.5-.5s-.2-.5-.5-.5H6c-.3 0-.5.2-.5.5s.3.5.5.5M16.5 9.9h-6.3c-.3 0-.5.2-.5.5s.2.5.5.5h6.3c.3 0 .5-.2.5-.5 0-.2-.2-.5-.5-.5M16.5 7.8h-3.1c-.3 0-.5.2-.5.5s.2.5.5.5h3.1c.3 0 .5-.2.5-.5 0-.2-.2-.5-.5-.5M6 13.1h4.7c.3 0 .5-.2.5-.5s-.2-.5-.5-.5H6c-.3 0-.5.2-.5.5 0 .2.3.5.5.5M6 15.2h2.1c.3 0 .5-.2.5-.5s-.2-.5-.5-.5H6c-.3 0-.5.2-.5.5 0 .2.3.5.5.5M6 11h2.1c.3 0 .5-.2.5-.5s-.2-.5-.5-.5H6c-.3 0-.5.2-.5.5 0 .2.3.5.5.5M17 12.5c0-.3-.2-.5-.5-.5h-3.7c-.3 0-.5.2-.5.5s.2.5.5.5h3.7c.3.1.5-.2.5-.5M9.7 14.6c0 .3.2.5.5.5h4.2c.3 0 .5-.2.5-.5s-.2-.5-.5-.5h-4.2c-.3 0-.5.2-.5.5M6 18.3c-.3 0-.5.2-.5.5s.2.5.5.5h5.2c.3 0 .5-.2.5-.5s-.2-.5-.5-.5z" class="st0"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
webui/public/downloadfile.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 24 24"><path d="M3 16.5v2.2C3 20 4 21 5.2 21h13.5c1.2 0 2.2-1 2.2-2.2v-2.2M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" style="fill:none;stroke:#000;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round"/></svg>
|
||||
|
After Width: | Height: | Size: 284 B |
1
webui/public/file.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 19.3 23"><path d="M18 5.8h-3.6c-.3 0-.5-.2-.5-.5V2.6c0-.3-.2-.5-.5-.5s-.5.2-.5.5v2.6c0 .9.7 1.6 1.6 1.6h3.1v14.6c0 .3-.2.5-.5.5H2.4c-.3 0-.5-.2-.5-.5V1.6c-.1-.3.2-.6.5-.6h11.3l4 3.5c.2.2.5.2.7 0s.2-.5 0-.7L14.2.1c-.1-.1-.2-.1-.3-.1H2.4C1.5 0 .8.7.8 1.6v19.9c0 .8.7 1.5 1.6 1.5H17c.9 0 1.6-.7 1.6-1.6V6.3c0-.3-.3-.5-.6-.5" style="fill:#a0a0a0"/></svg>
|
||||
|
After Width: | Height: | Size: 424 B |
1
webui/public/folder.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 23 16.3"><path d="M21.9 5.3h-.7V3.8c0-.6-.5-1.1-1.1-1.1h-8.9L9.9.7C9.6.2 9.3 0 8.8 0H3c-.6 0-1.1.5-1.1 1.2v4.2h-.8c-.3 0-.6.1-.9.4-.1.1-.2.5-.2.8l1 8.8c.1.6.6 1 1.1 1h18.6c.6 0 1.1-.4 1.1-1l1-8.8c0-.3-.1-.7-.3-.9 0-.3-.3-.4-.6-.4M2.8 1.1c0-.1.1-.1.2-.1h5.8c.1 0 .1 0 .2.1l1.4 2.3c.1.1.2.2.4.2H20c.1 0 .2.1.2.2v1.5H2.8zM21 15.2c0 .1-.1.1-.2.1H2.2c-.1 0-.2-.1-.2-.1L1 6.5v-.1l.1-.1h20.7c.1 0 .1 0 .1.1v.1z" style="fill:#a0a0a0"/></svg>
|
||||
|
After Width: | Height: | Size: 507 B |
1
webui/public/image.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 22.5 23"><style>.st0{fill:#00dd7f}</style><path d="M19.6 5.8H16c-.3 0-.5-.2-.5-.5V2.6c0-.3-.2-.5-.5-.5s-.5.2-.5.5v2.6c0 .9.7 1.6 1.6 1.6h3.1v14.6c0 .3-.2.5-.5.5H4c-.3 0-.5-.2-.5-.5V1.6c-.1-.3.2-.6.5-.6h11.3l4 3.5c.2.2.5.2.7 0s.2-.5 0-.7L15.8.1c-.1-.1-.2-.1-.3-.1H4c-.9 0-1.6.7-1.6 1.6v19.9c0 .8.7 1.5 1.6 1.5h14.6c.9 0 1.6-.7 1.6-1.6V6.3c0-.3-.3-.5-.6-.5" class="st0"/><path d="M7.8 14.8c.1-.2.5-.3.6 0L9.8 17c.1.2.3.2.5.3.2 0 .4-.1.4-.3l2.4-4.3c.1-.2.5-.2.6 0l2.8 5.5c.1.3.4.4.7.2.3-.1.4-.4.2-.7l-2.8-5.5c-.2-.5-.7-.8-1.2-.8s-1 .3-1.2.7l-1.9 3.5-.9-1.4c-.2-.4-.7-.7-1.2-.7s-1 .3-1.2.8l-1.7 3.4q-.3.45-.3.9c0 1 .8 1.8 1.8 1.8H17c.3 0 .5-.2.5-.5s-.2-.5-.5-.5H6.8c-.4-.1-.8-.4-.8-.8 0-.1 0-.2.1-.3zM10.2 9.4c0-1.2-.9-2.1-2.1-2.1S6 8.3 6 9.4s.9 2.1 2.1 2.1 2.1-.9 2.1-2.1m-3.1 0c0-.6.5-1 1-1 .6 0 1 .5 1 1 0 .6-.5 1-1 1-.5.1-1-.4-1-1" class="st0"/></svg>
|
||||
|
After Width: | Height: | Size: 964 B |
|
|
@ -32,6 +32,7 @@ select {
|
|||
}
|
||||
|
||||
.modal-header h2 {
|
||||
color: var(--color-primary);
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -41,6 +42,10 @@ select {
|
|||
line-height: 2em;
|
||||
}
|
||||
|
||||
#settings-title {
|
||||
padding: 0.5rem 2rem 0.5rem 2rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: var(--spacing-sm);
|
||||
overflow-y: auto;
|
||||
|
|
@ -240,6 +245,10 @@ input[type="range"] {
|
|||
transition: background 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-ok > svg {
|
||||
max-width: 20px;
|
||||
}
|
||||
|
||||
.btn-ok:hover {
|
||||
background: #3265c0;
|
||||
}
|
||||
|
|
@ -311,6 +320,15 @@ nav ul li a:hover {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#settings-sections {
|
||||
background-color: var(--color-secondary);
|
||||
padding: 0.3rem 2rem;
|
||||
line-height: 1.6rem;
|
||||
font-size: var(--font-size-normal);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-header {
|
||||
padding: var(--spacing-sm);
|
||||
|
|
|
|||