feat: implement drives, backend and frontend
This commit is contained in:
1 parent
6ad0a461e4
commit
3283b77ab9
12 files changed
+780
-92
No files matched your search
@@ -18,6 +18,7 @@ import agent as agent_mod
|
|||||||
import agents as agents_mod
|
import agents as agents_mod
|
||||||
import temporary as tmp
|
import temporary as tmp
|
||||||
from server import start_server
|
from server import start_server
|
||||||
|
from drives import init_drives
|
||||||
from chat import ChatView, Message, MESSAGE_PAGE_SIZE
|
from chat import ChatView, Message, MESSAGE_PAGE_SIZE
|
||||||
from remote import RemoteConnection, RemoteToolSpec, parse_function_specs
|
from remote import RemoteConnection, RemoteToolSpec, parse_function_specs
|
||||||
|
|
||||||
@@ -225,6 +226,8 @@ async def main():
|
|||||||
# 1. Initialize Database First
|
# 1. Initialize Database First
|
||||||
await db.init_database(data_directory)
|
await db.init_database(data_directory)
|
||||||
print("Database initialized with Tortoise ORM.")
|
print("Database initialized with Tortoise ORM.")
|
||||||
|
# Drives as well
|
||||||
|
await init_drives(data_directory)
|
||||||
|
|
||||||
# 2. Initialize App
|
# 2. Initialize App
|
||||||
horde_app = App(
|
horde_app = App(
|
||||||
|
|||||||
+21
-1
@@ -52,7 +52,7 @@ def is_instance (value):
|
|||||||
class User(models.Model):
|
class User(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(pk=True)
|
||||||
email = fields.CharField(max_length=255)
|
email = fields.CharField(max_length=255)
|
||||||
username = fields.CharField(max_length=100)
|
username = fields.CharField(max_length=100, unique=True)
|
||||||
password = fields.CharField(max_length=255)
|
password = fields.CharField(max_length=255)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -166,3 +166,23 @@ class Model(models.Model):
|
|||||||
name = fields.CharField(max_length=255)
|
name = fields.CharField(max_length=255)
|
||||||
kind = fields.CharField(max_length=50)
|
kind = fields.CharField(max_length=50)
|
||||||
value = fields.JSONField(null=True)
|
value = fields.JSONField(null=True)
|
||||||
|
|
||||||
|
class Drive(models.Model):
|
||||||
|
id = fields.IntField(pk=True)
|
||||||
|
user = fields.ForeignKeyField("models.User", related_name="drives")
|
||||||
|
name = fields.CharField(max_length=255)
|
||||||
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class DriveFile(models.Model):
|
||||||
|
id = fields.CharField(pk=True, max_length=64)
|
||||||
|
drive = fields.ForeignKeyField("models.Drive", related_name="files")
|
||||||
|
|
||||||
|
filename = fields.CharField(max_length=500) # Readable app-side name
|
||||||
|
mime_type = fields.CharField(max_length=255) # Content type
|
||||||
|
size_bytes = fields.BigIntField() # Physical size
|
||||||
|
|
||||||
|
accessed_at = fields.DatetimeField(null=True) # Last access timestamp
|
||||||
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "drive_files"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import datetime
|
||||||
|
import database as db
|
||||||
|
|
||||||
|
# TODO: Ugly ass vibe coded shit. Someone please rewrite.
|
||||||
|
|
||||||
|
# TODO: This belonds in app
|
||||||
|
DRIVES_ROOT = None
|
||||||
|
|
||||||
|
async def init_drives(data_directory):
|
||||||
|
"""Initialize DRIVES_ROOT global from app startup."""
|
||||||
|
global DRIVES_ROOT
|
||||||
|
DRIVES_ROOT = os.path.join(data_directory, "drives")
|
||||||
|
os.makedirs(DRIVES_ROOT, exist_ok=True)
|
||||||
|
|
||||||
|
def _generate_file_id():
|
||||||
|
"""Generate a random hex string for filesystem-level filenames."""
|
||||||
|
return secrets.token_hex(8)
|
||||||
|
|
||||||
|
async def upload_file(user_id, drive_name, filename, stream, mime_type=None):
|
||||||
|
"""Write an incoming stream to disk and register it in the DB. Returns file ID."""
|
||||||
|
# 1. Locate or create Drive
|
||||||
|
drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name)
|
||||||
|
if not drive:
|
||||||
|
drive = await db.Drive.create(user_id=user_id, name=drive_name)
|
||||||
|
|
||||||
|
# Check collision
|
||||||
|
if await db.Drive.get_or_none(drive_id=drive.id, filename=filename):
|
||||||
|
raise Exception("Duplicated file")
|
||||||
|
|
||||||
|
# 2. Prepare disk path
|
||||||
|
file_id = _generate_file_id()
|
||||||
|
drive_dir = os.path.join(DRIVES_ROOT, str(drive.id))
|
||||||
|
os.makedirs(drive_dir, exist_ok=True)
|
||||||
|
real_path = os.path.join(drive_dir, file_id)
|
||||||
|
|
||||||
|
# 3. Write stream and calculate size
|
||||||
|
size = 0
|
||||||
|
with open(real_path, "wb") as f:
|
||||||
|
while chunk := stream.read(8192):
|
||||||
|
f.write(chunk)
|
||||||
|
size += len(chunk)
|
||||||
|
|
||||||
|
# 4. Register in database
|
||||||
|
# TODO: catch id conflict and retry
|
||||||
|
record = await db.DriveFile.create(
|
||||||
|
id=file_id,
|
||||||
|
drive_id=drive.id,
|
||||||
|
filename=filename,
|
||||||
|
mime_type=mime_type or "application/octet-stream",
|
||||||
|
size_bytes=size,
|
||||||
|
)
|
||||||
|
return record.id
|
||||||
|
|
||||||
|
async def get_metadata(file_id):
|
||||||
|
"""Return JSON metadata for frontend display."""
|
||||||
|
file = await db.DriveFile.get_or_none(id=file_id)
|
||||||
|
if not file:
|
||||||
|
raise Exception("File not found")
|
||||||
|
return {
|
||||||
|
"id": file.id,
|
||||||
|
"name": file.filename,
|
||||||
|
"size": file.size_bytes,
|
||||||
|
"mime": file.mime_type,
|
||||||
|
"created": file.created_at.isoformat(),
|
||||||
|
"accessed": file.accessed_at.isoformat() if file.accessed_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
async def delete_file(file_id):
|
||||||
|
file = await db.DriveFile.get_or_none(id=file_id)
|
||||||
|
if not file:
|
||||||
|
raise Exception("File not found")
|
||||||
|
|
||||||
|
await file.delete()
|
||||||
|
|
||||||
|
abs_path = os.path.join(DRIVES_ROOT, str(file.drive_id), file.id)
|
||||||
|
if os.path.exists(abs_path):
|
||||||
|
os.remove(abs_path)
|
||||||
|
|
||||||
|
return abs_path
|
||||||
|
|
||||||
|
async def resolve_file(user_id, drive_name, filename):
|
||||||
|
"""Resolve file to absolute disk path after permission check. Returns (path, mime)."""
|
||||||
|
# Eager load drive and verify ownership
|
||||||
|
drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name)
|
||||||
|
if drive.user_id != user_id:
|
||||||
|
raise PermissionError("Access denied")
|
||||||
|
|
||||||
|
file = await db.DriveFile.get_or_none(filename=filename, drive_id=drive.id)
|
||||||
|
if not file:
|
||||||
|
raise FileNotFoundError("File not found")
|
||||||
|
|
||||||
|
# can do this asynchronously?
|
||||||
|
file.accessed_at = datetime.datetime.now(datetime.UTC)
|
||||||
|
await file.save()
|
||||||
|
|
||||||
|
abs_path = os.path.join(DRIVES_ROOT, str(drive.id), file.id)
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
|
raise FileNotFoundError("File missing on disk")
|
||||||
|
|
||||||
|
return abs_path, file.id
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from tortoise import migrations
|
||||||
|
from tortoise.migrations import operations as ops
|
||||||
|
from tortoise.fields.base import OnDelete
|
||||||
|
from tortoise import fields
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [('models', '0001_initial')]
|
||||||
|
|
||||||
|
initial = False
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
ops.CreateModel(
|
||||||
|
name='Drive',
|
||||||
|
fields=[
|
||||||
|
('id', fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
|
||||||
|
('user', fields.ForeignKeyField('models.User', source_field='user_id', db_constraint=True, to_field='id', related_name='drives', on_delete=OnDelete.CASCADE)),
|
||||||
|
('name', fields.CharField(max_length=255)),
|
||||||
|
('created_at', fields.DatetimeField(auto_now=False, auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={'table': 'drive', 'app': 'models', 'pk_attr': 'id'},
|
||||||
|
bases=['Model'],
|
||||||
|
),
|
||||||
|
ops.CreateModel(
|
||||||
|
name='DriveFile',
|
||||||
|
fields=[
|
||||||
|
('id', fields.CharField(primary_key=True, unique=True, db_index=True, max_length=64)),
|
||||||
|
('drive', fields.ForeignKeyField('models.Drive', source_field='drive_id', db_constraint=True, to_field='id', related_name='files', on_delete=OnDelete.CASCADE)),
|
||||||
|
('filename', fields.CharField(max_length=500)),
|
||||||
|
('mime_type', fields.CharField(max_length=255)),
|
||||||
|
('size_bytes', fields.BigIntField()),
|
||||||
|
('accessed_at', fields.DatetimeField(null=True, auto_now=False, auto_now_add=False)),
|
||||||
|
('created_at', fields.DatetimeField(auto_now=False, auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={'table': 'drive_files', 'app': 'models', 'pk_attr': 'id'},
|
||||||
|
bases=['Model'],
|
||||||
|
),
|
||||||
|
ops.AlterField(
|
||||||
|
model_name='User',
|
||||||
|
name='username',
|
||||||
|
field=fields.CharField(unique=True, max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1 +1 @@
|
|||||||
from server.start import start_server
|
from server.web import start_server
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from quart import Quart, request, send_from_directory
|
|
||||||
from quart_auth import QuartAuth, AuthUser, login_user
|
|
||||||
from server.websocket import register as register_websocket
|
|
||||||
from server.openai import register as register_openai
|
|
||||||
|
|
||||||
# Vue frontend
|
|
||||||
# relative to the module file???? Quart being unserious
|
|
||||||
frontend_path = '../../../frontend/dist'
|
|
||||||
|
|
||||||
async def start_server(app, port, shutdown_event):
|
|
||||||
server = Quart(__name__, static_folder=frontend_path)
|
|
||||||
server.secret_key = 'horde-secret'
|
|
||||||
auth_manager = QuartAuth(server, mode="bearer")
|
|
||||||
|
|
||||||
stream_listeners = {}
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
stream_listeners = []
|
|
||||||
|
|
||||||
@server.after_request
|
|
||||||
def after_request(response):
|
|
||||||
response.headers.add('Access-Control-Allow-Origin', '*')
|
|
||||||
response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
|
||||||
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
|
|
||||||
return response
|
|
||||||
|
|
||||||
@server.route('/')
|
|
||||||
async def index():
|
|
||||||
return await send_from_directory(server.static_folder, 'index.html')
|
|
||||||
|
|
||||||
# These paths must match the routes in the frontend SPA
|
|
||||||
# file: frontend/src/routes/index.js
|
|
||||||
@server.route('/user')
|
|
||||||
@server.route('/models')
|
|
||||||
@server.route('/remotes')
|
|
||||||
@server.route('/runs')
|
|
||||||
@server.route('/chat')
|
|
||||||
@server.route('/agent')
|
|
||||||
@server.route('/chat/<path:subpath>')
|
|
||||||
@server.route('/agent/<path:subpath>')
|
|
||||||
async def serve_spa_routes(subpath=None):
|
|
||||||
# Ignore subpath and render index, Vue router will take care of everything
|
|
||||||
return await send_from_directory(server.static_folder, 'index.html')
|
|
||||||
|
|
||||||
@server.route('/<path:path>')
|
|
||||||
async def serve_static(path):
|
|
||||||
return await send_from_directory(server.static_folder, path)
|
|
||||||
|
|
||||||
@server.route('/user/login', methods=['POST'])
|
|
||||||
async def login():
|
|
||||||
data = await request.get_json()
|
|
||||||
|
|
||||||
# token has precedence if it's present
|
|
||||||
if "token" in data:
|
|
||||||
user_id = auth_manager.load_token(data['token'])
|
|
||||||
elif "username" in data and "password" in data:
|
|
||||||
user_id = await app.verify_user_credentials(data['username'], data['password'])
|
|
||||||
else:
|
|
||||||
return "invalid login arguments", 400
|
|
||||||
|
|
||||||
if user_id == None:
|
|
||||||
return "bad login credentials", 401
|
|
||||||
|
|
||||||
token = auth_manager.dump_token(user_id)
|
|
||||||
login_user(AuthUser(auth_id=user_id))
|
|
||||||
return {"token": token}
|
|
||||||
|
|
||||||
@server.route('/user/signup', methods=['POST'])
|
|
||||||
async def signup():
|
|
||||||
data = await request.get_json()
|
|
||||||
|
|
||||||
user_id = None
|
|
||||||
|
|
||||||
username = data['username']
|
|
||||||
email = data['email']
|
|
||||||
password = data['password']
|
|
||||||
|
|
||||||
user_id = await app.create_new_user(username, email, password)
|
|
||||||
if user_id is None:
|
|
||||||
return "existing or incorrect credentials", 400
|
|
||||||
|
|
||||||
await app.generate_default_user_data(user_id)
|
|
||||||
|
|
||||||
token = auth_manager.dump_token(user_id)
|
|
||||||
return {"token": token}
|
|
||||||
|
|
||||||
register_websocket(server, app, auth_manager)
|
|
||||||
register_openai(server, app)
|
|
||||||
|
|
||||||
await server.run_task(debug=True, shutdown_trigger=shutdown_event.wait, port=port)
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
from quart import Quart, request, send_from_directory, send_file, current_app
|
||||||
|
from quart_auth import QuartAuth, AuthUser, login_required, login_user, current_user
|
||||||
|
from server.websocket import register as register_websocket
|
||||||
|
from server.openai import register as register_openai
|
||||||
|
|
||||||
|
import database as db
|
||||||
|
import drives
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
# Vue frontend
|
||||||
|
# relative to the module file???? Quart being unserious
|
||||||
|
frontend_path = '../../../frontend/dist'
|
||||||
|
|
||||||
|
async def start_server(app, port, shutdown_event):
|
||||||
|
server = Quart(__name__, static_folder=frontend_path)
|
||||||
|
server.secret_key = 'horde-secret'
|
||||||
|
auth_manager = QuartAuth(server, mode="bearer")
|
||||||
|
|
||||||
|
stream_listeners = {}
|
||||||
|
|
||||||
|
def query_auth(func):
|
||||||
|
"""A decorator to login with token.
|
||||||
|
|
||||||
|
Wrap the login_required decorator to inject a current user based
|
||||||
|
on the request query parameters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
user_id = auth_manager.load_token(request.args.get('token', ''))
|
||||||
|
if user_id:
|
||||||
|
login_user(AuthUser(auth_id=user_id))
|
||||||
|
return await current_app.ensure_async(func)(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
@server.after_request
|
||||||
|
def after_request(response):
|
||||||
|
response.headers.add('Access-Control-Allow-Origin', '*')
|
||||||
|
response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
||||||
|
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
|
||||||
|
return response
|
||||||
|
|
||||||
|
@server.route('/')
|
||||||
|
async def index():
|
||||||
|
return await send_from_directory(server.static_folder, 'index.html')
|
||||||
|
|
||||||
|
# These paths must match the routes in the frontend SPA
|
||||||
|
# file: frontend/src/routes/index.js
|
||||||
|
@server.route('/user')
|
||||||
|
@server.route('/models')
|
||||||
|
@server.route('/remotes')
|
||||||
|
@server.route('/drives')
|
||||||
|
@server.route('/runs')
|
||||||
|
@server.route('/chat')
|
||||||
|
@server.route('/agent')
|
||||||
|
@server.route('/chat/<path:subpath>')
|
||||||
|
@server.route('/agent/<path:subpath>')
|
||||||
|
async def serve_spa_routes(subpath=None):
|
||||||
|
# Ignore subpath and render index, Vue router will take care of everything
|
||||||
|
return await send_from_directory(server.static_folder, 'index.html')
|
||||||
|
|
||||||
|
@server.route('/<path:path>')
|
||||||
|
async def serve_static(path):
|
||||||
|
return await send_from_directory(server.static_folder, path)
|
||||||
|
|
||||||
|
@server.route('/user/login', methods=['POST'])
|
||||||
|
async def login():
|
||||||
|
data = await request.get_json()
|
||||||
|
|
||||||
|
# token has precedence if it's present
|
||||||
|
if "token" in data:
|
||||||
|
user_id = auth_manager.load_token(data['token'])
|
||||||
|
elif "username" in data and "password" in data:
|
||||||
|
user_id = await app.verify_user_credentials(data['username'], data['password'])
|
||||||
|
else:
|
||||||
|
return "invalid login arguments", 400
|
||||||
|
|
||||||
|
if user_id == None:
|
||||||
|
return "bad login credentials", 401
|
||||||
|
|
||||||
|
token = auth_manager.dump_token(user_id)
|
||||||
|
login_user(AuthUser(auth_id=user_id))
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
@server.route('/user/signup', methods=['POST'])
|
||||||
|
async def signup():
|
||||||
|
data = await request.get_json()
|
||||||
|
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
username = data['username']
|
||||||
|
email = data['email']
|
||||||
|
password = data['password']
|
||||||
|
|
||||||
|
user_id = await app.create_new_user(username, email, password)
|
||||||
|
if user_id is None:
|
||||||
|
return "existing or incorrect credentials", 400
|
||||||
|
|
||||||
|
await app.generate_default_user_data(user_id)
|
||||||
|
|
||||||
|
token = auth_manager.dump_token(user_id)
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
# === Drives === #
|
||||||
|
|
||||||
|
# this, too, is kinda vibe coded
|
||||||
|
# I need drives, ok?
|
||||||
|
|
||||||
|
@server.route('/files', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
async def list_drives():
|
||||||
|
drive_list = await db.Drive.filter(user_id=current_user.auth_id)
|
||||||
|
return [{"id": d.id, "name": d.name} for d in drive_list]
|
||||||
|
|
||||||
|
@server.route('/files/<drive_name>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
async def list_files(drive_name):
|
||||||
|
files = await db.DriveFile.filter(drive__user_id=current_user.auth_id, drive__name=drive_name)
|
||||||
|
return [{
|
||||||
|
"id": f.id,
|
||||||
|
"name": f.filename,
|
||||||
|
"size": f.size_bytes,
|
||||||
|
"mime": f.mime_type,
|
||||||
|
"created": f.created_at.isoformat(),
|
||||||
|
} for f in files]
|
||||||
|
|
||||||
|
@server.route('/files/<drive_name>/<path:filename>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
async def upload_file(drive_name, filename=None):
|
||||||
|
# whole file in one request, no partial transmission
|
||||||
|
upload = (await request.files).get('file')
|
||||||
|
if upload is None:
|
||||||
|
return "missing file part", 400
|
||||||
|
file_id = await drives.upload_file(
|
||||||
|
current_user.auth_id,
|
||||||
|
drive_name,
|
||||||
|
filename or upload.filename,
|
||||||
|
upload.stream,
|
||||||
|
upload.content_type,
|
||||||
|
)
|
||||||
|
return {"fileId": file_id}
|
||||||
|
|
||||||
|
@server.route('/files/<drive_name>/<path:filename>', methods=['GET'])
|
||||||
|
@query_auth
|
||||||
|
@login_required
|
||||||
|
async def serve_file(drive_name, filename):
|
||||||
|
try:
|
||||||
|
user_id = current_user.auth_id
|
||||||
|
file_path, file_id = await drives.resolve_file(user_id, drive_name, filename)
|
||||||
|
metadata = await drives.get_metadata(file_id)
|
||||||
|
if 'metadata' in request.args:
|
||||||
|
return metadata
|
||||||
|
else:
|
||||||
|
return await send_file(file_path, mimetype=metadata["mime"])
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "not found", 404
|
||||||
|
except PermissionError:
|
||||||
|
return "forbidden", 403
|
||||||
|
|
||||||
|
@server.route('/files/<drive_name>/<path:filename>', methods=['DELETE'])
|
||||||
|
@query_auth
|
||||||
|
@login_required
|
||||||
|
async def delete_file(drive_name, filename):
|
||||||
|
try:
|
||||||
|
user_id = current_user.auth_id
|
||||||
|
file_path, file_id = await drives.resolve_file(user_id, drive_name, filename)
|
||||||
|
metadata = await drives.delete_file(file_id)
|
||||||
|
return {"fileId": file_id}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "not found", 404
|
||||||
|
except PermissionError:
|
||||||
|
return "forbidden", 403
|
||||||
|
|
||||||
|
register_websocket(server, app, auth_manager)
|
||||||
|
register_openai(server, app)
|
||||||
|
|
||||||
|
await server.run_task(debug=True, shutdown_trigger=shutdown_event.wait, port=port)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Phase 3: Server Integration (REST)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Attach drive operations to `backend/src/server/web.py` using standard web routes only. This supersedes the WebSocket-based protocol from `drives-report.md`: no WS actions (`list-drives`, `list-files`, `upload-file`), no chunked transfer. Upstream transfer is a single `multipart/form-data` POST per file.
|
||||||
|
|
||||||
|
Assumes `drive_service` from the earlier phases provides `init_drives`, `upload_file`, `get_drive_file_metadata` and `resolve_drive_file_for_serving`.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Method | Description |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| `/drives` | GET | List drives of the authenticated user. |
|
||||||
|
| `/drives/<drive_name>/files` | GET | List file metadata for one drive. |
|
||||||
|
| `/drives/<drive_name>/files` | POST | Upload one file (multipart, field `file`). Creates the drive if missing. |
|
||||||
|
| `/files/<int:file_id>` | GET | Raw download. |
|
||||||
|
| `/files/<int:file_id>/meta` | GET | Metadata for existence checks. |
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
Reuse the existing `QuartAuth(server, mode="bearer")` setup:
|
||||||
|
- JSON endpoints use `@login_required` and read the user id from `current_user.auth_id`.
|
||||||
|
- `GET /files/<id>` additionally accepts `?token=`, because `<a download>` links cannot set an `Authorization` header.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### 3.1 Imports
|
||||||
|
|
||||||
|
Top of `backend/src/server/web.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import database as db
|
||||||
|
import drive_service
|
||||||
|
from quart import Quart, request, send_from_directory, send_file
|
||||||
|
from quart_auth import QuartAuth, AuthUser, login_user, login_required, current_user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Route handlers
|
||||||
|
|
||||||
|
Registered inside `start_server`, alongside the existing routes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@server.route('/drives', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
async def list_drives():
|
||||||
|
drives = await db.Drive.filter(user_id=current_user.auth_id)
|
||||||
|
return [{"id": d.id, "name": d.name} for d in drives]
|
||||||
|
|
||||||
|
@server.route('/drives/<drive_name>/files', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
async def list_files(drive_name):
|
||||||
|
files = await db.DriveFile.filter(drive__user_id=current_user.auth_id, drive__name=drive_name)
|
||||||
|
return [{
|
||||||
|
"id": f.id,
|
||||||
|
"name": f.filename,
|
||||||
|
"size": f.size_bytes,
|
||||||
|
"mime": f.mime_type,
|
||||||
|
"created": f.created_at.isoformat(),
|
||||||
|
} for f in files]
|
||||||
|
|
||||||
|
@server.route('/drives/<drive_name>/files', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
async def upload_file(drive_name):
|
||||||
|
# whole file in one request, no partial transmission
|
||||||
|
upload = (await request.files).get('file')
|
||||||
|
if upload is None:
|
||||||
|
return "missing file part", 400
|
||||||
|
file_id = await drive_service.upload_file(
|
||||||
|
current_user.auth_id, drive_name, upload.filename, upload.stream, upload.content_type,
|
||||||
|
)
|
||||||
|
return {"fileId": file_id}
|
||||||
|
|
||||||
|
@server.route('/files/<int:file_id>', methods=['GET'])
|
||||||
|
async def serve_file(file_id):
|
||||||
|
# query token instead of @login_required: anchor downloads can't set headers
|
||||||
|
user_id = auth_manager.load_token(request.args.get('token', ''))
|
||||||
|
if user_id is None:
|
||||||
|
return "unauthorized", 401
|
||||||
|
try:
|
||||||
|
file_path, mime_type = await drive_service.resolve_drive_file_for_serving(file_id, user_id)
|
||||||
|
return await send_file(file_path, mimetype=mime_type)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "not found", 404
|
||||||
|
except PermissionError:
|
||||||
|
return "forbidden", 403
|
||||||
|
|
||||||
|
@server.route('/files/<int:file_id>/meta', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
async def get_file_meta(file_id):
|
||||||
|
try:
|
||||||
|
# resolve performs the ownership check before anything is returned
|
||||||
|
await drive_service.resolve_drive_file_for_serving(file_id, current_user.auth_id)
|
||||||
|
return await drive_service.get_drive_file_metadata(file_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "not found", 404
|
||||||
|
except PermissionError:
|
||||||
|
return "forbidden", 403
|
||||||
|
```
|
||||||
|
|
||||||
|
No changes to `server/websocket.py`.
|
||||||
|
|
||||||
|
## Frontend protocol
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// listing
|
||||||
|
const drives = await fetch('/drives', {headers: {Authorization: `Bearer ${token}`}}).then(r => r.json());
|
||||||
|
const files = await fetch(`/drives/${encodeURIComponent(name)}/files`, {headers: {Authorization: `Bearer ${token}`}}).then(r => r.json());
|
||||||
|
|
||||||
|
// upload: single POST, no chunking
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', blob, 'report.pdf');
|
||||||
|
await fetch(`/drives/${encodeURIComponent(driveName)}/files`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {Authorization: `Bearer ${token}`},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
// download: plain anchor
|
||||||
|
// <a href={`/files/${fileId}?token=${token}`} download={file.name}>Download</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Routing is unaffected by the static catch-all: werkzeug prefers the exact/typed routes above over `/<path:path>`.
|
||||||
|
- `/drives` is now an API route; the SPA must not mount a view at that exact path.
|
||||||
|
- `drive_name` is a single URL segment (no `/`). The service layer should reject names containing path separators before they reach `os.path.join`.
|
||||||
|
- The query-token on the download endpoint trades header auth for `<a download>` compatibility (token visible in history/logs). Switch to fetch + blob or cookie auth if that's unacceptable.
|
||||||
@@ -137,6 +137,23 @@ export async function newToken () {
|
|||||||
|
|
||||||
// --- Communication ---
|
// --- Communication ---
|
||||||
|
|
||||||
|
export async function webFetch (path, options = {}) {
|
||||||
|
const tk = loginToken || await loginTokenDeferred.promise;
|
||||||
|
const response = await fetch(`${backendUrl}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tk}`,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Response Error: ${response.status}\n${errorText}`);
|
||||||
|
}
|
||||||
|
// TODO: Check content-type before parsing
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
export function sendAction(action, body = {}, callback = null) {
|
export function sendAction(action, body = {}, callback = null) {
|
||||||
if (socket?.readyState !== WebSocket.OPEN) {
|
if (socket?.readyState !== WebSocket.OPEN) {
|
||||||
return Promise.reject(new Error("WebSocket is not open. Cannot send action."));
|
return Promise.reject(new Error("WebSocket is not open. Cannot send action."));
|
||||||
@@ -358,6 +375,39 @@ export function invalidateCache({ chats=false, agents=false } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Drive Storage (REST) ---
|
||||||
|
// Drive endpoints are plain HTTP routes instead of WebSocket actions,
|
||||||
|
// so they authenticate with the bearer token in the Authorization header.
|
||||||
|
|
||||||
|
export function getDrives () {
|
||||||
|
return webFetch('/files');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDriveFiles (driveName) {
|
||||||
|
return webFetch(`/files/${driveName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadDriveFile (driveName, file) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('file', file, file.name);
|
||||||
|
return webFetch(`/files/${driveName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor downloads can't set headers, so the token travels as a query parameter
|
||||||
|
export function driveFileUrl (driveName, filename) {
|
||||||
|
return `${backendUrl}/files/${driveName}/${filename}?token=${loginToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteDriveFile (driveName, filename) {
|
||||||
|
const body = new FormData();
|
||||||
|
return webFetch(`/files/${driveName}/${filename}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Connection ---
|
// --- Connection ---
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
<RouterLink to="/user" class="nav-item">User</RouterLink>
|
<RouterLink to="/user" class="nav-item">User</RouterLink>
|
||||||
<RouterLink to="/models" class="nav-item">Manage Models</RouterLink>
|
<RouterLink to="/models" class="nav-item">Manage Models</RouterLink>
|
||||||
<RouterLink to="/remotes" class="nav-item">Remotes</RouterLink>
|
<RouterLink to="/remotes" class="nav-item">Remotes</RouterLink>
|
||||||
|
<RouterLink to="/drives" class="nav-item">Drives</RouterLink>
|
||||||
<RouterLink to="/runs" class="nav-item">Agent Runs</RouterLink>
|
<RouterLink to="/runs" class="nav-item">Agent Runs</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ const router = createRouter({
|
|||||||
path: `/remotes`,
|
path: `/remotes`,
|
||||||
component: () => import('../views/Remotes.vue')
|
component: () => import('../views/Remotes.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'drives',
|
||||||
|
path: `/drives`,
|
||||||
|
component: () => import('../views/Drives.vue')
|
||||||
|
},
|
||||||
objectView('chat', () => import('../views/Chat.vue')),
|
objectView('chat', () => import('../views/Chat.vue')),
|
||||||
objectView('agent', () => import('../views/Agent.vue')),
|
objectView('agent', () => import('../views/Agent.vue')),
|
||||||
staticView('user', () => import('../views/User.vue')),
|
staticView('user', () => import('../views/User.vue')),
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import TitleBar from '../components/TitleBar.vue';
|
||||||
|
import * as backend from '../backend';
|
||||||
|
import * as ui from '../ui';
|
||||||
|
|
||||||
|
// Folder structure is implicit in the stored file names ("docs/api/readme.md"):
|
||||||
|
// the tree is a pure function of the file list, rebuilt on every load. Which
|
||||||
|
// folders are open lives on the drive (drive.open), never inside tree nodes,
|
||||||
|
// which is why a rebuild never loses it.
|
||||||
|
// drive: { id, name, expanded, loading, error, root, count, open: Set<dirPath> }
|
||||||
|
// dir node: { type: 'dir', name, path, children: Map<name, node> }
|
||||||
|
// file node: { type: 'file', name, path, id, size, mime, created }
|
||||||
|
const drives = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const fileInput = ref(null);
|
||||||
|
let uploadTarget = null; // { drive, dirPath } while the file picker is open
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
drives.value = (await backend.getDrives()).map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name || `drive-${d.id}`,
|
||||||
|
expanded: false, loading: false, error: null,
|
||||||
|
root: null, count: 0, open: new Set(),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
ui.alert('Failed to load drives: ' + (error.message || error));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dirNode = (name, path) => ({ type: 'dir', name, path, children: new Map() });
|
||||||
|
|
||||||
|
function buildTree (files) {
|
||||||
|
const root = dirNode('', '');
|
||||||
|
for (const file of files) {
|
||||||
|
const parts = String(file.name).split('/').filter(Boolean);
|
||||||
|
if (!parts.length) continue;
|
||||||
|
let node = root;
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) { // create the parent folders as needed
|
||||||
|
const path = parts.slice(0, i + 1).join('/');
|
||||||
|
let dir = node.children.get(parts[i]);
|
||||||
|
if (!dir || dir.type !== 'dir') node.children.set(parts[i], dir = dirNode(parts[i], path));
|
||||||
|
node = dir;
|
||||||
|
}
|
||||||
|
const name = parts.at(-1);
|
||||||
|
if (node.children.get(name)?.type !== 'dir') { // a folder wins on a name clash
|
||||||
|
node.children.set(name, { type: 'file', name, path: parts.join('/'), id: file.id, size: file.size, mime: file.mime, created: file.created });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folders first, then natural name order ("a2" before "a10").
|
||||||
|
const byTypeThenName = (a, b) =>
|
||||||
|
a.type === b.type
|
||||||
|
? a.name.localeCompare(b.name, undefined, { numeric: true })
|
||||||
|
: a.type === 'dir' ? -1 : 1;
|
||||||
|
|
||||||
|
// Flattens the currently open part of a drive's tree into display rows.
|
||||||
|
function rows (drive) {
|
||||||
|
const out = [];
|
||||||
|
const walk = (node, depth) => {
|
||||||
|
for (const child of [...node.children.values()].sort(byTypeThenName)) {
|
||||||
|
out.push({ node: child, depth });
|
||||||
|
if (child.type === 'dir' && drive.open.has(child.path)) walk(child, depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(drive.root, 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles (drive) {
|
||||||
|
drive.loading = true;
|
||||||
|
drive.error = null;
|
||||||
|
try {
|
||||||
|
const files = await backend.getDriveFiles(drive.name);
|
||||||
|
drive.root = buildTree(files);
|
||||||
|
drive.count = files.length;
|
||||||
|
} catch (error) {
|
||||||
|
drive.error = error.message || String(error);
|
||||||
|
} finally {
|
||||||
|
drive.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDrive (drive) {
|
||||||
|
drive.expanded = !drive.expanded;
|
||||||
|
if (drive.expanded && !drive.root && !drive.loading) await loadFiles(drive); // lazy first load
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick (drive, node) {
|
||||||
|
if (node.type === 'dir') { // toggle folder
|
||||||
|
drive.open.has(node.path) ? drive.open.delete(node.path) : drive.open.add(node.path);
|
||||||
|
} else {
|
||||||
|
window.open(backend.driveFileUrl(drive.name, node.path), '_blank'); // served with its mime type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove (drive, node) {
|
||||||
|
if (!await ui.confirm(`Delete "${node.path}" from drive "${drive.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await backend.deleteDriveFile(drive.name, node.path);
|
||||||
|
await loadFiles(drive); // open folders survive: only the tree is replaced
|
||||||
|
} catch (error) {
|
||||||
|
ui.alert('Delete failed: ' + (error.message || error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickUpload (drive, dirPath = '') {
|
||||||
|
uploadTarget = { drive, dirPath };
|
||||||
|
fileInput.value.value = ''; // allow picking the same file again
|
||||||
|
fileInput.value.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixes('docs/api') -> ['docs', 'docs/api']
|
||||||
|
const prefixes = path => path.split('/').filter(Boolean).map((part, i, all) => all.slice(0, i + 1).join('/'));
|
||||||
|
|
||||||
|
async function onFilesPicked (event) {
|
||||||
|
const files = [...event.target.files];
|
||||||
|
const { drive, dirPath } = uploadTarget ?? {};
|
||||||
|
uploadTarget = null;
|
||||||
|
if (!drive || !files.length) return;
|
||||||
|
drive.expanded = true;
|
||||||
|
for (const file of files) {
|
||||||
|
const path = dirPath ? `${dirPath}/${file.name}` : file.name;
|
||||||
|
try {
|
||||||
|
await backend.uploadDriveFile(drive.name, file, path);
|
||||||
|
} catch (error) {
|
||||||
|
ui.alert(`Upload failed for "${path}": ` + (error.message || error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefixes(dirPath).forEach(path => drive.open.add(path)); // reveal the destination folder
|
||||||
|
await loadFiles(drive);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
|
||||||
|
function formatSize (bytes) {
|
||||||
|
if (bytes == null) return '';
|
||||||
|
let value = bytes, unit = 0;
|
||||||
|
while (value >= 1024 && unit < SIZE_UNITS.length - 1) { value /= 1024; unit += 1; }
|
||||||
|
return value < 10 && unit > 0 ? `${value.toFixed(1)} ${SIZE_UNITS[unit]}` : `${Math.round(value)} ${SIZE_UNITS[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate (iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const date = new Date(iso);
|
||||||
|
return isNaN(date) ? iso : date.toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<TitleBar><h1>Drives</h1></TitleBar>
|
||||||
|
|
||||||
|
<section class="drives">
|
||||||
|
<p v-if="loading" class="status">Loading drives…</p>
|
||||||
|
<p v-else-if="!drives.length" class="status">No drives available.</p>
|
||||||
|
|
||||||
|
<article v-for="drive in drives" :key="drive.id" class="drive">
|
||||||
|
<header class="row head" @click="toggleDrive(drive)">
|
||||||
|
<span class="caret">{{ drive.expanded ? '▾' : '▸' }}</span>
|
||||||
|
<span class="icon">🗄️</span>
|
||||||
|
<b>{{ drive.name }}</b>
|
||||||
|
<span v-if="drive.loading" class="muted">loading…</span>
|
||||||
|
<span v-else-if="drive.count" class="muted">{{ drive.count }} files</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="action" @click.stop="pickUpload(drive)">Upload</button>
|
||||||
|
<button v-if="drive.root" class="action" @click.stop="loadFiles(drive)">Refresh</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="drive.expanded && drive.error" class="error">{{ drive.error }}</p>
|
||||||
|
|
||||||
|
<template v-if="drive.expanded && drive.root">
|
||||||
|
<p v-if="!drive.count" class="status empty">This drive is empty. Use Upload to add files.</p>
|
||||||
|
<div v-for="row in rows(drive)" :key="row.node.path" class="row"
|
||||||
|
:style="{ paddingLeft: row.depth * 20 + 12 + 'px' }" @click="onRowClick(drive, row.node)">
|
||||||
|
<span class="caret">{{ row.node.type === 'dir' ? (drive.open.has(row.node.path) ? '▾' : '▸') : '' }}</span>
|
||||||
|
<span class="icon">{{ row.node.type === 'dir' ? '📁' : '📄' }}</span>
|
||||||
|
<span class="name">{{ row.node.name }}</span>
|
||||||
|
<span v-if="row.node.type === 'file'" class="muted meta">
|
||||||
|
{{ formatSize(row.node.size) }} · {{ row.node.mime }} · {{ formatDate(row.node.created) }}
|
||||||
|
</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button v-if="row.node.type === 'dir'" class="action" @click.stop="pickUpload(drive, row.node.path)">Upload</button>
|
||||||
|
<template v-else>
|
||||||
|
<a class="action" :href="backend.driveFileUrl(drive.name, row.node.path)" :download="row.node.name" @click.stop>Download</a>
|
||||||
|
<button class="action danger" @click.stop="remove(drive, row.node)">Delete</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<input ref="fileInput" type="file" multiple hidden @change="onFilesPicked" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.drives {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 60rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive { border: 1px solid rgba(128, 128, 128, 0.35); border-radius: 0.4rem; }
|
||||||
|
.status, .muted { color: gray; }
|
||||||
|
.muted { font-size: 0.85em; }
|
||||||
|
.error { color: #e05d5d; margin: 0.25rem 0.75rem; }
|
||||||
|
.empty { padding: 0 0.75rem; }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.15rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover { background: rgba(128, 128, 128, 0.1); }
|
||||||
|
.head { padding: 0.5rem 0.75rem; }
|
||||||
|
.caret { flex-shrink: 0; }
|
||||||
|
.name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.meta { white-space: nowrap; }
|
||||||
|
|
||||||
|
.action {
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.5);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:hover { background: rgba(128, 128, 128, 0.2); }
|
||||||
|
.action.danger { color: #e05d5d; border-color: rgba(224, 93, 93, 0.6); }
|
||||||
|
</style>
|
||||||
Reference in new issue
Block a user