Bolt Console: desktop app with chat list / new chat, dictation, file tools
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>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"""HTTP client for Bolt's desk API (ai/desk_api.py on the server).
|
||||
|
||||
Protocol is identical to desk_client/bolt_desk.py in the main tmn-api repo —
|
||||
the console is just another desk client, so it gets the exact same brain,
|
||||
memory, tools, and persona as Discord chat and the other desk clients:
|
||||
|
||||
text -> POST /desk/converse (or /desk/converse_stream)
|
||||
[server may relay a command back to run on THIS machine]
|
||||
... -> POST /desk/tool_result (repeat until the server sends a reply)
|
||||
reply <- returned to caller
|
||||
|
||||
Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Callable, NamedTuple, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from . import config
|
||||
|
||||
_MAX_RELAY_HOPS = 16
|
||||
_MAX_OUTPUT_CHARS = 6000
|
||||
|
||||
|
||||
class ServerError(Exception):
|
||||
"""Raised when the server responds with an error payload or unreachable."""
|
||||
|
||||
|
||||
class Reply(NamedTuple):
|
||||
"""One final reply from the desk API."""
|
||||
|
||||
text: str
|
||||
# True when the text already reached the screen sentence by sentence while
|
||||
# it streamed in — the caller must not print it a second time.
|
||||
streamed: bool = False
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"X-Desk-Api-Key": config.API_KEY}
|
||||
|
||||
|
||||
def check_health(timeout: float = 10.0) -> dict:
|
||||
response = requests.get(f"{config.SERVER_URL}/desk/health", headers=_headers(), timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def run_local_command(command: str, timeout: Optional[int] = None) -> str:
|
||||
"""Execute a relayed shell command as the current user, in the console's
|
||||
working directory (see /cd). Output is captured for the server, so stdin
|
||||
is closed rather than inherited — a command waiting on input would
|
||||
otherwise hang on a prompt nobody can see. `sudo` still works: it asks on
|
||||
the terminal itself, not on stdin."""
|
||||
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True, errors="replace",
|
||||
stdin=subprocess.DEVNULL, timeout=timeout, cwd=os.getcwd(),
|
||||
# From the window (pythonw, no console) every cmd.exe would
|
||||
# otherwise flash a console window. Output is captured either way.
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
output = (completed.stdout or "") + (completed.stderr or "")
|
||||
return f"[exit {completed.returncode}]\n{output}"[:_MAX_OUTPUT_CHARS]
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"[command timed out after {timeout}s]"
|
||||
except Exception as exc:
|
||||
return f"[command failed: {exc}]"
|
||||
|
||||
|
||||
def _reply_from(payload: dict, streamed: bool = False) -> Reply:
|
||||
if payload.get("type") == "reply":
|
||||
return Reply(text=str(payload.get("text") or ""), streamed=streamed)
|
||||
raise ServerError(str(payload.get("error") or "unknown server response"))
|
||||
|
||||
|
||||
def _relay(payload: dict, on_command, on_say) -> dict:
|
||||
"""Run every command the server hands back, posting each output to
|
||||
/desk/tool_result, until the payload is no longer a command."""
|
||||
for _ in range(_MAX_RELAY_HOPS):
|
||||
if payload.get("type") != "command":
|
||||
break
|
||||
holding = str(payload.get("say") or "").strip()
|
||||
if holding and on_say is not None:
|
||||
# Shown *before* the command runs — that's the point of it.
|
||||
try:
|
||||
on_say(holding)
|
||||
except Exception:
|
||||
pass # a failed acknowledgement must not cost the tool call
|
||||
output = on_command(str(payload.get("command") or ""))
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config.SERVER_URL}/desk/tool_result",
|
||||
json={"session_id": config.SESSION_ID,
|
||||
"token": payload.get("token"), "output": output},
|
||||
headers=_headers(), timeout=180,
|
||||
)
|
||||
payload = response.json()
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc
|
||||
return payload
|
||||
|
||||
|
||||
def converse(
|
||||
text: str,
|
||||
on_command: Callable[[str], str] = run_local_command,
|
||||
timeout: float = 120.0,
|
||||
on_say: Optional[Callable[[str], None]] = None,
|
||||
) -> Reply:
|
||||
"""Send one turn of conversation, relaying any commands the server sends
|
||||
back until it produces a final reply.
|
||||
|
||||
*on_say* receives the short holding line ("give me a sec") the server can
|
||||
send alongside a command."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config.SERVER_URL}/desk/converse",
|
||||
json={"session_id": config.SESSION_ID, "text": text},
|
||||
headers=_headers(), timeout=timeout,
|
||||
)
|
||||
payload = response.json()
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't reach the server: {exc}") from exc
|
||||
return _reply_from(_relay(payload, on_command, on_say))
|
||||
|
||||
|
||||
def converse_stream(
|
||||
text: str,
|
||||
on_say: Callable[[str], None],
|
||||
on_command: Callable[[str], str] = run_local_command,
|
||||
timeout: float = 180.0,
|
||||
) -> Reply:
|
||||
"""Same turn as converse(), but handing each sentence to *on_say* as it
|
||||
arrives, so the wait is time-to-first-sentence.
|
||||
|
||||
Raises ServerError if the stream fails before anything was shown — the
|
||||
caller then retries with converse(). Once a sentence *has* been shown,
|
||||
a late failure ends the turn with what was said rather than repeating it.
|
||||
"""
|
||||
shown_anything = False
|
||||
said = []
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config.SERVER_URL}/desk/converse_stream",
|
||||
json={"session_id": config.SESSION_ID, "text": text},
|
||||
headers=_headers(), timeout=timeout, stream=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
for raw in response.iter_lines(decode_unicode=True):
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
kind = str(event.get("type") or "")
|
||||
|
||||
if kind == "say":
|
||||
line = str(event.get("text") or "").strip()
|
||||
if line:
|
||||
shown_anything = True
|
||||
said.append(line)
|
||||
on_say(line)
|
||||
elif kind == "command":
|
||||
# The tool loop is request/response, so the rest of the turn
|
||||
# finishes through the ordinary relay rather than the stream.
|
||||
response.close()
|
||||
return _reply_from(_relay(event, on_command, on_say))
|
||||
elif kind == "reply":
|
||||
if event.get("already_spoken"):
|
||||
return _reply_from(event, streamed=True)
|
||||
return _unsaid(_reply_from(event), said)
|
||||
elif kind == "error":
|
||||
raise ServerError(str(event.get("error") or "stream failed"))
|
||||
except ServerError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if shown_anything:
|
||||
return Reply(text="", streamed=True)
|
||||
raise ServerError(f"streaming failed: {exc}") from exc
|
||||
raise ServerError("stream ended without a reply")
|
||||
|
||||
|
||||
def _unsaid(reply: Reply, said: list) -> Reply:
|
||||
"""The part of *reply* not already shown as streamed sentences.
|
||||
|
||||
Seen live: the server streams the sentences, then sends the whole text
|
||||
again in the final reply *without* `already_spoken`, so everything showed
|
||||
twice. Compared ignoring whitespace, since the stream splits on sentences
|
||||
and drops the line breaks the full text keeps."""
|
||||
if not said:
|
||||
return reply
|
||||
target = len("".join("".join(said).split())) # non-whitespace characters shown
|
||||
seen, cut = 0, None
|
||||
for index, char in enumerate(reply.text):
|
||||
if not char.isspace():
|
||||
seen += 1
|
||||
if seen == target:
|
||||
cut = index + 1
|
||||
break
|
||||
if cut is None or "".join(reply.text[:cut].split()) != "".join("".join(said).split()):
|
||||
return reply # a different text, not a repeat: show all of it
|
||||
rest = reply.text[cut:].strip()
|
||||
return Reply(text=rest, streamed=not rest)
|
||||
|
||||
|
||||
def list_chats(timeout: float = 15.0) -> list:
|
||||
"""This user's conversations across every client (console, pet, phone),
|
||||
newest first — each {session_id, label, last_message, last_active}. A
|
||||
chat only appears once it's been used, so a just-minted one isn't here."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return list(response.json().get("chats") or [])
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't list chats: {exc}") from exc
|
||||
|
||||
|
||||
def create_chat(timeout: float = 15.0) -> str:
|
||||
"""Mint a brand-new, never-used session id on the server. Bolt's memory is
|
||||
scoped to the user, not the session, so a new chat is fresh history, not
|
||||
a memory reset."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
session_id = str(response.json().get("session_id") or "")
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't create a chat: {exc}") from exc
|
||||
if not session_id:
|
||||
raise ServerError("the server didn't return a session_id for the new chat")
|
||||
return session_id
|
||||
|
||||
|
||||
def list_outbox_files(timeout: float = 15.0) -> list:
|
||||
"""Files the server has queued for this session via its deliver_files
|
||||
tool — each entry has id/name/size. Downloading one dequeues it
|
||||
server-side, so a file is only ever handed out once."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{config.SERVER_URL}/desk/files",
|
||||
params={"session_id": config.SESSION_ID},
|
||||
headers=_headers(), timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return list(response.json().get("files") or [])
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't list delivered files: {exc}") from exc
|
||||
|
||||
|
||||
def download_outbox_file(file_id: str, timeout: float = 60.0) -> bytes:
|
||||
"""Fetches and dequeues one file listed by list_outbox_files()."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{config.SERVER_URL}/desk/files/{file_id}",
|
||||
params={"session_id": config.SESSION_ID},
|
||||
headers=_headers(), timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't download delivered file {file_id!r}: {exc}") from exc
|
||||
Reference in New Issue
Block a user