mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 15:31:38 +08:00
Compare commits
1 Commits
fix/plugin
...
bb/tui-web
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ba6783b8 |
@@ -49,7 +49,7 @@ from hermes_cli.config import (
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -69,8 +69,14 @@ app = FastAPI(title="Hermes Agent", version=__version__)
|
||||
# Session token for protecting sensitive endpoints (reveal).
|
||||
# Generated fresh on every server start — dies when the process exits.
|
||||
# Injected into the SPA HTML so only the legitimate web UI can use it.
|
||||
#
|
||||
# Dev override: set HERMES_DASHBOARD_DEV_TOKEN to pin the token across
|
||||
# restarts so the Vite dev server (running on a different port than the
|
||||
# FastAPI backend) can inject the same value into its served index.html
|
||||
# and hit /api/* + /api/ws successfully. Not for production.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SESSION_TOKEN = secrets.token_urlsafe(32)
|
||||
|
||||
_SESSION_TOKEN = (os.environ.get("HERMES_DASHBOARD_DEV_TOKEN") or "").strip() or secrets.token_urlsafe(32)
|
||||
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
|
||||
|
||||
# Simple rate limiter for the reveal endpoint
|
||||
@@ -2787,6 +2793,34 @@ def _mount_plugin_api_routes():
|
||||
_log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tui_gateway WebSocket — wire-compatible with `python -m tui_gateway.entry`.
|
||||
#
|
||||
# Same newline-delimited JSON-RPC protocol the Ink TUI speaks over stdio,
|
||||
# exposed over WebSocket so browser / iOS / Android clients can drive the
|
||||
# exact same handlers with zero dispatcher duplication.
|
||||
#
|
||||
# Auth: client supplies the ephemeral session token via ``?token=`` query
|
||||
# parameter, matching the REST auth model. Must be validated before ``accept``
|
||||
# so unauthorised clients never see any traffic.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.websocket("/api/ws")
|
||||
async def _tui_gateway_websocket(ws: WebSocket):
|
||||
"""WebSocket entrypoint that replays stdio tui_gateway over a socket."""
|
||||
token = ws.query_params.get("token", "")
|
||||
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
||||
await ws.close(code=4401)
|
||||
return
|
||||
|
||||
# Imported lazily so this module can load in environments where
|
||||
# tui_gateway isn't available (e.g. config-only tooling).
|
||||
from tui_gateway.ws import handle_ws
|
||||
|
||||
await handle_ws(ws)
|
||||
|
||||
|
||||
# Mount plugin API routes before the SPA catch-all.
|
||||
_mount_plugin_api_routes()
|
||||
|
||||
|
||||
@@ -1677,3 +1677,454 @@ class TestDashboardPluginManifestExtensions:
|
||||
plugins = web_server._get_dashboard_plugins(force_rescan=True)
|
||||
entry = next(p for p in plugins if p["name"] == "mixed-slots")
|
||||
assert entry["slots"] == ["sidebar", "header-right"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/ws — WebSocket wire-compatible with stdio tui_gateway
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTuiGatewayWebSocket:
|
||||
"""E2E tests for /api/ws.
|
||||
|
||||
The WS endpoint multiplexes the same JSON-RPC protocol Ink speaks over
|
||||
stdio onto a browser/iOS-friendly socket. These tests exercise the
|
||||
transport boundary without booting a real AIAgent — handlers are
|
||||
monkey-patched in for deterministic byte-level assertions.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
from hermes_cli.web_server import app, _SESSION_TOKEN
|
||||
self.client = TestClient(app)
|
||||
self.token = _SESSION_TOKEN
|
||||
|
||||
def _url(self, token=None):
|
||||
tok = self.token if token is None else token
|
||||
return f"/api/ws?token={tok}" if tok else "/api/ws"
|
||||
|
||||
def _drain_ready(self, ws):
|
||||
"""Skip the ``gateway.ready`` event emitted on accept."""
|
||||
frame = ws.receive_json()
|
||||
assert frame.get("method") == "event"
|
||||
assert frame["params"]["type"] == "gateway.ready"
|
||||
return frame
|
||||
|
||||
def test_handshake_emits_gateway_ready(self):
|
||||
with self.client.websocket_connect(self._url()) as ws:
|
||||
first = ws.receive_json()
|
||||
assert first["jsonrpc"] == "2.0"
|
||||
assert first["method"] == "event"
|
||||
assert first["params"]["type"] == "gateway.ready"
|
||||
assert "skin" in first["params"]["payload"]
|
||||
|
||||
def test_rejects_missing_token(self):
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with self.client.websocket_connect(self._url(token="")) as ws:
|
||||
ws.receive_json()
|
||||
|
||||
def test_rejects_bad_token(self):
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with self.client.websocket_connect(self._url(token="bogus-token-xyz")) as ws:
|
||||
ws.receive_json()
|
||||
|
||||
def test_parse_error_on_bad_frame(self):
|
||||
with self.client.websocket_connect(self._url()) as ws:
|
||||
self._drain_ready(ws)
|
||||
ws.send_text("this is { not json")
|
||||
resp = ws.receive_json()
|
||||
assert resp["jsonrpc"] == "2.0"
|
||||
assert resp["error"]["code"] == -32700
|
||||
assert resp["error"]["message"] == "parse error"
|
||||
|
||||
def test_unknown_method_returns_rpc_error(self):
|
||||
with self.client.websocket_connect(self._url()) as ws:
|
||||
self._drain_ready(ws)
|
||||
ws.send_json({"jsonrpc": "2.0", "id": "u1", "method": "does.not.exist"})
|
||||
resp = ws.receive_json()
|
||||
assert resp["id"] == "u1"
|
||||
assert resp["error"]["code"] == -32601
|
||||
assert "does.not.exist" in resp["error"]["message"]
|
||||
|
||||
def test_inline_handler_returns_response(self):
|
||||
"""An inline handler's result round-trips via the WS transport."""
|
||||
from tui_gateway import server
|
||||
|
||||
sentinel = "_ws_inline_test"
|
||||
server._methods[sentinel] = lambda rid, params: server._ok(rid, {"pong": params.get("ping")})
|
||||
try:
|
||||
with self.client.websocket_connect(self._url()) as ws:
|
||||
self._drain_ready(ws)
|
||||
ws.send_json({"jsonrpc": "2.0", "id": "i1", "method": sentinel, "params": {"ping": "PONG"}})
|
||||
resp = ws.receive_json()
|
||||
assert resp == {"jsonrpc": "2.0", "id": "i1", "result": {"pong": "PONG"}}
|
||||
finally:
|
||||
server._methods.pop(sentinel, None)
|
||||
|
||||
def test_pool_handler_response_arrives_via_ws(self):
|
||||
"""Long-handler responses written from the thread pool must reach the WS client."""
|
||||
from tui_gateway import server
|
||||
|
||||
# Register a "slash.exec" replacement so we exercise the pool path
|
||||
# (_LONG_HANDLERS includes "slash.exec").
|
||||
original = server._methods.get("slash.exec")
|
||||
server._methods["slash.exec"] = lambda rid, params: server._ok(rid, {"output": "async-ok"})
|
||||
try:
|
||||
with self.client.websocket_connect(self._url()) as ws:
|
||||
self._drain_ready(ws)
|
||||
ws.send_json({"jsonrpc": "2.0", "id": "p1", "method": "slash.exec", "params": {}})
|
||||
resp = ws.receive_json()
|
||||
assert resp["id"] == "p1"
|
||||
assert resp["result"] == {"output": "async-ok"}
|
||||
finally:
|
||||
if original is not None:
|
||||
server._methods["slash.exec"] = original
|
||||
else:
|
||||
server._methods.pop("slash.exec", None)
|
||||
|
||||
def test_session_events_route_to_owning_ws(self):
|
||||
"""Events emitted for a session created over WS land on that WS."""
|
||||
from tui_gateway import server
|
||||
from tui_gateway.transport import current_transport
|
||||
|
||||
sentinel_create = "_ws_emit_test_create"
|
||||
sentinel_emit = "_ws_emit_test_fire"
|
||||
created_sid = {"value": ""}
|
||||
|
||||
def create(rid, params):
|
||||
sid = f"ws-emit-test-{uuid_hex()}"
|
||||
created_sid["value"] = sid
|
||||
server._sessions[sid] = {
|
||||
"session_key": sid,
|
||||
"transport": current_transport(),
|
||||
}
|
||||
return server._ok(rid, {"session_id": sid})
|
||||
|
||||
def fire(rid, params):
|
||||
sid = params["session_id"]
|
||||
server._emit("demo.event", sid, {"n": params.get("n", 0)})
|
||||
return server._ok(rid, {"ok": True})
|
||||
|
||||
def uuid_hex():
|
||||
import uuid
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
server._methods[sentinel_create] = create
|
||||
server._methods[sentinel_emit] = fire
|
||||
try:
|
||||
with self.client.websocket_connect(self._url()) as ws:
|
||||
self._drain_ready(ws)
|
||||
|
||||
ws.send_json({"jsonrpc": "2.0", "id": "c1", "method": sentinel_create})
|
||||
create_resp = ws.receive_json()
|
||||
assert create_resp["id"] == "c1"
|
||||
sid = create_resp["result"]["session_id"]
|
||||
assert sid == created_sid["value"]
|
||||
|
||||
ws.send_json({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "e1",
|
||||
"method": sentinel_emit,
|
||||
"params": {"session_id": sid, "n": 7},
|
||||
})
|
||||
# Event fires synchronously inside the handler, so it should
|
||||
# arrive before the response.
|
||||
frame1 = ws.receive_json()
|
||||
frame2 = ws.receive_json()
|
||||
|
||||
event_frame = frame1 if frame1.get("method") == "event" else frame2
|
||||
resp_frame = frame2 if frame2.get("id") == "e1" else frame1
|
||||
|
||||
assert event_frame["params"]["type"] == "demo.event"
|
||||
assert event_frame["params"]["session_id"] == sid
|
||||
assert event_frame["params"]["payload"] == {"n": 7}
|
||||
assert resp_frame["result"] == {"ok": True}
|
||||
finally:
|
||||
server._methods.pop(sentinel_create, None)
|
||||
server._methods.pop(sentinel_emit, None)
|
||||
server._sessions.pop(created_sid["value"], None)
|
||||
|
||||
def test_ws_disconnect_resets_session_transport(self):
|
||||
"""After a WS hangs up, sessions it owned fall back to stdio so stray emits don't crash."""
|
||||
from tui_gateway import server
|
||||
from tui_gateway.transport import current_transport
|
||||
|
||||
sentinel = "_ws_disconnect_test"
|
||||
captured = {"sid": "", "transport": None}
|
||||
|
||||
def create(rid, params):
|
||||
sid = "ws-disconnect-sid"
|
||||
captured["sid"] = sid
|
||||
captured["transport"] = current_transport()
|
||||
server._sessions[sid] = {
|
||||
"session_key": sid,
|
||||
"transport": captured["transport"],
|
||||
}
|
||||
return server._ok(rid, {"session_id": sid})
|
||||
|
||||
server._methods[sentinel] = create
|
||||
try:
|
||||
with self.client.websocket_connect(self._url()) as ws:
|
||||
self._drain_ready(ws)
|
||||
ws.send_json({"jsonrpc": "2.0", "id": "c1", "method": sentinel})
|
||||
ws.receive_json()
|
||||
|
||||
# Give the server a moment to run the finally-block cleanup.
|
||||
import time
|
||||
for _ in range(50):
|
||||
if server._sessions.get(captured["sid"], {}).get("transport") is not captured["transport"]:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
|
||||
sess = server._sessions.get(captured["sid"])
|
||||
assert sess is not None
|
||||
assert sess["transport"] is server._stdio_transport
|
||||
finally:
|
||||
server._methods.pop(sentinel, None)
|
||||
server._sessions.pop(captured["sid"], None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport parity — same RPC, stdio vs WS, byte-identical envelopes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTuiGatewayTransportParity:
|
||||
"""The whole point of the transport abstraction is that handlers don't
|
||||
know what's on the other end. These tests lock that in: the response
|
||||
envelope produced by ``server.handle_request`` directly (stdio fast path)
|
||||
must match what a WS client receives for the same request.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
from hermes_cli.web_server import app, _SESSION_TOKEN
|
||||
self.client = TestClient(app)
|
||||
self.token = _SESSION_TOKEN
|
||||
|
||||
def _ws_roundtrip(self, req: dict) -> dict:
|
||||
with self.client.websocket_connect(f"/api/ws?token={self.token}") as ws:
|
||||
ready = ws.receive_json()
|
||||
assert ready["params"]["type"] == "gateway.ready"
|
||||
ws.send_json(req)
|
||||
return ws.receive_json()
|
||||
|
||||
def test_parity_unknown_method(self):
|
||||
from tui_gateway import server
|
||||
req = {"jsonrpc": "2.0", "id": "p-unk", "method": "no.such.method"}
|
||||
assert self._ws_roundtrip(req) == server.handle_request(req)
|
||||
|
||||
def test_parity_inline_handler(self):
|
||||
from tui_gateway import server
|
||||
|
||||
sentinel = "_parity_inline"
|
||||
server._methods[sentinel] = lambda rid, params: server._ok(rid, {
|
||||
"echo": params,
|
||||
"const": 42,
|
||||
"nested": {"a": [1, 2, 3], "b": None},
|
||||
})
|
||||
try:
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "p-inline",
|
||||
"method": sentinel,
|
||||
"params": {"hello": "world", "n": 1},
|
||||
}
|
||||
assert self._ws_roundtrip(req) == server.handle_request(req)
|
||||
finally:
|
||||
server._methods.pop(sentinel, None)
|
||||
|
||||
def test_parity_error_envelope(self):
|
||||
from tui_gateway import server
|
||||
|
||||
sentinel = "_parity_err"
|
||||
server._methods[sentinel] = lambda rid, params: server._err(rid, 4242, "nope")
|
||||
try:
|
||||
req = {"jsonrpc": "2.0", "id": "p-err", "method": sentinel}
|
||||
assert self._ws_roundtrip(req) == server.handle_request(req)
|
||||
finally:
|
||||
server._methods.pop(sentinel, None)
|
||||
|
||||
def test_parity_stdio_transport_also_works(self):
|
||||
"""Calling dispatch() with the stdio transport explicitly must match the default."""
|
||||
from tui_gateway import server
|
||||
|
||||
sentinel = "_parity_stdio"
|
||||
server._methods[sentinel] = lambda rid, params: server._ok(rid, {"ok": True, "p": params})
|
||||
try:
|
||||
req = {"jsonrpc": "2.0", "id": "p-std", "method": sentinel, "params": {"x": 1}}
|
||||
# Default (no transport arg)
|
||||
default_resp = server.dispatch(dict(req))
|
||||
# Explicit stdio transport
|
||||
explicit_resp = server.dispatch(dict(req), server._stdio_transport)
|
||||
assert default_resp == explicit_resp
|
||||
assert default_resp["result"] == {"ok": True, "p": {"x": 1}}
|
||||
finally:
|
||||
server._methods.pop(sentinel, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2E: drive the "Ink --tui" JSON-RPC surface over ANY transport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTuiGatewayE2EAnyPort:
|
||||
"""Scripted multi-message conversations that exercise the real dispatcher.
|
||||
|
||||
The same scripted sequence runs over (a) direct ``handle_request`` calls
|
||||
and (b) a live WebSocket. Both must produce the same response envelopes
|
||||
in the same order. This is the "hermes --tui in any port" check.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
from hermes_cli.web_server import app, _SESSION_TOKEN
|
||||
self.client = TestClient(app)
|
||||
self.token = _SESSION_TOKEN
|
||||
|
||||
def _install_scripted_methods(self):
|
||||
"""Install a tiny surface that mimics what Ink exercises on startup:
|
||||
|
||||
- commands.ping returns a deterministic pong
|
||||
- session.sim_create creates a fake session (no real agent)
|
||||
- session.sim_close tears down the session
|
||||
- config.sim_get_value reads a key
|
||||
"""
|
||||
from tui_gateway import server
|
||||
from tui_gateway.transport import current_transport
|
||||
|
||||
added = []
|
||||
|
||||
def ping(rid, params):
|
||||
return server._ok(rid, {"pong": True, "id": rid})
|
||||
server._methods["commands.ping"] = ping
|
||||
added.append("commands.ping")
|
||||
|
||||
def sim_create(rid, params):
|
||||
import uuid
|
||||
sid = f"sim-{uuid.uuid4().hex[:6]}"
|
||||
server._sessions[sid] = {
|
||||
"session_key": sid,
|
||||
"transport": current_transport(),
|
||||
"agent": None,
|
||||
}
|
||||
return server._ok(rid, {"session_id": sid})
|
||||
server._methods["session.sim_create"] = sim_create
|
||||
added.append("session.sim_create")
|
||||
|
||||
def sim_close(rid, params):
|
||||
sid = params.get("session_id", "")
|
||||
removed = server._sessions.pop(sid, None) is not None
|
||||
return server._ok(rid, {"closed": removed})
|
||||
server._methods["session.sim_close"] = sim_close
|
||||
added.append("session.sim_close")
|
||||
|
||||
def sim_get_value(rid, params):
|
||||
return server._ok(rid, {"value": "deterministic", "key": params.get("key", "")})
|
||||
server._methods["config.sim_get_value"] = sim_get_value
|
||||
added.append("config.sim_get_value")
|
||||
|
||||
return added
|
||||
|
||||
def _uninstall(self, added):
|
||||
from tui_gateway import server
|
||||
for name in added:
|
||||
server._methods.pop(name, None)
|
||||
|
||||
def _script(self):
|
||||
return [
|
||||
{"jsonrpc": "2.0", "id": "s1", "method": "commands.ping"},
|
||||
{"jsonrpc": "2.0", "id": "s2", "method": "session.sim_create"},
|
||||
{"jsonrpc": "2.0", "id": "s3", "method": "config.sim_get_value",
|
||||
"params": {"key": "display.skin"}},
|
||||
]
|
||||
|
||||
def test_script_over_direct_and_ws_match(self):
|
||||
from tui_gateway import server
|
||||
|
||||
added = self._install_scripted_methods()
|
||||
try:
|
||||
script = self._script()
|
||||
|
||||
# Run over direct dispatch
|
||||
direct_resps = [server.handle_request(dict(req)) for req in script]
|
||||
# Clean up the session.create we just made so we don't leak into
|
||||
# the WS run.
|
||||
for r in direct_resps:
|
||||
sid = (r.get("result") or {}).get("session_id")
|
||||
if sid:
|
||||
server._sessions.pop(sid, None)
|
||||
|
||||
# Run over WS
|
||||
with self.client.websocket_connect(f"/api/ws?token={self.token}") as ws:
|
||||
ready = ws.receive_json()
|
||||
assert ready["params"]["type"] == "gateway.ready"
|
||||
|
||||
ws_resps = []
|
||||
for req in script:
|
||||
ws.send_json(req)
|
||||
ws_resps.append(ws.receive_json())
|
||||
|
||||
# Result shapes (stripping session-identity fields) should match.
|
||||
def normalize(r):
|
||||
r = dict(r)
|
||||
if "result" in r and isinstance(r["result"], dict):
|
||||
result = dict(r["result"])
|
||||
# session ids are random — compare only structure
|
||||
if "session_id" in result:
|
||||
result["session_id"] = "<random>"
|
||||
r["result"] = result
|
||||
return r
|
||||
|
||||
assert [normalize(r) for r in direct_resps] == [normalize(r) for r in ws_resps]
|
||||
|
||||
# And both surfaces ACTUALLY executed their handlers.
|
||||
assert all("result" in r for r in ws_resps)
|
||||
assert ws_resps[0]["result"]["pong"] is True
|
||||
assert ws_resps[2]["result"]["value"] == "deterministic"
|
||||
finally:
|
||||
# Clean up any sessions created during the WS run.
|
||||
for sid in [
|
||||
sid for sid, sess in list(server._sessions.items()) if sid.startswith("sim-")
|
||||
]:
|
||||
server._sessions.pop(sid, None)
|
||||
self._uninstall(added)
|
||||
|
||||
def test_session_lifecycle_over_ws(self):
|
||||
"""Open a session, then close it — via WS only."""
|
||||
from tui_gateway import server
|
||||
|
||||
added = self._install_scripted_methods()
|
||||
try:
|
||||
with self.client.websocket_connect(f"/api/ws?token={self.token}") as ws:
|
||||
ready = ws.receive_json()
|
||||
assert ready["params"]["type"] == "gateway.ready"
|
||||
|
||||
ws.send_json({"jsonrpc": "2.0", "id": "c1", "method": "session.sim_create"})
|
||||
create = ws.receive_json()
|
||||
sid = create["result"]["session_id"]
|
||||
assert sid in server._sessions
|
||||
|
||||
ws.send_json({
|
||||
"jsonrpc": "2.0", "id": "x1", "method": "session.sim_close",
|
||||
"params": {"session_id": sid},
|
||||
})
|
||||
close = ws.receive_json()
|
||||
assert close["result"] == {"closed": True}
|
||||
assert sid not in server._sessions
|
||||
finally:
|
||||
self._uninstall(added)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import atexit
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@@ -12,9 +13,17 @@ import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from tui_gateway.transport import (
|
||||
StdioTransport,
|
||||
Transport,
|
||||
bind_transport,
|
||||
current_transport,
|
||||
reset_transport,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -147,6 +156,12 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
|
||||
_real_stdout = sys.stdout
|
||||
sys.stdout = sys.stderr
|
||||
|
||||
# Module-level stdio transport used as the fallback sink when no transport is
|
||||
# bound via contextvar or session. The stream is resolved through a lambda so
|
||||
# runtime monkey-patches of `_real_stdout` (used extensively in tests) still
|
||||
# land in the right place.
|
||||
_stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock)
|
||||
|
||||
|
||||
class _SlashWorker:
|
||||
"""Persistent HermesCLI subprocess for slash commands."""
|
||||
@@ -266,14 +281,24 @@ def _db_unavailable_error(rid, *, code: int):
|
||||
|
||||
|
||||
def write_json(obj: dict) -> bool:
|
||||
line = json.dumps(obj, ensure_ascii=False) + "\n"
|
||||
try:
|
||||
with _stdout_lock:
|
||||
_real_stdout.write(line)
|
||||
_real_stdout.flush()
|
||||
return True
|
||||
except BrokenPipeError:
|
||||
return False
|
||||
"""Emit one JSON frame. Routes via the most-specific transport available.
|
||||
|
||||
Precedence:
|
||||
|
||||
1. Event frames with a session id → the transport stored on that session,
|
||||
so async events land with the client that owns the session even if
|
||||
the emitting thread has no contextvar binding.
|
||||
2. Otherwise the transport bound on the current context (set by
|
||||
:func:`dispatch` for the lifetime of a request).
|
||||
3. Otherwise the module-level stdio transport, matching the historical
|
||||
behaviour and keeping tests that monkey-patch ``_real_stdout`` green.
|
||||
"""
|
||||
if obj.get("method") == "event":
|
||||
sid = ((obj.get("params") or {}).get("session_id")) or ""
|
||||
if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None:
|
||||
return t.write(obj)
|
||||
|
||||
return (current_transport() or _stdio_transport).write(obj)
|
||||
|
||||
|
||||
def _emit(event: str, sid: str, payload: dict | None = None):
|
||||
@@ -343,27 +368,39 @@ def handle_request(req: dict) -> dict | None:
|
||||
return fn(req.get("id"), req.get("params", {}))
|
||||
|
||||
|
||||
def dispatch(req: dict) -> dict | None:
|
||||
def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
|
||||
"""Route inbound RPCs — long handlers to the pool, everything else inline.
|
||||
|
||||
Returns a response dict when handled inline. Returns None when the
|
||||
handler was scheduled on the pool; the worker writes its own
|
||||
response via write_json when done.
|
||||
response via the bound transport when done.
|
||||
|
||||
*transport* (optional): pins every write produced by this request —
|
||||
including any events emitted by the handler — to the given transport.
|
||||
When omitted, writes fall back to the module-level stdio transport,
|
||||
preserving the original behaviour for ``tui_gateway.entry``.
|
||||
"""
|
||||
if req.get("method") not in _LONG_HANDLERS:
|
||||
return handle_request(req)
|
||||
t = transport or _stdio_transport
|
||||
token = bind_transport(t)
|
||||
try:
|
||||
if req.get("method") not in _LONG_HANDLERS:
|
||||
return handle_request(req)
|
||||
|
||||
def run():
|
||||
try:
|
||||
resp = handle_request(req)
|
||||
except Exception as exc:
|
||||
resp = _err(req.get("id"), -32000, f"handler error: {exc}")
|
||||
if resp is not None:
|
||||
write_json(resp)
|
||||
# Snapshot the context so the pool worker sees the bound transport.
|
||||
ctx = contextvars.copy_context()
|
||||
|
||||
_pool.submit(run)
|
||||
def run():
|
||||
try:
|
||||
resp = handle_request(req)
|
||||
except Exception as exc:
|
||||
resp = _err(req.get("id"), -32000, f"handler error: {exc}")
|
||||
if resp is not None:
|
||||
t.write(resp)
|
||||
|
||||
return None
|
||||
_pool.submit(lambda: ctx.run(run))
|
||||
return None
|
||||
finally:
|
||||
reset_transport(token)
|
||||
|
||||
|
||||
def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None:
|
||||
@@ -1256,6 +1293,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
||||
"tool_progress_mode": _load_tool_progress_mode(),
|
||||
"edit_snapshots": {},
|
||||
"tool_started_at": {},
|
||||
"transport": current_transport() or _stdio_transport,
|
||||
}
|
||||
try:
|
||||
_sessions[sid]["slash_worker"] = _SlashWorker(
|
||||
@@ -1398,6 +1436,7 @@ def _(rid, params: dict) -> dict:
|
||||
"slash_worker": None,
|
||||
"tool_progress_mode": _load_tool_progress_mode(),
|
||||
"tool_started_at": {},
|
||||
"transport": current_transport() or _stdio_transport,
|
||||
}
|
||||
|
||||
def _build() -> None:
|
||||
|
||||
91
tui_gateway/transport.py
Normal file
91
tui_gateway/transport.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Transport abstraction for the tui_gateway JSON-RPC server.
|
||||
|
||||
Historically the gateway wrote every JSON frame directly to real stdout. This
|
||||
module decouples the I/O sink from the handler logic so the same dispatcher
|
||||
can be driven over stdio (``tui_gateway.entry``) or WebSocket
|
||||
(``tui_gateway.ws``) without duplicating code.
|
||||
|
||||
A :class:`Transport` is anything that can accept a JSON-serialisable dict and
|
||||
forward it to its peer. The active transport for the current request is
|
||||
tracked in a :class:`contextvars.ContextVar` so handlers — including those
|
||||
dispatched onto the worker pool — route their writes to the right peer.
|
||||
|
||||
Backward compatibility
|
||||
----------------------
|
||||
``tui_gateway.server.write_json`` still works without any transport bound.
|
||||
When nothing is on the contextvar and no session-level transport is found,
|
||||
it falls back to the module-level :class:`StdioTransport`, which wraps the
|
||||
original ``_real_stdout`` + ``_stdout_lock`` pair. Tests that monkey-patch
|
||||
``server._real_stdout`` continue to work because the stdio transport resolves
|
||||
the stream lazily through a callback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Callable, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Transport(Protocol):
|
||||
"""Minimal interface every transport implements."""
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
"""Emit one JSON frame. Return ``False`` when the peer is gone."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release any resources owned by this transport."""
|
||||
|
||||
|
||||
_current_transport: contextvars.ContextVar[Optional[Transport]] = (
|
||||
contextvars.ContextVar(
|
||||
"hermes_gateway_transport",
|
||||
default=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def current_transport() -> Optional[Transport]:
|
||||
"""Return the transport bound for the current request, if any."""
|
||||
return _current_transport.get()
|
||||
|
||||
|
||||
def bind_transport(transport: Optional[Transport]):
|
||||
"""Bind *transport* for the current context. Returns a token for :func:`reset_transport`."""
|
||||
return _current_transport.set(transport)
|
||||
|
||||
|
||||
def reset_transport(token) -> None:
|
||||
"""Restore the transport binding captured by :func:`bind_transport`."""
|
||||
_current_transport.reset(token)
|
||||
|
||||
|
||||
class StdioTransport:
|
||||
"""Writes JSON frames to a stream (usually ``sys.stdout``).
|
||||
|
||||
The stream is resolved via a callable so runtime monkey-patches of the
|
||||
underlying stream continue to work — this preserves the behaviour the
|
||||
existing test suite relies on (``monkeypatch.setattr(server, "_real_stdout", ...)``).
|
||||
"""
|
||||
|
||||
__slots__ = ("_stream_getter", "_lock")
|
||||
|
||||
def __init__(self, stream_getter: Callable[[], Any], lock: threading.Lock) -> None:
|
||||
self._stream_getter = stream_getter
|
||||
self._lock = lock
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
line = json.dumps(obj, ensure_ascii=False) + "\n"
|
||||
try:
|
||||
with self._lock:
|
||||
stream = self._stream_getter()
|
||||
stream.write(line)
|
||||
stream.flush()
|
||||
return True
|
||||
except BrokenPipeError:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
174
tui_gateway/ws.py
Normal file
174
tui_gateway/ws.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""WebSocket transport for the tui_gateway JSON-RPC server.
|
||||
|
||||
Reuses :func:`tui_gateway.server.dispatch` verbatim so every RPC method, every
|
||||
slash command, every approval/clarify/sudo flow, and every agent event flows
|
||||
through the same handlers whether the client is Ink over stdio or an iOS /
|
||||
web client over WebSocket.
|
||||
|
||||
Wire protocol
|
||||
-------------
|
||||
Identical to stdio: newline-delimited JSON-RPC in both directions. The server
|
||||
emits a ``gateway.ready`` event immediately after connection accept, then
|
||||
echoes responses/events for inbound requests. No framing differences.
|
||||
|
||||
Mounting
|
||||
--------
|
||||
from fastapi import WebSocket
|
||||
from tui_gateway.ws import handle_ws
|
||||
|
||||
@app.websocket("/api/ws")
|
||||
async def ws(ws: WebSocket):
|
||||
await handle_ws(ws)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from tui_gateway import server
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Max seconds a pool-dispatched handler will block waiting for the event loop
|
||||
# to flush a WS frame before we mark the transport dead. Protects handler
|
||||
# threads from a wedged socket.
|
||||
_WS_WRITE_TIMEOUT_S = 10.0
|
||||
|
||||
# Keep starlette optional at import time; handle_ws uses the real class when
|
||||
# it's available and falls back to a generic Exception sentinel otherwise.
|
||||
try:
|
||||
from starlette.websockets import WebSocketDisconnect as _WebSocketDisconnect
|
||||
except ImportError: # pragma: no cover - starlette is a required install path
|
||||
_WebSocketDisconnect = Exception # type: ignore[assignment]
|
||||
|
||||
|
||||
class WSTransport:
|
||||
"""Per-connection WS transport.
|
||||
|
||||
``write`` is safe to call from any thread *other than* the event loop
|
||||
thread that owns the socket. Pool workers (the only real caller) run in
|
||||
their own threads, so marshalling onto the loop via
|
||||
:func:`asyncio.run_coroutine_threadsafe` + ``future.result()`` is correct
|
||||
and deadlock-free there.
|
||||
|
||||
When called from the loop thread itself (e.g. by ``handle_ws`` for an
|
||||
inline response) the same call would deadlock: we'd schedule work onto
|
||||
the loop we're currently blocking. We detect that case and fire-and-
|
||||
forget instead. Callers that need to know when the bytes are on the wire
|
||||
should use :meth:`write_async` from the loop thread.
|
||||
"""
|
||||
|
||||
def __init__(self, ws: Any, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._ws = ws
|
||||
self._loop = loop
|
||||
self._closed = False
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
if self._closed:
|
||||
return False
|
||||
|
||||
line = json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
on_loop = asyncio.get_running_loop() is self._loop
|
||||
except RuntimeError:
|
||||
on_loop = False
|
||||
|
||||
if on_loop:
|
||||
# Fire-and-forget — don't block the loop waiting on itself.
|
||||
self._loop.create_task(self._safe_send(line))
|
||||
return True
|
||||
|
||||
try:
|
||||
fut = asyncio.run_coroutine_threadsafe(self._safe_send(line), self._loop)
|
||||
fut.result(timeout=_WS_WRITE_TIMEOUT_S)
|
||||
return not self._closed
|
||||
except Exception as exc:
|
||||
self._closed = True
|
||||
_log.debug("ws write failed: %s", exc)
|
||||
return False
|
||||
|
||||
async def write_async(self, obj: dict) -> bool:
|
||||
"""Send from the owning event loop. Awaits until the frame is on the wire."""
|
||||
if self._closed:
|
||||
return False
|
||||
await self._safe_send(json.dumps(obj, ensure_ascii=False))
|
||||
return not self._closed
|
||||
|
||||
async def _safe_send(self, line: str) -> None:
|
||||
try:
|
||||
await self._ws.send_text(line)
|
||||
except Exception as exc:
|
||||
self._closed = True
|
||||
_log.debug("ws send failed: %s", exc)
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
|
||||
async def handle_ws(ws: Any) -> None:
|
||||
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``."""
|
||||
await ws.accept()
|
||||
|
||||
transport = WSTransport(ws, asyncio.get_running_loop())
|
||||
|
||||
await transport.write_async(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "event",
|
||||
"params": {
|
||||
"type": "gateway.ready",
|
||||
"payload": {"skin": server.resolve_skin()},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
raw = await ws.receive_text()
|
||||
except _WebSocketDisconnect:
|
||||
break
|
||||
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
ok = await transport.write_async(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32700, "message": "parse error"},
|
||||
"id": None,
|
||||
}
|
||||
)
|
||||
if not ok:
|
||||
break
|
||||
continue
|
||||
|
||||
# dispatch() may schedule long handlers on the pool; it returns
|
||||
# None in that case and the worker writes the response itself via
|
||||
# the transport we pass in (a separate thread, so transport.write
|
||||
# is the safe path there). For inline handlers it returns the
|
||||
# response dict, which we write here from the loop.
|
||||
resp = await asyncio.to_thread(server.dispatch, req, transport)
|
||||
if resp is not None and not await transport.write_async(resp):
|
||||
break
|
||||
finally:
|
||||
transport.close()
|
||||
|
||||
# Detach the transport from any sessions it owned so later emits
|
||||
# fall back to stdio instead of crashing into a closed socket.
|
||||
for _, sess in list(server._sessions.items()):
|
||||
if sess.get("transport") is transport:
|
||||
sess["transport"] = server._stdio_transport
|
||||
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11,16 +11,22 @@ Browser-based dashboard for managing Hermes Agent configuration, API keys, and m
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start the backend API server
|
||||
cd ../
|
||||
python -m hermes_cli.main web --no-open
|
||||
# Pin a shared dev token so Vite (5173) and FastAPI (9119) agree.
|
||||
# Without this, the SPA can't authenticate against the backend in dev mode.
|
||||
export HERMES_DASHBOARD_DEV_TOKEN="dev-$(openssl rand -hex 16)"
|
||||
|
||||
# In another terminal, start the Vite dev server (with HMR + API proxy)
|
||||
# Terminal 1 — backend on :9119
|
||||
hermes dashboard --no-open
|
||||
|
||||
# Terminal 2 — Vite dev server on :5173 with HMR + /api proxy
|
||||
cd web/
|
||||
npm run dev
|
||||
# then open http://localhost:5173
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
|
||||
The Vite dev server proxies `/api` and `/api/ws` (WebSocket) requests to `http://127.0.0.1:9119` (the FastAPI backend). The dev token is injected into the served `index.html` so the SPA's `window.__HERMES_SESSION_TOKEN__` matches what the backend expects.
|
||||
|
||||
For a one-shot demo without HMR, skip the env var and just run `hermes dashboard` — it builds and serves the SPA directly on :9119 with a fresh random token injected.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Cell, Grid, SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
import ChatPage from "@/pages/ChatPage";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
import SessionsPage from "@/pages/SessionsPage";
|
||||
@@ -45,6 +46,7 @@ import { useTheme } from "@/themes";
|
||||
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
|
||||
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
||||
"/": StatusPage,
|
||||
"/chat": ChatPage,
|
||||
"/sessions": SessionsPage,
|
||||
"/analytics": AnalyticsPage,
|
||||
"/logs": LogsPage,
|
||||
@@ -56,6 +58,7 @@ const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
||||
|
||||
const BUILTIN_NAV: NavItem[] = [
|
||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||
{ path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal },
|
||||
{
|
||||
path: "/sessions",
|
||||
labelKey: "sessions",
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer for LLM output.
|
||||
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
|
||||
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
|
||||
*
|
||||
* `streaming` renders a blinking caret at the tail of the last block so it
|
||||
* appears to hug the final character instead of wrapping onto a new line
|
||||
* after a block element (paragraph/list/code/…).
|
||||
*/
|
||||
export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) {
|
||||
export function Markdown({
|
||||
content,
|
||||
highlightTerms,
|
||||
streaming,
|
||||
}: {
|
||||
content: string;
|
||||
highlightTerms?: string[];
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const blocks = useMemo(() => parseBlocks(content), [content]);
|
||||
const caret = streaming ? <StreamingCaret /> : null;
|
||||
|
||||
return (
|
||||
<div className="text-sm text-foreground leading-relaxed space-y-2">
|
||||
{blocks.map((block, i) => (
|
||||
<Block key={i} block={block} highlightTerms={highlightTerms} />
|
||||
<Block
|
||||
key={i}
|
||||
block={block}
|
||||
highlightTerms={highlightTerms}
|
||||
caret={caret && i === blocks.length - 1 ? caret : null}
|
||||
/>
|
||||
))}
|
||||
{blocks.length === 0 && caret}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingCaret() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block w-[0.5em] h-[1em] ml-0.5 align-[-0.15em] bg-foreground/50 animate-pulse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -58,7 +86,11 @@ function parseBlocks(text: string): BlockNode[] {
|
||||
// Heading
|
||||
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
|
||||
if (headingMatch) {
|
||||
blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] });
|
||||
blocks.push({
|
||||
type: "heading",
|
||||
level: headingMatch[1].length,
|
||||
content: headingMatch[2],
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -124,12 +156,23 @@ function parseBlocks(text: string): BlockNode[] {
|
||||
/* Block renderer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) {
|
||||
function Block({
|
||||
block,
|
||||
highlightTerms,
|
||||
caret,
|
||||
}: {
|
||||
block: BlockNode;
|
||||
highlightTerms?: string[];
|
||||
caret?: ReactNode;
|
||||
}) {
|
||||
switch (block.type) {
|
||||
case "code":
|
||||
return (
|
||||
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
|
||||
<code>{block.content}</code>
|
||||
<code>
|
||||
{block.content}
|
||||
{caret}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
@@ -141,25 +184,46 @@ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: s
|
||||
h3: "text-sm font-semibold",
|
||||
h4: "text-sm font-medium",
|
||||
};
|
||||
return <Tag className={sizes[Tag]}><InlineContent text={block.content} highlightTerms={highlightTerms} /></Tag>;
|
||||
return (
|
||||
<Tag className={sizes[Tag]}>
|
||||
<InlineContent text={block.content} highlightTerms={highlightTerms} />
|
||||
{caret}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
case "hr":
|
||||
return <hr className="border-border" />;
|
||||
return (
|
||||
<>
|
||||
<hr className="border-border" />
|
||||
{caret}
|
||||
</>
|
||||
);
|
||||
|
||||
case "list": {
|
||||
const Tag = block.ordered ? "ol" : "ul";
|
||||
const last = block.items.length - 1;
|
||||
return (
|
||||
<Tag className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}>
|
||||
<Tag
|
||||
className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}
|
||||
>
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i}><InlineContent text={item} highlightTerms={highlightTerms} /></li>
|
||||
<li key={i}>
|
||||
<InlineContent text={item} highlightTerms={highlightTerms} />
|
||||
{i === last ? caret : null}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
case "paragraph":
|
||||
return <p><InlineContent text={block.content} highlightTerms={highlightTerms} /></p>;
|
||||
return (
|
||||
<p>
|
||||
<InlineContent text={block.content} highlightTerms={highlightTerms} />
|
||||
{caret}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +242,8 @@ type InlineNode =
|
||||
function parseInline(text: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = [];
|
||||
// Pattern priority: code > link > bold > italic > bare URL > line break
|
||||
const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
|
||||
const pattern =
|
||||
/(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
@@ -217,7 +282,13 @@ function parseInline(text: string): InlineNode[] {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) {
|
||||
function InlineContent({
|
||||
text,
|
||||
highlightTerms,
|
||||
}: {
|
||||
text: string;
|
||||
highlightTerms?: string[];
|
||||
}) {
|
||||
const nodes = useMemo(() => parseInline(text), [text]);
|
||||
|
||||
return (
|
||||
@@ -225,17 +296,34 @@ function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?
|
||||
{nodes.map((node, i) => {
|
||||
switch (node.type) {
|
||||
case "text":
|
||||
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
|
||||
return (
|
||||
<HighlightedText
|
||||
key={i}
|
||||
text={node.content}
|
||||
terms={highlightTerms}
|
||||
/>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<code key={i} className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
|
||||
<code
|
||||
key={i}
|
||||
className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90"
|
||||
>
|
||||
{node.content}
|
||||
</code>
|
||||
);
|
||||
case "bold":
|
||||
return <strong key={i} className="font-semibold"><HighlightedText text={node.content} terms={highlightTerms} /></strong>;
|
||||
return (
|
||||
<strong key={i} className="font-semibold">
|
||||
<HighlightedText text={node.content} terms={highlightTerms} />
|
||||
</strong>
|
||||
);
|
||||
case "italic":
|
||||
return <em key={i}><HighlightedText text={node.content} terms={highlightTerms} /></em>;
|
||||
return (
|
||||
<em key={i}>
|
||||
<HighlightedText text={node.content} terms={highlightTerms} />
|
||||
</em>
|
||||
);
|
||||
case "link":
|
||||
return (
|
||||
<a
|
||||
@@ -269,10 +357,12 @@ function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-warning/30 text-warning px-0.5">{part}</mark>
|
||||
<mark key={i} className="bg-warning/30 text-warning px-0.5">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
392
web/src/components/ModelPickerDialog.tsx
Normal file
392
web/src/components/ModelPickerDialog.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
import { Check, Loader2, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Two-stage model picker modal.
|
||||
*
|
||||
* Mirrors ui-tui/src/components/modelPicker.tsx:
|
||||
* Stage 1: pick provider (authenticated providers only)
|
||||
* Stage 2: pick model within that provider
|
||||
*
|
||||
* On confirm, emits `/model <model> --provider <slug> [--global]` through
|
||||
* the parent callback so ChatPage can dispatch it via the existing slash
|
||||
* pipeline. That keeps persistence + actual switch logic in one place.
|
||||
*/
|
||||
|
||||
interface ModelOptionProvider {
|
||||
name: string;
|
||||
slug: string;
|
||||
models?: string[];
|
||||
total_models?: number;
|
||||
is_current?: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
interface ModelOptionsResponse {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
providers?: ModelOptionProvider[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
gw: GatewayClient;
|
||||
sessionId: string;
|
||||
onClose(): void;
|
||||
/** Parent runs the resulting slash command through slashExec. */
|
||||
onSubmit(slashCommand: string): void;
|
||||
}
|
||||
|
||||
export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
|
||||
const [currentModel, setCurrentModel] = useState("");
|
||||
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSlug, setSelectedSlug] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [persistGlobal, setPersistGlobal] = useState(false);
|
||||
const closedRef = useRef(false);
|
||||
|
||||
// Load providers + models on open.
|
||||
useEffect(() => {
|
||||
closedRef.current = false;
|
||||
|
||||
gw.request<ModelOptionsResponse>(
|
||||
"model.options",
|
||||
sessionId ? { session_id: sessionId } : {},
|
||||
)
|
||||
.then((r) => {
|
||||
if (closedRef.current) return;
|
||||
const next = r?.providers ?? [];
|
||||
setProviders(next);
|
||||
setCurrentModel(String(r?.model ?? ""));
|
||||
setCurrentProviderSlug(String(r?.provider ?? ""));
|
||||
setSelectedSlug(
|
||||
(next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
|
||||
);
|
||||
setSelectedModel("");
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (closedRef.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
closedRef.current = true;
|
||||
};
|
||||
}, [gw, sessionId]);
|
||||
|
||||
// Esc closes.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const selectedProvider = useMemo(
|
||||
() => providers.find((p) => p.slug === selectedSlug) ?? null,
|
||||
[providers, selectedSlug],
|
||||
);
|
||||
|
||||
const models = useMemo(
|
||||
() => selectedProvider?.models ?? [],
|
||||
[selectedProvider],
|
||||
);
|
||||
|
||||
const needle = query.trim().toLowerCase();
|
||||
|
||||
const filteredProviders = useMemo(
|
||||
() =>
|
||||
!needle
|
||||
? providers
|
||||
: providers.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(needle) ||
|
||||
p.slug.toLowerCase().includes(needle) ||
|
||||
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
|
||||
),
|
||||
[providers, needle],
|
||||
);
|
||||
|
||||
const filteredModels = useMemo(
|
||||
() =>
|
||||
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
|
||||
[models, needle],
|
||||
);
|
||||
|
||||
const canConfirm = !!selectedProvider && !!selectedModel;
|
||||
|
||||
const confirm = () => {
|
||||
if (!canConfirm) return;
|
||||
const global = persistGlobal ? " --global" : "";
|
||||
onSubmit(
|
||||
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-100 flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="model-picker-title"
|
||||
>
|
||||
<div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="model-picker-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
>
|
||||
Switch Model
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">
|
||||
current: {currentModel || "(unknown)"}
|
||||
{currentProviderSlug && ` · ${currentProviderSlug}`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="px-5 pt-3 pb-2 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Filter providers and models…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-7 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
|
||||
<ProviderColumn
|
||||
loading={loading}
|
||||
error={error}
|
||||
providers={filteredProviders}
|
||||
total={providers.length}
|
||||
selectedSlug={selectedSlug}
|
||||
query={needle}
|
||||
onSelect={(slug) => {
|
||||
setSelectedSlug(slug);
|
||||
setSelectedModel("");
|
||||
}}
|
||||
/>
|
||||
|
||||
<ModelColumn
|
||||
provider={selectedProvider}
|
||||
models={filteredModels}
|
||||
allModels={models}
|
||||
selectedModel={selectedModel}
|
||||
currentModel={currentModel}
|
||||
currentProviderSlug={currentProviderSlug}
|
||||
onSelect={setSelectedModel}
|
||||
onConfirm={(m) => {
|
||||
setSelectedModel(m);
|
||||
// Confirm on next tick so state settles.
|
||||
window.setTimeout(confirm, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={persistGlobal}
|
||||
onChange={(e) => setPersistGlobal(e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Persist globally (otherwise this session only)
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirm} disabled={!canConfirm}>
|
||||
Switch
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider column */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ProviderColumn({
|
||||
loading,
|
||||
error,
|
||||
providers,
|
||||
total,
|
||||
selectedSlug,
|
||||
query,
|
||||
onSelect,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
providers: ModelOptionProvider[];
|
||||
total: number;
|
||||
selectedSlug: string;
|
||||
query: string;
|
||||
onSelect(slug: string): void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-r border-border overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
|
||||
|
||||
{!loading && !error && providers.length === 0 && (
|
||||
<div className="p-4 text-xs text-muted-foreground italic">
|
||||
{query
|
||||
? "no matches"
|
||||
: total === 0
|
||||
? "no authenticated providers"
|
||||
: "no matches"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providers.map((p) => {
|
||||
const active = p.slug === selectedSlug;
|
||||
return (
|
||||
<button
|
||||
key={p.slug}
|
||||
type="button"
|
||||
onClick={() => onSelect(p.slug)}
|
||||
className={`w-full text-left px-3 py-2 text-xs border-l-2 transition-colors cursor-pointer flex items-start gap-2 ${
|
||||
active
|
||||
? "bg-primary/10 border-l-primary text-foreground"
|
||||
: "border-l-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium truncate">{p.name}</span>
|
||||
{p.is_current && <CurrentTag />}
|
||||
</div>
|
||||
<div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate">
|
||||
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Model column */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ModelColumn({
|
||||
provider,
|
||||
models,
|
||||
allModels,
|
||||
selectedModel,
|
||||
currentModel,
|
||||
currentProviderSlug,
|
||||
onSelect,
|
||||
onConfirm,
|
||||
}: {
|
||||
provider: ModelOptionProvider | null;
|
||||
models: string[];
|
||||
allModels: string[];
|
||||
selectedModel: string;
|
||||
currentModel: string;
|
||||
currentProviderSlug: string;
|
||||
onSelect(model: string): void;
|
||||
onConfirm(model: string): void;
|
||||
}) {
|
||||
if (!provider) {
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
<div className="p-4 text-xs text-muted-foreground italic">
|
||||
pick a provider →
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
{provider.warning && (
|
||||
<div className="p-3 text-xs text-destructive border-b border-border">
|
||||
{provider.warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{models.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground italic">
|
||||
{allModels.length
|
||||
? "no models match your filter"
|
||||
: "no models listed for this provider"}
|
||||
</div>
|
||||
) : (
|
||||
models.map((m) => {
|
||||
const active = m === selectedModel;
|
||||
const isCurrent =
|
||||
m === currentModel && provider.slug === currentProviderSlug;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => onSelect(m)}
|
||||
onDoubleClick={() => onConfirm(m)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors cursor-pointer flex items-center gap-2 ${
|
||||
active
|
||||
? "bg-primary/15 text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<Check
|
||||
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
||||
/>
|
||||
<span className="flex-1 truncate">{m}</span>
|
||||
{isCurrent && <CurrentTag />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentTag() {
|
||||
return (
|
||||
<span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
|
||||
current
|
||||
</span>
|
||||
);
|
||||
}
|
||||
174
web/src/components/SlashPopover.tsx
Normal file
174
web/src/components/SlashPopover.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Slash-command autocomplete popover, rendered above the composer in ChatPage.
|
||||
* Mirrors the completion UX of the Ink TUI — type `/`, see matching commands,
|
||||
* arrow keys or click to select, Tab to apply, Enter to submit.
|
||||
*
|
||||
* The parent owns all keyboard handling via `ref.handleKey`, which returns
|
||||
* true when the popover consumed the event, so the composer's Enter/arrow
|
||||
* logic stays in one place.
|
||||
*/
|
||||
|
||||
export interface CompletionItem {
|
||||
display: string;
|
||||
text: string;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface SlashPopoverHandle {
|
||||
/** Returns true if the key was consumed by the popover. */
|
||||
handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
input: string;
|
||||
gw: GatewayClient | null;
|
||||
onApply(nextInput: string): void;
|
||||
}
|
||||
|
||||
interface CompletionResponse {
|
||||
items?: CompletionItem[];
|
||||
replace_from?: number;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 60;
|
||||
|
||||
export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
|
||||
function SlashPopover({ input, gw, onApply }, ref) {
|
||||
const [items, setItems] = useState<CompletionItem[]>([]);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [replaceFrom, setReplaceFrom] = useState(1);
|
||||
const lastInputRef = useRef<string>("");
|
||||
|
||||
// Debounced completion fetch. We never clear `items` in the effect body
|
||||
// (doing so would flag react-hooks/set-state-in-effect); instead the
|
||||
// render guard below hides stale items once the input stops matching.
|
||||
useEffect(() => {
|
||||
const trimmed = input ?? "";
|
||||
|
||||
if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
|
||||
if (!trimmed.startsWith("/")) lastInputRef.current = "";
|
||||
return;
|
||||
}
|
||||
lastInputRef.current = trimmed;
|
||||
|
||||
const timer = window.setTimeout(async () => {
|
||||
if (lastInputRef.current !== trimmed) return;
|
||||
try {
|
||||
const r = await gw.request<CompletionResponse>("complete.slash", {
|
||||
text: trimmed,
|
||||
});
|
||||
if (lastInputRef.current !== trimmed) return;
|
||||
setItems(r?.items ?? []);
|
||||
setReplaceFrom(r?.replace_from ?? 1);
|
||||
setSelected(0);
|
||||
} catch {
|
||||
if (lastInputRef.current === trimmed) setItems([]);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [input, gw]);
|
||||
|
||||
const apply = useCallback(
|
||||
(item: CompletionItem) => {
|
||||
onApply(input.slice(0, replaceFrom) + item.text);
|
||||
},
|
||||
[input, replaceFrom, onApply],
|
||||
);
|
||||
|
||||
// Only consume keys when the popover is actually visible. Stale items from
|
||||
// a previous slash prefix are ignored once the user deletes the "/".
|
||||
const visible = items.length > 0 && input.startsWith("/");
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
handleKey: (e) => {
|
||||
if (!visible) return false;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelected((s) => (s + 1) % items.length);
|
||||
return true;
|
||||
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelected((s) => (s - 1 + items.length) % items.length);
|
||||
return true;
|
||||
|
||||
case "Tab": {
|
||||
e.preventDefault();
|
||||
const item = items[selected];
|
||||
if (item) apply(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setItems([]);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[visible, items, selected, apply],
|
||||
);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
|
||||
role="listbox"
|
||||
>
|
||||
{items.map((it, i) => {
|
||||
const active = i === selected;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${it.text}-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
onClick={() => apply(it)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left cursor-pointer transition-colors ${
|
||||
active
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
||||
/>
|
||||
|
||||
<span className="font-mono text-xs shrink-0 truncate">
|
||||
{it.display}
|
||||
</span>
|
||||
|
||||
{it.meta && (
|
||||
<span className="text-[0.7rem] text-muted-foreground/70 truncate ml-auto">
|
||||
{it.meta}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
228
web/src/components/ToolCall.tsx
Normal file
228
web/src/components/ToolCall.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Expandable tool call row — the web equivalent of Ink's ToolTrail node.
|
||||
*
|
||||
* Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress`
|
||||
* in between) as a single collapsible item in the transcript:
|
||||
*
|
||||
* ▸ ● read_file(path=/foo) 2.3s
|
||||
*
|
||||
* Click the header to reveal a preformatted body with context (args), the
|
||||
* streaming preview (while running), and the final summary or error. Error
|
||||
* rows auto-expand so failures aren't silently collapsed.
|
||||
*/
|
||||
|
||||
export interface ToolEntry {
|
||||
kind: "tool";
|
||||
id: string;
|
||||
tool_id: string;
|
||||
name: string;
|
||||
context?: string;
|
||||
preview?: string;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
inline_diff?: string;
|
||||
status: "running" | "done" | "error";
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<ToolEntry["status"], string> = {
|
||||
running: "border-primary/40 bg-primary/[0.04]",
|
||||
done: "border-border bg-muted/20",
|
||||
error: "border-destructive/50 bg-destructive/[0.04]",
|
||||
};
|
||||
|
||||
const BULLET_TONE: Record<ToolEntry["status"], string> = {
|
||||
running: "text-primary",
|
||||
done: "text-primary/80",
|
||||
error: "text-destructive",
|
||||
};
|
||||
|
||||
const TICK_MS = 500;
|
||||
|
||||
export function ToolCall({ tool }: { tool: ToolEntry }) {
|
||||
// `open` is derived: errors default-expanded, everything else collapsed.
|
||||
// `null` means "follow the default"; any explicit bool is the user's override.
|
||||
// This lets a running tool flip to expanded automatically when it errors,
|
||||
// without mirroring state in an effect.
|
||||
const [userOverride, setUserOverride] = useState<boolean | null>(null);
|
||||
const open = userOverride ?? tool.status === "error";
|
||||
|
||||
// Tick `now` while the tool is running so the elapsed label updates live.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (tool.status !== "running") return;
|
||||
const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [tool.status]);
|
||||
|
||||
// Historical tools (hydrated from session.resume) signal missing timestamps
|
||||
// with `startedAt === 0`; we hide the elapsed badge for those rather than
|
||||
// rendering a misleading "0ms".
|
||||
const hasTimestamps = tool.startedAt > 0;
|
||||
const elapsed = hasTimestamps
|
||||
? fmtElapsed((tool.completedAt ?? now) - tool.startedAt)
|
||||
: null;
|
||||
|
||||
const hasBody = !!(
|
||||
tool.context ||
|
||||
tool.preview ||
|
||||
tool.summary ||
|
||||
tool.error ||
|
||||
tool.inline_diff
|
||||
);
|
||||
|
||||
const Chevron = open ? ChevronDown : ChevronRight;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserOverride(!open)}
|
||||
disabled={!hasBody}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-foreground/2 disabled:cursor-default cursor-pointer transition-colors"
|
||||
>
|
||||
{hasBody ? (
|
||||
<Chevron className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
|
||||
<Zap className={`h-3 w-3 shrink-0 ${BULLET_TONE[tool.status]}`} />
|
||||
|
||||
<span className="font-mono font-medium shrink-0">{tool.name}</span>
|
||||
|
||||
<span className="font-mono text-muted-foreground/80 truncate min-w-0 flex-1">
|
||||
{tool.context ?? ""}
|
||||
</span>
|
||||
|
||||
{tool.status === "running" && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full bg-primary animate-pulse shrink-0"
|
||||
title="running"
|
||||
/>
|
||||
)}
|
||||
{tool.status === "error" && (
|
||||
<AlertCircle
|
||||
className="h-3 w-3 shrink-0 text-destructive"
|
||||
aria-label="error"
|
||||
/>
|
||||
)}
|
||||
{tool.status === "done" && (
|
||||
<Check
|
||||
className="h-3 w-3 shrink-0 text-primary/80"
|
||||
aria-label="done"
|
||||
/>
|
||||
)}
|
||||
|
||||
{elapsed && (
|
||||
<span className="font-mono text-[0.65rem] text-muted-foreground tabular-nums shrink-0">
|
||||
{elapsed}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && hasBody && (
|
||||
<div className="border-t border-border/60 px-3 py-2 space-y-2 text-xs font-mono">
|
||||
{tool.context && <Section label="context">{tool.context}</Section>}
|
||||
|
||||
{tool.preview && tool.status === "running" && (
|
||||
<Section label="streaming">
|
||||
{tool.preview}
|
||||
<span className="inline-block w-1.5 h-3 align-middle bg-foreground/40 ml-0.5 animate-pulse" />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{tool.inline_diff && (
|
||||
<Section label="diff">
|
||||
<pre className="whitespace-pre overflow-x-auto text-[0.7rem] leading-snug">
|
||||
{colorizeDiff(tool.inline_diff)}
|
||||
</pre>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{tool.summary && (
|
||||
<Section label="result">
|
||||
<span className="text-foreground/90 whitespace-pre-wrap">
|
||||
{tool.summary}
|
||||
</span>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{tool.error && (
|
||||
<Section label="error" tone="error">
|
||||
<span className="text-destructive whitespace-pre-wrap">
|
||||
{tool.error}
|
||||
</span>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
tone?: "error";
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span
|
||||
className={`uppercase tracking-wider text-[0.6rem] shrink-0 w-14 pt-0.5 ${
|
||||
tone === "error" ? "text-destructive/80" : "text-muted-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0 text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtElapsed(ms: number): string {
|
||||
const sec = Math.max(0, ms) / 1000;
|
||||
if (sec < 1) return `${Math.round(ms)}ms`;
|
||||
if (sec < 10) return `${sec.toFixed(1)}s`;
|
||||
if (sec < 60) return `${Math.round(sec)}s`;
|
||||
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.round(sec % 60);
|
||||
return s ? `${m}m ${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Colorize unified-diff lines for the inline diff section. */
|
||||
function colorizeDiff(diff: string): React.ReactNode {
|
||||
return diff.split("\n").map((line, i) => (
|
||||
<div key={i} className={diffLineClass(line)}>
|
||||
{line || "\u00A0"}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
function diffLineClass(line: string): string {
|
||||
if (line.startsWith("+") && !line.startsWith("+++"))
|
||||
return "text-emerald-500 dark:text-emerald-400";
|
||||
if (line.startsWith("-") && !line.startsWith("---"))
|
||||
return "text-destructive";
|
||||
if (line.startsWith("@@")) return "text-primary";
|
||||
return "text-muted-foreground/80";
|
||||
}
|
||||
232
web/src/lib/gatewayClient.ts
Normal file
232
web/src/lib/gatewayClient.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Browser WebSocket client for the tui_gateway JSON-RPC protocol.
|
||||
*
|
||||
* Speaks the exact same newline-delimited JSON-RPC dialect that the Ink TUI
|
||||
* drives over stdio. The server-side transport abstraction
|
||||
* (tui_gateway/transport.py + ws.py) routes the same dispatcher's writes
|
||||
* onto either stdout or a WebSocket depending on how the client connected.
|
||||
*
|
||||
* const gw = new GatewayClient()
|
||||
* await gw.connect()
|
||||
* const { session_id } = await gw.request<{ session_id: string }>("session.create")
|
||||
* gw.on("message.delta", (ev) => console.log(ev.payload?.text))
|
||||
* await gw.request("prompt.submit", { session_id, text: "hi" })
|
||||
*/
|
||||
|
||||
export type GatewayEventName =
|
||||
| "gateway.ready"
|
||||
| "session.info"
|
||||
| "message.start"
|
||||
| "message.delta"
|
||||
| "message.complete"
|
||||
| "thinking.delta"
|
||||
| "reasoning.delta"
|
||||
| "reasoning.available"
|
||||
| "status.update"
|
||||
| "tool.start"
|
||||
| "tool.progress"
|
||||
| "tool.complete"
|
||||
| "tool.generating"
|
||||
| "clarify.request"
|
||||
| "approval.request"
|
||||
| "sudo.request"
|
||||
| "secret.request"
|
||||
| "background.complete"
|
||||
| "btw.complete"
|
||||
| "error"
|
||||
| "skin.changed"
|
||||
| (string & {});
|
||||
|
||||
export interface GatewayEvent<P = unknown> {
|
||||
type: GatewayEventName;
|
||||
session_id?: string;
|
||||
payload?: P;
|
||||
}
|
||||
|
||||
export type ConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "open"
|
||||
| "closed"
|
||||
| "error";
|
||||
|
||||
interface Pending {
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Wildcard listener key: subscribe to every event regardless of type. */
|
||||
const ANY = "*";
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private reqId = 0;
|
||||
private pending = new Map<string, Pending>();
|
||||
private listeners = new Map<string, Set<(ev: GatewayEvent) => void>>();
|
||||
private _state: ConnectionState = "idle";
|
||||
private stateListeners = new Set<(s: ConnectionState) => void>();
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
private setState(s: ConnectionState) {
|
||||
if (this._state === s) return;
|
||||
this._state = s;
|
||||
for (const cb of this.stateListeners) cb(s);
|
||||
}
|
||||
|
||||
onState(cb: (s: ConnectionState) => void): () => void {
|
||||
this.stateListeners.add(cb);
|
||||
cb(this._state);
|
||||
return () => this.stateListeners.delete(cb);
|
||||
}
|
||||
|
||||
/** Subscribe to a specific event type. Returns an unsubscribe function. */
|
||||
on<P = unknown>(
|
||||
type: GatewayEventName,
|
||||
cb: (ev: GatewayEvent<P>) => void,
|
||||
): () => void {
|
||||
let set = this.listeners.get(type);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(type, set);
|
||||
}
|
||||
set.add(cb as (ev: GatewayEvent) => void);
|
||||
return () => set!.delete(cb as (ev: GatewayEvent) => void);
|
||||
}
|
||||
|
||||
/** Subscribe to every event (fires after type-specific listeners). */
|
||||
onAny(cb: (ev: GatewayEvent) => void): () => void {
|
||||
return this.on(ANY as GatewayEventName, cb);
|
||||
}
|
||||
|
||||
async connect(token?: string): Promise<void> {
|
||||
if (this._state === "open" || this._state === "connecting") return;
|
||||
this.setState("connecting");
|
||||
|
||||
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!resolved) {
|
||||
this.setState("error");
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard",
|
||||
);
|
||||
}
|
||||
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${scheme}//${location.host}/api/ws?token=${encodeURIComponent(resolved)}`,
|
||||
);
|
||||
this.ws = ws;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
ws.removeEventListener("error", onError);
|
||||
this.setState("open");
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
ws.removeEventListener("open", onOpen);
|
||||
this.setState("error");
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
};
|
||||
ws.addEventListener("open", onOpen, { once: true });
|
||||
ws.addEventListener("error", onError, { once: true });
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
try {
|
||||
this.dispatch(JSON.parse(ev.data));
|
||||
} catch {
|
||||
/* malformed frame — ignore */
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
this.setState("closed");
|
||||
this.rejectAllPending(new Error("WebSocket closed"));
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
private dispatch(msg: Record<string, unknown>) {
|
||||
const id = msg.id as string | undefined;
|
||||
|
||||
if (id !== undefined && this.pending.has(id)) {
|
||||
const p = this.pending.get(id)!;
|
||||
this.pending.delete(id);
|
||||
clearTimeout(p.timer);
|
||||
|
||||
const err = msg.error as { message?: string } | undefined;
|
||||
if (err) p.reject(new Error(err.message ?? "request failed"));
|
||||
else p.resolve(msg.result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method !== "event") return;
|
||||
|
||||
const params = (msg.params ?? {}) as GatewayEvent;
|
||||
if (typeof params.type !== "string") return;
|
||||
|
||||
for (const cb of this.listeners.get(params.type) ?? []) cb(params);
|
||||
for (const cb of this.listeners.get(ANY) ?? []) cb(params);
|
||||
}
|
||||
|
||||
private rejectAllPending(err: Error) {
|
||||
for (const p of this.pending.values()) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
/** Send a JSON-RPC request. Rejects on error response or timeout. */
|
||||
request<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
if (!this.ws || this._state !== "open") {
|
||||
return Promise.reject(
|
||||
new Error(`gateway not connected (state=${this._state})`),
|
||||
);
|
||||
}
|
||||
|
||||
const id = `w${++this.reqId}`;
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pending.delete(id)) {
|
||||
reject(new Error(`request timed out: ${method}`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve: (v) => resolve(v as T),
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
|
||||
try {
|
||||
this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(id);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
}
|
||||
}
|
||||
163
web/src/lib/slashExec.ts
Normal file
163
web/src/lib/slashExec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Slash command execution pipeline for the web chat.
|
||||
*
|
||||
* Mirrors the Ink TUI's createSlashHandler.ts:
|
||||
*
|
||||
* 1. Parse the command into `name` + `arg`.
|
||||
* 2. Try `slash.exec` — covers every registry-backed command the terminal
|
||||
* UI knows about (/help, /resume, /compact, /model, …). Output is
|
||||
* rendered into the transcript.
|
||||
* 3. If `slash.exec` errors (command rejected, unknown, or needs client
|
||||
* behaviour), fall back to `command.dispatch` which returns a typed
|
||||
* directive: `exec` | `plugin` | `alias` | `skill` | `send`.
|
||||
* 4. Each directive is dispatched to the appropriate callback.
|
||||
*
|
||||
* Keeping the pipeline here (instead of inline in ChatPage) lets future
|
||||
* clients (SwiftUI, Android) implement the same logic by reading the same
|
||||
* contract.
|
||||
*/
|
||||
|
||||
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
|
||||
export interface SlashExecResponse {
|
||||
output?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export type CommandDispatchResponse =
|
||||
| { type: "exec" | "plugin"; output?: string }
|
||||
| { type: "alias"; target: string }
|
||||
| { type: "skill"; name: string; message?: string }
|
||||
| { type: "send"; message: string };
|
||||
|
||||
export interface SlashExecCallbacks {
|
||||
/** Render a transcript system message. */
|
||||
sys(text: string): void;
|
||||
/** Submit a user message to the agent (prompt.submit). */
|
||||
send(message: string): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface SlashExecOptions {
|
||||
/** Raw command including the leading slash (e.g. "/model opus-4.6"). */
|
||||
command: string;
|
||||
/** Session id. If empty the call is still issued — some commands are session-less. */
|
||||
sessionId: string;
|
||||
gw: GatewayClient;
|
||||
callbacks: SlashExecCallbacks;
|
||||
}
|
||||
|
||||
export type SlashExecResult = "done" | "sent" | "error";
|
||||
|
||||
/**
|
||||
* Run a slash command. Returns the terminal state so callers can decide
|
||||
* whether to clear the composer, queue retries, etc.
|
||||
*/
|
||||
export async function executeSlash({
|
||||
command,
|
||||
sessionId,
|
||||
gw,
|
||||
callbacks: { sys, send },
|
||||
}: SlashExecOptions): Promise<SlashExecResult> {
|
||||
const { name, arg } = parseSlash(command);
|
||||
|
||||
if (!name) {
|
||||
sys("empty slash command");
|
||||
return "error";
|
||||
}
|
||||
|
||||
// Primary dispatcher.
|
||||
try {
|
||||
const r = await gw.request<SlashExecResponse>("slash.exec", {
|
||||
command: command.replace(/^\/+/, ""),
|
||||
session_id: sessionId,
|
||||
});
|
||||
const body = r?.output || `/${name}: no output`;
|
||||
sys(r?.warning ? `warning: ${r.warning}\n${body}` : body);
|
||||
return "done";
|
||||
} catch {
|
||||
/* fall through to command.dispatch */
|
||||
}
|
||||
|
||||
try {
|
||||
const d = parseCommandDispatch(
|
||||
await gw.request<unknown>("command.dispatch", {
|
||||
name,
|
||||
arg,
|
||||
session_id: sessionId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!d) {
|
||||
sys("error: invalid response: command.dispatch");
|
||||
return "error";
|
||||
}
|
||||
|
||||
switch (d.type) {
|
||||
case "exec":
|
||||
case "plugin":
|
||||
sys(d.output ?? "(no output)");
|
||||
return "done";
|
||||
|
||||
case "alias":
|
||||
return executeSlash({
|
||||
command: `/${d.target}${arg ? ` ${arg}` : ""}`,
|
||||
sessionId,
|
||||
gw,
|
||||
callbacks: { sys, send },
|
||||
});
|
||||
|
||||
case "skill":
|
||||
case "send": {
|
||||
const msg = d.message?.trim() ?? "";
|
||||
if (!msg) {
|
||||
sys(
|
||||
`/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`,
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`);
|
||||
await send(msg);
|
||||
return "sent";
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sys(`error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSlash(command: string): { name: string; arg: string } {
|
||||
const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/);
|
||||
return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" };
|
||||
}
|
||||
|
||||
function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const r = raw as Record<string, unknown>;
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
||||
|
||||
switch (r.type) {
|
||||
case "exec":
|
||||
case "plugin":
|
||||
return { type: r.type, output: str(r.output) };
|
||||
|
||||
case "alias":
|
||||
return typeof r.target === "string"
|
||||
? { type: "alias", target: r.target }
|
||||
: null;
|
||||
|
||||
case "skill":
|
||||
return typeof r.name === "string"
|
||||
? { type: "skill", name: r.name, message: str(r.message) }
|
||||
: null;
|
||||
|
||||
case "send":
|
||||
return typeof r.message === "string"
|
||||
? { type: "send", message: r.message }
|
||||
: null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
752
web/src/pages/ChatPage.tsx
Normal file
752
web/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,752 @@
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
||||
import {
|
||||
SlashPopover,
|
||||
type SlashPopoverHandle,
|
||||
} from "@/components/SlashPopover";
|
||||
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
|
||||
import { executeSlash } from "@/lib/slashExec";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Heart,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
/*
|
||||
* Chat — the "Ink TUI in a browser" proof.
|
||||
*
|
||||
* Drives the exact same tui_gateway JSON-RPC surface Ink drives over stdio,
|
||||
* but over a WebSocket served by hermes_cli/web_server.py. Covers message
|
||||
* streaming, tool calls, interrupts, slash commands, and model switching.
|
||||
* Approvals / clarify / resume picker / attachments are still TODO; the
|
||||
* event listeners on GatewayClient give type-safe hooks for each.
|
||||
*/
|
||||
|
||||
type MessageRole = "user" | "assistant" | "system";
|
||||
|
||||
interface TextMessage {
|
||||
kind: "message";
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
text: string;
|
||||
streaming?: boolean;
|
||||
rendered?: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
type ChatEntry = TextMessage | ToolEntry;
|
||||
|
||||
/** Shape of messages returned by session.resume — see _history_to_messages in tui_gateway/server.py. */
|
||||
interface HydratedMessage {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
text?: string;
|
||||
name?: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
interface SessionResumeResponse {
|
||||
session_id: string;
|
||||
resumed: string;
|
||||
message_count: number;
|
||||
messages: HydratedMessage[];
|
||||
info?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
cwd?: string;
|
||||
tools?: Record<string, unknown>;
|
||||
skills?: Record<string, unknown>;
|
||||
credential_warning?: string;
|
||||
}
|
||||
|
||||
const STATE_LABEL: Record<ConnectionState, string> = {
|
||||
idle: "idle",
|
||||
connecting: "connecting",
|
||||
open: "connected",
|
||||
closed: "closed",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const STATE_TONE: Record<ConnectionState, string> = {
|
||||
idle: "bg-muted text-muted-foreground",
|
||||
connecting: "bg-primary/10 text-primary",
|
||||
open: "bg-emerald-500/10 text-emerald-500 dark:text-emerald-400",
|
||||
closed: "bg-muted text-muted-foreground",
|
||||
error: "bg-destructive/10 text-destructive",
|
||||
};
|
||||
|
||||
const randId = (prefix: string) =>
|
||||
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Mirror ui-tui/src/app/useMainApp.ts — same regex, same palette, same beat.
|
||||
// Web parity with the Ink TUI's GoodVibesHeart easter egg: a thank-you pulses
|
||||
// a heart next to the connection badge.
|
||||
const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i;
|
||||
const HEART_COLORS = ["#ff5fa2", "#ff4d6d", "#ffbd38"];
|
||||
|
||||
export default function ChatPage() {
|
||||
const gwRef = useRef<GatewayClient | null>(null);
|
||||
const slashRef = useRef<SlashPopoverHandle | null>(null);
|
||||
const transcriptEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const resumeId = searchParams.get("resume") ?? "";
|
||||
|
||||
const [connState, setConnState] = useState<ConnectionState>("idle");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
|
||||
const [entries, setEntries] = useState<ChatEntry[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [connectError, setConnectError] = useState("");
|
||||
const [runtimeError, setRuntimeError] = useState("");
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
||||
const [goodVibesTick, setGoodVibesTick] = useState(0);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Entry helpers */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/** Replace the most recent streaming assistant message, if any. */
|
||||
const updateStreamingAssistant = useCallback(
|
||||
(fn: (m: TextMessage) => TextMessage) => {
|
||||
setEntries((list) => {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const e = list[i];
|
||||
if (e.kind === "message" && e.role === "assistant" && e.streaming) {
|
||||
const next = list.slice();
|
||||
next[i] = fn(e);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const pushMessage = useCallback(
|
||||
(role: MessageRole, text: string, extra: Partial<TextMessage> = {}) => {
|
||||
setEntries((list) => [
|
||||
...list,
|
||||
{ kind: "message", id: randId(role[0]), role, text, ...extra },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const pushSystem = useCallback(
|
||||
(text: string) => pushMessage("system", text),
|
||||
[pushMessage],
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Bootstrap: connect, wire events, open or resume a session */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
setEntries([]);
|
||||
setSessionId("");
|
||||
setSessionInfo(null);
|
||||
setBusy(false);
|
||||
setConnectError("");
|
||||
setRuntimeError("");
|
||||
|
||||
const gw = gwRef.current ?? new GatewayClient();
|
||||
gwRef.current = gw;
|
||||
|
||||
gw.onState(setConnState);
|
||||
|
||||
gw.on<SessionInfo>("session.info", (ev) => {
|
||||
if (ev.payload) setSessionInfo(ev.payload);
|
||||
});
|
||||
|
||||
gw.on("message.start", () => {
|
||||
pushMessage("assistant", "", { streaming: true });
|
||||
setBusy(true);
|
||||
});
|
||||
|
||||
gw.on<{ text?: string; rendered?: string }>("message.delta", (ev) => {
|
||||
const d = ev.payload?.text ?? "";
|
||||
if (!d) return;
|
||||
updateStreamingAssistant((m) => ({ ...m, text: m.text + d }));
|
||||
});
|
||||
|
||||
gw.on<{ text?: string; rendered?: string; reasoning?: string }>(
|
||||
"message.complete",
|
||||
(ev) => {
|
||||
updateStreamingAssistant((m) => ({
|
||||
...m,
|
||||
text: ev.payload?.text ?? m.text,
|
||||
rendered: ev.payload?.rendered,
|
||||
streaming: false,
|
||||
}));
|
||||
setBusy(false);
|
||||
},
|
||||
);
|
||||
|
||||
gw.on<{ tool_id: string; name?: string; context?: string }>(
|
||||
"tool.start",
|
||||
(ev) => {
|
||||
if (!ev.payload) return;
|
||||
const { tool_id, name, context } = ev.payload;
|
||||
|
||||
// Insert tool rows BEFORE the current streaming assistant bubble so
|
||||
// the transcript reads "user → tools → final message" rather than
|
||||
// "empty bubble → tool → bubble filling in". If there's no streaming
|
||||
// assistant (tool fired before message.start, or no message at all),
|
||||
// append to the end.
|
||||
const row: ToolEntry = {
|
||||
kind: "tool",
|
||||
id: `t-${tool_id}`,
|
||||
tool_id,
|
||||
name: name ?? "tool",
|
||||
context,
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
setEntries((list) => {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const e = list[i];
|
||||
if (e.kind === "message" && e.role === "assistant" && e.streaming) {
|
||||
return [...list.slice(0, i), row, ...list.slice(i)];
|
||||
}
|
||||
}
|
||||
return [...list, row];
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
gw.on<{ name?: string; preview?: string }>("tool.progress", (ev) => {
|
||||
const name = ev.payload?.name ?? "";
|
||||
const preview = ev.payload?.preview ?? "";
|
||||
if (!name || !preview) return;
|
||||
|
||||
// Update the most recent running tool entry with this name.
|
||||
setEntries((list) => {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const e = list[i];
|
||||
if (e.kind === "tool" && e.status === "running" && e.name === name) {
|
||||
const next = list.slice();
|
||||
next[i] = { ...e, preview };
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
});
|
||||
});
|
||||
|
||||
gw.on<{
|
||||
tool_id: string;
|
||||
name?: string;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
inline_diff?: string;
|
||||
}>("tool.complete", (ev) => {
|
||||
if (!ev.payload) return;
|
||||
const { tool_id, summary, error, inline_diff } = ev.payload;
|
||||
|
||||
setEntries((list) =>
|
||||
list.map((e) =>
|
||||
e.kind === "tool" && e.tool_id === tool_id
|
||||
? {
|
||||
...e,
|
||||
status: error ? "error" : "done",
|
||||
summary: summary ?? (error ? undefined : e.summary),
|
||||
error: error ?? e.error,
|
||||
inline_diff: inline_diff ?? e.inline_diff,
|
||||
completedAt: Date.now(),
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
gw.on<{ message?: string }>("error", (ev) => {
|
||||
setRuntimeError(ev.payload?.message ?? "unknown error");
|
||||
setBusy(false);
|
||||
});
|
||||
|
||||
try {
|
||||
await gw.connect();
|
||||
|
||||
if (resumeId) {
|
||||
const resp = await gw.request<SessionResumeResponse>("session.resume", {
|
||||
session_id: resumeId,
|
||||
cols: 100,
|
||||
});
|
||||
setSessionId(resp.session_id);
|
||||
setEntries(hydrateMessages(resp.messages ?? []));
|
||||
pushSystem(
|
||||
`resumed session ${resp.resumed} · ${resp.message_count ?? resp.messages?.length ?? 0} messages`,
|
||||
);
|
||||
// NOTE: intentionally NOT clearing the ?resume= param. Doing so
|
||||
// flips `resumeId` back to "" which is a dep of the bootstrap
|
||||
// effect, re-triggering cleanup + a fresh session.create and
|
||||
// wiping the transcript we just hydrated.
|
||||
} else {
|
||||
const { session_id } = await gw.request<{ session_id: string }>(
|
||||
"session.create",
|
||||
{ cols: 100 },
|
||||
);
|
||||
setSessionId(session_id);
|
||||
}
|
||||
} catch (err) {
|
||||
setConnectError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [pushMessage, pushSystem, resumeId, updateStreamingAssistant]);
|
||||
|
||||
// Rebootstrap whenever the resume target changes. React Router keeps the
|
||||
// component mounted when the search params flip, so navigating to
|
||||
// /chat?resume=X from within the app must tear down the current WS
|
||||
// connection and open a fresh session.
|
||||
useEffect(() => {
|
||||
bootstrap();
|
||||
return () => {
|
||||
gwRef.current?.close();
|
||||
gwRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resumeId]);
|
||||
|
||||
useEffect(() => {
|
||||
transcriptEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
}, [entries]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Submission */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const submitUserMessage = useCallback(
|
||||
async (text: string) => {
|
||||
const gw = gwRef.current;
|
||||
const trimmed = text.trim();
|
||||
if (!gw || !sessionId || !trimmed) return;
|
||||
|
||||
pushMessage("user", trimmed);
|
||||
setRuntimeError("");
|
||||
|
||||
try {
|
||||
await gw.request("prompt.submit", {
|
||||
session_id: sessionId,
|
||||
text: trimmed,
|
||||
});
|
||||
} catch (err) {
|
||||
setRuntimeError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
updateStreamingAssistant((m) => ({
|
||||
...m,
|
||||
streaming: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[sessionId, pushMessage, updateStreamingAssistant],
|
||||
);
|
||||
|
||||
const submitSlash = useCallback(
|
||||
async (command: string) => {
|
||||
const gw = gwRef.current;
|
||||
if (!gw || !sessionId) return;
|
||||
|
||||
pushSystem(command);
|
||||
await executeSlash({
|
||||
command,
|
||||
sessionId,
|
||||
gw,
|
||||
callbacks: { sys: pushSystem, send: submitUserMessage },
|
||||
});
|
||||
},
|
||||
[sessionId, pushSystem, submitUserMessage],
|
||||
);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const text = draft.trim();
|
||||
if (!text || busy || !sessionId) return;
|
||||
|
||||
setDraft("");
|
||||
if (!text.startsWith("/") && GOOD_VIBES_RE.test(text)) {
|
||||
setGoodVibesTick((v) => v + 1);
|
||||
}
|
||||
await (text.startsWith("/") ? submitSlash(text) : submitUserMessage(text));
|
||||
}, [busy, draft, sessionId, submitSlash, submitUserMessage]);
|
||||
|
||||
const interrupt = useCallback(() => {
|
||||
gwRef.current
|
||||
?.request("session.interrupt", { session_id: sessionId })
|
||||
.catch(() => {
|
||||
/* resync on next status event */
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Render */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const canSend =
|
||||
connState === "open" && !!sessionId && !busy && draft.trim().length > 0;
|
||||
const canPickModel = connState === "open" && !!sessionId;
|
||||
const placeholder =
|
||||
connState !== "open"
|
||||
? "waiting for gateway…"
|
||||
: busy
|
||||
? "agent is running — press Interrupt to stop, or queue a follow-up"
|
||||
: "message hermes… (Enter to send, Shift+Enter for newline, / for commands)";
|
||||
|
||||
return (
|
||||
// Opt out of the App root's `font-mondwest uppercase` — the dashboard
|
||||
// uses pixel-display caps for chrome, but chat prose needs readable
|
||||
// mixed-case. `font-courier` matches the terminal aesthetic without
|
||||
// fighting the rest of the app's typography.
|
||||
<div className="flex flex-col gap-4 h-[calc(100vh-8rem)] font-courier normal-case">
|
||||
<header className="flex flex-wrap items-center gap-2 justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className={STATE_TONE[connState]}>
|
||||
<span className="mr-1 h-1.5 w-1.5 rounded-full bg-current inline-block" />
|
||||
{STATE_LABEL[connState]}
|
||||
</Badge>
|
||||
|
||||
<GoodVibesHeart tick={goodVibesTick} />
|
||||
|
||||
<ModelBadge
|
||||
model={sessionInfo?.model}
|
||||
enabled={canPickModel}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
/>
|
||||
|
||||
{sessionId && (
|
||||
<button
|
||||
onClick={() =>
|
||||
navigator.clipboard?.writeText(sessionId).catch(() => {})
|
||||
}
|
||||
className="inline-flex items-center gap-1 font-mono text-[0.7rem] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
title="Copy session id"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
{sessionId}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{busy && (
|
||||
<Button onClick={interrupt} variant="outline" size="sm">
|
||||
<Square className="h-3 w-3 mr-1" fill="currentColor" />
|
||||
Interrupt
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button onClick={bootstrap} variant="ghost" size="sm">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Reset session
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{connectError && (
|
||||
<Card className="p-3 border-destructive/50 bg-destructive/5 text-sm flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-destructive" />
|
||||
<div>
|
||||
<div className="font-medium text-destructive">
|
||||
Can't connect to gateway
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs mt-0.5">
|
||||
{connectError}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-3">
|
||||
{entries.length === 0 && !connectError && (
|
||||
<EmptyState connState={connState} cwd={sessionInfo?.cwd} />
|
||||
)}
|
||||
|
||||
{entries.map((entry) =>
|
||||
entry.kind === "tool" ? (
|
||||
<ToolCall key={entry.id} tool={entry} />
|
||||
) : (
|
||||
<MessageRow key={entry.id} message={entry} />
|
||||
),
|
||||
)}
|
||||
|
||||
{runtimeError && (
|
||||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{runtimeError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={transcriptEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3 sm:p-4 relative">
|
||||
<SlashPopover
|
||||
ref={slashRef}
|
||||
input={draft}
|
||||
gw={gwRef.current}
|
||||
onApply={(next) => {
|
||||
setDraft(next);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-stretch overflow-hidden rounded-md border border-border bg-background/40 transition-colors focus-within:border-foreground/30 focus-within:bg-background/60 focus-within:ring-1 focus-within:ring-foreground/20">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (slashRef.current?.handleKey(e)) return;
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-3.5 py-2.5 text-sm leading-relaxed placeholder:text-muted-foreground/50 focus:outline-none min-h-[40px] max-h-[200px] disabled:opacity-50"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
disabled={connState !== "open"}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={!canSend}
|
||||
aria-label="Send message"
|
||||
className="shrink-0 w-11 flex items-center justify-center border-l border-border bg-foreground/90 text-background transition-colors cursor-pointer hover:bg-foreground active:bg-foreground/80 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/90"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{modelPickerOpen && gwRef.current && (
|
||||
<ModelPickerDialog
|
||||
gw={gwRef.current}
|
||||
sessionId={sessionId}
|
||||
onClose={() => setModelPickerOpen(false)}
|
||||
onSubmit={submitSlash}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Subcomponents */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Port of ui-tui's GoodVibesHeart — a ♥ glows for 650ms in a random palette
|
||||
* colour every time the user says something kind. Same regex, same beat, just
|
||||
* rendered via a Lucide icon instead of an Ink Text node.
|
||||
*/
|
||||
function GoodVibesHeart({ tick }: { tick: number }) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [color, setColor] = useState(HEART_COLORS[0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tick <= 0) return;
|
||||
setColor(HEART_COLORS[Math.floor(Math.random() * HEART_COLORS.length)]);
|
||||
setActive(true);
|
||||
const id = setTimeout(() => setActive(false), 650);
|
||||
return () => clearTimeout(id);
|
||||
}, [tick]);
|
||||
|
||||
return (
|
||||
<Heart
|
||||
aria-hidden
|
||||
className={`h-4 w-4 transition-all duration-300 ${
|
||||
active ? "scale-125 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
fill={active ? color : "none"}
|
||||
style={{ color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelBadge({
|
||||
model,
|
||||
enabled,
|
||||
onClick,
|
||||
}: {
|
||||
model: string | undefined;
|
||||
enabled: boolean;
|
||||
onClick(): void;
|
||||
}) {
|
||||
const hasModel = !!model;
|
||||
const className = hasModel
|
||||
? "inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[0.7rem] hover:bg-muted hover:border-foreground/30 transition-colors cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
: "inline-flex items-center gap-1 rounded-md border border-dashed border-border px-2 py-0.5 font-mono text-[0.7rem] text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => enabled && onClick()}
|
||||
disabled={!enabled}
|
||||
title="Click to switch model (same as /model)"
|
||||
className={className}
|
||||
>
|
||||
{hasModel ? (
|
||||
<>
|
||||
<span>{model}</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
pick model
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
connState,
|
||||
cwd,
|
||||
}: {
|
||||
connState: ConnectionState;
|
||||
cwd: string | undefined;
|
||||
}) {
|
||||
const ready = connState === "open";
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-center px-4">
|
||||
<div className="max-w-md space-y-4">
|
||||
<div className="text-base text-foreground/80">
|
||||
{ready ? (
|
||||
<>
|
||||
hermes is ready
|
||||
<span className="ml-0.5 inline-block w-1.5 h-4 bg-foreground/60 align-middle animate-pulse" />
|
||||
</>
|
||||
) : (
|
||||
"connecting to gateway…"
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground/70 leading-relaxed">
|
||||
same agent, same tools — served over a socket.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center items-center gap-1.5 text-[0.7rem] text-muted-foreground/60 pt-1">
|
||||
<span>type</span>
|
||||
<kbd className="rounded border border-border bg-muted/40 px-1.5 py-0.5 font-mono">
|
||||
/
|
||||
</kbd>
|
||||
<span>for slash commands,</span>
|
||||
<kbd className="rounded border border-border bg-muted/40 px-1.5 py-0.5 font-mono">
|
||||
Enter
|
||||
</kbd>
|
||||
<span>to send</span>
|
||||
</div>
|
||||
|
||||
{cwd && (
|
||||
<div className="pt-2 font-mono text-[0.65rem] text-muted-foreground/40 truncate">
|
||||
cwd · {cwd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({ message }: { message: TextMessage }) {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 whitespace-pre-wrap text-sm">
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.role === "system") {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="max-w-full rounded-md border border-dashed border-border bg-muted/20 px-3 py-1.5 text-xs text-muted-foreground font-mono whitespace-pre-wrap">
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg border px-3.5 py-2.5 ${
|
||||
message.error
|
||||
? "border-destructive/50 bg-destructive/5"
|
||||
: "border-border bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{message.text ? (
|
||||
<Markdown content={message.text} streaming={message.streaming} />
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground text-sm italic">
|
||||
thinking…
|
||||
{message.streaming && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block w-[0.5em] h-[1em] align-[-0.15em] bg-foreground/50 animate-pulse"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Hydration */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function hydrateMessages(list: HydratedMessage[]): ChatEntry[] {
|
||||
return list.map(
|
||||
(m, i): ChatEntry =>
|
||||
m.role === "tool"
|
||||
? {
|
||||
kind: "tool",
|
||||
id: `h-tool-${i}`,
|
||||
tool_id: `h-tool-${i}`,
|
||||
name: m.name ?? "tool",
|
||||
context: m.context || undefined,
|
||||
status: "done",
|
||||
// Historical — no reliable timestamps in the hydrated payload.
|
||||
startedAt: 0,
|
||||
}
|
||||
: {
|
||||
kind: "message",
|
||||
id: `h-msg-${i}`,
|
||||
role: m.role,
|
||||
text: m.text ?? "",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Play,
|
||||
Search,
|
||||
Trash2,
|
||||
Clock,
|
||||
@@ -238,6 +240,7 @@ function SessionRow({
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
snippet?: string;
|
||||
@@ -245,6 +248,7 @@ function SessionRow({
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -329,6 +333,19 @@ function SessionRow({
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label="Open in chat"
|
||||
title="Open in chat"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -346,6 +363,12 @@ function SessionRow({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border bg-background/50 p-4">
|
||||
<div className="flex items-center justify-end pb-3">
|
||||
<Button size="sm" variant="outline" onClick={onOpen}>
|
||||
<Play className="h-3 w-3 mr-1.5" />
|
||||
Open in chat
|
||||
</Button>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
@@ -382,6 +405,14 @@ export default function SessionsPage() {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(id: string) => {
|
||||
navigate(`/chat?resume=${encodeURIComponent(id)}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const loadSessions = useCallback((p: number) => {
|
||||
setLoading(true);
|
||||
@@ -517,6 +548,7 @@ export default function SessionsPage() {
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => handleDelete(s.id)}
|
||||
onOpen={() => handleOpen(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,11 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": BACKEND,
|
||||
// REST endpoints + the /api/ws WebSocket (ws: true enables upgrade forwarding).
|
||||
"/api": {
|
||||
target: BACKEND,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user