feat(cli): add live demo mode (#302)
This commit is contained in:
parent
62e50ab725
commit
1ea44ca548
11
README.md
11
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 <url>` if your server is not on `http://127.0.0.1:8000`.
|
||||
|
||||
### 5. Try Your First Memory
|
||||
|
||||
Add a tiny conversation:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue