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,162 @@
|
||||
"""Environment configuration for Bolt Console.
|
||||
|
||||
Same lightweight ".env next to the script" pattern as desk_client/bolt_desk.py
|
||||
in the main tmn-api repo, so this project can be copied anywhere (it does not
|
||||
import anything from that repo) and configured the same way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
# PyInstaller build: the .env sits next to the .exe, not inside the bundle.
|
||||
HERE = Path(sys.executable).resolve().parent
|
||||
else:
|
||||
HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_console/)
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
env_path = HERE / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
|
||||
_load_env()
|
||||
|
||||
|
||||
def _flag(name: str, default: str) -> bool:
|
||||
return os.environ.get(name, default).lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _node_name() -> str:
|
||||
try:
|
||||
return platform.node() or "desktop"
|
||||
except Exception:
|
||||
return "desktop"
|
||||
|
||||
|
||||
# ── server / identity ───────────────────────────────────────────────────────
|
||||
# The session id keeps the "pet-" prefix on purpose: the desk API advertises
|
||||
# the `filectl` read/write/edit tool to pet sessions, and this console is
|
||||
# built around that tool. "-console" keeps it a separate conversation from a
|
||||
# Bolt-Pet running on the same machine.
|
||||
|
||||
SERVER_URL = os.environ.get("BOLT_SERVER_URL", "").rstrip("/")
|
||||
API_KEY = os.environ.get("DESK_API_KEY", "")
|
||||
#
|
||||
# "New chat" (GUI) / /new (terminal) rotates to a fresh id, remembered in
|
||||
# STATE_FILE so a restart carries on the same conversation. DESK_SESSION_ID in
|
||||
# .env pins the session and takes precedence over the remembered one.
|
||||
|
||||
STATE_FILE = Path.home() / ".cache" / "bolt-console" / "session.json"
|
||||
_BASE_SESSION = "pet-console-" + _node_name()
|
||||
|
||||
|
||||
def _remembered_session() -> str:
|
||||
try:
|
||||
import json
|
||||
|
||||
value = json.loads(STATE_FILE.read_text(encoding="utf-8")).get("session_id")
|
||||
return str(value) if value else ""
|
||||
except Exception:
|
||||
return "" # missing or corrupt state means the default, never a crash
|
||||
|
||||
|
||||
SESSION_ID = os.environ.get("DESK_SESSION_ID") or _remembered_session() or _BASE_SESSION
|
||||
|
||||
|
||||
def set_session(session_id: str) -> str:
|
||||
"""Make *session_id* the current conversation and remember it for the
|
||||
next start. A plain reassignment of the module attribute every
|
||||
server_client call reads; not locked against an in-flight request, which
|
||||
is fine for a rare, deliberate user action (the window blocks switching
|
||||
while a turn is running anyway)."""
|
||||
import json
|
||||
|
||||
global SESSION_ID
|
||||
SESSION_ID = session_id
|
||||
try:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps({"session_id": SESSION_ID}), encoding="utf-8")
|
||||
except OSError:
|
||||
pass # switched for this run; it just won't survive a restart
|
||||
return SESSION_ID
|
||||
|
||||
|
||||
def new_session() -> str:
|
||||
"""A locally made fresh id — the fallback when the server can't mint one
|
||||
(POST /desk/chats is the normal route; see Session.new_conversation)."""
|
||||
import time
|
||||
|
||||
return set_session(f"{_BASE_SESSION}-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||
|
||||
|
||||
def session_pinned() -> bool:
|
||||
return bool(os.environ.get("DESK_SESSION_ID"))
|
||||
|
||||
# ── relayed commands ────────────────────────────────────────────────────────
|
||||
|
||||
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "120"))
|
||||
|
||||
# What happens when Bolt wants to change something on this machine:
|
||||
# ask — shell commands and file writes/edits wait for y/n at the prompt
|
||||
# (reads and directory listings never ask).
|
||||
# auto — everything runs without asking, like the pet did.
|
||||
# Toggle for the current session with /auto and /ask.
|
||||
APPROVAL_MODE = os.environ.get("APPROVAL_MODE", "ask").strip().lower()
|
||||
|
||||
# ── replies ─────────────────────────────────────────────────────────────────
|
||||
# Print each sentence as the server produces it instead of waiting out the
|
||||
# whole model call. Falls back to the ordinary request/response path when the
|
||||
# stream fails before anything was printed.
|
||||
STREAMING_REPLIES = _flag("STREAMING_REPLIES", "true")
|
||||
|
||||
# Append a one-line note (working directory, OS) to each message, so "what's
|
||||
# in this folder?" has a referent and Bolt knows there's no pet body here.
|
||||
CONSOLE_CONTEXT = _flag("CONSOLE_CONTEXT", "true")
|
||||
|
||||
# ANSI colour. Also off when NO_COLOR is set or stdout isn't a terminal.
|
||||
COLOR = _flag("COLOR", "true") and "NO_COLOR" not in os.environ
|
||||
|
||||
# ── file delivery ───────────────────────────────────────────────────────────
|
||||
# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo)
|
||||
# queues files on this session — e.g. "send me that report" — for the client
|
||||
# to fetch via GET /desk/files. Downloading a file dequeues it server-side, so
|
||||
# each one lands here exactly once.
|
||||
|
||||
RECEIVE_FILES = _flag("RECEIVE_FILES", "true")
|
||||
DELIVERED_FILES_DIR = Path(
|
||||
os.environ.get("DELIVERED_FILES_DIR") or str(Path.home() / "Downloads" / "Bolt")
|
||||
).expanduser()
|
||||
|
||||
|
||||
# ── dictation (the mic button) ──────────────────────────────────────────────
|
||||
# Speech goes to the server's /desk/stt relay, like Bolt-Pet. MIC_DEVICE is an
|
||||
# input index or name substring (blank = system default); a recording stops
|
||||
# itself after DICTATION_SILENCE_SECONDS of quiet once you've said something.
|
||||
|
||||
MIC_DEVICE = os.environ.get("MIC_DEVICE", "")
|
||||
DICTATION_SILENCE_SECONDS = float(os.environ.get("DICTATION_SILENCE_SECONDS", "3"))
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
return bool(SERVER_URL and API_KEY)
|
||||
|
||||
|
||||
def missing_config() -> list:
|
||||
missing = []
|
||||
if not SERVER_URL:
|
||||
missing.append("BOLT_SERVER_URL")
|
||||
if not API_KEY:
|
||||
missing.append("DESK_API_KEY")
|
||||
return missing
|
||||
Reference in New Issue
Block a user