Compare commits

..
3 Commits
Author SHA1 Message Date
Shivering Ocelot ea938a1248 fix: drives 2026-10-14 02:02:31 -05:00
Shivering Ocelot 0d79b1f706 feat(backend): refactor websocket handler 2026-10-14 01:44:34 -05:00
hubdev 4e7be79926 feat(chat): collapse log messages 2026-10-14 03:31:05 +00:00
6 changed files with 527 additions and 286 deletions

No files matched your search

+2 -2
View File
@@ -27,7 +27,7 @@ async def upload_file(user_id, drive_name, filename, stream, mime_type=None):
drive = await db.Drive.create(user_id=user_id, name=drive_name) drive = await db.Drive.create(user_id=user_id, name=drive_name)
# Check collision # Check collision
if await db.Drive.get_or_none(drive_id=drive.id, filename=filename): if await db.DriveFile.get_or_none(drive_id=drive.id, filename=filename):
raise Exception("Duplicated file") raise Exception("Duplicated file")
# 2. Prepare disk path # 2. Prepare disk path
@@ -82,7 +82,7 @@ async def delete_file(file_id):
return abs_path return abs_path
async def resolve_file(user_id, drive_name, filename): async def resolve_file(user_id, drive_name, filename):
"""Resolve file to absolute disk path after permission check. Returns (path, mime).""" """Resolve file to absolute disk path after permission check. Returns (abs_path, file.id)."""
# Eager load drive and verify ownership # Eager load drive and verify ownership
drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name) drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name)
if drive.user_id != user_id: if drive.user_id != user_id:
+293 -268
View File
@@ -68,275 +68,10 @@ def register(server, app, auth_manager):
stop_listening = app.on_event(on_event) stop_listening = app.on_event(on_event)
while True: while True:
req = json.loads(await websocket.receive()) asyncio.create_task(handle_request(
if not 'action' in req: app, user_id, session_connections, send, await websocket.receive()
await send({'error': 'no action'}) ))
continue
action = req['action']
body = req.get('body', {})
nonce = req.get('nonce', None)
print(f"[ws] action @{user_id} {action}")
async def agent_id_from_body (allow_none=False):
if 'agentId' in body:
return body['agentId']
elif 'agentname' in body:
return await app.agent_id_from_name(body['agentname'])
elif allow_none:
return None
else:
raise Exception("field 'agentId' or 'agentname' required")
if action == "ping":
await send({'message': "pong"})
elif action == "create-agent":
name = body['name']
agent_id = await app.create_agent(
user_id=user_id,
name=name,
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
kind=body['kind'],
data=body.get('data'),
temporary=body.get('temporary'),
)
await send({'message': "agent created", 'agentId': agent_id, 'nonce': nonce})
elif action == "update-agent":
agent_id = await agent_id_from_body()
await app.update_agent(
agent_id=agent_id,
name=body.get('name'),
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
data=body.get('data', {})
)
await send({'message': "agent updated", 'agentId': agent_id, 'nonce': nonce})
elif action == "get-models":
await send({
'models': [{"id": model.id, "name": model.name, "value": model.value, "kind": model.kind} for model in await app.get_models()],
'nonce': nonce
})
elif action == "save-model":
model = await app.save_model(
model_id=body.get("modelId"),
name=body.get("name"),
kind=body.get("kind"),
value=body.get("value"),
)
if model:
await send({'message': "model saved", 'modelId': model.id, 'nonce': nonce})
elif "modelId" in body:
await send({'message': "model deleted", 'modelId': body["modelId"], 'nonce': nonce})
elif action == "get-agent-config-fields":
await send({'fields': await app.get_agent_config_fields(await agent_id_from_body()), 'nonce': nonce})
elif action == "get-agent-kinds":
await send({'message': "agents retrieved", 'kinds': app.get_agent_kinds(), 'nonce': nonce})
elif action == "get-agent-kind-info":
await send({'message': "agent kind retrieved", 'info': app.get_agent_kind_info(body['kind']), 'nonce': nonce})
elif action == "get-agents":
await send({'message': "agents retrieved", 'agents': await app.get_agents(user_id), 'nonce': nonce})
elif action == "get-agent-resources":
values = await app.get_agent_resources(await agent_id_from_body(), body.get('kind'), body.get('key'))
await send({'resources': [{'kind': r.kind, 'key': r.key, 'value': r.value} for r in values], 'nonce': nonce})
elif action == "save-agent-resource":
await app.save_agent_resource(await agent_id_from_body(), body['kind'], body['key'], body['value'])
await send({'message': "resource saved", 'nonce': nonce})
elif action == "delete-agent-resource":
await app.delete_agent_resource(await agent_id_from_body(), body['kind'], body['key'])
await send({'message': "resource deleted", 'nonce': nonce})
elif action == "get-agent-runs":
await send({
'runs': await app.get_agent_runs(agent_id=await agent_id_from_body(allow_none=True), user_id=user_id),
'nonce': nonce
})
elif action == "cancel-agent-run":
await app.cancel_agent_run(body['runId'])
elif action == "remote-list":
await send({'remotes': app.listRemotes(user_id), 'nonce': nonce})
elif action == "remote-connect":
# Connect a remote served by THIS session's channel.
# Expected failures (duplicate name) are replied, not
# raised, so the session stays alive.
try:
connection = app.connectRemote(user_id, body['remotename'], body.get('functions', []), send)
session_connections[body['remotename']] = connection
await send({'message': "remote connected", 'remotename': body['remotename'], 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
elif action == "remote-disconnect":
# Manual disconnect, allowed from any session of the user
connection = app.disconnectRemote(user_id, body['remotename'])
session_connections.pop(body['remotename'], None)
if connection is None:
await send({'error': "no connected remote named '" + str(body['remotename']) + "'", 'nonce': nonce})
else:
await send({'message': "remote disconnected", 'remotename': body['remotename'], 'nonce': nonce})
elif action == "remote-call":
# Invoke a function of one of the user's connected remotes.
# Expected failures (unknown remote/function, timeout,
# remote-side error) are replied, not raised.
async def _handle_remote_call(user_id, remotename, func_name, args, send, nonce):
try:
result = await app.callRemote(user_id, remotename, func_name, args)
await send({'result': result, 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
asyncio.create_task(_handle_remote_call(
user_id, body['remotename'], body['function'], body.get('args', {}), send, nonce
))
elif action == "remote-call-result":
# A session serving remotes replies to a downstream call.
# Fields live at the message top level, not in 'body'.
# requestIds are '<remotename>#<n>' and resolve/reject
# are no-ops for unknown ids, so fanning out over this
# session's connections routes the reply correctly.
app.resolve_remote_call(body['requestId'], body.get('result'), error=body.get('error'))
elif action == "create-chat":
chat_id = await app.create_chat(
user_id=user_id,
name=body['name'],
description=body.get('description'),
picture=body.get('picture'),
members=body.get('members', []),
temporary=body.get('temporary'),
)
await send({'message': "chat created", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chats":
chats_data = await app.get_chats(user_id)
await send({'message': "chats retrieved", 'chats': chats_data, 'nonce': nonce})
elif action == "delete-chat":
chat_id = await app.delete_chat(
chat_id=body['chatId']
)
await send({'message': "chat deleted", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chat-messages":
messages_data = await app.get_chat_messages(
body['chatId'],
earliest_index=body.get('earliestIndex', 0)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-chat-threads":
await send({'message': "threads retrieved", 'nonce': nonce,
'threads': await app.get_chat_threads(body['chatId'])
})
elif action == "get-chat-thread-heads":
messages_data = await app.get_chat_thread_heads(
body['chatId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-thread-messages":
messages_data = await app.get_thread_messages(
body['chatId'],
body['threadId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "send-chat-message":
message = await app.send_chat_message(
chat_id=body['chatId'],
thread_id=body.get('threadId'),
content=body['content'],
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': message['id'], 'nonce': nonce})
elif action == "update-chat-message":
message = await app.update_chat_message(
message_id=body['messageId'],
content=body.get('content'),
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': body['messageId'], 'nonce': nonce})
elif action == "create-chat-thread":
thread_id = await app.create_chat_thread(
chat_id=body['chatId'],
name=body.get('name'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "fork-chat-thread":
thread_id = await app.fork_chat_thread(
thread_id=body['threadId'],
message_id=body.get('messageId'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "prompt-agent":
if 'instruction' not in body and 'input' not in body:
await send({'error': "instruction or input required", 'nonce': nonce})
continue
# Multiple-result action, same shape as read-stream: every
# generated message is its own packet carrying the request
# nonce, so the client callback runs once per message. The
# terminal eof packet resolves the action. Streams referenced
# by open assistant/reasoning messages are read by the
# client via the existing read-stream action.
generator = app.prompt_agent(
await agent_id_from_body(),
body.get('instruction'),
body.get('input'),
body.get('options', {})
)
try:
async for msg in generator:
await send({'message': 'agent-message', 'data': msg, 'nonce': nonce})
finally:
# Disconnect / cancellation point: closes the run task.
await generator.aclose()
await send({'eof': True, 'nonce': nonce})
elif action == "read-stream":
stream_id = body['streamId']
print("Read Stream" + str(stream_id))
stream = app.streams.get(stream_id)
if stream is None:
await send({'error': 'bad stream id: ' + str(stream_id), 'nonce': nonce})
else:
async def chunk_fn(chunk):
await send({'data': chunk, 'eof': False, 'nonce': nonce})
async def eof_fn():
await send({'eof': True, 'fullData': stream.content, 'nonce': nonce})
stream.listen(chunk_fn, eof_fn)
else:
await send({'error': 'unknown action: ' + action, 'nonce': nonce})
finally: finally:
print("User logged out:", user_id) print("User logged out:", user_id)
sender_task.cancel() sender_task.cancel()
@@ -352,3 +87,293 @@ def register(server, app, auth_manager):
for remotename in list(session_connections): for remotename in list(session_connections):
app.disconnectRemote(user_id, remotename) app.disconnectRemote(user_id, remotename)
app.remove_user_connection(user_id, send_queue) app.remove_user_connection(user_id, send_queue)
# Error handling
async def handle_request(app, user_id, session_connections, send, req):
try:
req = json.loads(req)
await process_request(app, user_id, session_connections, send, req)
except Exception as err:
action = req.get('action') if isinstance(req, dict) else None
nonce = req.get('nonce') if isinstance(req, dict) else None
# Maintainers
print(f"[ws] req error @{user_id} {action!r}: {err!r}")
print(traceback.format_exc())
# Client — nonce makes this routable to the caller's promise/callback
try:
await send({'error': str(err), 'nonce': nonce})
except Exception:
print(traceback.format_exc())
# Actual processing
async def process_request(app, user_id, session_connections, send, req):
if 'action' not in req:
await send({'error': 'no action'})
return
action = req['action']
body = req.get('body', {})
nonce = req.get('nonce', None)
print(f"[ws] req @{user_id} {action}")
async def agent_id_from_body (allow_none=False):
if 'agentId' in body:
return body['agentId']
elif 'agentname' in body:
return await app.agent_id_from_name(body['agentname'])
elif allow_none:
return None
else:
raise Exception("field 'agentId' or 'agentname' required")
if action == "ping":
await send({'message': "pong", 'nonce': nonce})
elif action == "create-agent":
name = body['name']
agent_id = await app.create_agent(
user_id=user_id,
name=name,
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
kind=body['kind'],
data=body.get('data'),
temporary=body.get('temporary'),
)
await send({'message': "agent created", 'agentId': agent_id, 'nonce': nonce})
elif action == "update-agent":
agent_id = await agent_id_from_body()
await app.update_agent(
agent_id=agent_id,
name=body.get('name'),
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
data=body.get('data', {})
)
await send({'message': "agent updated", 'agentId': agent_id, 'nonce': nonce})
elif action == "get-models":
await send({
'models': [{"id": model.id, "name": model.name, "value": model.value, "kind": model.kind} for model in await app.get_models()],
'nonce': nonce
})
elif action == "save-model":
model = await app.save_model(
model_id=body.get("modelId"),
name=body.get("name"),
kind=body.get("kind"),
value=body.get("value"),
)
if model:
await send({'message': "model saved", 'modelId': model.id, 'nonce': nonce})
elif "modelId" in body:
await send({'message': "model deleted", 'modelId': body["modelId"], 'nonce': nonce})
elif action == "get-agent-config-fields":
await send({'fields': await app.get_agent_config_fields(await agent_id_from_body()), 'nonce': nonce})
elif action == "get-agent-kinds":
await send({'message': "agents retrieved", 'kinds': app.get_agent_kinds(), 'nonce': nonce})
elif action == "get-agent-kind-info":
await send({'message': "agent kind retrieved", 'info': app.get_agent_kind_info(body['kind']), 'nonce': nonce})
elif action == "get-agents":
await send({'message': "agents retrieved", 'agents': await app.get_agents(user_id), 'nonce': nonce})
elif action == "get-agent-resources":
values = await app.get_agent_resources(await agent_id_from_body(), body.get('kind'), body.get('key'))
await send({'resources': [{'kind': r.kind, 'key': r.key, 'value': r.value} for r in values], 'nonce': nonce})
elif action == "save-agent-resource":
await app.save_agent_resource(await agent_id_from_body(), body['kind'], body['key'], body['value'])
await send({'message': "resource saved", 'nonce': nonce})
elif action == "delete-agent-resource":
await app.delete_agent_resource(await agent_id_from_body(), body['kind'], body['key'])
await send({'message': "resource deleted", 'nonce': nonce})
elif action == "get-agent-runs":
await send({
'runs': await app.get_agent_runs(agent_id=await agent_id_from_body(allow_none=True), user_id=user_id),
'nonce': nonce
})
elif action == "cancel-agent-run":
await app.cancel_agent_run(body['runId'])
elif action == "remote-list":
await send({'remotes': app.listRemotes(user_id), 'nonce': nonce})
elif action == "remote-connect":
# Connect a remote served by THIS session's channel.
# Expected failures (duplicate name) are replied, not
# raised, so the session stays alive.
try:
connection = app.connectRemote(user_id, body['remotename'], body.get('functions', []), send)
session_connections[body['remotename']] = connection
await send({'message': "remote connected", 'remotename': body['remotename'], 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
elif action == "remote-disconnect":
# Manual disconnect, allowed from any session of the user
connection = app.disconnectRemote(user_id, body['remotename'])
session_connections.pop(body['remotename'], None)
if connection is None:
await send({'error': "no connected remote named '" + str(body['remotename']) + "'", 'nonce': nonce})
else:
await send({'message': "remote disconnected", 'remotename': body['remotename'], 'nonce': nonce})
elif action == "remote-call":
# Invoke a function of one of the user's connected remotes.
# Expected failures (unknown remote/function, timeout,
# remote-side error) are replied, not raised.
async def _handle_remote_call(user_id, remotename, func_name, args, send, nonce):
try:
result = await app.callRemote(user_id, remotename, func_name, args)
await send({'result': result, 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
asyncio.create_task(_handle_remote_call(
user_id, body['remotename'], body['function'], body.get('args', {}), send, nonce
))
elif action == "remote-call-result":
# A session serving remotes replies to a downstream call.
# Fields live at the message top level, not in 'body'.
# requestIds are '<remotename>#<n>' and resolve/reject
# are no-ops for unknown ids, so fanning out over this
# session's connections routes the reply correctly.
app.resolve_remote_call(body['requestId'], body.get('result'), error=body.get('error'))
elif action == "create-chat":
chat_id = await app.create_chat(
user_id=user_id,
name=body['name'],
description=body.get('description'),
picture=body.get('picture'),
members=body.get('members', []),
temporary=body.get('temporary'),
)
await send({'message': "chat created", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chats":
chats_data = await app.get_chats(user_id)
await send({'message': "chats retrieved", 'chats': chats_data, 'nonce': nonce})
elif action == "delete-chat":
chat_id = await app.delete_chat(
chat_id=body['chatId']
)
await send({'message': "chat deleted", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chat-messages":
messages_data = await app.get_chat_messages(
body['chatId'],
earliest_index=body.get('earliestIndex', 0)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-chat-threads":
await send({'message': "threads retrieved", 'nonce': nonce,
'threads': await app.get_chat_threads(body['chatId'])
})
elif action == "get-chat-thread-heads":
messages_data = await app.get_chat_thread_heads(
body['chatId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-thread-messages":
messages_data = await app.get_thread_messages(
body['chatId'],
body['threadId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "send-chat-message":
message = await app.send_chat_message(
chat_id=body['chatId'],
thread_id=body.get('threadId'),
content=body['content'],
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': message['id'], 'nonce': nonce})
elif action == "update-chat-message":
message = await app.update_chat_message(
message_id=body['messageId'],
content=body.get('content'),
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': body['messageId'], 'nonce': nonce})
elif action == "create-chat-thread":
thread_id = await app.create_chat_thread(
chat_id=body['chatId'],
name=body.get('name'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "fork-chat-thread":
thread_id = await app.fork_chat_thread(
thread_id=body['threadId'],
message_id=body.get('messageId'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "prompt-agent":
if 'instruction' not in body and 'input' not in body:
await send({'error': "instruction or input required", 'nonce': nonce})
return
# Multiple-result action, same shape as read-stream: every
# generated message is its own packet carrying the request
# nonce, so the client callback runs once per message. The
# terminal eof packet resolves the action. Streams referenced
# by open assistant/reasoning messages are read by the
# client via the existing read-stream action.
generator = app.prompt_agent(
await agent_id_from_body(),
body.get('instruction'),
body.get('input'),
body.get('options', {})
)
try:
async for msg in generator:
await send({'message': 'agent-message', 'data': msg, 'nonce': nonce})
finally:
# Disconnect / cancellation point: closes the run task.
await generator.aclose()
await send({'eof': True, 'nonce': nonce})
elif action == "read-stream":
stream_id = body['streamId']
print("Read Stream" + str(stream_id))
stream = app.streams.get(stream_id)
if stream is None:
await send({'error': 'bad stream id: ' + str(stream_id), 'nonce': nonce})
else:
async def chunk_fn(chunk):
await send({'data': chunk, 'eof': False, 'nonce': nonce})
async def eof_fn():
await send({'eof': True, 'fullData': stream.content, 'nonce': nonce})
stream.listen(chunk_fn, eof_fn)
else:
await send({'error': 'unknown action: ' + action, 'nonce': nonce})
+13 -11
View File
@@ -1,27 +1,29 @@
# Drives # Drives
Per-user file storage: a **drive** is a named container owned by a user, and each **file** is a database record plus a blob on disk. Everything is addressed by *drive name + filename* — the random file id never appears in URLs. Drives bypass the WebSocket action protocol entirely: all endpoints are plain REST (routes in `backend/src/server/web.py`, logic in `backend/src/drives.py`), so downloads can be plain `<a>` links. Per-user file storage: a **drive** is a named container owned by a user, and each **file** is a database record plus a blob on disk. Client-facing routes are addressed by *drive name + filename* — the random file id never appears in URLs — though several `drives.py` helpers below are id-addressed internally. Drives bypass the WebSocket action protocol entirely: all endpoints are plain REST (routes in `backend/src/server/web.py`, logic in `backend/src/drives.py`), so downloads can be plain `<a>` links.
## Logical model ## Logical model
- **Drive** — user-scoped, addressed by name. Created implicitly on first upload to a new name; there is no create/rename/delete-drive endpoint. - **Drive** — user-scoped, addressed by name. Created implicitly on first upload to a new name; there is no create/rename/delete-drive endpoint.
- **File** — readable `filename`, mime type, size, `created`/`accessed` timestamps. - **File** — readable `filename`, mime type, size, `created`/`accessed` timestamps.
- **Folders are virtual** — a filename may contain slashes (`docs/api/readme.md`); the Drives view splits names to render a tree, but storage stays flat. - **Folders are virtual** — a filename may contain slashes (`docs/api/readme.md`); the Drives view splits names to render a tree, but storage stays flat.
- **Identity** — `DriveFile.id` is a random hex string (`secrets.token_hex(8)`) that doubles as the on-disk filename, so user-facing names never touch the filesystem. - **Identity** — `DriveFile.id` is a random hex string (`secrets.token_hex(8)`, 16 hex chars) that doubles as the on-disk filename, so user-facing names never touch the filesystem.
## Backend interface ## Backend interface
Download links can't set headers, so the GET/DELETE file routes additionally accept the token as a `?token=` query parameter. Download links can't set headers, so the GET/DELETE file routes additionally accept the token as a `?token=` query parameter.
Client-facing file routes are drive+filename-addressed, but the `drives.py` helpers behind them are mixed: `upload_file` accepts drive+filename directly, while `get_metadata(file_id)` and `delete_file(file_id)` are id-addressed, so their routes must first resolve filename → id (via the drive's file records) before calling them. The two listing routes are not implemented in `drives.py` at all — their logic lives elsewhere.
| Route | Behavior | | Route | Behavior |
| --- | --- | | --- | --- |
| `GET /files` | List the user's drives: `[{id, name}]` | | `GET /files` | List the user's drives: `[{id, name}]`. Not implemented in `drives.py`. |
| `GET /files/<drive>` | List files: `[{id, name, size, mime, created}]` | | `GET /files/<drive>` | List files: `[{id, name, size, mime, created}]`. Not implemented in `drives.py`. |
| `POST /files/<drive>/<filename>` | Multipart upload (field `file`) in a single request. The URL tail — `<path:filename>`, slashes allowed — becomes the stored filename; the part's own name is the fallback. Returns `{fileId}`. | | `POST /files/<drive>/<filename>` | Multipart upload (field `file`) in a single request. The URL tail — `<path:filename>`, slashes allowed — becomes the stored filename; the part's own name is the fallback. Returns `{fileId}`. |
| `GET /files/<drive>/<filename>` | Serve the bytes with their stored mime type, or return `{id, name, size, mime, created, accessed}` when `?metadata` is present. | | `GET /files/<drive>/<filename>` | Serve the bytes, with the mime type looked up from the file record (not from `resolve_file`), or return `{id, name, size, mime, created, accessed}` when `?metadata` is present. |
| `DELETE /files/<drive>/<filename>` | Delete the record, then the blob. Returns `{fileId}`. | | `DELETE /files/<drive>/<filename>` | Delete the record, then the blob. Returns the removed absolute disk path (`abs_path`). |
Every resolve (download, metadata, delete) stamps `accessed_at`. Only `resolve_file` stamps `accessed_at`; `get_metadata` and `delete_file` never touch it.
## Frontend interface ## Frontend interface
@@ -29,20 +31,20 @@ In `frontend/src/backend.js`:
- `webFetch(path, options)` — generic REST helper: sets `Authorization: Bearer` (waiting for login if needed), parses JSON, throws on non-2xx. Downloads bypass it. - `webFetch(path, options)` — generic REST helper: sets `Authorization: Bearer` (waiting for login if needed), parses JSON, throws on non-2xx. Downloads bypass it.
- `getDrives()`, `getDriveFiles(driveName)`, `deleteDriveFile(driveName, filename)` - `getDrives()`, `getDriveFiles(driveName)`, `deleteDriveFile(driveName, filename)`
- `uploadDriveFile(driveName, file)` — multipart POST of the whole file. - `uploadDriveFile(driveName, file, path)` — multipart-POSTs the whole file to `/files/<driveName>/<path>`, where `path` is the destination filename including any virtual folder prefix (`docs/api/readme.md`). The composite path belongs in the URL, since the upload route only exists at `/files/<drive>/<path:filename>`.
- `driveFileUrl(driveName, filename)` — GET URL carrying `?token=`, used for download anchors and for opening a file in a tab. - `driveFileUrl(driveName, filename)` — GET URL carrying `?token=`, used for download anchors and for opening a file in a tab.
`frontend/src/views/Drives.vue` lazy-loads a drive's files on first expand, rebuilds the folder tree from the flat file list on every load (open folders live on the drive object, so they survive reloads), uploads through a file picker, and deletes with confirmation. `frontend/src/views/Drives.vue` lazy-loads a drive's files on first expand, rebuilds the folder tree from the flat file list on every load (open folders live on the drive object, so they survive reloads), uploads through a file picker (building the target path from the picked folder and passing it as `uploadDriveFile`'s third argument), and deletes with confirmation.
## Implementation notes ## Implementation notes
- Models in `backend/src/database.py` (migration `0002_auto_20260921_1840`): `Drive(id, user_id, name, created_at)` and `DriveFile(id char(64) pk, drive_id, filename, mime_type, size_bytes, accessed_at, created_at)`, table `drive_files`. Ownership rides the `User → Drive → File` chain and is checked before any disk access. - Models in `backend/src/database.py` (migration `0002_auto_20260921_1840`): `Drive(id, user_id, name, created_at)` and `DriveFile(id, drive_id, filename, mime_type, size_bytes, accessed_at, created_at)`, table `drive_files`. The `id` is a 16-char hex string, not `char(64)`. Ownership rides the `User → Drive → File` chain; it is checked in `resolve_file` before disk access, but `delete_file` and `get_metadata` take a bare `file_id` and perform no ownership check of their own — their routes must guard them.
- Disk layout: `{data_dir}/drives/{drive_id}/{file_id}` — keyed by drive id, not by user or name. `init_drives(data_directory)`, called from `main()`, creates the root. - Disk layout: `{data_dir}/drives/{drive_id}/{file_id}` — keyed by drive id, not by user or name. `init_drives(data_directory)`, called from `main()`, creates the root.
- Upload streams to disk in 8 KiB chunks and only then writes the DB record; delete removes the record first, then the blob. - Upload streams to disk in 8 KiB chunks and only then writes the DB record; delete removes the record first, then the blob.
## Known issues ## Known issues
- `uploadDriveFile` POSTs to `/files/<drive>`, where no POST route exists, and drops the folder-path argument `Drives.vue` passes it — UI uploads fail, and folder-targeted upload is not wired up.
- Drives are only created implicitly, and the view only uploads into existing drives, so the first drive cannot be created from the frontend. - Drives are only created implicitly, and the view only uploads into existing drives, so the first drive cannot be created from the frontend.
- Uploads are meant to refuse a filename already used in the drive, but the check queries the `Drive` model with `DriveFile`'s fields, so it errors instead of detecting duplicates. - Uploads are meant to refuse a filename already used in the drive, but the check queries the `Drive` model with `DriveFile`'s fields, so it errors instead of detecting duplicates.
- `resolve_file` dereferences the drive without a None check, so a nonexistent drive yields a 500 instead of a 404. - `resolve_file` dereferences the drive without a None check, so a nonexistent drive yields a 500 instead of a 404.
- `delete_file` and `get_metadata` take a bare `file_id` with no ownership check; if their routes don't resolve and authorize the file against the owning user, any authenticated user could delete or inspect another user's file by id.
+4 -2
View File
@@ -379,6 +379,8 @@ export function invalidateCache({ chats=false, agents=false } = {}) {
// Drive endpoints are plain HTTP routes instead of WebSocket actions, // Drive endpoints are plain HTTP routes instead of WebSocket actions,
// so they authenticate with the bearer token in the Authorization header. // so they authenticate with the bearer token in the Authorization header.
// TODO: Next time these functions need updating, move them directly to views/Drives.vue
export function getDrives () { export function getDrives () {
return webFetch('/files'); return webFetch('/files');
} }
@@ -387,10 +389,10 @@ export function getDriveFiles (driveName) {
return webFetch(`/files/${driveName}`); return webFetch(`/files/${driveName}`);
} }
export function uploadDriveFile (driveName, file) { export function uploadDriveFile (driveName, file, path) {
const body = new FormData(); const body = new FormData();
body.append('file', file, file.name); body.append('file', file, file.name);
return webFetch(`/files/${driveName}`, { return webFetch(`/files/${driveName}/${path}`, {
method: 'POST', method: 'POST',
body, body,
}); });
+189 -1
View File
@@ -2,6 +2,7 @@
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { marked } from "marked"; import { marked } from "marked";
import * as backend from '../backend'; import * as backend from '../backend';
import * as iconoir from '@iconoir/vue';
marked.use({ marked.use({
renderer: { renderer: {
code ({lang, text}) { code ({lang, text}) {
@@ -33,6 +34,12 @@ const props = defineProps({
logExpanded: { logExpanded: {
type: Boolean, type: Boolean,
required: false, required: false,
},
// Toggle set of expanded message ids, shared with the parent view.
// Used by log-groups to expand/collapse the previewed log.
expandedLogs: {
type: Object,
required: false,
} }
}); });
@@ -45,6 +52,8 @@ function chatEntryClass(msg) {
return ['resource', 'resource-' + (msg.subkind || 'generic')]; return ['resource', 'resource-' + (msg.subkind || 'generic')];
} else if (msg.type == 'log') { } else if (msg.type == 'log') {
return ['log']; return ['log'];
} else if (msg.type == 'log-group') {
return ['log-group'];
} else if (msg.type == 'thread-header') { } else if (msg.type == 'thread-header') {
let cls = ['thread']; let cls = ['thread'];
if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed') if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed')
@@ -108,10 +117,106 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
} }
}, {immediate: true}) }, {immediate: true})
// === Log groups === //
// A log-group collects consecutive log messages. Each one is shown as a square
// icon (which selects it as the preview); the selected log is previewed below.
const logs = computed(() =>
props.message.type == 'log-group' ? (props.message.logs || []) : []
);
// Icon used for a log's square button, chosen by log subkind
function logIcon(subkind) {
if (subkind == 'reasoning') return iconoir.Brain;
if (subkind == 'tool-call') return iconoir.Wrench;
return iconoir.Terminal;
}
// Null means "default to the last log in the group"
const selectedLogId = ref(null);
const selectedLog = computed(() => {
const arr = logs.value;
if (!arr.length) return null;
if (selectedLogId.value != null) {
const found = arr.find(l => l.id == selectedLogId.value);
if (found) return found;
}
return arr[arr.length - 1];
});
// Streamed logs resolve their content through a shared per-stream cache, so
// switching the preview between logs still shows up-to-date content.
const boundStreams = new Set();
const streamContents = ref({});
function bindLogStreams() {
for (const log of logs.value) {
if (log.format != 'stream') continue;
const streamId = parseInt(log.content);
if (boundStreams.has(streamId)) continue;
boundStreams.add(streamId);
const stream = backend.readStream(streamId);
const update = () => {
streamContents.value = { ...streamContents.value, [streamId]: stream.partialContent };
};
update();
stream.on('data', update);
}
}
watch(logs, bindLogStreams, { immediate: true });
const selectedLogText = computed(() => {
const log = selectedLog.value;
if (!log) return '';
if (log.format == 'stream') return streamContents.value[parseInt(log.content)] ?? '';
return log.content ?? '';
});
const groupLogLong = computed(() => {
if (!selectedLog.value) return false;
const lines = String(selectedLogText.value ?? '').split('\n');
return lines.length > LOG_MAX_LINES || lines.some(line => line.length > LOG_MAX_LENGTH);
});
const groupExpanded = computed(() =>
selectedLog.value != null && !!props.expandedLogs?.has(selectedLog.value.id)
);
// Collapsed preview keeps the *last* N characters (the tail), not the head;
// CSS then clips the top so only the bottom lines show.
const groupLogContent = computed(() => {
const text = String(selectedLogText.value ?? '');
if (groupLogLong.value && !groupExpanded.value) {
return text.slice(-(LOG_MAX_LENGTH * LOG_MAX_LINES));
}
return text;
});
const groupLogMeta = computed(() => groupLogLong.value ? {expanded: groupExpanded.value} : null);
function toggleGroupLog() {
if (selectedLog.value) props.expandedLogs?.toggle(selectedLog.value.id);
}
function onContextMenu() {
if (props.message.type == 'log-group') {
if (selectedLog.value) emit('open-menu', selectedLog.value, groupLogMeta.value);
} else {
emit('open-menu', props.message, logMeta.value);
}
}
</script> </script>
<template> <template>
<div :class="chatEntryClass(message)" @contextmenu.prevent="$emit('open-menu', message, logMeta)" @click="handleClick(message)"> <div :class="chatEntryClass(message)" @contextmenu.prevent="onContextMenu" @click="handleClick(message)">
<template v-if="message.type == 'message' || message.type == null"> <template v-if="message.type == 'message' || message.type == null">
<div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div> <div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div>
@@ -135,6 +240,28 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
<a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a> <a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a>
</template> </template>
<template v-if="message.type == 'log-group'">
<div class="log-icons">
<button
v-for="log in logs"
:key="log.id"
type="button"
class="icon"
:class="{ highlight: selectedLog && log.id == selectedLog.id }"
:title="(log.sender || '') + (log.subkind ? ' [' + log.subkind + ']' : '')"
@click="selectedLogId = log.id"
>
<component :is="logIcon(log.subkind)" />
</button>
</div>
<div v-if="selectedLog" class="log-preview">
<div class="log-sender">{{selectedLog.sender}}<span v-if="selectedLog.subkind"> [{{selectedLog.subkind}}]</span></div>
<div class="content" :class="{ clipped: !groupExpanded }">{{groupLogContent}}</div>
<a v-if="groupLogLong" class="log-toggle" @click.prevent="toggleGroupLog()">{{groupExpanded ? '(collapse)' : '(expand)'}}</a>
</div>
</template>
<template v-if="message.type == 'thread-header'"> <template v-if="message.type == 'thread-header'">
<div class="thread-title"> <div class="thread-title">
{{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }} {{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }}
@@ -219,6 +346,67 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
user-select: none; user-select: none;
} }
/* === Log groups === */
/* Completely transparent container; stacks icons above the preview and
stretches to the full width of the chat */
.log-group {
align-self: stretch;
width: 100%;
padding: 0.6em 1.4em;
background: transparent;
display: flex;
flex-direction: column;
gap: 0.4em;
}
/* Square buttons (sized by the global `button.icon`) that wrap to the width,
each line centered within the container */
.log-icons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.3em;
}
.log-icons .highlight {
color: var(--color-highlight);
border-color: var(--color-highlight);
}
.log-preview {
font-family: monospace;
font-size: 80%;
max-width: 70em;
}
.log-preview .log-sender {
font-weight: bold;
}
.log-preview .content {
white-space: pre-wrap;
word-wrap: anywhere;
width: fit-content;
max-width: 70em;
line-height: 1.3;
}
/* Clipping shows only the bottom lines: the content is bottom-aligned, so any
vertical overflow is clipped off the top. ~6 lines at line-height 1.3. */
.log-preview .content.clipped {
display: flex;
flex-direction: column;
justify-content: flex-end;
max-height: 7.8em;
overflow: hidden;
}
.log-preview .log-toggle {
cursor: pointer;
user-select: none;
}
.resource { .resource {
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: 1px dashed var(--color-border); border: 1px dashed var(--color-border);
+26 -2
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { import {
ref, watch, ref, computed, watch,
onMounted, onUnmounted, onMounted, onUnmounted,
} from "vue"; } from "vue";
@@ -30,6 +30,29 @@
creatingThread, creatingThread,
} = session; } = session;
// Consecutive log messages collapse into a single "log-group" entry, which
// Message.vue renders as a container of icons plus a preview.
const displayEntries = computed(() => {
const entries = chatEntries.value || [];
const out = [];
let group = null;
for (const entry of entries) {
if (entry.type == 'log') {
if (!group) {
group = { id: 'log-group-' + entry.id, type: 'log-group', logs: [] };
out.push(group);
}
group.logs.push(entry);
} else {
group = null;
out.push(entry);
}
}
return out;
});
// UI state coupled to the document itself // UI state coupled to the document itself
// Message ids of logs currently expanded // Message ids of logs currently expanded
@@ -216,7 +239,7 @@
<div class="row" style="overflow: auto;"> <div class="row" style="overflow: auto;">
<div class="column"> <div class="column">
<div class="messages-container" ref="chatContainerElem"> <div class="messages-container" ref="chatContainerElem">
<div class="message-container" v-for="msg in chatEntries"> <div class="message-container" v-for="msg in displayEntries">
<Message <Message
:key="msg.id" :key="msg.id"
:message="msg" :message="msg"
@@ -225,6 +248,7 @@
:log-expanded="expandedLogs.has(msg.id)" :log-expanded="expandedLogs.has(msg.id)"
@toggle-log="expandedLogs.toggle(msg.id)" @toggle-log="expandedLogs.toggle(msg.id)"
:expanded-logs="expandedLogs"
:cancellable="messageRunMap[msg.id]" :cancellable="messageRunMap[msg.id]"
@cancel="session.cancelMessageRun(msg.id)" @cancel="session.cancelMessageRun(msg.id)"