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
|
from gateway.status import get_running_pid, read_runtime_status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request, WebSocket
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -69,8 +69,14 @@ app = FastAPI(title="Hermes Agent", version=__version__)
|
|||||||
# Session token for protecting sensitive endpoints (reveal).
|
# Session token for protecting sensitive endpoints (reveal).
|
||||||
# Generated fresh on every server start — dies when the process exits.
|
# 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.
|
# 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"
|
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
|
||||||
|
|
||||||
# Simple rate limiter for the reveal endpoint
|
# 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)
|
_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 before the SPA catch-all.
|
||||||
_mount_plugin_api_routes()
|
_mount_plugin_api_routes()
|
||||||
|
|
||||||
|
|||||||
@@ -1677,3 +1677,454 @@ class TestDashboardPluginManifestExtensions:
|
|||||||
plugins = web_server._get_dashboard_plugins(force_rescan=True)
|
plugins = web_server._get_dashboard_plugins(force_rescan=True)
|
||||||
entry = next(p for p in plugins if p["name"] == "mixed-slots")
|
entry = next(p for p in plugins if p["name"] == "mixed-slots")
|
||||||
assert entry["slots"] == ["sidebar", "header-right"]
|
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 atexit
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import contextvars
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -12,9 +13,17 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from hermes_cli.env_loader import load_hermes_dotenv
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -147,6 +156,12 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
|
|||||||
_real_stdout = sys.stdout
|
_real_stdout = sys.stdout
|
||||||
sys.stdout = sys.stderr
|
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:
|
class _SlashWorker:
|
||||||
"""Persistent HermesCLI subprocess for slash commands."""
|
"""Persistent HermesCLI subprocess for slash commands."""
|
||||||
@@ -266,14 +281,24 @@ def _db_unavailable_error(rid, *, code: int):
|
|||||||
|
|
||||||
|
|
||||||
def write_json(obj: dict) -> bool:
|
def write_json(obj: dict) -> bool:
|
||||||
line = json.dumps(obj, ensure_ascii=False) + "\n"
|
"""Emit one JSON frame. Routes via the most-specific transport available.
|
||||||
try:
|
|
||||||
with _stdout_lock:
|
Precedence:
|
||||||
_real_stdout.write(line)
|
|
||||||
_real_stdout.flush()
|
1. Event frames with a session id → the transport stored on that session,
|
||||||
return True
|
so async events land with the client that owns the session even if
|
||||||
except BrokenPipeError:
|
the emitting thread has no contextvar binding.
|
||||||
return False
|
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):
|
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", {}))
|
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.
|
"""Route inbound RPCs — long handlers to the pool, everything else inline.
|
||||||
|
|
||||||
Returns a response dict when handled inline. Returns None when the
|
Returns a response dict when handled inline. Returns None when the
|
||||||
handler was scheduled on the pool; the worker writes its own
|
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:
|
t = transport or _stdio_transport
|
||||||
return handle_request(req)
|
token = bind_transport(t)
|
||||||
|
try:
|
||||||
|
if req.get("method") not in _LONG_HANDLERS:
|
||||||
|
return handle_request(req)
|
||||||
|
|
||||||
def run():
|
# Snapshot the context so the pool worker sees the bound transport.
|
||||||
try:
|
ctx = contextvars.copy_context()
|
||||||
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)
|
|
||||||
|
|
||||||
_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:
|
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(),
|
"tool_progress_mode": _load_tool_progress_mode(),
|
||||||
"edit_snapshots": {},
|
"edit_snapshots": {},
|
||||||
"tool_started_at": {},
|
"tool_started_at": {},
|
||||||
|
"transport": current_transport() or _stdio_transport,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
_sessions[sid]["slash_worker"] = _SlashWorker(
|
_sessions[sid]["slash_worker"] = _SlashWorker(
|
||||||
@@ -1398,6 +1436,7 @@ def _(rid, params: dict) -> dict:
|
|||||||
"slash_worker": None,
|
"slash_worker": None,
|
||||||
"tool_progress_mode": _load_tool_progress_mode(),
|
"tool_progress_mode": _load_tool_progress_mode(),
|
||||||
"tool_started_at": {},
|
"tool_started_at": {},
|
||||||
|
"transport": current_transport() or _stdio_transport,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build() -> None:
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start the backend API server
|
# Pin a shared dev token so Vite (5173) and FastAPI (9119) agree.
|
||||||
cd ../
|
# Without this, the SPA can't authenticate against the backend in dev mode.
|
||||||
python -m hermes_cli.main web --no-open
|
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/
|
cd web/
|
||||||
npm run dev
|
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
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { Cell, Grid, SelectionSwitcher, Typography } from "@nous-research/ui";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Backdrop } from "@/components/Backdrop";
|
import { Backdrop } from "@/components/Backdrop";
|
||||||
import StatusPage from "@/pages/StatusPage";
|
import StatusPage from "@/pages/StatusPage";
|
||||||
|
import ChatPage from "@/pages/ChatPage";
|
||||||
import ConfigPage from "@/pages/ConfigPage";
|
import ConfigPage from "@/pages/ConfigPage";
|
||||||
import EnvPage from "@/pages/EnvPage";
|
import EnvPage from "@/pages/EnvPage";
|
||||||
import SessionsPage from "@/pages/SessionsPage";
|
import SessionsPage from "@/pages/SessionsPage";
|
||||||
@@ -45,6 +46,7 @@ import { useTheme } from "@/themes";
|
|||||||
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
|
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
|
||||||
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
||||||
"/": StatusPage,
|
"/": StatusPage,
|
||||||
|
"/chat": ChatPage,
|
||||||
"/sessions": SessionsPage,
|
"/sessions": SessionsPage,
|
||||||
"/analytics": AnalyticsPage,
|
"/analytics": AnalyticsPage,
|
||||||
"/logs": LogsPage,
|
"/logs": LogsPage,
|
||||||
@@ -56,6 +58,7 @@ const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
|||||||
|
|
||||||
const BUILTIN_NAV: NavItem[] = [
|
const BUILTIN_NAV: NavItem[] = [
|
||||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||||
|
{ path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal },
|
||||||
{
|
{
|
||||||
path: "/sessions",
|
path: "/sessions",
|
||||||
labelKey: "sessions",
|
labelKey: "sessions",
|
||||||
|
|||||||
@@ -1,22 +1,50 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight markdown renderer for LLM output.
|
* Lightweight markdown renderer for LLM output.
|
||||||
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
|
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
|
||||||
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
|
* 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 blocks = useMemo(() => parseBlocks(content), [content]);
|
||||||
|
const caret = streaming ? <StreamingCaret /> : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-foreground leading-relaxed space-y-2">
|
<div className="text-sm text-foreground leading-relaxed space-y-2">
|
||||||
{blocks.map((block, i) => (
|
{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>
|
</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 */
|
/* Types */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -58,7 +86,11 @@ function parseBlocks(text: string): BlockNode[] {
|
|||||||
// Heading
|
// Heading
|
||||||
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
|
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
|
||||||
if (headingMatch) {
|
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++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -124,12 +156,23 @@ function parseBlocks(text: string): BlockNode[] {
|
|||||||
/* Block renderer */
|
/* Block renderer */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) {
|
function Block({
|
||||||
|
block,
|
||||||
|
highlightTerms,
|
||||||
|
caret,
|
||||||
|
}: {
|
||||||
|
block: BlockNode;
|
||||||
|
highlightTerms?: string[];
|
||||||
|
caret?: ReactNode;
|
||||||
|
}) {
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case "code":
|
case "code":
|
||||||
return (
|
return (
|
||||||
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
|
<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>
|
</pre>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -141,25 +184,46 @@ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: s
|
|||||||
h3: "text-sm font-semibold",
|
h3: "text-sm font-semibold",
|
||||||
h4: "text-sm font-medium",
|
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":
|
case "hr":
|
||||||
return <hr className="border-border" />;
|
return (
|
||||||
|
<>
|
||||||
|
<hr className="border-border" />
|
||||||
|
{caret}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
const Tag = block.ordered ? "ol" : "ul";
|
const Tag = block.ordered ? "ol" : "ul";
|
||||||
|
const last = block.items.length - 1;
|
||||||
return (
|
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) => (
|
{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>
|
</Tag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "paragraph":
|
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[] {
|
function parseInline(text: string): InlineNode[] {
|
||||||
const nodes: InlineNode[] = [];
|
const nodes: InlineNode[] = [];
|
||||||
// Pattern priority: code > link > bold > italic > bare URL > line break
|
// 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 lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
@@ -217,7 +282,13 @@ function parseInline(text: string): InlineNode[] {
|
|||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) {
|
function InlineContent({
|
||||||
|
text,
|
||||||
|
highlightTerms,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
highlightTerms?: string[];
|
||||||
|
}) {
|
||||||
const nodes = useMemo(() => parseInline(text), [text]);
|
const nodes = useMemo(() => parseInline(text), [text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -225,17 +296,34 @@ function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?
|
|||||||
{nodes.map((node, i) => {
|
{nodes.map((node, i) => {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case "text":
|
case "text":
|
||||||
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
|
return (
|
||||||
|
<HighlightedText
|
||||||
|
key={i}
|
||||||
|
text={node.content}
|
||||||
|
terms={highlightTerms}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "code":
|
case "code":
|
||||||
return (
|
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}
|
{node.content}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
case "bold":
|
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":
|
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":
|
case "link":
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@@ -269,10 +357,12 @@ function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
|
|||||||
<>
|
<>
|
||||||
{parts.map((part, i) =>
|
{parts.map((part, i) =>
|
||||||
regex.test(part) ? (
|
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>
|
<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 { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Play,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -238,6 +240,7 @@ function SessionRow({
|
|||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
session: SessionInfo;
|
session: SessionInfo;
|
||||||
snippet?: string;
|
snippet?: string;
|
||||||
@@ -245,6 +248,7 @@ function SessionRow({
|
|||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onOpen: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -329,6 +333,19 @@ function SessionRow({
|
|||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{session.source ?? "local"}
|
{session.source ?? "local"}
|
||||||
</Badge>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -346,6 +363,12 @@ function SessionRow({
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border bg-background/50 p-4">
|
<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 && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-8">
|
<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" />
|
<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 [searching, setSearching] = useState(false);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleOpen = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
navigate(`/chat?resume=${encodeURIComponent(id)}`);
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
const loadSessions = useCallback((p: number) => {
|
const loadSessions = useCallback((p: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -517,6 +548,7 @@ export default function SessionsPage() {
|
|||||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||||
}
|
}
|
||||||
onDelete={() => handleDelete(s.id)}
|
onDelete={() => handleDelete(s.id)}
|
||||||
|
onOpen={() => handleOpen(s.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
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