b0aa0438af
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>
533 lines
19 KiB
Python
533 lines
19 KiB
Python
#!/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()
|