feat: live status dashboard for the voice loop
Add a stdlib-only observability site so you can open a browser and watch, step by step: whether it is listening, what it heard, what the brain thought and answered, how long each stage took, and whether anything errored. - wsai/monitor.py: thread-safe telemetry hub (per-turn timed steps, status header, error log) with a pub/sub for live push. - wsai/dashboard.py: stdlib http.server serving a self-contained page plus an SSE (/events) live stream; /api/state snapshot fallback. - Pipeline emits step-by-step turn telemetry (화면 맥락 → 두뇌 → 응답) and listening/running status; optional monitor, so existing paths are untouched. - `python -m wsai --dashboard` starts the site (0.0.0.0:8787, WSAI_DASHBOARD_PORT) and loops the mock voice demo so there is always live activity to watch. - Tests cover turn recording, per-step timing, error marking, and live push. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,12 @@
|
||||
|
||||
python -m wsai # mock pipeline (no deps, no keys) — runs a demo
|
||||
python -m wsai --voice # eyes-free voice loop demo (STT -> Brain -> TTS)
|
||||
python -m wsai --dashboard # live status website + a continuous voice demo
|
||||
python -m wsai --live # capture this screen + Claude eyes/brain
|
||||
python -m wsai --env # build from WSAI_* environment variables
|
||||
|
||||
The mock run is bounded (a few frames + a scripted conversation) so it exits on
|
||||
its own; --live/--env run until Ctrl-C.
|
||||
its own; --dashboard/--live/--env run until Ctrl-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,32 +15,53 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
|
||||
from .config import Settings
|
||||
from .factory import build
|
||||
from .monitor import Monitor
|
||||
|
||||
|
||||
async def _run(settings: Settings, demo: bool) -> None:
|
||||
def _lan_ip() -> str:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except OSError:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
async def _run(settings: Settings, demo: bool, monitor: Monitor | None) -> None:
|
||||
if demo:
|
||||
# Bounded demo so CI / a quick check terminates.
|
||||
from .backends.mock import MockFrameSource, MockSTT
|
||||
from .pipeline import Pipeline
|
||||
|
||||
pipe = build(settings)
|
||||
pipe = build(settings, monitor=monitor)
|
||||
if pipe.source is not None: # keep eyes-free configs eyes-free
|
||||
pipe.source = MockFrameSource(interval=0.3, limit=4)
|
||||
if pipe.stt is not None:
|
||||
pipe.stt = MockSTT(interval=0.4)
|
||||
# When serving the dashboard, keep talking forever so there's always
|
||||
# something live to watch; otherwise stay bounded so the demo exits.
|
||||
loop = monitor is not None
|
||||
pipe.stt = MockSTT(interval=2.0 if loop else 0.4, loop=loop)
|
||||
await pipe.run()
|
||||
return
|
||||
await build(settings).run()
|
||||
await build(settings, monitor=monitor).run()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(prog="wsai")
|
||||
ap.add_argument("--voice", action="store_true", help="eyes-free voice loop (STT -> Brain -> TTS)")
|
||||
ap.add_argument("--dashboard", action="store_true", help="serve the live status website (voice demo loops)")
|
||||
ap.add_argument("--live", action="store_true", help="capture screen + Claude backends")
|
||||
ap.add_argument("--env", action="store_true", help="build from WSAI_* env vars")
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("WSAI_DASHBOARD_PORT", "8787")),
|
||||
help="dashboard port (default 8787, or WSAI_DASHBOARD_PORT)")
|
||||
ap.add_argument("--host", default=os.environ.get("WSAI_DASHBOARD_HOST", "0.0.0.0"),
|
||||
help="dashboard bind host (default 0.0.0.0)")
|
||||
ap.add_argument("-v", "--verbose", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
@@ -48,7 +70,11 @@ def main() -> None:
|
||||
format="%(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
if args.voice:
|
||||
if args.dashboard:
|
||||
# Default to the eyes-free voice preset for the demo; env can override.
|
||||
settings = Settings.from_env() if args.env else Settings.voice()
|
||||
demo = not args.env
|
||||
elif args.voice:
|
||||
settings, demo = Settings.voice(), True
|
||||
elif args.live:
|
||||
settings, demo = Settings.live(), False
|
||||
@@ -57,10 +83,25 @@ def main() -> None:
|
||||
else:
|
||||
settings, demo = Settings.mock(), True
|
||||
|
||||
monitor: Monitor | None = None
|
||||
dash = None
|
||||
if args.dashboard:
|
||||
from .dashboard import Dashboard
|
||||
|
||||
monitor = Monitor()
|
||||
dash = Dashboard(monitor, host=args.host, port=args.port)
|
||||
dash.start()
|
||||
shown = args.host if args.host not in ("0.0.0.0", "") else _lan_ip()
|
||||
print(f"\n 실시간 상태 사이트: http://{shown}:{args.port}")
|
||||
print(f" (로컬: http://127.0.0.1:{args.port} )\n")
|
||||
|
||||
try:
|
||||
asyncio.run(_run(settings, demo))
|
||||
asyncio.run(_run(settings, demo, monitor))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if dash is not None:
|
||||
dash.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user