mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-14 04:02:26 +00:00
feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621) Adds an embedded web UI dashboard accessible via `hermes web`: - Status page: agent version, active sessions, gateway status, connected platforms - Config editor: schema-driven form with tabbed categories, import/export, reset - API Keys page: set, clear, and view redacted values with category grouping - Sessions, Skills, Cron, Logs, and Analytics pages Backend: - hermes_cli/web_server.py: FastAPI server with REST endpoints - hermes_cli/config.py: reload_env() utility for hot-reloading .env - hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open) - cli.py / commands.py: /reload slash command for .env hot-reload - pyproject.toml: [web] optional dependency extra (fastapi + uvicorn) - Both update paths (git + zip) auto-build web frontend when npm available Frontend: - Vite + React + TypeScript + Tailwind v4 SPA in web/ - shadcn/ui-style components, Nous design language - Auto-refresh status page, toast notifications, masked password inputs Security: - Path traversal guard (resolve().is_relative_to()) on SPA file serving - CORS localhost-only via allow_origin_regex - Generic error messages (no internal leak), SessionDB handles closed properly Tests: 47 tests covering reload_env, redact_key, API endpoints, schema generation, path traversal, category merging, internal key stripping, and full config round-trip. Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor (PR #7621 → #8204), re-salvaged onto current main with stale-branch regressions removed. * fix(web): clean up status page cards, always rebuild on `hermes web` - Remove config version migration alert banner from status page - Remove config version card (internal noise, not surfaced in TUI) - Reorder status cards: Agent → Gateway → Active Sessions (3-col grid) - `hermes web` now always rebuilds from source before serving, preventing stale web_dist when editing frontend files * feat(web): full-text search across session messages - Add GET /api/sessions/search endpoint backed by FTS5 - Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby') - Debounced search (300ms) with spinner in the search icon slot - Search results show FTS5 snippets with highlighted match delimiters - Expanding a search hit auto-scrolls to the first matching message - Matching messages get a warning ring + 'match' badge - Inline term highlighting within Markdown (text, bold, italic, headings, lists) - Clear button (x) on search input for quick reset --------- Co-authored-by: emozilla <emozilla@nousresearch.com>
This commit is contained in:
parent
c052cf0eea
commit
e2a9b5369f
55 changed files with 10187 additions and 3 deletions
429
web/src/pages/SessionsPage.tsx
Normal file
429
web/src/pages/SessionsPage.tsx
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Trash2,
|
||||
Clock,
|
||||
Terminal,
|
||||
Globe,
|
||||
MessageCircle,
|
||||
Hash,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
user: { bg: "bg-primary/10", text: "text-primary", label: "User" },
|
||||
assistant: { bg: "bg-success/10", text: "text-success", label: "Assistant" },
|
||||
system: { bg: "bg-muted", text: "text-muted-foreground", label: "System" },
|
||||
tool: { bg: "bg-warning/10", text: "text-warning", label: "Tool" },
|
||||
};
|
||||
|
||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = {
|
||||
cli: { icon: Terminal, color: "text-primary" },
|
||||
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
|
||||
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
|
||||
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
|
||||
whatsapp: { icon: Globe, color: "text-success" },
|
||||
cron: { icon: Clock, color: "text-warning" },
|
||||
};
|
||||
|
||||
/** Render an FTS5 snippet with highlighted matches.
|
||||
* The backend wraps matches in >>> and <<< delimiters. */
|
||||
function SnippetHighlight({ snippet }: { snippet: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const regex = />>>(.*?)<<</g;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
while ((match = regex.exec(snippet)) !== null) {
|
||||
if (match.index > last) {
|
||||
parts.push(snippet.slice(last, match.index));
|
||||
}
|
||||
parts.push(
|
||||
<mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
|
||||
{match[1]}
|
||||
</mark>
|
||||
);
|
||||
last = regex.lastIndex;
|
||||
}
|
||||
if (last < snippet.length) {
|
||||
parts.push(snippet.slice(last));
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5">
|
||||
{parts}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
let args = toolCall.function.arguments;
|
||||
try {
|
||||
args = JSON.stringify(JSON.parse(args), null, 2);
|
||||
} catch {
|
||||
// keep as-is
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label={`${open ? "Collapse" : "Expand"} tool call ${toolCall.function.name}`}
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
<span className="font-mono-ui font-medium">{toolCall.function.name}</span>
|
||||
<span className="text-warning/50 ml-auto">{toolCall.id}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{args}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
|
||||
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
|
||||
const label = msg.tool_name ? `Tool: ${msg.tool_name}` : style.label;
|
||||
|
||||
// Check if any search term appears as a prefix of any word in content
|
||||
const isHit = (() => {
|
||||
if (!highlight || !msg.content) return false;
|
||||
const content = msg.content.toLowerCase();
|
||||
const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return terms.some((term) => content.includes(term));
|
||||
})();
|
||||
|
||||
// Split search query into terms for inline highlighting
|
||||
const highlightTerms = isHit && highlight
|
||||
? highlight.split(/\s+/).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`} data-search-hit={isHit || undefined}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
|
||||
{isHit && (
|
||||
<Badge variant="warning" className="text-[9px] py-0 px-1.5">match</Badge>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span>
|
||||
)}
|
||||
</div>
|
||||
{msg.content && (
|
||||
msg.role === "system"
|
||||
? <div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{msg.content}</div>
|
||||
: <Markdown content={msg.content} highlightTerms={highlightTerms} />
|
||||
)}
|
||||
{msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||
<div className="mt-1">
|
||||
{msg.tool_calls.map((tc) => (
|
||||
<ToolCallBlock key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Message list with auto-scroll to first search hit. */
|
||||
function MessageList({ messages, highlight }: { messages: SessionMessage[]; highlight?: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlight || !containerRef.current) return;
|
||||
// Scroll to first hit after render
|
||||
const timer = setTimeout(() => {
|
||||
const hit = containerRef.current?.querySelector("[data-search-hit]");
|
||||
if (hit) {
|
||||
hit.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [messages, highlight]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2">
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} highlight={highlight} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
snippet,
|
||||
searchQuery,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
snippet?: string;
|
||||
searchQuery?: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && messages === null && !loading) {
|
||||
setLoading(true);
|
||||
api
|
||||
.getSessionMessages(session.id)
|
||||
.then((resp) => setMessages(resp.messages))
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isExpanded, session.id, messages, loading]);
|
||||
|
||||
const sourceInfo = (session.source ? SOURCE_CONFIG[session.source] : null) ?? { icon: Globe, color: "text-muted-foreground" };
|
||||
const SourceIcon = sourceInfo.icon;
|
||||
const hasTitle = session.title && session.title !== "Untitled";
|
||||
|
||||
return (
|
||||
<div className={`border overflow-hidden transition-colors ${
|
||||
session.is_active
|
||||
? "border-success/30 bg-success/[0.03]"
|
||||
: "border-border"
|
||||
}`}>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={`shrink-0 ${sourceInfo.color}`}>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}>
|
||||
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : "Untitled session")}
|
||||
</span>
|
||||
{session.is_active && (
|
||||
<Badge variant="success" className="text-[10px] shrink-0">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[180px]">{(session.model ?? "unknown").split("/").pop()}</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>{session.message_count} msgs</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span>{session.tool_call_count} tools</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</span>
|
||||
<span>{timeAgo(session.last_active)}</span>
|
||||
</div>
|
||||
{snippet && (
|
||||
<SnippetHighlight snippet={snippet} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
aria-label="Delete session"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border bg-background/50 p-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive py-4 text-center">{error}</p>
|
||||
)}
|
||||
{messages && messages.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No messages</p>
|
||||
)}
|
||||
{messages && messages.length > 0 && (
|
||||
<MessageList messages={messages} highlight={searchQuery} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionsPage() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const loadSessions = useCallback(() => {
|
||||
api
|
||||
.getSessions()
|
||||
.then(setSessions)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// Debounced FTS search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (!search.trim()) {
|
||||
setSearchResults(null);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
api
|
||||
.searchSessions(search.trim())
|
||||
.then((resp) => setSearchResults(resp.results))
|
||||
.catch(() => setSearchResults(null))
|
||||
.finally(() => setSearching(false));
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.deleteSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Build snippet map from search results (session_id → snippet)
|
||||
const snippetMap = new Map<string, string>();
|
||||
if (searchResults) {
|
||||
for (const r of searchResults) {
|
||||
snippetMap.set(r.session_id, r.snippet);
|
||||
}
|
||||
}
|
||||
|
||||
// When searching, filter sessions to those with FTS matches;
|
||||
// when not searching, show all sessions
|
||||
const filtered = searchResults
|
||||
? sessions.filter((s) => snippetMap.has(s.id))
|
||||
: sessions;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header outside card for lighter feel */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">Sessions</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sessions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative w-64">
|
||||
{searching ? (
|
||||
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" />
|
||||
) : (
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<Input
|
||||
placeholder="Search message content..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 pr-7 h-8 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">
|
||||
{search ? "No sessions match your search" : "No sessions yet"}
|
||||
</p>
|
||||
{!search && (
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">Start a conversation to see it here</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
snippet={snippetMap.get(s.id)}
|
||||
searchQuery={search || undefined}
|
||||
isExpanded={expandedId === s.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => handleDelete(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue