Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
@@ -303,7 +303,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 normal-case lg:w-80",
|
||||
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 normal-case lg:w-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -355,12 +355,12 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="flex min-h-0 flex-1 flex-col px-2 py-2">
|
||||
<Card className="flex min-h-0 flex-none flex-col px-2 py-2">
|
||||
<div className="px-1 pb-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
tools
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-col gap-1.5">
|
||||
{tools.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
no tool calls yet
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
const BASE = "";
|
||||
// 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
|
||||
// injects ``window.__HERMES_BASE_PATH__`` into index.html based on the
|
||||
// incoming ``X-Forwarded-Prefix`` header so the SPA can address its own
|
||||
// ``/api/...`` and ``/dashboard-plugins/...`` URLs correctly without a
|
||||
// rebuild. Empty string means "served at root".
|
||||
function readBasePath(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const raw = window.__HERMES_BASE_PATH__ ?? "";
|
||||
if (!raw) return "";
|
||||
// Normalise: ensure leading slash, strip trailing slash.
|
||||
const withLead = raw.startsWith("/") ? raw : `/${raw}`;
|
||||
return withLead.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export const HERMES_BASE_PATH = readBasePath();
|
||||
const BASE = HERMES_BASE_PATH;
|
||||
|
||||
import type { DashboardTheme } from "@/themes/types";
|
||||
|
||||
@@ -7,6 +24,7 @@ import type { DashboardTheme } from "@/themes/types";
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_BASE_PATH__?: string;
|
||||
}
|
||||
}
|
||||
let _sessionToken: string | null = null;
|
||||
@@ -49,6 +67,10 @@ export const api = {
|
||||
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
||||
getSessionMessages: (id: string) =>
|
||||
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
||||
getSessionLatestDescendant: (id: string) =>
|
||||
fetchJSON<SessionLatestDescendantResponse>(
|
||||
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
|
||||
),
|
||||
deleteSession: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
@@ -373,6 +395,14 @@ export interface SessionInfo {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
preview: string | null;
|
||||
parent_session_id?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionLatestDescendantResponse {
|
||||
requested_session_id: string;
|
||||
session_id: string;
|
||||
path: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedSessions {
|
||||
|
||||
@@ -6,13 +6,14 @@ import { SystemActionsProvider } from "./contexts/SystemActions";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { exposePluginSDK } from "./plugins";
|
||||
import { ThemeProvider } from "./themes";
|
||||
import { HERMES_BASE_PATH } from "./lib/api";
|
||||
|
||||
// Expose the plugin SDK before rendering so plugins loaded via <script>
|
||||
// can access React, components, etc. immediately.
|
||||
exposePluginSDK();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={HERMES_BASE_PATH || undefined}>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<SystemActionsProvider>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useSearchParams } from "react-router-dom";
|
||||
import { ChatSidebar } from "@/components/ChatSidebar";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { api } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
function buildWsUrl(
|
||||
@@ -111,7 +112,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
// the moment `isActive` flips back to true (display:none → display:flex
|
||||
// collapses the host's box, so ResizeObserver never fires on return).
|
||||
const syncMetricsRef = useRef<(() => void) | null>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Lazy-init: the missing-token check happens at construction so the effect
|
||||
// body doesn't have to setState (React 19's set-state-in-effect rule).
|
||||
const [banner, setBanner] = useState<string | null>(() =>
|
||||
@@ -147,8 +148,39 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
: false,
|
||||
);
|
||||
|
||||
const resumeRef = useRef<string | null>(searchParams.get("resume"));
|
||||
const channel = useMemo(() => generateChannelId(), []);
|
||||
// The dashboard keeps ChatPage mounted persistently so the PTY survives tab
|
||||
// switches. That is great for ordinary /chat navigation, but it means query
|
||||
// param changes do NOT remount the component. Resume-in-chat from the
|
||||
// Sessions page relies on `/chat?resume=<id>` changing at runtime, so we must
|
||||
// treat the current resume target as part of the PTY identity and rebuild the
|
||||
// terminal session when it changes.
|
||||
const resumeParam = searchParams.get("resume");
|
||||
const channel = useMemo(() => generateChannelId(), [resumeParam]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumeParam) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
api
|
||||
.getSessionLatestDescendant(resumeParam)
|
||||
.then((res) => {
|
||||
if (cancelled || !res.session_id || res.session_id === resumeParam) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("resume", res.session_id);
|
||||
setSearchParams(next, { replace: true });
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort: old servers or missing sessions should not block chat.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resumeParam, searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(max-width: 1023px)");
|
||||
@@ -254,6 +286,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
fontWeight: "400",
|
||||
fontWeightBold: "700",
|
||||
macOptionIsMeta: true,
|
||||
// Single-scroll-system experiment:
|
||||
// let the inner Hermes TUI own transcript history/scroll behavior.
|
||||
// The outer browser xterm should act as a display/input bridge only.
|
||||
scrollback: 0,
|
||||
theme: TERMINAL_THEME,
|
||||
});
|
||||
@@ -357,6 +392,40 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
fitRef.current = fit;
|
||||
term.loadAddon(fit);
|
||||
|
||||
// Single-scroll-system experiment:
|
||||
// keep browser xterm as a display/input bridge only, and let the inner
|
||||
// Hermes TUI own transcript scrolling.
|
||||
//
|
||||
// In practice, the most reliable path here is NOT terminal mouse-wheel
|
||||
// protocol emulation — that can vary by terminal mode and parser path.
|
||||
// The inner TUI already handles keyboard-driven transcript scrolling
|
||||
// correctly (`Shift+Up` / `Shift+Down`, `PageUp` / `PageDown`), so we
|
||||
// translate browser wheel gestures into those known-good key sequences.
|
||||
term.attachCustomWheelEventHandler((ev) => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const delta = ev.deltaY;
|
||||
if (!delta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shift+Up / Shift+Down: the TUI maps these to line-by-line
|
||||
// transcript scrolling, which feels much closer to wheel behavior
|
||||
// than PageUp/PageDown's half-page jumps.
|
||||
const step = Math.max(1, Math.round(Math.abs(delta) / 50));
|
||||
const seq = delta > 0 ? "\x1b[1;2B" : "\x1b[1;2A";
|
||||
|
||||
for (let i = 0; i < step; i++) {
|
||||
wsRef.current.send(seq);
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
const unicode11 = new Unicode11Addon();
|
||||
term.loadAddon(unicode11);
|
||||
term.unicode.activeVersion = "11";
|
||||
@@ -463,7 +532,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
|
||||
window.addEventListener("resize", scheduleSyncTerminalMetrics);
|
||||
window.visualViewport?.addEventListener("resize", scheduleSyncTerminalMetrics);
|
||||
window.visualViewport?.addEventListener("scroll", scheduleSyncTerminalMetrics);
|
||||
scheduleHostSync();
|
||||
requestAnimationFrame(() => scheduleHostSync());
|
||||
|
||||
@@ -484,7 +552,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
});
|
||||
|
||||
// WebSocket
|
||||
const url = buildWsUrl(token, resumeRef.current, channel);
|
||||
const url = buildWsUrl(token, resumeParam, channel);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
@@ -530,53 +598,27 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
|
||||
};
|
||||
|
||||
// Keystrokes + mouse events → PTY, with cell-level dedup for motion.
|
||||
// Keystrokes → PTY.
|
||||
//
|
||||
// Ink enables `\x1b[?1003h` (any-motion tracking), which asks the
|
||||
// terminal to report every mouse-move as an SGR mouse event even with
|
||||
// no button held. xterm.js happily emits one report per pixel of
|
||||
// mouse motion; without deduping, a casual mouse-over floods Ink with
|
||||
// hundreds of redraw-triggering reports and the UI goes laggy
|
||||
// (scrolling stutters, clicks land on stale positions by the time
|
||||
// Ink finishes processing the motion backlog).
|
||||
// IMPORTANT:
|
||||
// The embedded web chat has occasionally surfaced stray letters/digits
|
||||
// in the input line after a turn completes. The most likely culprit is
|
||||
// browser-side terminal control traffic being forwarded back into the
|
||||
// PTY as if it were user text. SGR mouse tracking is the highest-risk
|
||||
// path here: xterm.js emits raw CSI reports (`\x1b[<...`) that look like
|
||||
// ordinary bytes to the backend.
|
||||
//
|
||||
// We keep track of the last cell we reported a motion for. Press,
|
||||
// release, and wheel events always pass through; motion events only
|
||||
// pass through if the cell changed. Parsing is cheap — SGR reports
|
||||
// are short literal strings.
|
||||
// For the browser embed we prefer input stability over terminal-style
|
||||
// mouse reporting, so we drop SGR mouse reports entirely instead of
|
||||
// forwarding them into Hermes. Keyboard input, paste, and resize still
|
||||
// behave normally.
|
||||
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
|
||||
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
||||
let lastMotionCell = { col: -1, row: -1 };
|
||||
let lastMotionCb = -1;
|
||||
const onDataDisposable = term.onData((data) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const m = SGR_MOUSE_RE.exec(data);
|
||||
if (m) {
|
||||
const cb = parseInt(m[1], 10);
|
||||
const col = parseInt(m[2], 10);
|
||||
const row = parseInt(m[3], 10);
|
||||
const released = m[4] === "m";
|
||||
// Motion events have bit 0x20 (32) set in the button code.
|
||||
// Wheel events have bit 0x40 (64); always forward wheel.
|
||||
const isMotion = (cb & 0x20) !== 0 && (cb & 0x40) === 0;
|
||||
const isWheel = (cb & 0x40) !== 0;
|
||||
if (isMotion && !isWheel && !released) {
|
||||
if (
|
||||
col === lastMotionCell.col &&
|
||||
row === lastMotionCell.row &&
|
||||
cb === lastMotionCb
|
||||
) {
|
||||
return; // same cell + same button state; skip redundant report
|
||||
}
|
||||
lastMotionCell = { col, row };
|
||||
lastMotionCb = cb;
|
||||
} else {
|
||||
// Non-motion event (press, release, wheel) — reset dedup state
|
||||
// so the next motion after this always reports.
|
||||
lastMotionCell = { col: -1, row: -1 };
|
||||
lastMotionCb = -1;
|
||||
}
|
||||
if (SGR_MOUSE_RE.test(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(data);
|
||||
@@ -601,10 +643,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
"resize",
|
||||
scheduleSyncTerminalMetrics,
|
||||
);
|
||||
window.visualViewport?.removeEventListener(
|
||||
"scroll",
|
||||
scheduleSyncTerminalMetrics,
|
||||
);
|
||||
ro.disconnect();
|
||||
if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
|
||||
if (settleRaf1) cancelAnimationFrame(settleRaf1);
|
||||
@@ -619,7 +657,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
copyResetRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [channel]);
|
||||
}, [channel, resumeParam]);
|
||||
|
||||
// When the user returns to the chat tab (isActive: false → true), the
|
||||
// terminal host just transitioned from display:none to display:flex.
|
||||
@@ -814,9 +852,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
id="chat-side-panel"
|
||||
role="complementary"
|
||||
aria-label={modelToolsLabel}
|
||||
className="flex min-h-0 shrink-0 flex-col lg:h-full lg:w-80"
|
||||
className="flex min-h-0 shrink-0 flex-col overflow-hidden lg:h-full lg:w-80"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ChatSidebar channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,15 @@ export default function DocsPage() {
|
||||
className={cn(
|
||||
"min-h-0 w-full min-w-0 flex-1",
|
||||
"rounded-sm border border-current/20",
|
||||
"bg-background",
|
||||
// Docusaurus paints over a transparent <html> / <body> and
|
||||
// relies on the browser's canvas color (light by default) to
|
||||
// fill the viewport. Inheriting the dashboard's dark color
|
||||
// scheme makes that canvas dark, so the docs body text — which
|
||||
// is tuned for a light canvas — becomes near-invisible. Force a
|
||||
// light color scheme + white background on the iframe element so
|
||||
// the docs render cleanly regardless of the active dashboard
|
||||
// theme or the user's prefers-color-scheme.
|
||||
"[color-scheme:light] bg-white",
|
||||
)}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
|
||||
@@ -22,6 +22,12 @@ export interface PluginManifest {
|
||||
entry: string;
|
||||
css?: string | null;
|
||||
has_api: boolean;
|
||||
/**
|
||||
* Optional Subresource Integrity hash (e.g. "sha384-..."). When set,
|
||||
* the browser will refuse to execute the plugin bundle if its hash
|
||||
* does not match. This protects against tampered plugin delivery.
|
||||
*/
|
||||
integrity?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { api, HERMES_BASE_PATH } from "@/lib/api";
|
||||
import type { PluginManifest, RegisteredPlugin } from "./types";
|
||||
import {
|
||||
getPluginComponent,
|
||||
@@ -43,7 +43,7 @@ export function usePlugins() {
|
||||
for (const manifest of manifests) {
|
||||
// Inject CSS if specified.
|
||||
if (manifest.css) {
|
||||
const cssUrl = `/dashboard-plugins/${manifest.name}/${manifest.css}`;
|
||||
const cssUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.css}`;
|
||||
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
@@ -55,7 +55,7 @@ export function usePlugins() {
|
||||
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
|
||||
// in-memory registry while the browser would otherwise never
|
||||
// re-execute a previously cached <script> URL.
|
||||
const baseUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
const baseUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
const scriptSrc = import.meta.env.DEV
|
||||
? `${baseUrl}?hermes_dv=${Date.now()}`
|
||||
: baseUrl;
|
||||
@@ -68,6 +68,16 @@ export function usePlugins() {
|
||||
script.setAttribute("data-hermes-plugin", manifest.name);
|
||||
script.src = scriptSrc;
|
||||
script.async = true;
|
||||
// SRI integrity verification — defense against compromised plugin
|
||||
// delivery. Plugin manifests can declare an integrity hash
|
||||
// (e.g. "sha384-...") which the browser verifies before executing.
|
||||
// Without this, a man-in-the-middle or compromised plugin server
|
||||
// can substitute the JS bundle silently. Opt-in: when no integrity
|
||||
// is declared in the manifest, behavior is unchanged.
|
||||
if (manifest.integrity && typeof manifest.integrity === "string") {
|
||||
script.integrity = manifest.integrity;
|
||||
script.crossOrigin = "anonymous";
|
||||
}
|
||||
script.onerror = () => {
|
||||
setPluginLoadError(manifest.name, "LOAD_FAILED");
|
||||
console.warn(
|
||||
|
||||
Reference in New Issue
Block a user