Add a chat list and server-minted new chats on top of the desk API's
GET/POST /desk/chats:
- server_client.list_chats() / create_chat(), same shape and error
handling as list_outbox_files().
- Switching is config.set_session(id): a plain reassignment of the
SESSION_ID every call reads, persisted to session.json. The window
refuses to switch mid-turn rather than locking.
- gui/chat_list_window.py: a Chats dialog (Ctrl+K) with a QListWidget,
populated on open, with Refresh and New Chat. Network calls run as
QRunnables on the global QThreadPool and come back through Signals,
never from a slot. The header New chat (Ctrl+N) uses the same path.
- Switching clears the local transcript; there is no endpoint for a
past chat's messages. Bolt's memory is per user, so it carries over.
- Terminal: /chats, /chat <n|id>, and /new (server-minted id, local
fallback).
This commit also carries the uncommitted work since 54030a7 that turned
this Bolt-Pet fork into Bolt Console: the pet, wake word, TTS and screen
features removed; a Claude Code-style terminal client; the PySide6
window with tool cards, approvals and diffs; mic dictation through the
server's /desk/stt relay; filectl/JSON repairs; and detection of
invented tool results.
Build output (build/, dist/, Release/, *.spec) is now ignored.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
55 lines
2.0 KiB
Python
55 lines
2.0 KiB
Python
"""Saves files the server queues via its deliver_files tool (ai/desk_api.py
|
|
in the main tmn-api repo) to a local downloads folder.
|
|
|
|
The server side of this is already generic — any desk client can list
|
|
GET /desk/files and fetch GET /desk/files/<id> (see server_client.
|
|
list_outbox_files / download_outbox_file) — so this module is just the
|
|
filesystem half: turn a server-supplied display name into a safe path and
|
|
write the bytes.
|
|
|
|
Pure filename/path logic lives here so it's testable without touching a real
|
|
mic/network; the only I/O is the final write in save().
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
_FALLBACK_NAME = "delivered_file"
|
|
|
|
|
|
def sanitize_filename(name: str) -> str:
|
|
"""Reduce a server-supplied name to a bare filename. Defends against a
|
|
delivered name that's actually a path (../../etc, an absolute path, ...)
|
|
— Path(...).name strips every directory component, and anything that
|
|
collapses to nothing (or "." / "..") falls back to a generic name."""
|
|
candidate = Path(str(name or "").strip()).name
|
|
if candidate in ("", ".", ".."):
|
|
return _FALLBACK_NAME
|
|
return candidate
|
|
|
|
|
|
def unique_path(directory: Path, name: str) -> Path:
|
|
"""*name* under *directory*, suffixed " (1)", " (2)", ... if that name is
|
|
already taken — a delivered file never overwrites an earlier download."""
|
|
directory = Path(directory)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
candidate = directory / name
|
|
if not candidate.exists():
|
|
return candidate
|
|
stem, suffix = candidate.stem, candidate.suffix
|
|
n = 1
|
|
while True:
|
|
candidate = directory / f"{stem} ({n}){suffix}"
|
|
if not candidate.exists():
|
|
return candidate
|
|
n += 1
|
|
|
|
|
|
def save(directory: Path, name: str, data: bytes) -> Path:
|
|
"""Write *data* under *directory* as *name* (sanitized + uniquified),
|
|
returning the path written."""
|
|
path = unique_path(directory, sanitize_filename(name))
|
|
path.write_bytes(data)
|
|
return path
|