Show local conversation activity in web console
This commit is contained in:
@@ -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.
|
||||
|
||||
Local mode records LLM responses, reasoning fields, tool calls, tool results,
|
||||
and final replies as JSONL in `host_agent_data/conversations.jsonl`. Image
|
||||
bytes are excluded; set `HOST_AGENT_CONVERSATION_LOG_PATH` to change the path.
|
||||
and final replies in a local SQLite database. View them at
|
||||
`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
|
||||
`/status`, adopts a healthy existing Appium instance, starts Appium when no
|
||||
|
||||
@@ -276,6 +276,7 @@ def create_application(
|
||||
mcp_token_store=mcp_token_store,
|
||||
mcp_busy_tracker=mcp_busy_tracker,
|
||||
conversation_agent=conversation_agent,
|
||||
conversation_log=conversation_log,
|
||||
)
|
||||
console_server = _EmbeddedConsoleServer(
|
||||
uvicorn.Config(
|
||||
|
||||
@@ -49,7 +49,7 @@ class HostAgentConfig:
|
||||
dependency_restart_max_attempts: int = 5
|
||||
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
|
||||
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_age_days: int = 7
|
||||
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"
|
||||
).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(
|
||||
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
@@ -8,17 +9,35 @@ from typing import Any
|
||||
|
||||
|
||||
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:
|
||||
self.path = Path(path)
|
||||
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:
|
||||
record = {"timestamp": datetime.now(UTC).isoformat(), **_safe(event)}
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._lock, self.path.open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(record, ensure_ascii=True, default=str) + "\n")
|
||||
with self._lock, self._connect() as connection:
|
||||
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)))
|
||||
|
||||
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:
|
||||
|
||||
@@ -21,6 +21,7 @@ from host_agent.client import (
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
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.history import ConsoleHistoryStore
|
||||
from host_agent.identity import HostIdentityStore
|
||||
@@ -234,6 +235,7 @@ def create_console_app(
|
||||
mcp_token_store: McpTokenStore | None = None,
|
||||
mcp_busy_tracker: McpBusyTracker | None = None,
|
||||
conversation_agent: ConversationAgent | None = None,
|
||||
conversation_log: ConversationLogStore | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="Host Agent Console")
|
||||
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
|
||||
@@ -605,6 +607,15 @@ def create_console_app(
|
||||
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(
|
||||
session: SessionState,
|
||||
*,
|
||||
|
||||
@@ -26,6 +26,7 @@ form.inline { display: inline; margin: 0; }
|
||||
<a href="/tasks">Tasks</a>
|
||||
<a href="/account">Account</a>
|
||||
<a href="/history">History</a>
|
||||
<a href="/conversations">Conversations</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
|
||||
<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 %}
|
||||
Reference in New Issue
Block a user