mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: mobile chat in new layout
This commit is contained in:
parent
f49afd3122
commit
63975aa75b
17 changed files with 526 additions and 100 deletions
|
|
@ -6715,9 +6715,15 @@ def cmd_dashboard(args):
|
||||||
try:
|
try:
|
||||||
import fastapi # noqa: F401
|
import fastapi # noqa: F401
|
||||||
import uvicorn # noqa: F401
|
import uvicorn # noqa: F401
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
print("Web UI dependencies not installed.")
|
print("Web UI dependencies not installed (need fastapi + uvicorn).")
|
||||||
print(f"Install them with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'")
|
print(
|
||||||
|
f"Re-install the package into this interpreter so metadata updates apply:\n"
|
||||||
|
f" cd {PROJECT_ROOT}\n"
|
||||||
|
f" {sys.executable} -m pip install -e .\n"
|
||||||
|
"If `pip` is missing in this venv, use: uv pip install -e ."
|
||||||
|
)
|
||||||
|
print(f"Import error: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if "HERMES_WEB_DIST" not in os.environ:
|
if "HERMES_WEB_DIST" not in os.environ:
|
||||||
|
|
@ -6726,11 +6732,13 @@ def cmd_dashboard(args):
|
||||||
|
|
||||||
from hermes_cli.web_server import start_server
|
from hermes_cli.web_server import start_server
|
||||||
|
|
||||||
|
embedded_chat = args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1"
|
||||||
start_server(
|
start_server(
|
||||||
host=args.host,
|
host=args.host,
|
||||||
port=args.port,
|
port=args.port,
|
||||||
open_browser=not args.no_open,
|
open_browser=not args.no_open,
|
||||||
allow_public=getattr(args, "insecure", False),
|
allow_public=getattr(args, "insecure", False),
|
||||||
|
embedded_chat=embedded_chat,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8916,6 +8924,14 @@ Examples:
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
|
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
|
||||||
)
|
)
|
||||||
|
dashboard_parser.add_argument(
|
||||||
|
"--tui",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Expose the in-browser Chat tab (embedded `hermes --tui` via PTY/WebSocket). "
|
||||||
|
"Alternatively set HERMES_DASHBOARD_TUI=1."
|
||||||
|
),
|
||||||
|
)
|
||||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -96,10 +96,18 @@ class PtyBridge:
|
||||||
ordinary exec failures (missing binary, bad cwd, etc.).
|
ordinary exec failures (missing binary, bad cwd, etc.).
|
||||||
"""
|
"""
|
||||||
if not _PTY_AVAILABLE:
|
if not _PTY_AVAILABLE:
|
||||||
raise PtyUnavailableError(
|
if sys.platform.startswith("win"):
|
||||||
"Pseudo-terminals are unavailable on this platform. "
|
raise PtyUnavailableError(
|
||||||
"Hermes Agent supports Windows only via WSL."
|
"Pseudo-terminals are unavailable on this platform. "
|
||||||
)
|
"Hermes Agent supports Windows only via WSL."
|
||||||
|
)
|
||||||
|
if ptyprocess is None:
|
||||||
|
raise PtyUnavailableError(
|
||||||
|
"The `ptyprocess` package is missing. "
|
||||||
|
"Install with: pip install ptyprocess "
|
||||||
|
"(or pip install -e '.[pty]')."
|
||||||
|
)
|
||||||
|
raise PtyUnavailableError("Pseudo-terminals are unavailable.")
|
||||||
# Let caller-supplied env fully override inheritance; if they pass
|
# Let caller-supplied env fully override inheritance; if they pass
|
||||||
# None we inherit the server's env (same semantics as subprocess).
|
# None we inherit the server's env (same semantics as subprocess).
|
||||||
spawn_env = os.environ.copy() if env is None else env
|
spawn_env = os.environ.copy() if env is None else env
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ app = FastAPI(title="Hermes Agent", version=__version__)
|
||||||
_SESSION_TOKEN = secrets.token_urlsafe(32)
|
_SESSION_TOKEN = secrets.token_urlsafe(32)
|
||||||
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
|
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
|
||||||
|
|
||||||
|
# In-browser Chat tab (/chat, /api/pty, …). Off unless ``hermes dashboard --tui``
|
||||||
|
# or HERMES_DASHBOARD_TUI=1. Set from :func:`start_server`.
|
||||||
|
_DASHBOARD_EMBEDDED_CHAT_ENABLED = False
|
||||||
|
|
||||||
# Simple rate limiter for the reveal endpoint
|
# Simple rate limiter for the reveal endpoint
|
||||||
_reveal_timestamps: List[float] = []
|
_reveal_timestamps: List[float] = []
|
||||||
_REVEAL_MAX_PER_WINDOW = 5
|
_REVEAL_MAX_PER_WINDOW = 5
|
||||||
|
|
@ -2370,6 +2374,10 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
|
||||||
|
|
||||||
@app.websocket("/api/pty")
|
@app.websocket("/api/pty")
|
||||||
async def pty_ws(ws: WebSocket) -> None:
|
async def pty_ws(ws: WebSocket) -> None:
|
||||||
|
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
|
||||||
|
await ws.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
# --- auth + loopback check (before accept so we can close cleanly) ---
|
# --- auth + loopback check (before accept so we can close cleanly) ---
|
||||||
token = ws.query_params.get("token", "")
|
token = ws.query_params.get("token", "")
|
||||||
expected = _SESSION_TOKEN
|
expected = _SESSION_TOKEN
|
||||||
|
|
@ -2476,6 +2484,10 @@ async def pty_ws(ws: WebSocket) -> None:
|
||||||
|
|
||||||
@app.websocket("/api/ws")
|
@app.websocket("/api/ws")
|
||||||
async def gateway_ws(ws: WebSocket) -> None:
|
async def gateway_ws(ws: WebSocket) -> None:
|
||||||
|
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
|
||||||
|
await ws.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
token = ws.query_params.get("token", "")
|
token = ws.query_params.get("token", "")
|
||||||
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
||||||
await ws.close(code=4401)
|
await ws.close(code=4401)
|
||||||
|
|
@ -2505,6 +2517,10 @@ async def gateway_ws(ws: WebSocket) -> None:
|
||||||
|
|
||||||
@app.websocket("/api/pub")
|
@app.websocket("/api/pub")
|
||||||
async def pub_ws(ws: WebSocket) -> None:
|
async def pub_ws(ws: WebSocket) -> None:
|
||||||
|
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
|
||||||
|
await ws.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
token = ws.query_params.get("token", "")
|
token = ws.query_params.get("token", "")
|
||||||
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
||||||
await ws.close(code=4401)
|
await ws.close(code=4401)
|
||||||
|
|
@ -2531,6 +2547,10 @@ async def pub_ws(ws: WebSocket) -> None:
|
||||||
|
|
||||||
@app.websocket("/api/events")
|
@app.websocket("/api/events")
|
||||||
async def events_ws(ws: WebSocket) -> None:
|
async def events_ws(ws: WebSocket) -> None:
|
||||||
|
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
|
||||||
|
await ws.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
token = ws.query_params.get("token", "")
|
token = ws.query_params.get("token", "")
|
||||||
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
||||||
await ws.close(code=4401)
|
await ws.close(code=4401)
|
||||||
|
|
@ -2591,8 +2611,10 @@ def mount_spa(application: FastAPI):
|
||||||
def _serve_index():
|
def _serve_index():
|
||||||
"""Return index.html with the session token injected."""
|
"""Return index.html with the session token injected."""
|
||||||
html = _index_path.read_text()
|
html = _index_path.read_text()
|
||||||
|
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
||||||
token_script = (
|
token_script = (
|
||||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";</script>'
|
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
|
||||||
|
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};</script>"
|
||||||
)
|
)
|
||||||
html = html.replace("</head>", f"{token_script}</head>", 1)
|
html = html.replace("</head>", f"{token_script}</head>", 1)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
|
|
@ -3105,10 +3127,15 @@ def start_server(
|
||||||
port: int = 9119,
|
port: int = 9119,
|
||||||
open_browser: bool = True,
|
open_browser: bool = True,
|
||||||
allow_public: bool = False,
|
allow_public: bool = False,
|
||||||
|
*,
|
||||||
|
embedded_chat: bool = False,
|
||||||
):
|
):
|
||||||
"""Start the web UI server."""
|
"""Start the web UI server."""
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
global _DASHBOARD_EMBEDDED_CHAT_ENABLED
|
||||||
|
_DASHBOARD_EMBEDDED_CHAT_ENABLED = embedded_chat
|
||||||
|
|
||||||
_LOCALHOST = ("127.0.0.1", "localhost", "::1")
|
_LOCALHOST = ("127.0.0.1", "localhost", "::1")
|
||||||
if host not in _LOCALHOST and not allow_public:
|
if host not in _LOCALHOST and not allow_public:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ dependencies = [
|
||||||
"edge-tts>=7.2.7,<8",
|
"edge-tts>=7.2.7,<8",
|
||||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
||||||
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
||||||
|
# Web dashboard (`hermes dashboard` — localhost SPA + API)
|
||||||
|
"fastapi>=0.104.0,<1",
|
||||||
|
"uvicorn[standard]>=0.24.0,<1",
|
||||||
|
# Embedded Chat tab (/api/pty) on POSIX — required for PTY, not optional
|
||||||
|
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
@ -78,6 +83,7 @@ termux = [
|
||||||
]
|
]
|
||||||
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"]
|
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"]
|
||||||
feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"]
|
feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"]
|
||||||
|
# Same pins as core; kept so `hermes-agent[web]` stays a no-op alias for older docs/scripts.
|
||||||
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
||||||
rl = [
|
rl = [
|
||||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
|
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
|
||||||
|
|
|
||||||
|
|
@ -1707,6 +1707,7 @@ class TestPtyWebSocket:
|
||||||
# Avoid exec'ing the actual TUI in tests: every test below installs
|
# Avoid exec'ing the actual TUI in tests: every test below installs
|
||||||
# its own fake argv via ``ws._resolve_chat_argv``.
|
# its own fake argv via ``ws._resolve_chat_argv``.
|
||||||
self.ws_module = ws
|
self.ws_module = ws
|
||||||
|
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||||
self.token = ws._SESSION_TOKEN
|
self.token = ws._SESSION_TOKEN
|
||||||
self.client = TestClient(ws.app)
|
self.client = TestClient(ws.app)
|
||||||
|
|
||||||
|
|
@ -1719,6 +1720,15 @@ class TestPtyWebSocket:
|
||||||
q = {"token": tok, **params}
|
q = {"token": tok, **params}
|
||||||
return f"/api/pty?{urlencode(q)}"
|
return f"/api/pty?{urlencode(q)}"
|
||||||
|
|
||||||
|
def test_rejects_when_embedded_chat_disabled(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(self.ws_module, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", False)
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
with pytest.raises(WebSocketDisconnect) as exc:
|
||||||
|
with self.client.websocket_connect(self._url()):
|
||||||
|
pass
|
||||||
|
assert exc.value.code == 4403
|
||||||
|
|
||||||
def test_rejects_missing_token(self, monkeypatch):
|
def test_rejects_missing_token(self, monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
self.ws_module,
|
self.ws_module,
|
||||||
|
|
|
||||||
10
uv.lock
generated
10
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[options]
|
[options]
|
||||||
exclude-newer = "2026-04-16T11:49:00.318115Z"
|
exclude-newer = "2026-04-17T15:09:44.835508886Z"
|
||||||
exclude-newer-span = "P7D"
|
exclude-newer-span = "P7D"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1870,13 +1870,14 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermes-agent"
|
name = "hermes-agent"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "edge-tts" },
|
{ name = "edge-tts" },
|
||||||
{ name = "exa-py" },
|
{ name = "exa-py" },
|
||||||
{ name = "fal-client" },
|
{ name = "fal-client" },
|
||||||
|
{ name = "fastapi" },
|
||||||
{ name = "fire" },
|
{ name = "fire" },
|
||||||
{ name = "firecrawl-py" },
|
{ name = "firecrawl-py" },
|
||||||
{ name = "httpx", extra = ["socks"] },
|
{ name = "httpx", extra = ["socks"] },
|
||||||
|
|
@ -1884,6 +1885,7 @@ dependencies = [
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "parallel-web" },
|
{ name = "parallel-web" },
|
||||||
{ name = "prompt-toolkit" },
|
{ name = "prompt-toolkit" },
|
||||||
|
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pyjwt", extra = ["crypto"] },
|
{ name = "pyjwt", extra = ["crypto"] },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
|
@ -1891,6 +1893,7 @@ dependencies = [
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "tenacity" },
|
{ name = "tenacity" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|
@ -2059,6 +2062,7 @@ requires-dist = [
|
||||||
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
|
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
|
||||||
{ name = "exa-py", specifier = ">=2.9.0,<3" },
|
{ name = "exa-py", specifier = ">=2.9.0,<3" },
|
||||||
{ name = "fal-client", specifier = ">=0.13.1,<1" },
|
{ name = "fal-client", specifier = ">=0.13.1,<1" },
|
||||||
|
{ name = "fastapi", specifier = ">=0.104.0,<1" },
|
||||||
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
|
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
|
||||||
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" },
|
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" },
|
||||||
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
|
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
|
||||||
|
|
@ -2105,6 +2109,7 @@ requires-dist = [
|
||||||
{ name = "openai", specifier = ">=2.21.0,<3" },
|
{ name = "openai", specifier = ">=2.21.0,<3" },
|
||||||
{ name = "parallel-web", specifier = ">=0.4.2,<1" },
|
{ name = "parallel-web", specifier = ">=0.4.2,<1" },
|
||||||
{ name = "prompt-toolkit", specifier = ">=3.0.52,<4" },
|
{ name = "prompt-toolkit", specifier = ">=3.0.52,<4" },
|
||||||
|
{ name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },
|
||||||
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" },
|
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5,<3" },
|
{ name = "pydantic", specifier = ">=2.12.5,<3" },
|
||||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
|
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
|
||||||
|
|
@ -2131,6 +2136,7 @@ requires-dist = [
|
||||||
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
||||||
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" },
|
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" },
|
||||||
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" },
|
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" },
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" },
|
||||||
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
|
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
|
||||||
|
|
|
||||||
|
|
@ -65,15 +65,22 @@ import { useI18n } from "@/i18n";
|
||||||
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
||||||
import type { PluginManifest } from "@/plugins";
|
import type { PluginManifest } from "@/plugins";
|
||||||
import { useTheme } from "@/themes";
|
import { useTheme } from "@/themes";
|
||||||
|
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
return <Navigate to="/sessions" replace />;
|
return <Navigate to="/sessions" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */
|
const CHAT_NAV_ITEM: NavItem = {
|
||||||
const BUILTIN_ROUTES: Record<string, ComponentType> = {
|
path: "/chat",
|
||||||
|
labelKey: "chat",
|
||||||
|
label: "Chat",
|
||||||
|
icon: Terminal,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Built-in routes except /chat (only with `hermes dashboard --tui`). */
|
||||||
|
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||||
"/": RootRedirect,
|
"/": RootRedirect,
|
||||||
"/chat": ChatPage,
|
|
||||||
"/sessions": SessionsPage,
|
"/sessions": SessionsPage,
|
||||||
"/analytics": AnalyticsPage,
|
"/analytics": AnalyticsPage,
|
||||||
"/logs": LogsPage,
|
"/logs": LogsPage,
|
||||||
|
|
@ -84,8 +91,7 @@ const BUILTIN_ROUTES: Record<string, ComponentType> = {
|
||||||
"/docs": DocsPage,
|
"/docs": DocsPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BUILTIN_NAV: NavItem[] = [
|
const BUILTIN_NAV_REST: NavItem[] = [
|
||||||
{ path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal },
|
|
||||||
{
|
{
|
||||||
path: "/sessions",
|
path: "/sessions",
|
||||||
labelKey: "sessions",
|
labelKey: "sessions",
|
||||||
|
|
@ -170,7 +176,10 @@ function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRoutes(manifests: PluginManifest[]): Array<{
|
function buildRoutes(
|
||||||
|
builtinRoutes: Record<string, ComponentType>,
|
||||||
|
manifests: PluginManifest[],
|
||||||
|
): Array<{
|
||||||
key: string;
|
key: string;
|
||||||
path: string;
|
path: string;
|
||||||
element: ReactNode;
|
element: ReactNode;
|
||||||
|
|
@ -192,7 +201,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||||
element: ReactNode;
|
element: ReactNode;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
|
for (const [path, Component] of Object.entries(builtinRoutes)) {
|
||||||
const om = byOverride.get(path);
|
const om = byOverride.get(path);
|
||||||
if (om) {
|
if (om) {
|
||||||
routes.push({
|
routes.push({
|
||||||
|
|
@ -207,7 +216,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||||
|
|
||||||
for (const m of addons) {
|
for (const m of addons) {
|
||||||
if (m.tab.hidden) continue;
|
if (m.tab.hidden) continue;
|
||||||
if (BUILTIN_ROUTES[m.tab.path]) continue;
|
if (builtinRoutes[m.tab.path]) continue;
|
||||||
routes.push({
|
routes.push({
|
||||||
key: `plugin:${m.name}`,
|
key: `plugin:${m.name}`,
|
||||||
path: m.tab.path,
|
path: m.tab.path,
|
||||||
|
|
@ -217,7 +226,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||||
|
|
||||||
for (const m of manifests) {
|
for (const m of manifests) {
|
||||||
if (!m.tab.hidden) continue;
|
if (!m.tab.hidden) continue;
|
||||||
if (BUILTIN_ROUTES[m.tab.path] || m.tab.override) continue;
|
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
|
||||||
routes.push({
|
routes.push({
|
||||||
key: `plugin:hidden:${m.name}`,
|
key: `plugin:hidden:${m.name}`,
|
||||||
path: m.tab.path,
|
path: m.tab.path,
|
||||||
|
|
@ -236,12 +245,32 @@ export default function App() {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||||
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
|
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
|
||||||
|
const normalizedPath = pathname.replace(/\/$/, "") || "/";
|
||||||
|
const isChatRoute = normalizedPath === "/chat";
|
||||||
|
const embeddedChat = isDashboardEmbeddedChatEnabled();
|
||||||
|
|
||||||
|
const builtinRoutes = useMemo(
|
||||||
|
() => ({
|
||||||
|
...BUILTIN_ROUTES_CORE,
|
||||||
|
...(embeddedChat ? { "/chat": ChatPage } : {}),
|
||||||
|
}),
|
||||||
|
[embeddedChat],
|
||||||
|
);
|
||||||
|
|
||||||
|
const builtinNav = useMemo(
|
||||||
|
() =>
|
||||||
|
embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST,
|
||||||
|
[embeddedChat],
|
||||||
|
);
|
||||||
|
|
||||||
const navItems = useMemo(
|
const navItems = useMemo(
|
||||||
() => buildNavItems(BUILTIN_NAV, manifests),
|
() => buildNavItems(builtinNav, manifests),
|
||||||
[manifests],
|
[builtinNav, manifests],
|
||||||
|
);
|
||||||
|
const routes = useMemo(
|
||||||
|
() => buildRoutes(builtinRoutes, manifests),
|
||||||
|
[builtinRoutes, manifests],
|
||||||
);
|
);
|
||||||
const routes = useMemo(() => buildRoutes(manifests), [manifests]);
|
|
||||||
const pluginTabMeta = useMemo(
|
const pluginTabMeta = useMemo(
|
||||||
() =>
|
() =>
|
||||||
manifests
|
manifests
|
||||||
|
|
@ -468,8 +497,9 @@ export default function App() {
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
|
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
|
||||||
"px-3 sm:px-6",
|
"px-3 sm:px-6",
|
||||||
"pt-2 sm:pt-4 lg:pt-6",
|
isChatRoute
|
||||||
"pb-4 sm:pb-8",
|
? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4"
|
||||||
|
: "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8",
|
||||||
isDocsRoute && "min-h-0 flex-1",
|
isDocsRoute && "min-h-0 flex-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -477,7 +507,7 @@ export default function App() {
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full min-w-0",
|
"w-full min-w-0",
|
||||||
isDocsRoute && "min-h-0 flex flex-1 flex-col",
|
(isDocsRoute || isChatRoute) && "min-h-0 flex flex-1 flex-col",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
||||||
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
|
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
|
||||||
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
|
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
|
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
|
@ -66,9 +67,10 @@ const STATE_TONE: Record<ConnectionState, string> = {
|
||||||
|
|
||||||
interface ChatSidebarProps {
|
interface ChatSidebarProps {
|
||||||
channel: string;
|
channel: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatSidebar({ channel }: ChatSidebarProps) {
|
export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
||||||
// `version` bumps on reconnect; gw is derived so we never call setState
|
// `version` bumps on reconnect; gw is derived so we never call setState
|
||||||
// for it inside an effect (React 19's set-state-in-effect rule). The
|
// for it inside an effect (React 19's set-state-in-effect rule). The
|
||||||
// counter is the dependency on purpose — it's not read in the memo body,
|
// counter is the dependency on purpose — it's not read in the memo body,
|
||||||
|
|
@ -284,7 +286,12 @@ export function ChatSidebar({ channel }: ChatSidebarProps) {
|
||||||
const banner = error ?? info.credential_warning ?? null;
|
const banner = error ?? info.credential_warning ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-80 shrink-0 flex-col gap-3 normal-case">
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 normal-case lg:w-80",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Card className="flex items-center justify-between gap-2 px-3 py-2">
|
<Card className="flex items-center justify-between gap-2 px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ export function PageHeaderProvider({
|
||||||
);
|
);
|
||||||
const displayTitle = titleOverride ?? defaultTitle;
|
const displayTitle = titleOverride ?? defaultTitle;
|
||||||
|
|
||||||
|
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
setAfterTitle,
|
setAfterTitle,
|
||||||
|
|
@ -59,8 +61,10 @@ export function PageHeaderProvider({
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full min-w-0 flex-1 flex-col justify-center gap-2",
|
"flex h-full w-full min-w-0 flex-1 gap-2 px-3 py-2 sm:gap-3 sm:px-6 sm:py-0",
|
||||||
"px-3 py-2 sm:flex-row sm:items-center sm:gap-3 sm:px-6 sm:py-0",
|
isChatRoute
|
||||||
|
? "flex-row items-center"
|
||||||
|
: "flex-col justify-center sm:flex-row sm:items-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||||
|
|
@ -74,7 +78,12 @@ export function PageHeaderProvider({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{end ? (
|
{end ? (
|
||||||
<div className="flex w-full min-w-0 justify-end sm:max-w-md sm:flex-1">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 justify-end sm:max-w-md sm:flex-1",
|
||||||
|
isChatRoute ? "w-auto shrink-0" : "w-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{end}
|
{end}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -84,7 +93,9 @@ export function PageHeaderProvider({
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
|
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
|
||||||
"overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
isChatRoute
|
||||||
|
? "overflow-hidden"
|
||||||
|
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export const en: Translations = {
|
||||||
brand: "Hermes Agent",
|
brand: "Hermes Agent",
|
||||||
brandShort: "HA",
|
brandShort: "HA",
|
||||||
closeNavigation: "Close navigation",
|
closeNavigation: "Close navigation",
|
||||||
|
closeModelTools: "Close model and tools",
|
||||||
footer: {
|
footer: {
|
||||||
org: "Nous Research",
|
org: "Nous Research",
|
||||||
},
|
},
|
||||||
|
|
@ -76,6 +77,8 @@ export const en: Translations = {
|
||||||
sessions: "Sessions",
|
sessions: "Sessions",
|
||||||
skills: "Skills",
|
skills: "Skills",
|
||||||
},
|
},
|
||||||
|
modelToolsSheetSubtitle: "& tools",
|
||||||
|
modelToolsSheetTitle: "Model",
|
||||||
navigation: "Navigation",
|
navigation: "Navigation",
|
||||||
openDocumentation: "Open documentation in a new tab",
|
openDocumentation: "Open documentation in a new tab",
|
||||||
openNavigation: "Open navigation",
|
openNavigation: "Open navigation",
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export interface Translations {
|
||||||
brand: string;
|
brand: string;
|
||||||
brandShort: string;
|
brandShort: string;
|
||||||
closeNavigation: string;
|
closeNavigation: string;
|
||||||
|
closeModelTools: string;
|
||||||
footer: {
|
footer: {
|
||||||
org: string;
|
org: string;
|
||||||
};
|
};
|
||||||
|
|
@ -76,6 +77,8 @@ export interface Translations {
|
||||||
sessions: string;
|
sessions: string;
|
||||||
skills: string;
|
skills: string;
|
||||||
};
|
};
|
||||||
|
modelToolsSheetSubtitle: string;
|
||||||
|
modelToolsSheetTitle: string;
|
||||||
navigation: string;
|
navigation: string;
|
||||||
openDocumentation: string;
|
openDocumentation: string;
|
||||||
openNavigation: string;
|
openNavigation: string;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export const zh: Translations = {
|
||||||
brand: "Hermes Agent",
|
brand: "Hermes Agent",
|
||||||
brandShort: "HA",
|
brandShort: "HA",
|
||||||
closeNavigation: "关闭导航",
|
closeNavigation: "关闭导航",
|
||||||
|
closeModelTools: "关闭模型与工具",
|
||||||
footer: {
|
footer: {
|
||||||
org: "Nous Research",
|
org: "Nous Research",
|
||||||
},
|
},
|
||||||
|
|
@ -75,6 +76,8 @@ export const zh: Translations = {
|
||||||
sessions: "会话",
|
sessions: "会话",
|
||||||
skills: "技能",
|
skills: "技能",
|
||||||
},
|
},
|
||||||
|
modelToolsSheetSubtitle: "与工具",
|
||||||
|
modelToolsSheetTitle: "模型",
|
||||||
navigation: "导航",
|
navigation: "导航",
|
||||||
openDocumentation: "在新标签页中打开文档",
|
openDocumentation: "在新标签页中打开文档",
|
||||||
openNavigation: "打开导航",
|
openNavigation: "打开导航",
|
||||||
|
|
|
||||||
15
web/src/lib/dashboard-flags.ts
Normal file
15
web/src/lib/dashboard-flags.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/** Set true by the server only for `hermes dashboard --tui` (or HERMES_DASHBOARD_TUI=1). */
|
||||||
|
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
|
||||||
|
/** @deprecated Older injected name; treated as on when true. */
|
||||||
|
__HERMES_DASHBOARD_TUI__?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True only when the dashboard was started with embedded TUI Chat (`hermes dashboard --tui`). */
|
||||||
|
export function isDashboardEmbeddedChatEnabled(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
if (window.__HERMES_DASHBOARD_EMBEDDED_CHAT__ === true) return true;
|
||||||
|
return window.__HERMES_DASHBOARD_TUI__ === true;
|
||||||
|
}
|
||||||
|
|
@ -22,11 +22,16 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { Copy } from "lucide-react";
|
import { Typography } from "@nous-research/ui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Copy, PanelRight, X } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { ChatSidebar } from "@/components/ChatSidebar";
|
import { ChatSidebar } from "@/components/ChatSidebar";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
|
import { useI18n } from "@/i18n";
|
||||||
|
|
||||||
function buildWsUrl(
|
function buildWsUrl(
|
||||||
token: string,
|
token: string,
|
||||||
|
|
@ -62,6 +67,39 @@ const TERMINAL_THEME = {
|
||||||
selectionBackground: "#f0e6d244",
|
selectionBackground: "#f0e6d244",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS width for xterm font tiers.
|
||||||
|
*
|
||||||
|
* Prefer the terminal host's `clientWidth` — Chrome DevTools device mode often
|
||||||
|
* keeps `window.innerWidth` at the full desktop value while the *drawn* layout
|
||||||
|
* is phone-sized, which made us pick desktop font sizes (~14px) and look huge.
|
||||||
|
*/
|
||||||
|
function terminalTierWidthPx(host: HTMLElement | null): number {
|
||||||
|
if (typeof window === "undefined") return 1280;
|
||||||
|
const fromHost = host?.clientWidth ?? 0;
|
||||||
|
if (fromHost > 2) return Math.round(fromHost);
|
||||||
|
const doc = document.documentElement?.clientWidth ?? 0;
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
const inner = window.innerWidth;
|
||||||
|
const vvw = vv?.width ?? inner;
|
||||||
|
const layout = Math.min(inner, vvw, doc > 0 ? doc : inner);
|
||||||
|
return Math.max(1, Math.round(layout));
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalFontSizeForWidth(layoutWidthPx: number): number {
|
||||||
|
if (layoutWidthPx < 300) return 7;
|
||||||
|
if (layoutWidthPx < 360) return 8;
|
||||||
|
if (layoutWidthPx < 420) return 9;
|
||||||
|
if (layoutWidthPx < 520) return 10;
|
||||||
|
if (layoutWidthPx < 720) return 11;
|
||||||
|
if (layoutWidthPx < 1024) return 12;
|
||||||
|
return 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalLineHeightForWidth(layoutWidthPx: number): number {
|
||||||
|
return layoutWidthPx < 1024 ? 1.02 : 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
|
|
@ -77,10 +115,83 @@ export default function ChatPage() {
|
||||||
);
|
);
|
||||||
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
|
||||||
|
const { setEnd } = usePageHeader();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const closeMobilePanel = useCallback(() => setMobilePanelOpen(false), []);
|
||||||
|
const modelToolsLabel = useMemo(
|
||||||
|
() => `${t.app.modelToolsSheetTitle} ${t.app.modelToolsSheetSubtitle}`,
|
||||||
|
[t.app.modelToolsSheetSubtitle, t.app.modelToolsSheetTitle],
|
||||||
|
);
|
||||||
|
const [portalRoot] = useState<HTMLElement | null>(() =>
|
||||||
|
typeof document !== "undefined" ? document.body : null,
|
||||||
|
);
|
||||||
|
const [narrow, setNarrow] = useState(() =>
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.matchMedia("(max-width: 1023px)").matches
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
|
||||||
const resumeRef = useRef<string | null>(searchParams.get("resume"));
|
const resumeRef = useRef<string | null>(searchParams.get("resume"));
|
||||||
const channel = useMemo(() => generateChannelId(), []);
|
const channel = useMemo(() => generateChannelId(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia("(max-width: 1023px)");
|
||||||
|
const sync = () => setNarrow(mql.matches);
|
||||||
|
sync();
|
||||||
|
mql.addEventListener("change", sync);
|
||||||
|
return () => mql.removeEventListener("change", sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mobilePanelOpen) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") closeMobilePanel();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
};
|
||||||
|
}, [mobilePanelOpen, closeMobilePanel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia("(min-width: 1024px)");
|
||||||
|
const onChange = (e: MediaQueryListEvent) => {
|
||||||
|
if (e.matches) setMobilePanelOpen(false);
|
||||||
|
};
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
return () => mql.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!narrow) {
|
||||||
|
setEnd(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEnd(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobilePanelOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded border border-current/20",
|
||||||
|
"px-2 py-1 text-[0.65rem] font-medium tracking-wide normal-case",
|
||||||
|
"text-midground/80 hover:text-midground hover:bg-midground/5",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||||
|
"shrink-0 cursor-pointer",
|
||||||
|
)}
|
||||||
|
aria-expanded={mobilePanelOpen}
|
||||||
|
aria-controls="chat-side-panel"
|
||||||
|
>
|
||||||
|
<PanelRight className="h-3 w-3 shrink-0" />
|
||||||
|
{modelToolsLabel}
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
return () => setEnd(null);
|
||||||
|
}, [narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
|
||||||
|
|
||||||
const handleCopyLast = () => {
|
const handleCopyLast = () => {
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
@ -110,13 +221,17 @@ export default function ChatPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tierW0 = terminalTierWidthPx(host);
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontFamily:
|
fontFamily:
|
||||||
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
|
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
|
||||||
fontSize: 14,
|
fontSize: terminalFontSizeForWidth(tierW0),
|
||||||
lineHeight: 1.2,
|
lineHeight: terminalLineHeightForWidth(tierW0),
|
||||||
|
letterSpacing: 0,
|
||||||
|
fontWeight: "400",
|
||||||
|
fontWeightBold: "700",
|
||||||
macOptionIsMeta: true,
|
macOptionIsMeta: true,
|
||||||
scrollback: 0,
|
scrollback: 0,
|
||||||
theme: TERMINAL_THEME,
|
theme: TERMINAL_THEME,
|
||||||
|
|
@ -212,19 +327,23 @@ export default function ChatPage() {
|
||||||
|
|
||||||
term.open(host);
|
term.open(host);
|
||||||
|
|
||||||
// WebGL renderer: rasterizes glyphs to a GPU texture atlas, paints
|
// WebGL draws from a texture atlas sized with device pixels. On phones and
|
||||||
// each cell at an integer-pixel position. Box-drawing glyphs connect
|
// in DevTools device mode that often produces *visually* much larger cells
|
||||||
// cleanly between rows (no DOM baseline / line-height math). Falls
|
// than `fontSize` suggests — users see "huge" text even at 7–9px settings.
|
||||||
// back to the default DOM renderer if WebGL is unavailable.
|
// The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow
|
||||||
try {
|
// hosts. Wide layouts still get WebGL for crisp box-drawing.
|
||||||
const webgl = new WebglAddon();
|
const useWebgl = terminalTierWidthPx(host) >= 768;
|
||||||
webgl.onContextLoss(() => webgl.dispose());
|
if (useWebgl) {
|
||||||
term.loadAddon(webgl);
|
try {
|
||||||
} catch (err) {
|
const webgl = new WebglAddon();
|
||||||
console.warn(
|
webgl.onContextLoss(() => webgl.dispose());
|
||||||
"[hermes-chat] WebGL renderer unavailable; falling back to default",
|
term.loadAddon(webgl);
|
||||||
err,
|
} catch (err) {
|
||||||
);
|
console.warn(
|
||||||
|
"[hermes-chat] WebGL renderer unavailable; falling back to default",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial fit + resize observer. fit.fit() reads the container's
|
// Initial fit + resize observer. fit.fit() reads the container's
|
||||||
|
|
@ -244,22 +363,65 @@ export default function ChatPage() {
|
||||||
// frames. rAF→rAF guarantees one layout commit between the two
|
// frames. rAF→rAF guarantees one layout commit between the two
|
||||||
// callbacks, giving CSS transitions and font metrics time to finalize
|
// callbacks, giving CSS transitions and font metrics time to finalize
|
||||||
// before we take the authoritative measurement.
|
// before we take the authoritative measurement.
|
||||||
let rafId = 0;
|
let hostSyncRaf = 0;
|
||||||
const scheduleFit = () => {
|
const scheduleHostSync = () => {
|
||||||
if (rafId) return;
|
if (hostSyncRaf) return;
|
||||||
rafId = requestAnimationFrame(() => {
|
hostSyncRaf = requestAnimationFrame(() => {
|
||||||
rafId = 0;
|
hostSyncRaf = 0;
|
||||||
try {
|
syncTerminalMetrics();
|
||||||
fit.fit();
|
|
||||||
} catch {
|
|
||||||
// Element was removed mid-resize; cleanup will handle it.
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
fit.fit();
|
|
||||||
const ro = new ResizeObserver(scheduleFit);
|
let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const syncTerminalMetrics = () => {
|
||||||
|
const w = terminalTierWidthPx(host);
|
||||||
|
const nextSize = terminalFontSizeForWidth(w);
|
||||||
|
const nextLh = terminalLineHeightForWidth(w);
|
||||||
|
const fontChanged =
|
||||||
|
term.options.fontSize !== nextSize ||
|
||||||
|
term.options.lineHeight !== nextLh;
|
||||||
|
if (fontChanged) {
|
||||||
|
term.options.fontSize = nextSize;
|
||||||
|
term.options.lineHeight = nextLh;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fontChanged && term.rows > 0) {
|
||||||
|
try {
|
||||||
|
term.refresh(0, term.rows - 1);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fontChanged &&
|
||||||
|
wsRef.current &&
|
||||||
|
wsRef.current.readyState === WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleSyncTerminalMetrics = () => {
|
||||||
|
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||||
|
metricsDebounce = setTimeout(() => {
|
||||||
|
metricsDebounce = null;
|
||||||
|
syncTerminalMetrics();
|
||||||
|
}, 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => scheduleHostSync());
|
||||||
ro.observe(host);
|
ro.observe(host);
|
||||||
|
|
||||||
|
window.addEventListener("resize", scheduleSyncTerminalMetrics);
|
||||||
|
window.visualViewport?.addEventListener("resize", scheduleSyncTerminalMetrics);
|
||||||
|
window.visualViewport?.addEventListener("scroll", scheduleSyncTerminalMetrics);
|
||||||
|
scheduleHostSync();
|
||||||
|
requestAnimationFrame(() => scheduleHostSync());
|
||||||
|
|
||||||
// Double-rAF authoritative fit. On the second frame the layout has
|
// Double-rAF authoritative fit. On the second frame the layout has
|
||||||
// committed at least once since mount; fit.fit() then reads the
|
// committed at least once since mount; fit.fit() then reads the
|
||||||
// stable container size. We always send a RESIZE escape afterwards
|
// stable container size. We always send a RESIZE escape afterwards
|
||||||
|
|
@ -272,15 +434,7 @@ export default function ChatPage() {
|
||||||
settleRaf1 = 0;
|
settleRaf1 = 0;
|
||||||
settleRaf2 = requestAnimationFrame(() => {
|
settleRaf2 = requestAnimationFrame(() => {
|
||||||
settleRaf2 = 0;
|
settleRaf2 = 0;
|
||||||
try {
|
syncTerminalMetrics();
|
||||||
fit.fit();
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sock = wsRef.current;
|
|
||||||
if (sock && sock.readyState === WebSocket.OPEN) {
|
|
||||||
sock.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -387,8 +541,18 @@ export default function ChatPage() {
|
||||||
return () => {
|
return () => {
|
||||||
onDataDisposable.dispose();
|
onDataDisposable.dispose();
|
||||||
onResizeDisposable.dispose();
|
onResizeDisposable.dispose();
|
||||||
|
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||||
|
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
|
||||||
|
window.visualViewport?.removeEventListener(
|
||||||
|
"resize",
|
||||||
|
scheduleSyncTerminalMetrics,
|
||||||
|
);
|
||||||
|
window.visualViewport?.removeEventListener(
|
||||||
|
"scroll",
|
||||||
|
scheduleSyncTerminalMetrics,
|
||||||
|
);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
if (rafId) cancelAnimationFrame(rafId);
|
if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
|
||||||
if (settleRaf1) cancelAnimationFrame(settleRaf1);
|
if (settleRaf1) cancelAnimationFrame(settleRaf1);
|
||||||
if (settleRaf2) cancelAnimationFrame(settleRaf2);
|
if (settleRaf2) cancelAnimationFrame(settleRaf2);
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|
@ -416,52 +580,149 @@ export default function ChatPage() {
|
||||||
//
|
//
|
||||||
// `normal-case` opts out of the dashboard's global `uppercase` rule on
|
// `normal-case` opts out of the dashboard's global `uppercase` rule on
|
||||||
// the root `<div>` in App.tsx — terminal output must preserve case.
|
// the root `<div>` in App.tsx — terminal output must preserve case.
|
||||||
|
//
|
||||||
|
// Mobile model/tools sheet is portaled to `document.body` so it stacks
|
||||||
|
// above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
|
||||||
|
// dashboard column uses `relative z-2`, which traps `position:fixed`
|
||||||
|
// descendants below those layers (see Toast.tsx).
|
||||||
|
const mobileModelToolsPortal =
|
||||||
|
narrow &&
|
||||||
|
portalRoot &&
|
||||||
|
createPortal(
|
||||||
|
<>
|
||||||
|
{mobilePanelOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t.app.closeModelTools}
|
||||||
|
onClick={closeMobilePanel}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-[55]",
|
||||||
|
"bg-black/60 backdrop-blur-sm cursor-pointer",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="chat-side-panel"
|
||||||
|
role="complementary"
|
||||||
|
aria-label={modelToolsLabel}
|
||||||
|
className={cn(
|
||||||
|
"font-mondwest fixed top-0 right-0 z-[60] flex h-dvh max-h-dvh w-64 min-w-0 flex-col antialiased",
|
||||||
|
"border-l border-current/20 text-midground",
|
||||||
|
"bg-background-base/95 backdrop-blur-sm",
|
||||||
|
"transition-transform duration-200 ease-out",
|
||||||
|
"[background:var(--component-sidebar-background)]",
|
||||||
|
"[clip-path:var(--component-sidebar-clip-path)]",
|
||||||
|
"[border-image:var(--component-sidebar-border-image)]",
|
||||||
|
mobilePanelOpen
|
||||||
|
? "translate-x-0"
|
||||||
|
: "pointer-events-none translate-x-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 shrink-0 items-center justify-between gap-2 border-b border-current/20 px-5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||||
|
style={{ mixBlendMode: "plus-lighter" }}
|
||||||
|
>
|
||||||
|
{t.app.modelToolsSheetTitle}
|
||||||
|
<br />
|
||||||
|
{t.app.modelToolsSheetSubtitle}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeMobilePanel}
|
||||||
|
aria-label={t.app.closeModelTools}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-7 w-7 items-center justify-center",
|
||||||
|
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
|
||||||
|
"border-t border-current/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChatSidebar channel={channel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
portalRoot,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-10rem)] flex-col gap-2 normal-case">
|
<div className="flex min-h-0 flex-1 flex-col gap-2 normal-case">
|
||||||
|
{mobileModelToolsPortal}
|
||||||
|
|
||||||
{banner && (
|
{banner && (
|
||||||
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
|
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
|
||||||
{banner}
|
{banner}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-h-0 flex-1 gap-3">
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row lg:gap-3">
|
||||||
<div
|
<div
|
||||||
className="relative min-w-0 flex-1 overflow-hidden rounded-lg"
|
className={cn(
|
||||||
|
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg",
|
||||||
|
"p-2 sm:p-3",
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: TERMINAL_THEME.background,
|
backgroundColor: TERMINAL_THEME.background,
|
||||||
padding: "12px",
|
|
||||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={hostRef} className="h-full w-full" />
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyLast}
|
onClick={handleCopyLast}
|
||||||
title="Copy last assistant response as raw markdown"
|
title="Copy last assistant response as raw markdown"
|
||||||
aria-label="Copy last assistant response"
|
aria-label="Copy last assistant response"
|
||||||
className={[
|
className={cn(
|
||||||
"absolute bottom-4 right-4 z-10",
|
"absolute z-10 flex items-center gap-1.5",
|
||||||
"flex items-center gap-1.5",
|
|
||||||
"rounded border border-current/30",
|
"rounded border border-current/30",
|
||||||
"bg-black/20 backdrop-blur-sm",
|
"bg-black/20 backdrop-blur-sm",
|
||||||
"px-2.5 py-1.5 text-xs",
|
|
||||||
"opacity-60 hover:opacity-100 hover:border-current/60",
|
"opacity-60 hover:opacity-100 hover:border-current/60",
|
||||||
"transition-opacity duration-150",
|
"transition-opacity duration-150",
|
||||||
"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current",
|
"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current",
|
||||||
"cursor-pointer",
|
"cursor-pointer",
|
||||||
].join(" ")}
|
"bottom-2 right-2 px-2 py-1 text-[0.65rem] sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5 sm:text-xs",
|
||||||
|
"lg:bottom-4 lg:right-4",
|
||||||
|
)}
|
||||||
style={{ color: TERMINAL_THEME.foreground }}
|
style={{ color: TERMINAL_THEME.foreground }}
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3 shrink-0" />
|
||||||
<span className="tracking-wide">
|
<span className="hidden min-[400px]:inline tracking-wide">
|
||||||
{copyState === "copied" ? "copied" : "copy last response"}
|
{copyState === "copied" ? "copied" : "copy last response"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden min-h-0 lg:block">
|
{!narrow && (
|
||||||
<ChatSidebar channel={channel} />
|
<div
|
||||||
</div>
|
id="chat-side-panel"
|
||||||
|
role="complementary"
|
||||||
|
aria-label={modelToolsLabel}
|
||||||
|
className="flex min-h-0 shrink-0 flex-col lg:h-full lg:w-80"
|
||||||
|
>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
|
<ChatSidebar channel={channel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export default function DocsPage() {
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 w-full min-w-0 flex-1 flex-col",
|
"flex min-h-0 w-full min-w-0 flex-1 flex-col",
|
||||||
"-mx-3 sm:-mx-6",
|
"pt-1 sm:pt-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<iframe
|
<iframe
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import { useSystemActions } from "@/contexts/useSystemActions";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
|
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||||
|
|
||||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
|
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
|
||||||
{
|
{
|
||||||
|
|
@ -258,6 +259,7 @@ function SessionRow({
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
resumeInChatEnabled,
|
||||||
}: {
|
}: {
|
||||||
session: SessionInfo;
|
session: SessionInfo;
|
||||||
snippet?: string;
|
snippet?: string;
|
||||||
|
|
@ -265,6 +267,7 @@ function SessionRow({
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
resumeInChatEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -350,19 +353,21 @@ function SessionRow({
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{session.source ?? "local"}
|
{session.source ?? "local"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
{resumeInChatEnabled && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-success"
|
size="icon"
|
||||||
aria-label={t.sessions.resumeInChat}
|
className="h-7 w-7 text-muted-foreground hover:text-success"
|
||||||
title={t.sessions.resumeInChat}
|
aria-label={t.sessions.resumeInChat}
|
||||||
onClick={(e) => {
|
title={t.sessions.resumeInChat}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
e.stopPropagation();
|
||||||
}}
|
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||||
>
|
}}
|
||||||
<Play className="h-3.5 w-3.5" />
|
>
|
||||||
</Button>
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -422,6 +427,7 @@ export default function SessionsPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { setAfterTitle, setEnd } = usePageHeader();
|
const { setAfterTitle, setEnd } = usePageHeader();
|
||||||
const { activeAction, actionStatus, dismissLog } = useSystemActions();
|
const { activeAction, actionStatus, dismissLog } = useSystemActions();
|
||||||
|
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -786,6 +792,7 @@ export default function SessionsPage() {
|
||||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||||
}
|
}
|
||||||
onDelete={() => sessionDelete.requestDelete(s.id)}
|
onDelete={() => sessionDelete.requestDelete(s.id)}
|
||||||
|
resumeInChatEnabled={resumeInChatEnabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ const BACKEND = process.env.HERMES_DASHBOARD_URL ?? "http://127.0.0.1:9119";
|
||||||
*/
|
*/
|
||||||
function hermesDevToken(): Plugin {
|
function hermesDevToken(): Plugin {
|
||||||
const TOKEN_RE = /window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/;
|
const TOKEN_RE = /window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/;
|
||||||
|
const EMBEDDED_RE =
|
||||||
|
/window\.__HERMES_DASHBOARD_EMBEDDED_CHAT__\s*=\s*(true|false)/;
|
||||||
|
const LEGACY_TUI_RE =
|
||||||
|
/window\.__HERMES_DASHBOARD_TUI__\s*=\s*(true|false)/;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "hermes:dev-session-token",
|
name: "hermes:dev-session-token",
|
||||||
|
|
@ -33,11 +37,20 @@ function hermesDevToken(): Plugin {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const embeddedMatch = html.match(EMBEDDED_RE);
|
||||||
|
const legacyMatch = html.match(LEGACY_TUI_RE);
|
||||||
|
const embeddedJs = embeddedMatch
|
||||||
|
? embeddedMatch[1]
|
||||||
|
: legacyMatch
|
||||||
|
? legacyMatch[1]
|
||||||
|
: "false";
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: "script",
|
tag: "script",
|
||||||
injectTo: "head",
|
injectTo: "head",
|
||||||
children: `window.__HERMES_SESSION_TOKEN__="${match[1]}";`,
|
children:
|
||||||
|
`window.__HERMES_SESSION_TOKEN__="${match[1]}";` +
|
||||||
|
`window.__HERMES_DASHBOARD_EMBEDDED_CHAT__=${embeddedJs};`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue