mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
refactor(desktop+dashboard): extract shared WebSocket/JSON-RPC layer
The Electron desktop app and the web dashboard each carried their own copy of the tui_gateway JSON-RPC WebSocket client plus near-identical auth'd WS-URL construction. The dashboard's copy was the historical source of the "is the dashboard required to run the desktop app?" confusion, since the two surfaces looked coupled. Consolidate the genuinely shared transport into the existing framework-agnostic `@hermes/shared` package so both surfaces consume it independently — neither app depends on the other: - Move `resolveGatewayWsUrl` + `GatewayReauthRequiredError` (single-use OAuth ticket re-mint vs long-lived token fallback) into `@hermes/shared`; desktop now imports them directly. - Add `buildHermesWebSocketUrl`, one base-path/scheme/auth-aware URL builder, and route every dashboard WS endpoint through it (`/api/ws`, `/api/events`, `/api/pty`, plugin WS URLs). - Reduce the dashboard `GatewayClient` to a thin subclass of the shared `JsonRpcGatewayClient`, deleting ~210 lines of duplicated pending-call /event-dispatch/connect plumbing while keeping its dashboard-specific ticket-vs-token auth selection. - Drop the stale "start it with --tui" chat banner, which implied the dashboard flag was required. Behavior is preserved on both surfaces; the dashboard additionally inherits the shared client's 15s connect timeout (previously desktop-only), so a hung connect now fails fast instead of pinning the composer in "connecting".
This commit is contained in:
parent
c8fd47be14
commit
dfb561a3ae
15 changed files with 210 additions and 339 deletions
|
|
@ -1,10 +1,10 @@
|
|||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
applyDesktopBootProgress,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url'
|
||||
|
||||
const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' }
|
||||
const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
import type { HermesConnection } from '@/global'
|
||||
|
||||
/**
|
||||
* The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket
|
||||
* URL immediately before every `gateway.connect()`. For OAuth-gated remote
|
||||
* gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked
|
||||
* into the cached `conn.wsUrl` is stale (and, after the first connect, already
|
||||
* consumed). For local/token gateways the URL carries a long-lived token and
|
||||
* never needs re-minting.
|
||||
*
|
||||
* Resolution rules:
|
||||
*
|
||||
* - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall
|
||||
* back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to
|
||||
* fail with an opaque "connection closed" error. Instead, let the mint error
|
||||
* propagate so the caller can surface the gateway's reauth message
|
||||
* ("session has expired… Sign in again").
|
||||
*
|
||||
* - token / local, or when the preload method is genuinely absent (older
|
||||
* preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so
|
||||
* the fallback is safe and preserves compatibility.
|
||||
*
|
||||
* The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so
|
||||
* callers can distinguish "the user must re-authenticate" from a generic
|
||||
* transport failure.
|
||||
*/
|
||||
export interface ResolveGatewayWsUrlDeps {
|
||||
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. The
|
||||
* optional profile selects which backend to mint for — critical when swapping
|
||||
* to a pooled profile, since the default mint resolves the primary backend. */
|
||||
getGatewayWsUrl?: (profile?: null | string) => Promise<string>
|
||||
}
|
||||
|
||||
export class GatewayReauthRequiredError extends Error {
|
||||
readonly needsOauthLogin = true
|
||||
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options)
|
||||
this.name = 'GatewayReauthRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
|
||||
return (
|
||||
error instanceof GatewayReauthRequiredError ||
|
||||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveGatewayWsUrl(
|
||||
desktop: ResolveGatewayWsUrlDeps,
|
||||
conn: Pick<HermesConnection, 'authMode' | 'profile' | 'wsUrl'>
|
||||
): Promise<string> {
|
||||
const mint = desktop.getGatewayWsUrl
|
||||
// Mint for THIS connection's profile, not the primary. Without it a pooled
|
||||
// profile swap re-mints the default backend's URL and connects to the wrong
|
||||
// backend.
|
||||
const profile = conn.profile ?? null
|
||||
|
||||
if (conn.authMode === 'oauth') {
|
||||
if (!mint) {
|
||||
// OAuth gateway but no way to mint a fresh ticket: the cached ticket is
|
||||
// dead, so connecting with it cannot succeed. Surface a reauth error
|
||||
// rather than silently attempting a doomed connect.
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return await mint(profile)
|
||||
} catch (error) {
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// token / local: the URL carries a long-lived token. Re-mint when available
|
||||
// (cheap, keeps parity), but the cached URL is a safe fallback.
|
||||
if (mint) {
|
||||
const fresh = await mint(profile).catch(() => null)
|
||||
|
||||
if (fresh) {
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
return conn.wsUrl
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import type { ConnectionState, GatewayEvent } from '@hermes/shared'
|
||||
import { type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { setGatewayState } from '@/store/session'
|
||||
|
||||
// ── Multi-profile gateway routing ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -8,3 +8,14 @@ export {
|
|||
type JsonRpcFrame,
|
||||
type WebSocketLike
|
||||
} from './json-rpc-gateway'
|
||||
export {
|
||||
GatewayReauthRequiredError,
|
||||
buildHermesWebSocketUrl,
|
||||
isGatewayReauthRequired,
|
||||
resolveGatewayWsUrl,
|
||||
type GatewayAuthMode,
|
||||
type GatewayWsConnection,
|
||||
type HermesWebSocketUrlOptions,
|
||||
type ResolveGatewayWsUrlDeps,
|
||||
type WebSocketAuthParam
|
||||
} from './websocket-url'
|
||||
|
|
|
|||
123
apps/shared/src/websocket-url.ts
Normal file
123
apps/shared/src/websocket-url.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
export type GatewayAuthMode = 'oauth' | 'token' | (string & {})
|
||||
|
||||
export interface GatewayWsConnection {
|
||||
authMode?: GatewayAuthMode | null
|
||||
profile?: null | string
|
||||
wsUrl: string
|
||||
}
|
||||
|
||||
export interface ResolveGatewayWsUrlDeps {
|
||||
/**
|
||||
* Returns a fresh WebSocket URL for the selected backend/profile.
|
||||
* OAuth-gated gateways use single-use tickets, so callers should mint
|
||||
* immediately before opening the socket.
|
||||
*/
|
||||
getGatewayWsUrl?: (profile?: null | string) => Promise<string>
|
||||
}
|
||||
|
||||
export class GatewayReauthRequiredError extends Error {
|
||||
readonly needsOauthLogin = true
|
||||
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options)
|
||||
this.name = 'GatewayReauthRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
|
||||
return (
|
||||
error instanceof GatewayReauthRequiredError ||
|
||||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveGatewayWsUrl(
|
||||
deps: ResolveGatewayWsUrlDeps,
|
||||
conn: GatewayWsConnection
|
||||
): Promise<string> {
|
||||
const mint = deps.getGatewayWsUrl
|
||||
const profile = conn.profile ?? null
|
||||
|
||||
if (conn.authMode === 'oauth') {
|
||||
if (!mint) {
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session needs to be refreshed. Open Settings -> Gateway and click "Sign in" again.'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return await mint(profile)
|
||||
} catch (error) {
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (mint) {
|
||||
const fresh = await mint(profile).catch(() => null)
|
||||
|
||||
if (fresh) {
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
return conn.wsUrl
|
||||
}
|
||||
|
||||
export type WebSocketAuthParam = readonly [name: string, value: string]
|
||||
|
||||
export interface HermesWebSocketUrlOptions {
|
||||
/** Dashboard or gateway-relative endpoint path, e.g. "/api/ws". */
|
||||
path: string
|
||||
/** Optional URL prefix when the backend is reverse-proxied below a subpath. */
|
||||
basePath?: string
|
||||
/** Query auth pair, usually ["token", value] or ["ticket", value]. */
|
||||
authParam?: WebSocketAuthParam
|
||||
/** Extra query params merged before auth. */
|
||||
params?: Record<string, string>
|
||||
/** Browser protocol string such as "https:"; defaults to window.location.protocol. */
|
||||
protocol?: string
|
||||
/** Host with optional port; defaults to window.location.host. */
|
||||
host?: string
|
||||
}
|
||||
|
||||
function readWindowLocation(): { host: string; protocol: string } {
|
||||
if (typeof window === 'undefined') {
|
||||
return { host: '', protocol: 'http:' }
|
||||
}
|
||||
|
||||
return { host: window.location.host, protocol: window.location.protocol }
|
||||
}
|
||||
|
||||
function normalizeBasePath(basePath: string | undefined): string {
|
||||
if (!basePath) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const withLead = basePath.startsWith('/') ? basePath : `/${basePath}`
|
||||
return withLead.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function normalizeEndpointPath(path: string): string {
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
export function buildHermesWebSocketUrl(options: HermesWebSocketUrlOptions): string {
|
||||
const loc = readWindowLocation()
|
||||
const protocol = options.protocol ?? loc.protocol
|
||||
const host = options.host ?? loc.host
|
||||
const wsScheme = protocol === 'https:' || protocol === 'wss:' ? 'wss:' : 'ws:'
|
||||
const qs = new URLSearchParams(options.params ?? {})
|
||||
|
||||
if (options.authParam) {
|
||||
const [name, value] = options.authParam
|
||||
qs.set(name, value)
|
||||
}
|
||||
|
||||
const query = qs.toString()
|
||||
const suffix = query ? `?${query}` : ''
|
||||
|
||||
return `${wsScheme}//${host}${normalizeBasePath(options.basePath)}${normalizeEndpointPath(options.path)}${suffix}`
|
||||
}
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -19571,6 +19571,7 @@
|
|||
"web": {
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../apps/shared",
|
||||
"@nous-research/ui": "0.18.2",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../apps/shared",
|
||||
"@nous-research/ui": "0.18.2",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Card } from "@nous-research/ui/ui/components/card";
|
||||
import { buildHermesWebSocketUrl } from "@hermes/shared";
|
||||
|
||||
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
||||
import { ModelReloadConfirm } from "@/components/ModelReloadConfirm";
|
||||
|
|
@ -230,14 +231,17 @@ export function ChatSidebar({
|
|||
let unmounting = false;
|
||||
let ws: WebSocket | null = null;
|
||||
void (async () => {
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
if (!authValue || unmounting) {
|
||||
const authParam = await buildWsAuthParam();
|
||||
if (!authParam[1] || unmounting) {
|
||||
return;
|
||||
}
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({ [authName]: authValue, channel });
|
||||
ws = new WebSocket(
|
||||
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
|
||||
buildHermesWebSocketUrl({
|
||||
authParam,
|
||||
basePath: HERMES_BASE_PATH,
|
||||
params: { channel },
|
||||
path: "/api/events",
|
||||
}),
|
||||
);
|
||||
|
||||
// `unmounting` suppresses the banner during cleanup — `ws.close()`
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { buildHermesWebSocketUrl } from "@hermes/shared";
|
||||
|
||||
// The dashboard can be served either at the root of its host (e.g.
|
||||
// https://kanban.tilos.com/) or under a URL prefix when reverse-proxied
|
||||
// (e.g. https://mission-control.tilos.com/hermes/). The Python backend
|
||||
|
|
@ -291,11 +293,12 @@ export async function buildWsUrl(
|
|||
path: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams(params ?? {});
|
||||
qs.set(authName, authValue);
|
||||
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
|
||||
return buildHermesWebSocketUrl({
|
||||
authParam: await buildWsAuthParam(),
|
||||
basePath: BASE,
|
||||
params,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a ``?profile=<name>`` query suffix, or "" when unset.
|
||||
|
|
|
|||
|
|
@ -13,241 +13,53 @@
|
|||
* await gw.request("prompt.submit", { session_id, text: "hi" })
|
||||
*/
|
||||
|
||||
import { HERMES_BASE_PATH, getWsTicket } from "@/lib/api";
|
||||
import {
|
||||
JsonRpcGatewayClient,
|
||||
buildHermesWebSocketUrl,
|
||||
type ConnectionState,
|
||||
type GatewayEvent,
|
||||
type GatewayEventName,
|
||||
} from "@hermes/shared";
|
||||
|
||||
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"
|
||||
| "error"
|
||||
| "skin.changed"
|
||||
| (string & {});
|
||||
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
|
||||
export interface GatewayEvent<P = unknown> {
|
||||
type: GatewayEventName;
|
||||
session_id?: string;
|
||||
payload?: P;
|
||||
}
|
||||
export type { ConnectionState, GatewayEvent, GatewayEventName };
|
||||
|
||||
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>();
|
||||
export class GatewayClient extends JsonRpcGatewayClient {
|
||||
constructor() {
|
||||
super({
|
||||
closedErrorMessage: "WebSocket closed",
|
||||
connectErrorMessage: "WebSocket connection failed",
|
||||
notConnectedErrorMessage: "gateway not connected",
|
||||
requestIdPrefix: "w",
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
return this.connectionState;
|
||||
}
|
||||
|
||||
async connect(token?: string): Promise<void> {
|
||||
if (this._state === "open" || this._state === "connecting") return;
|
||||
this.setState("connecting");
|
||||
|
||||
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
|
||||
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
|
||||
// Explicit ``token`` overrides the gate check (test-only path).
|
||||
let authParamName: string;
|
||||
let authParamValue: string;
|
||||
if (token) {
|
||||
authParamName = "token";
|
||||
authParamValue = token;
|
||||
} else if (window.__HERMES_AUTH_REQUIRED__) {
|
||||
const { ticket } = await getWsTicket();
|
||||
authParamName = "ticket";
|
||||
authParamValue = ticket;
|
||||
} else {
|
||||
authParamName = "token";
|
||||
authParamValue = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!authParamValue) {
|
||||
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}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
|
||||
);
|
||||
this.ws = ws;
|
||||
|
||||
// Register message + close BEFORE awaiting open — the server emits
|
||||
// `gateway.ready` immediately after accept, so a listener attached
|
||||
// after the open promise resolves can race past it and drop the
|
||||
// initial skin payload.
|
||||
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"));
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
if (this.connectionState === "open" || this.connectionState === "connecting") {
|
||||
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})`),
|
||||
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the SPA
|
||||
// must fetch a single-use ticket. Explicit ``token`` keeps the test-only
|
||||
// override path.
|
||||
const authParam = token ? (["token", token] as const) : await buildWsAuthParam();
|
||||
if (!authParam[1]) {
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard server",
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
await super.connect(
|
||||
buildHermesWebSocketUrl({
|
||||
authParam,
|
||||
basePath: HERMES_BASE_PATH,
|
||||
path: "/api/ws",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { Terminal } from "@xterm/xterm";
|
|||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { buildHermesWebSocketUrl, type WebSocketAuthParam } from "@hermes/shared";
|
||||
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy, PanelRight, RotateCcw, X } from "lucide-react";
|
||||
|
|
@ -42,24 +43,28 @@ import { useTheme } from "@/themes";
|
|||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
|
||||
function buildWsUrl(
|
||||
authParam: [string, string],
|
||||
authParam: WebSocketAuthParam,
|
||||
resume: string | null,
|
||||
channel: string,
|
||||
profile: string,
|
||||
fresh: boolean,
|
||||
): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// ``authParam`` is ``["token", <session>]`` in loopback mode and
|
||||
// ``["ticket", <minted>]`` in gated mode. The server-side helper
|
||||
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
|
||||
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
|
||||
if (resume) qs.set("resume", resume);
|
||||
if (fresh) qs.set("fresh", "1");
|
||||
const params: Record<string, string> = { channel };
|
||||
if (resume) params.resume = resume;
|
||||
if (fresh) params.fresh = "1";
|
||||
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
|
||||
// selected profile, so the conversation runs with that profile's model,
|
||||
// skills, memory, and sessions (see web_server._resolve_chat_argv).
|
||||
if (profile) qs.set("profile", profile);
|
||||
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
|
||||
if (profile) params.profile = profile;
|
||||
return buildHermesWebSocketUrl({
|
||||
authParam,
|
||||
basePath: HERMES_BASE_PATH,
|
||||
params,
|
||||
path: "/api/pty",
|
||||
});
|
||||
}
|
||||
|
||||
// Channel id ties this chat tab's PTY child (publisher) to its sidebar
|
||||
|
|
@ -767,7 +772,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
}
|
||||
if (ev.code === 4404) {
|
||||
setBanner(
|
||||
"Embedded chat is disabled on this server (start it with --tui).",
|
||||
ev.reason
|
||||
? `Chat websocket unavailable: ${ev.reason}.`
|
||||
: "Chat websocket unavailable on this server.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
|
||||
/* Path aliases */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@hermes/shared": ["../apps/shared/src/index.ts"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
|
|
@ -29,5 +30,5 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "../apps/shared/src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export default defineConfig({
|
|||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@hermes/shared": path.resolve(__dirname, "../apps/shared/src"),
|
||||
},
|
||||
// When @nous-research/ui is symlinked via `file:../../design-language`,
|
||||
// Node's module resolution would pick up shared deps from
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue