From 1ea44ca548d1eb4810a64f7a62a541556db400a0 Mon Sep 17 00:00:00 2001 From: Elliot Chen <2340896+cyfyifanchen@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:11:23 +0800 Subject: [PATCH] feat(cli): add live demo mode (#302) --- README.md | 11 + README.zh-CN.md | 11 + docs/everos-demo.md | 27 ++- src/everos/entrypoints/cli/commands/demo.py | 204 ++++++++++++++++++ .../test_cli/test_demo_command.py | 82 +++++++ 5 files changed, 332 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0d6f68e..9611e1b 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,17 @@ The endpoint stack is OpenAI-protocol compatible (OpenAI / OpenRouter / vLLM / Ollama / DeepInfra) - override `*__BASE_URL` in the generated `.env` to point at any of them. +Now make the demo real. In the second terminal, run: + +```bash +everos demo --live +``` + +Live demo mode connects to the running server and performs the real +`/health` -> `/api/v1/memory/add` -> `/api/v1/memory/flush` -> +`/api/v1/memory/search` flow before opening the same memory sphere UI. Use +`--server-url ` if your server is not on `http://127.0.0.1:8000`. + ### 5. Try Your First Memory Add a tiny conversation: diff --git a/README.zh-CN.md b/README.zh-CN.md index 7409588..a3c636f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -208,6 +208,17 @@ curl http://127.0.0.1:8000/health vLLM / Ollama / DeepInfra)。你可以覆盖生成的 `.env` 中的 `*__BASE_URL` 来指向任意这些模型服务。 +现在可以把 demo 跑成真实 server flow。在第二个 terminal 里运行: + +```bash +everos demo --live +``` + +Live demo mode 会连接正在运行的 server,并在打开同一个 memory sphere UI +之前真实执行 `/health` -> `/api/v1/memory/add` -> `/api/v1/memory/flush` -> +`/api/v1/memory/search`。如果 server 不在 `http://127.0.0.1:8000`,可以使用 +`--server-url `。 + ### 5. 试写第一条记忆 添加一个很小的 conversation: diff --git a/docs/everos-demo.md b/docs/everos-demo.md index b962e02..53b3c20 100644 --- a/docs/everos-demo.md +++ b/docs/everos-demo.md @@ -26,11 +26,32 @@ For the looping showroom view used by README media, use: everos demo --cinematic ``` +## Run It Against A Server + +After `everos init` and `everos server start`, run: + +```bash +everos demo --live +``` + +Live mode keeps the same TUI, but the memory lifecycle is backed by real +server calls: + +1. `GET /health` +2. `POST /api/v1/memory/add` +3. `POST /api/v1/memory/flush` +4. `POST /api/v1/memory/search` + +If your server is not running on `http://127.0.0.1:8000`, pass +`--server-url `. + ## What It Does Not Do -The demo does not connect to the EverOS server, call LLM providers, or write -production memory files. It is intentionally hardcoded so users can try the -experience before configuring the full runtime. +By default, `everos demo` does not connect to the EverOS server, call LLM +providers, or write production memory files. It is intentionally hardcoded so +users can try the experience before configuring the full runtime. Use +`everos demo --live` when you want the same visual flow backed by a running +server. ## Source Layout diff --git a/src/everos/entrypoints/cli/commands/demo.py b/src/everos/entrypoints/cli/commands/demo.py index 893eadd..12c3463 100644 --- a/src/everos/entrypoints/cli/commands/demo.py +++ b/src/everos/entrypoints/cli/commands/demo.py @@ -2,12 +2,19 @@ from __future__ import annotations +import json import sys +import time +import urllib.error +import urllib.request +from collections.abc import Callable +from typing import Any import typer from rich.console import Console from rich.panel import Panel +from everos.component.utils.datetime import get_utc_now from everos.entrypoints.tui.demo.data import ( DEFAULT_MEMORY_SEED, DEFAULT_QUERY, @@ -22,6 +29,15 @@ from everos.entrypoints.tui.demo.widgets.sphere import ( render_dot_sphere_text, ) +LIVE_DEMO_SERVER_URL = "http://127.0.0.1:8000" +LIVE_DEMO_SESSION_ID = "everos-demo-live" +LIVE_DEMO_USER_ID = "everos_demo_user" +LIVE_DEMO_APP_ID = "default" +LIVE_DEMO_PROJECT_ID = "default" +LIVE_DEMO_TIMEOUT_SECONDS = 10.0 +LIVE_DEMO_SEARCH_ATTEMPTS = 6 +LIVE_DEMO_SEARCH_INTERVAL_SECONDS = 0.5 + def register(parent: typer.Typer) -> None: """Attach the ``demo`` command to the root CLI app.""" @@ -38,8 +54,26 @@ def register(parent: typer.Typer) -> None: "--cinematic", help="Skip prompts and launch the looping README-style demo.", ), + live: bool = typer.Option( + False, + "--live", + help="Connect to a running EverOS server and run add/flush/search.", + ), + server_url: str = typer.Option( + LIVE_DEMO_SERVER_URL, + "--server-url", + help="EverOS server URL used by --live.", + ), ) -> None: """Launch the EverOS first-memory Textual TUI.""" + if live: + _run_live_demo( + cinematic=cinematic, + plain=plain or not sys.stdout.isatty(), + base_url=server_url, + ) + return + if plain or not sys.stdout.isatty(): _print_plain_demo() return @@ -53,6 +87,20 @@ def _run_interactive_demo(*, cinematic: bool) -> None: run_demo_tui(story=story) +def _run_live_demo(*, cinematic: bool, plain: bool, base_url: str) -> None: + run_demo_tui = None if plain else _load_run_demo_tui() + story = default_demo_story() if cinematic or plain else _collect_playable_story() + live_story = _run_live_demo_flow(story, base_url=base_url) + + if plain: + _print_plain_demo(live_story) + return + + if run_demo_tui is None: # pragma: no cover - guarded by plain branch. + raise typer.Exit(code=1) + run_demo_tui(story=live_story) + + def _load_run_demo_tui(): try: from everos.entrypoints.tui.demo.app import run_demo_tui @@ -86,6 +134,162 @@ def _collect_playable_story() -> DemoStory: return build_demo_story(memory, query) +def _run_live_demo_flow( + story: DemoStory, + *, + base_url: str, + request_json: Callable[..., dict[str, Any]] | None = None, + timeout_seconds: float = LIVE_DEMO_TIMEOUT_SECONDS, + search_attempts: int = LIVE_DEMO_SEARCH_ATTEMPTS, + search_interval_seconds: float = LIVE_DEMO_SEARCH_INTERVAL_SECONDS, +) -> DemoStory: + """Run the educational demo story through a live EverOS server.""" + + request = request_json or _request_json + health = request( + "GET", + "/health", + base_url=base_url, + timeout_seconds=timeout_seconds, + ) + if health.get("status") != "ok": + raise typer.BadParameter( + f"EverOS server at {base_url} did not return healthy status" + ) + + timestamp_ms = int(get_utc_now().timestamp() * 1000) + request( + "POST", + "/api/v1/memory/add", + base_url=base_url, + json_body={ + "session_id": LIVE_DEMO_SESSION_ID, + "app_id": LIVE_DEMO_APP_ID, + "project_id": LIVE_DEMO_PROJECT_ID, + "messages": [ + { + "sender_id": LIVE_DEMO_USER_ID, + "role": "user", + "timestamp": timestamp_ms, + "content": story.memory, + } + ], + }, + timeout_seconds=timeout_seconds, + ) + request( + "POST", + "/api/v1/memory/flush", + base_url=base_url, + json_body={ + "session_id": LIVE_DEMO_SESSION_ID, + "app_id": LIVE_DEMO_APP_ID, + "project_id": LIVE_DEMO_PROJECT_ID, + }, + timeout_seconds=timeout_seconds, + ) + + search_payload = { + "user_id": LIVE_DEMO_USER_ID, + "app_id": LIVE_DEMO_APP_ID, + "project_id": LIVE_DEMO_PROJECT_ID, + "query": story.query, + "top_k": 5, + } + for attempt in range(search_attempts): + search = request( + "POST", + "/api/v1/memory/search", + base_url=base_url, + json_body=search_payload, + timeout_seconds=timeout_seconds, + ) + episode = _first_live_episode(search) + if episode is not None: + return _story_from_live_episode(story, episode) + if attempt < search_attempts - 1: + time.sleep(search_interval_seconds) + + raise typer.BadParameter( + "EverOS server accepted the memory, but search did not return it yet. " + "Try `everos demo --live` again after indexing catches up." + ) + + +def _request_json( + method: str, + path: str, + *, + base_url: str, + json_body: dict[str, object] | None = None, + timeout_seconds: float, +) -> dict[str, Any]: + url = f"{base_url.rstrip('/')}{path}" + data = None if json_body is None else json.dumps(json_body).encode("utf-8") + request = urllib.request.Request( + url, + data=data, + method=method, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + raw = response.read().decode("utf-8") + except urllib.error.URLError as exc: + raise typer.BadParameter( + f"Could not reach EverOS server at {base_url}. " + "Start it with `everos server start` and try again." + ) from exc + if not raw: + return {} + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise typer.BadParameter(f"EverOS server returned non-object JSON: {url}") + return parsed + + +def _first_live_episode(payload: dict[str, Any]) -> dict[str, Any] | None: + data = payload.get("data") + if not isinstance(data, dict): + return None + episodes = data.get("episodes") + if not isinstance(episodes, list) or not episodes: + return None + first = episodes[0] + return first if isinstance(first, dict) else None + + +def _story_from_live_episode(story: DemoStory, episode: dict[str, Any]) -> DemoStory: + facts = episode.get("atomic_facts") + first_fact = facts[0] if isinstance(facts, list) and facts else None + fact_id = _string_field(first_fact, "id") if isinstance(first_fact, dict) else "" + answer = ( + _string_field(first_fact, "content") if isinstance(first_fact, dict) else "" + ) + if not answer: + answer = ( + _string_field(episode, "summary") + or _string_field(episode, "episode") + or story.answer + ) + episode_id = _string_field(episode, "id") or "live" + return DemoStory( + owner=LIVE_DEMO_USER_ID, + memory=story.memory, + query=story.query, + answer=answer, + source_filename=f"episode:{episode_id}", + fact_filename=f"fact:{fact_id or 'live'}", + ) + + +def _string_field(payload: dict[str, Any] | None, key: str) -> str: + if payload is None: + return "" + value = payload.get(key) + return value if isinstance(value, str) else "" + + def _print_plain_demo(story: DemoStory | None = None) -> None: story = story or default_demo_story() console = Console() diff --git a/tests/unit/test_entrypoints/test_cli/test_demo_command.py b/tests/unit/test_entrypoints/test_cli/test_demo_command.py index cc3f571..adc01d1 100644 --- a/tests/unit/test_entrypoints/test_cli/test_demo_command.py +++ b/tests/unit/test_entrypoints/test_cli/test_demo_command.py @@ -23,6 +23,18 @@ def test_demo_help_exposes_cinematic_mode() -> None: assert "--cinematic" in _strip_ansi(result.stdout) +def test_demo_help_exposes_live_mode() -> None: + app = typer.Typer() + demo_command.register(app) + + result = CliRunner().invoke(app, ["demo", "--help"], terminal_width=120) + + help_text = _strip_ansi(result.stdout) + assert result.exit_code == 0 + assert "--live" in help_text + assert "--server-url" in help_text + + def test_collect_playable_story_prompts_for_memory_then_query(monkeypatch) -> None: prompts: list[tuple[str, str]] = [] replies = iter( @@ -102,5 +114,75 @@ def test_plain_demo_prints_custom_story(monkeypatch) -> None: assert "episode-demo.md" in printed_text +def test_live_demo_flow_calls_server_and_builds_story() -> None: + story = build_demo_story( + "I love climbing in Yosemite every spring.", + "Where do I like to climb?", + ) + calls: list[tuple[str, str, dict[str, object] | None]] = [] + + def fake_request( + method: str, + path: str, + *, + base_url: str, + json_body: dict[str, object] | None = None, + timeout_seconds: float, + ) -> dict[str, object]: + calls.append((method, path, json_body)) + assert base_url == "http://server.test" + assert timeout_seconds == 3.0 + if path == "/health": + return {"status": "ok"} + if path == "/api/v1/memory/add": + return {"message_count": 1, "status": "accumulated"} + if path == "/api/v1/memory/flush": + return {"message_count": 1, "status": "extracted"} + if path == "/api/v1/memory/search": + return { + "data": { + "episodes": [ + { + "id": "alice_ep_20260623_0001", + "episode": "Alice loves climbing in Yosemite every spring.", + "summary": "Alice climbs in Yosemite every spring.", + "subject": "Yosemite climbing", + "score": 0.82, + "atomic_facts": [ + { + "id": "alice_af_20260623_0001", + "content": ( + "Alice loves climbing in Yosemite every spring." + ), + "score": 0.91, + } + ], + } + ] + } + } + raise AssertionError(f"unexpected request: {method} {path}") + + live_story = demo_command._run_live_demo_flow( + story, + base_url="http://server.test", + request_json=fake_request, + timeout_seconds=3.0, + ) + + assert [path for _, path, _ in calls] == [ + "/health", + "/api/v1/memory/add", + "/api/v1/memory/flush", + "/api/v1/memory/search", + ] + add_body = calls[1][2] + assert add_body is not None + assert add_body["session_id"] == "everos-demo-live" + assert live_story.answer == "Alice loves climbing in Yosemite every spring." + assert live_story.source_filename == "episode:alice_ep_20260623_0001" + assert live_story.fact_filename == "fact:alice_af_20260623_0001" + + def _strip_ansi(value: str) -> str: return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", value)