- 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 агентов.
201 lines
7.8 KiB
Python
201 lines
7.8 KiB
Python
#!/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())
|