feat: implement drives, backend and frontend
This commit is contained in:
1 parent
6ad0a461e4
commit
3283b77ab9
12 files changed
+781
-93
No files matched your search
@@ -18,6 +18,7 @@ import agent as agent_mod
|
||||
import agents as agents_mod
|
||||
import temporary as tmp
|
||||
from server import start_server
|
||||
from drives import init_drives
|
||||
from chat import ChatView, Message, MESSAGE_PAGE_SIZE
|
||||
from remote import RemoteConnection, RemoteToolSpec, parse_function_specs
|
||||
|
||||
@@ -225,6 +226,8 @@ async def main():
|
||||
# 1. Initialize Database First
|
||||
await db.init_database(data_directory)
|
||||
print("Database initialized with Tortoise ORM.")
|
||||
# Drives as well
|
||||
await init_drives(data_directory)
|
||||
|
||||
# 2. Initialize App
|
||||
horde_app = App(
|
||||
|
||||
+22
-2
@@ -52,7 +52,7 @@ def is_instance (value):
|
||||
class User(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
@@ -165,4 +165,24 @@ class Model(models.Model):
|
||||
#user = fields.ForeignKeyField("models.User", null=True)
|
||||
name = fields.CharField(max_length=255)
|
||||
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)
|
||||
Reference in new issue
Block a user