Files
NexusOS/management/nexus-chat.py
T
jon b0aa0438af Initial commit: NexusOS project + desktop theme baseline
Captures the known-good state after theme consolidation and
consistency reconciliation:
- NexusOS GTK/xfwm4/icon theme assets (assets/themes, management/Mint-Y-Nexus)
- Tightened right-click menus, visible separators, no menu icons
- assets/themes/install-theme.sh: idempotent restore of all wiring
  (symlinks, xfconf xsettings+xfwm4, GTK 3/4 settings.ini)
- .gitignore excludes venv/ollama/models/runtime/db

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:39:48 -05:00

533 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Nexus Chat — interactive TUI for the Synapse backend."""
import curses
import httpx
import json
import os
import signal
import subprocess
import sys
import textwrap
import threading
import time
import uuid
NEXUS_ROOT = os.path.expanduser("~/nexus-core")
API_BASE = "http://localhost:8000"
MEMORY_BASE = "http://localhost:8001"
PID_FILE = os.path.join(NEXUS_ROOT, "runtime/pids/backend.pid")
LOG_FILE = os.path.join(NEXUS_ROOT, "runtime/backend.log")
VENV_PY = os.path.join(NEXUS_ROOT, "Promethean/bin/python3")
BACKEND_CMD = [
"uvicorn", "synapse.main:sio_app",
"--host", "0.0.0.0", "--port", "8000",
]
# ─── backend helpers ──────────────────────────────────────────────────────────
def backend_running() -> bool:
try:
return httpx.get(f"{API_BASE}/", timeout=2.0).status_code == 200
except Exception:
return False
def start_backend() -> int | None:
"""Start the backend and return its PID, or None on failure."""
os.makedirs(os.path.dirname(PID_FILE), exist_ok=True)
python = VENV_PY if os.path.exists(VENV_PY) else sys.executable
cmd = [python, "-m"] + BACKEND_CMD
with open(LOG_FILE, "a") as log:
proc = subprocess.Popen(
cmd, cwd=NEXUS_ROOT, stdout=log, stderr=log, start_new_session=True
)
with open(PID_FILE, "w") as f:
f.write(str(proc.pid))
for _ in range(30):
time.sleep(1)
if backend_running():
return proc.pid
return None
def stop_backend(pid: int):
"""Gracefully stop the backend process we started."""
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return
for _ in range(10):
time.sleep(0.5)
try:
os.kill(pid, 0)
except ProcessLookupError:
break
else:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
os.remove(PID_FILE)
except FileNotFoundError:
pass
def fetch_models() -> tuple[list[str], str]:
"""Return (model_list, selected_model)."""
try:
r = httpx.get(f"{API_BASE}/models", timeout=2.0)
if r.status_code == 200:
data = r.json()
models = data.get("models", [])
selected = data.get("selected") or (models[0] if models else "mistral")
return models, selected
except Exception:
pass
return ["mistral"], "mistral"
# ─── TUI ──────────────────────────────────────────────────────────────────────
SHORTCUTS = [
("^X", "Exit"),
("^R", "Regen"),
("^N", "New"),
("^L", "Clear"),
("^T", "Model"),
("PgUp", "Up"),
("PgDn", "Down"),
("/remember", "Save fact"),
]
# color pair indices
CP_HEADER = 1 # header bar
CP_SHORTBAR = 2 # shortcut bar bg
CP_USER = 3 # "You" label
CP_NEXUS = 4 # "Nexus" label
CP_DIVIDER = 5 # divider line
CP_STREAMING = 6 # streaming indicator
CP_STATS = 7 # token stats line
class NexusChat:
def __init__(self, stdscr):
self.scr = stdscr
self.conv_id = str(uuid.uuid4())
self.history = [] # [{"role": ..., "content": ..., "stats": {...}|None}]
self.input_buf = ""
self.scroll = 0 # lines scrolled up from bottom
self.streaming = False
self.stream_buf = ""
self._last_meta: dict | None = None
self._memory_saved: dict | None = None # set when Mistral saves a fact
self.models, self.model = fetch_models()
self.model_idx = self.models.index(self.model) if self.model in self.models else 0
self._lock = threading.Lock()
curses.start_color()
curses.use_default_colors()
curses.init_pair(CP_HEADER, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(CP_SHORTBAR, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(CP_USER, curses.COLOR_CYAN, -1)
curses.init_pair(CP_NEXUS, curses.COLOR_MAGENTA, -1)
curses.init_pair(CP_DIVIDER, curses.COLOR_CYAN, -1)
curses.init_pair(CP_STREAMING,curses.COLOR_YELLOW, -1)
curses.init_pair(CP_STATS, curses.COLOR_WHITE, -1)
curses.curs_set(1)
self.scr.keypad(True)
self.scr.timeout(50)
# ── geometry ──────────────────────────────────────────────────────────────
def dims(self):
return self.scr.getmaxyx() # (h, w)
def msg_rows(self, h):
# row 0 = header, row h-3 = divider, row h-2 = input, row h-1 = shortbar
return range(1, h - 3)
# ── drawing ───────────────────────────────────────────────────────────────
def _put(self, row, col, text, attr=0):
h, w = self.dims()
if row < 0 or row >= h:
return
try:
self.scr.addstr(row, col, text[:max(0, w - col)], attr)
except curses.error:
pass
def draw_header(self):
h, w = self.dims()
left = " NEXUS CHAT "
right = f" model: {self.model} "
if self.streaming:
mid = " streaming… "
mid_attr = curses.color_pair(CP_STREAMING) | curses.A_BOLD
else:
mid = ""
mid_attr = 0
gap = max(0, w - len(left) - len(right) - len(mid))
bar = left + " " * (gap // 2) + mid + " " * (gap - gap // 2) + right
self.scr.attron(curses.color_pair(CP_HEADER) | curses.A_BOLD)
self._put(0, 0, bar.ljust(w)[:w])
self.scr.attroff(curses.color_pair(CP_HEADER) | curses.A_BOLD)
def draw_shortbar(self):
h, w = self.dims()
# Fill bg
self.scr.attron(curses.color_pair(CP_SHORTBAR))
self._put(h - 1, 0, " " * (w - 1))
self.scr.attroff(curses.color_pair(CP_SHORTBAR))
# Draw key/label pairs
x = 1
for key, label in SHORTCUTS:
if x + len(key) + len(label) + 2 >= w:
break
self.scr.attron(curses.A_REVERSE)
self._put(h - 1, x, key)
self.scr.attroff(curses.A_REVERSE)
x += len(key)
self.scr.attron(curses.color_pair(CP_SHORTBAR))
self._put(h - 1, x, f" {label} ")
self.scr.attroff(curses.color_pair(CP_SHORTBAR))
x += len(label) + 3
def draw_divider(self):
h, w = self.dims()
self.scr.attron(curses.color_pair(CP_DIVIDER))
self._put(h - 3, 0, "─" * (w - 1))
self.scr.attroff(curses.color_pair(CP_DIVIDER))
def draw_input(self):
h, w = self.dims()
prompt = " " # prompt
avail = w - len(prompt) - 1
display = self.input_buf[-avail:] if len(self.input_buf) > avail else self.input_buf
try:
self.scr.move(h - 2, 0)
self.scr.clrtoeol()
except curses.error:
pass
self._put(h - 2, 0, prompt)
self._put(h - 2, len(prompt), display)
def _build_lines(self, w):
"""Render history (+ live stream) into a flat list of (text, attr) tuples."""
lines = []
with self._lock:
history = list(self.history)
sbuf = self.stream_buf
in_stream = self.streaming
for msg in history:
role = msg["role"]
content = msg["content"]
if role == "user":
label = " You "
attr = curses.color_pair(CP_USER) | curses.A_BOLD
else:
label = " Nexus "
attr = curses.color_pair(CP_NEXUS) | curses.A_BOLD
lines.append((label, attr))
for wl in textwrap.wrap(content, w - 4) or [""]:
lines.append((f" {wl}", 0))
if role == "assistant":
stats = msg.get("stats")
if stats:
tok_s = f" {stats['tokens']} tok · {stats['elapsed_s']}s · {stats['tokens_per_s']} t/s"
else:
est = max(1, len(content) // 4)
tok_s = f" ~{est} tok (est)"
lines.append((tok_s, curses.color_pair(CP_STATS)))
mem = msg.get("memory")
if mem:
mem_s = f" + memory saved [{mem['section']}]: {mem['text'][:60]}"
lines.append((mem_s, curses.color_pair(CP_STREAMING) | curses.A_BOLD))
lines.append(("", 0))
if in_stream:
lines.append((" Nexus ", curses.color_pair(CP_NEXUS) | curses.A_BOLD))
for wl in textwrap.wrap(sbuf, w - 4) or ["…"]:
lines.append((f" {wl}", 0))
return lines
def draw_messages(self):
h, w = self.dims()
rows = self.msg_rows(h)
height = len(rows)
if height <= 0:
return
lines = self._build_lines(w)
total = len(lines)
if self.scroll == 0:
start = max(0, total - height)
else:
start = max(0, total - height - self.scroll)
visible = lines[start:start + height]
for i, row in enumerate(rows):
try:
self.scr.move(row, 0)
self.scr.clrtoeol()
except curses.error:
pass
if i < len(visible):
text, attr = visible[i]
if attr:
self.scr.attron(attr)
self._put(row, 0, text)
if attr:
self.scr.attroff(attr)
def draw(self):
h, w = self.dims()
self.draw_header()
self.draw_messages()
self.draw_divider()
self.draw_input()
self.draw_shortbar()
# cursor to end of input
prompt_len = 4 # " "
avail = w - prompt_len - 1
cx = prompt_len + min(len(self.input_buf), avail)
try:
self.scr.move(h - 2, cx)
except curses.error:
pass
self.scr.refresh()
# ── actions ───────────────────────────────────────────────────────────────
def send(self, message: str):
with self._lock:
self.history.append({"role": "user", "content": message})
prior = [m for m in self.history[:-1]]
self.streaming = True
self.stream_buf = ""
self.scroll = 0
def _stream():
capture_meta = False
capture_memory = False
try:
payload = {
"message": message,
"model": self.model,
"conversation_id": self.conv_id,
"history": prior,
}
with httpx.Client(timeout=180.0) as client:
with client.stream("POST", f"{API_BASE}/chat/stream",
json=payload) as resp:
for line in resp.iter_lines():
if line == "event: meta":
capture_meta = True
continue
if line == "event: memory":
capture_memory = True
continue
if capture_meta:
capture_meta = False
if line.startswith("data: "):
try:
with self._lock:
self._last_meta = json.loads(line[6:])
except Exception:
pass
continue
if capture_memory:
capture_memory = False
if line.startswith("data: "):
try:
with self._lock:
self._memory_saved = json.loads(line[6:])
except Exception:
pass
continue
if line.startswith("data: "):
with self._lock:
self.stream_buf += line[6:]
except Exception as e:
with self._lock:
self.stream_buf += f"\n[Error: {e}]"
finally:
with self._lock:
if self.stream_buf:
self.history.append({
"role": "assistant",
"content": self.stream_buf,
"stats": self._last_meta,
"memory": self._memory_saved,
})
self.streaming = False
self.stream_buf = ""
self._last_meta = None
self._memory_saved = None
threading.Thread(target=_stream, daemon=True).start()
def regenerate(self):
if self.streaming:
return
with self._lock:
if not self.history:
return
if self.history[-1]["role"] == "assistant":
self.history.pop()
last_user = None
for i in range(len(self.history) - 1, -1, -1):
if self.history[i]["role"] == "user":
last_user = self.history.pop(i)["content"]
break
if last_user:
self.send(last_user)
def cycle_model(self):
if self.streaming or not self.models:
return
self.model_idx = (self.model_idx + 1) % len(self.models)
self.model = self.models[self.model_idx]
def new_conversation(self):
if self.streaming:
return
with self._lock:
self.history = []
self.conv_id = str(uuid.uuid4())
self.stream_buf = ""
self.scroll = 0
def _cmd_remember(self, text: str):
"""Write a fact directly to the memory service, bypassing the LLM curator.
Syntax: /remember <text>
/remember [Section] <text>
"""
import re as _re
section = "General"
m = _re.match(r'^\[([^\]]+)\]\s*(.+)', text)
if m:
section, text = m.group(1).strip(), m.group(2).strip()
if not text:
return
try:
r = httpx.post(
f"{MEMORY_BASE}/memories",
json={"section": section, "text": text},
timeout=5.0,
)
if r.status_code == 200:
data = r.json()
saved = {"section": data["section"], "text": data["text"]}
with self._lock:
self.history.append({
"role": "assistant",
"content": f"Saved to memory [{saved['section']}]: {saved['text']}",
"stats": None,
"memory": saved,
})
else:
with self._lock:
self.history.append({
"role": "assistant",
"content": f"Memory service returned {r.status_code}",
"stats": None,
"memory": None,
})
except Exception as e:
with self._lock:
self.history.append({
"role": "assistant",
"content": f"Memory service unreachable: {e}",
"stats": None,
"memory": None,
})
# ── main loop ─────────────────────────────────────────────────────────────
def run(self):
while True:
self.draw()
try:
key = self.scr.getch()
except KeyboardInterrupt:
break
if key == -1:
continue
if key == 24: # ^X exit
break
elif key == 18 and not self.streaming: # ^R regen
self.regenerate()
elif key == 14: # ^N new conversation
self.new_conversation()
elif key == 12: # ^L clear (alias new)
self.new_conversation()
elif key == 20 and not self.streaming: # ^T cycle model
self.cycle_model()
elif key in (10, 13): # Enter send
msg = self.input_buf.strip()
if msg and not self.streaming:
self.input_buf = ""
if msg.lower().startswith("/remember "):
self._cmd_remember(msg[10:].strip())
else:
self.send(msg)
elif key in (127, curses.KEY_BACKSPACE, 8):
self.input_buf = self.input_buf[:-1]
elif key == curses.KEY_UP:
self.scroll += 1
elif key == curses.KEY_DOWN:
self.scroll = max(0, self.scroll - 1)
elif key == curses.KEY_PPAGE: # PgUp
h, _ = self.dims()
self.scroll += max(1, h - 6)
elif key == curses.KEY_NPAGE: # PgDn
h, _ = self.dims()
self.scroll = max(0, self.scroll - max(1, h - 6))
elif key == curses.KEY_HOME:
self.scroll = 9999 # jump to top
elif key == curses.KEY_END:
self.scroll = 0 # jump to bottom
elif 32 <= key <= 126:
self.input_buf += chr(key)
# ─── entry point ──────────────────────────────────────────────────────────────
def main():
print("Checking Nexus backend…", flush=True)
started_pid = None
if not backend_running():
print("Synapse is not running — starting backend…", flush=True)
started_pid = start_backend()
if started_pid is None:
print(f"ERROR: backend failed to start. Logs: {LOG_FILE}")
sys.exit(1)
print("Backend ready.", flush=True)
else:
print("Backend is running.", flush=True)
time.sleep(0.2)
try:
curses.wrapper(lambda s: NexusChat(s).run())
finally:
if started_pid is not None:
print("\nShutting down Synapse backend…", flush=True)
stop_backend(started_pid)
print("Done.", flush=True)
if __name__ == "__main__":
main()