simbiot-harness/tools/tui_chat.py
Simbiot Harness cbacc99b7b voice: звук из TG-видео (t-261004152714) + tools/tui_chat.py в репо (t-261004153216)
- voice.py: kind=video в TRANSCRIBABLE_KINDS; extract_audio_track() — ffmpeg
  вытягивает дорожку (моно 16к opus) из видеоконтейнера; речь ≤60с
  (voice/video_note/video) сразу идёт в faster-whisper-large-v3-turbo
  (base на видео давал кашу). E2E: m-516173698 расшифрован.
- tg_inbox.py: поллер транскрибирует и msg.video, не только voice/audio/video_note.
- tools/tui_chat.py: TUI-чат (t-261004132716) лежал только у Volya (33301d3) —
  в origin его не было, из-за этого терминал не грузился у 6 агентов.
2026-10-04 16:08:52 +00:00

201 lines
7.8 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
# -*- coding: utf-8 -*-
"""TUI-чат с работающим агентом в веб-терминале (t-261004132716).
Задача Alex (m-516173688): терминал портала должен сразу давать полноценный
чат с агентом (как Claude Code/Clide/Hermes), не требуя запуска чего-то
руками. Агент уже работает демоном (systemd simbiot-harness) — TUI к нему
НЕ лезет процессом: он кладёт сообщение в event store агента (чат 'tui:*'),
мин-тик агента (unreplied_inbox → critical-сигнал гейта) отвечает через
tg_inbox.send_reply → ReplySent пишется в стор, TUI его читает и печатает.
Транспорт: только локальный event store. Никаких внешних API.
Выход: Ctrl-D / 'exit' (после выхода в терминале остаётся bash).
"""
import os
import select
import sys
import time
import uuid
from datetime import datetime
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO)
from harness import es # noqa: E402
AGENT = os.environ.get("SIMBIOT_AGENT_NAME") or "agent"
TUI_CHAT = "tui:local"
POLL_SEC = 2.0
REPLY_TIMEOUT = 240 # сек ожидания ответа на своё сообщение
WRAP = 100 # ширина переноса
C_BOLD, C_DIM, C_CYAN, C_GREEN, C_YEL = "\033[1m", "\033[2m", "\033[36m", "\033[32m", "\033[33m"
C_RST = "\033[0m"
def _wrap(text, width=WRAP, indent=" "):
out, line = [], ""
for word in str(text or "").split():
if len(line) + len(word) + 1 > width - len(indent):
out.append(indent + line)
line = word
else:
line = (line + " " + word).strip()
if line:
out.append(indent + line)
return "\n".join(out)
def _model():
return es.run_projections()
def _inbox(model):
return model.get("inbox") or {}
def _ts_sort(v):
return v.get("ts") or ""
def show_history(limit=3):
model = _model()
items = [(k, v) for k, v in _inbox(model).items()
if str(v.get("chat_id") or "").startswith(("tui:", "portal:"))]
items.sort(key=lambda kv: _ts_sort(kv[1]))
if not items:
return
print(f"{C_DIM}— последние сообщения чата с агентом —{C_RST}")
for mid, v in items[-limit * 2:]:
who = "ты" if (v.get("kind") == "tui") else (v.get("username") or "портал")
txt = (v.get("text") or "").replace("\n", " ")[:90]
print(f"{C_DIM}{mid}{C_RST} {C_YEL}{who}▸{C_RST} {txt}")
if v.get("replied") and v.get("reply_text") and not v["reply_text"].startswith("(сводный"):
print(_wrap(v["reply_text"][:200], indent=" "))
print()
def resolve_reply(model, mid):
"""Текст ответа: у головного полный, у остальных — ссылка на головной."""
m = _inbox(model).get(mid)
if not m or not m.get("replied"):
return None
txt = m.get("reply_text") or ""
if txt.startswith("(сводный"):
import re
mt = re.search(r"см\. (\S+)", txt)
if mt:
head = _inbox(model).get(mt.group(1))
if head and head.get("reply_text"):
return head["reply_text"]
return txt or "(пустой ответ)"
def send_message(text):
mid = f"tui-{datetime.utcnow().strftime('%y%m%d%H%M%S')}-{uuid.uuid4().hex[:4]}"
es.append_event("inbox", mid, "MessageReceived", {
"update_id": mid, "chat_id": TUI_CHAT, "user_id": "alex",
"username": "alex · tui", "first_name": "Alex",
"text": str(text)[:4000], "kind": "tui",
"file_id": None, "file_unique_id": None, "duration": None,
"date": int(time.time()), "source": "tui",
})
return mid
def wait_replies(watch, deadline_by, stdin_fd, pending_input):
"""Ждём ответы на watch (dict mid→deadline), подбирая ввод в очередь."""
while watch:
now = time.time()
for mid in [m for m, d in watch.items() if now > d]:
print(f"{C_DIM}(ответ на {mid} не пришёл за {REPLY_TIMEOUT}s — "
f"агент молчит? systemctl status simbiot-harness){C_RST}")
watch.pop(mid)
if not watch:
break
try:
model = _model()
except Exception as e:
print(f"{C_DIM}(стор занят: {str(e)[:60]}){C_RST}")
model = None
if model:
for mid in list(watch):
txt = resolve_reply(model, mid)
if txt is not None:
print(f"{C_GREEN}{AGENT}▸{C_RST}")
print(_wrap(txt))
watch.pop(mid)
r, _, _ = select.select([stdin_fd], [], [], POLL_SEC)
if r:
line = sys.stdin.readline()
if line == "": # EOF
return False
line = line.strip()
if line:
pending_input.append(line)
return True
def main():
fd = sys.stdin.fileno()
interactive = sys.stdin.isatty()
es.init_db()
# агент должен работать демоном; TUI сам ничего не стартует,
# но явно скажет, если демон не жив
alive = os.system("systemctl is-active --quiet simbiot-harness 2>/dev/null") == 0 \
or bool(os.popen("pgrep -f 'harness.scheduler'").read().strip())
print(f"{C_BOLD}╭─ {AGENT} · TUI-чат {'· агент работает' if alive else C_YEL + '· ДЕМОН НЕ ОТВЕЧАЕТ'}{C_RST}")
if not alive:
print(f"{C_YEL} демон simbiot-harness не запущен — ответы не придут.{C_RST}")
print(f"{C_YEL} запусти: systemctl start simbiot-harness{C_RST}")
print(f"{C_DIM}│ пишешь → мин-тик агента отвечает (обычно ≤ минуты; команды "
f"/статус /лестница /задачи — мгновенно){C_RST}")
print(f"{C_DIM}│ выход: Ctrl-D или 'exit'{C_RST}")
print(f"{C_BOLD}╰{'─' * 40}{C_RST}")
try:
show_history()
except Exception:
pass
# сообщения, оставшиеся без ответа от прошлой TUI-сессии — досмотрим
watch = {}
try:
model = _model()
for mid, v in _inbox(model).items():
if str(v.get("chat_id") or "").startswith("tui:") and not v.get("replied"):
watch[mid] = time.time() + REPLY_TIMEOUT
except Exception:
pass
pending_input = []
while True:
if watch and not wait_replies(watch, None, fd, pending_input):
break # EOF во время ожидания
if pending_input:
line = pending_input.pop(0)
else:
if interactive:
sys.stdout.write(f"{C_CYAN}ты▸{C_RST} ")
sys.stdout.flush()
r, _, _ = select.select([fd], [], [], POLL_SEC if watch else None)
if not r:
continue
line = sys.stdin.readline()
if line == "":
break # Ctrl-D
line = line.strip()
if not line:
continue
if line.lower() in ("exit", "quit", "/exit", "/quit"):
break
mid = send_message(line)
watch[mid] = time.time() + REPLY_TIMEOUT
print(f"{C_DIM}… {mid} (ждём мин-тик){C_RST}")
print(f"{C_DIM}пока. (агент продолжает работать демоном; bash — по Ctrl-D/exit){C_RST}")
return 0
if __name__ == "__main__":
sys.exit(main())