feat(cli): add live demo mode (#302)

This commit is contained in:
Elliot Chen 2026-06-23 20:11:23 +08:00 committed by GitHub
parent 62e50ab725
commit 1ea44ca548
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 332 additions and 3 deletions

View File

@ -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 <url>` if your server is not on `http://127.0.0.1:8000`.
### 5. Try Your First Memory
Add a tiny conversation:

View File

@ -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 <url>`
### 5. 试写第一条记忆
添加一个很小的 conversation

View File

@ -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 <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

View File

@ -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()

View File

@ -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)