Show local conversation activity in web console

This commit is contained in:
showtan001
2026-08-30 21:46:50 +08:00
parent 3c9e65c78e
commit fdaca7539b
7 changed files with 56 additions and 9 deletions
+3 -2
View File
@@ -112,8 +112,9 @@ is also accepted. The model can inspect the supplied image and then call a
phone tool such as `tap` in the same conversation. phone tool such as `tap` in the same conversation.
Local mode records LLM responses, reasoning fields, tool calls, tool results, Local mode records LLM responses, reasoning fields, tool calls, tool results,
and final replies as JSONL in `host_agent_data/conversations.jsonl`. Image and final replies in a local SQLite database. View them at
bytes are excluded; set `HOST_AGENT_CONVERSATION_LOG_PATH` to change the path. `http://127.0.0.1:8765/conversations`; image bytes are excluded. Set
`HOST_AGENT_CONVERSATION_LOG_PATH` to change the database path.
In local mode, Appium supervision is enabled by default. Host Agent probes In local mode, Appium supervision is enabled by default. Host Agent probes
`/status`, adopts a healthy existing Appium instance, starts Appium when no `/status`, adopts a healthy existing Appium instance, starts Appium when no
+1
View File
@@ -276,6 +276,7 @@ def create_application(
mcp_token_store=mcp_token_store, mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker, mcp_busy_tracker=mcp_busy_tracker,
conversation_agent=conversation_agent, conversation_agent=conversation_agent,
conversation_log=conversation_log,
) )
console_server = _EmbeddedConsoleServer( console_server = _EmbeddedConsoleServer(
uvicorn.Config( uvicorn.Config(
+2 -2
View File
@@ -49,7 +49,7 @@ class HostAgentConfig:
dependency_restart_max_attempts: int = 5 dependency_restart_max_attempts: int = 5
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3") task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
task_artifact_dir: Path = Path("host_agent_data/history") task_artifact_dir: Path = Path("host_agent_data/history")
conversation_log_path: Path = Path("host_agent_data/conversations.jsonl") conversation_log_path: Path = Path("host_agent_data/conversations.sqlite3")
task_retention_max_count: int = 50 task_retention_max_count: int = 50
task_retention_max_age_days: int = 7 task_retention_max_age_days: int = 7
skill_sync_interval_seconds: float = 300.0 skill_sync_interval_seconds: float = 300.0
@@ -160,7 +160,7 @@ def load_host_agent_config(
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history" "HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
).strip() ).strip()
), ),
conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.jsonl").strip()), conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.sqlite3").strip()),
task_retention_max_count=_positive_int( task_retention_max_count=_positive_int(
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50 values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
), ),
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
@@ -8,17 +9,35 @@ from typing import Any
class ConversationLogStore: class ConversationLogStore:
"""Append-only local audit log for chat and tool activity.""" """SQLite-backed local audit log for chat and tool activity."""
def __init__(self, path: str | Path) -> None: def __init__(self, path: str | Path) -> None:
self.path = Path(path) self.path = Path(path)
self._lock = Lock() self._lock = Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
connection.execute("CREATE TABLE IF NOT EXISTS conversation_events (id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at TEXT NOT NULL, event_type TEXT NOT NULL, payload_json TEXT NOT NULL)")
def append(self, event: dict[str, Any]) -> None: def append(self, event: dict[str, Any]) -> None:
record = {"timestamp": datetime.now(UTC).isoformat(), **_safe(event)} with self._lock, self._connect() as connection:
self.path.parent.mkdir(parents=True, exist_ok=True) connection.execute("INSERT INTO conversation_events (occurred_at, event_type, payload_json) VALUES (?, ?, ?)", (datetime.now(UTC).isoformat(), str(event.get("type") or "event"), json.dumps(_safe(event), ensure_ascii=True, default=str)))
with self._lock, self.path.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(record, ensure_ascii=True, default=str) + "\n") def list_recent(self, *, limit: int = 200) -> list[dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute("SELECT id, occurred_at, event_type, payload_json FROM conversation_events ORDER BY id DESC LIMIT ?", (max(1, min(limit, 1000)),)).fetchall()
result = []
for row in rows:
try:
payload = json.loads(row["payload_json"])
except (TypeError, ValueError):
payload = {}
result.append({"id": row["id"], "occurred_at": row["occurred_at"], "event_type": row["event_type"], **payload})
return result
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path)
connection.row_factory = sqlite3.Row
return connection
def _safe(value: Any) -> Any: def _safe(value: Any) -> Any:
@@ -21,6 +21,7 @@ from host_agent.client import (
) )
from host_agent.config import HostAgentConfig from host_agent.config import HostAgentConfig
from host_agent.conversation import ConversationAgent from host_agent.conversation import ConversationAgent
from host_agent.conversation_log import ConversationLogStore
from host_agent.devices import register_local_device, unregister_local_device from host_agent.devices import register_local_device, unregister_local_device
from host_agent.history import ConsoleHistoryStore from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore from host_agent.identity import HostIdentityStore
@@ -234,6 +235,7 @@ def create_console_app(
mcp_token_store: McpTokenStore | None = None, mcp_token_store: McpTokenStore | None = None,
mcp_busy_tracker: McpBusyTracker | None = None, mcp_busy_tracker: McpBusyTracker | None = None,
conversation_agent: ConversationAgent | None = None, conversation_agent: ConversationAgent | None = None,
conversation_log: ConversationLogStore | None = None,
) -> FastAPI: ) -> FastAPI:
app = FastAPI(title="Host Agent Console") app = FastAPI(title="Host Agent Console")
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
@@ -605,6 +607,15 @@ def create_console_app(
entries=entries, entries=entries,
) )
@app.get("/conversations", response_class=HTMLResponse)
async def conversations_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
events = await asyncio.to_thread(
conversation_log.list_recent if conversation_log is not None else (lambda: [])
)
return _render("conversations.html", title="Conversations", session=session, events=events)
def _tasks_list_context( def _tasks_list_context(
session: SessionState, session: SessionState,
*, *,
@@ -26,6 +26,7 @@ form.inline { display: inline; margin: 0; }
<a href="/tasks">Tasks</a> <a href="/tasks">Tasks</a>
<a href="/account">Account</a> <a href="/account">Account</a>
<a href="/history">History</a> <a href="/history">History</a>
<a href="/conversations">Conversations</a>
<form class="inline" method="post" action="/logout"> <form class="inline" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
<button type="submit">Logout</button> <button type="submit">Logout</button>
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block body %}
<h1>Conversations</h1>
{% if not events %}<p>No conversation activity recorded yet.</p>{% endif %}
{% for event in events %}
<article>
<h2>{{ event["event_type"] }} <small>{{ event["occurred_at"] }}</small></h2>
{% if event.get("content") %}<pre>{{ event["content"] }}</pre>{% endif %}
{% if event.get("thinking") %}<details><summary>LLM reasoning</summary><pre>{{ event["thinking"] }}</pre></details>{% endif %}
{% if event.get("tool_calls") %}<details open><summary>Tool calls</summary><pre>{{ event["tool_calls"] | tojson(indent=2) }}</pre></details>{% endif %}
{% if event.get("tool_name") %}<p><strong>{{ event["tool_name"] }}</strong></p><pre>{{ event.get("arguments") | tojson(indent=2) }}</pre><pre>{{ event.get("result") | tojson(indent=2) }}</pre>{% endif %}
</article>
{% endfor %}
{% endblock %}