diff --git a/web/index.html b/web/index.html index c9f0d18e1..e420ce6db 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - Hermes Agent + Hermes Agent - Dashboard
diff --git a/web/package-lock.json b/web/package-lock.json index 47c6595ab..9e1bdc22a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -29,6 +29,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "three": "^0.180.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", "vite": "^7.3.1" @@ -3840,6 +3841,14 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/web/package.json b/web/package.json index e10a10127..03796fddf 100644 --- a/web/package.json +++ b/web/package.json @@ -34,6 +34,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "three": "^0.180.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", "vite": "^7.3.1" diff --git a/web/src/App.tsx b/web/src/App.tsx index 74d225b49..5a96b5891 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,12 +1,30 @@ import { useMemo } from "react"; import { Routes, Route, NavLink, Navigate } from "react-router-dom"; import { - Activity, BarChart3, Clock, FileText, KeyRound, - MessageSquare, Package, Settings, Puzzle, - Sparkles, Terminal, Globe, Database, Shield, - Wrench, Zap, Heart, Star, Code, Eye, + Activity, + BarChart3, + Clock, + FileText, + KeyRound, + MessageSquare, + Package, + Settings, + Puzzle, + Sparkles, + Terminal, + Globe, + Database, + Shield, + Wrench, + Zap, + Heart, + Star, + Code, + Eye, } from "lucide-react"; +import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index"; import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher"; +import { Typography } from "@nous-research/ui/ui/components/typography/index"; import { cn } from "@/lib/utils"; import { Backdrop } from "@/components/Backdrop"; import StatusPage from "@/pages/StatusPage"; @@ -25,8 +43,18 @@ import type { RegisteredPlugin } from "@/plugins"; const BUILTIN_NAV: NavItem[] = [ { path: "/", labelKey: "status", label: "Status", icon: Activity }, - { path: "/sessions", labelKey: "sessions", label: "Sessions", icon: MessageSquare }, - { path: "/analytics", labelKey: "analytics", label: "Analytics", icon: BarChart3 }, + { + path: "/sessions", + labelKey: "sessions", + label: "Sessions", + icon: MessageSquare, + }, + { + path: "/analytics", + labelKey: "analytics", + label: "Analytics", + icon: BarChart3, + }, { path: "/logs", labelKey: "logs", label: "Logs", icon: FileText }, { path: "/cron", labelKey: "cron", label: "Cron", icon: Clock }, { path: "/skills", labelKey: "skills", label: "Skills", icon: Package }, @@ -37,17 +65,38 @@ const BUILTIN_NAV: NavItem[] = [ // Plugins can reference any of these by name in their manifest β€” keeps bundle // size sane vs. importing the full lucide-react set. const ICON_MAP: Record> = { - Activity, BarChart3, Clock, FileText, KeyRound, - MessageSquare, Package, Settings, Puzzle, - Sparkles, Terminal, Globe, Database, Shield, - Wrench, Zap, Heart, Star, Code, Eye, + Activity, + BarChart3, + Clock, + FileText, + KeyRound, + MessageSquare, + Package, + Settings, + Puzzle, + Sparkles, + Terminal, + Globe, + Database, + Shield, + Wrench, + Zap, + Heart, + Star, + Code, + Eye, }; -function resolveIcon(name: string): React.ComponentType<{ className?: string }> { +function resolveIcon( + name: string, +): React.ComponentType<{ className?: string }> { return ICON_MAP[name] ?? Puzzle; } -function buildNavItems(builtIn: NavItem[], plugins: RegisteredPlugin[]): NavItem[] { +function buildNavItems( + builtIn: NavItem[], + plugins: RegisteredPlugin[], +): NavItem[] { const items = [...builtIn]; for (const { manifest } of plugins) { @@ -97,69 +146,86 @@ export default function App() { "bg-background-base/90 backdrop-blur-sm", )} > -
-
- +
+ - Hermes -
- Agent - -
+ + + Hermes +
+ Agent +
+
- - -
- - - - {t.app.webUi} - + + + ))} +
+ + + + + + + {t.app.webUi} + + +
@@ -187,17 +253,25 @@ export default function App() {
-
- - {t.app.footer.name} - - - {t.app.footer.org} - -
+ + + + {t.app.footer.name} + + + + + {t.app.footer.org} + + +
); diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx index abd8eaa5a..5be588111 100644 --- a/web/src/components/LanguageSwitcher.tsx +++ b/web/src/components/LanguageSwitcher.tsx @@ -1,3 +1,4 @@ +import { Typography } from "@nous-research/ui/ui/components/typography/index"; import { useI18n } from "@/i18n/context"; /** @@ -19,9 +20,12 @@ export function LanguageSwitcher() { > {/* Show the *current* language's flag β€” tooltip advertises the click action */} {locale === "en" ? "πŸ‡¬πŸ‡§" : "πŸ‡¨πŸ‡³"} - + {locale === "en" ? "EN" : "δΈ­ζ–‡"} - + ); } diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index 8e5b4c118..a5caa10bc 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react"; +import { Typography } from "@nous-research/ui/ui/components/typography/index"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -12,9 +13,21 @@ interface Props { onError: (msg: string) => void; } -type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error"; +type Phase = + | "idle" + | "starting" + | "awaiting_user" + | "submitting" + | "polling" + | "approved" + | "error"; -export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) { +export function OAuthLoginModal({ + provider, + onClose, + onSuccess, + onError, +}: Props) { const [phase, setPhase] = useState("starting"); const [start, setStart] = useState(null); const [pkceCode, setPkceCode] = useState(""); @@ -81,13 +94,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props if (!isMounted.current) return; if (resp.status === "approved") { setPhase("approved"); - if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + if (pollTimer.current !== null) + window.clearInterval(pollTimer.current); onSuccess(`${provider.name} connected`); window.setTimeout(() => isMounted.current && onClose(), 1500); } else if (resp.status !== "pending") { setPhase("error"); setErrorMsg(resp.error_message || `Login ${resp.status}`); - if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + if (pollTimer.current !== null) + window.clearInterval(pollTimer.current); } } catch (e) { if (!isMounted.current) return; @@ -107,7 +122,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props setPhase("submitting"); setErrorMsg(null); try { - const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim()); + const resp = await api.submitOAuthCode( + provider.id, + start.session_id, + pkceCode.trim(), + ); if (!isMounted.current) return; if (resp.ok && resp.status === "approved") { setPhase("approved"); @@ -175,14 +194,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
-

+ {t.oauth.connect} {provider.name} -

- {secondsLeft !== null && phase !== "approved" && phase !== "error" && ( -

- {t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))} -

- )} + + {secondsLeft !== null && + phase !== "approved" && + phase !== "error" && ( +

+ {t.oauth.sessionExpires.replace( + "{time}", + fmtTime(secondsLeft), + )} +

+ )}
{/* ── starting ───────────────────────────────────── */} @@ -211,7 +240,10 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props />
).auth_url} + href={ + (start as Extract) + .auth_url + } target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" @@ -219,7 +251,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props {t.oauth.reOpenAuth} -
@@ -243,23 +279,46 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props

- {(start as Extract).user_code} + { + ( + start as Extract< + OAuthStartResponse, + { flow: "device_code" } + > + ).user_code + }
).verification_url} + href={ + ( + start as Extract< + OAuthStartResponse, + { flow: "device_code" } + > + ).verification_url + } target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" @@ -302,21 +361,36 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props setStart(null); setPkceCode(""); setPhase("starting"); - api.startOAuthLogin(provider.id).then((resp) => { - if (!isMounted.current) return; - setStart(resp); - setSecondsLeft(resp.expires_in); - setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user"); - if (resp.flow === "pkce") { - window.open(resp.auth_url, "_blank", "noopener,noreferrer"); - } else { - window.open(resp.verification_url, "_blank", "noopener,noreferrer"); - } - }).catch((e) => { - if (!isMounted.current) return; - setPhase("error"); - setErrorMsg(`${t.common.retry} failed: ${e}`); - }); + api + .startOAuthLogin(provider.id) + .then((resp) => { + if (!isMounted.current) return; + setStart(resp); + setSecondsLeft(resp.expires_in); + setPhase( + resp.flow === "device_code" + ? "polling" + : "awaiting_user", + ); + if (resp.flow === "pkce") { + window.open( + resp.auth_url, + "_blank", + "noopener,noreferrer", + ); + } else { + window.open( + resp.verification_url, + "_blank", + "noopener,noreferrer", + ); + } + }) + .catch((e) => { + if (!isMounted.current) return; + setPhase("error"); + setErrorMsg(`${t.common.retry} failed: ${e}`); + }); }} > {t.common.retry} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index dd32264b3..9a7679a13 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Palette, Check } from "lucide-react"; +import { Typography } from "@nous-research/ui/ui/components/typography/index"; import { BUILTIN_THEMES, useTheme } from "@/themes"; import { useI18n } from "@/i18n"; import { cn } from "@/lib/utils"; @@ -56,9 +57,12 @@ export function ThemeSwitcher() { aria-haspopup="listbox" > - + {label} - + {open && ( @@ -72,9 +76,12 @@ export function ThemeSwitcher() { )} >
- + {t.theme?.title ?? "Theme"} - +
{availableThemes.map((th) => { @@ -100,13 +107,16 @@ export function ThemeSwitcher() { {preset ? : }
- + {th.label} - + {th.description && ( - + {th.description} - + )}
diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 62dce200a..e6a433d08 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react"; +import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { CronJob } from "@/lib/api"; import { useToast } from "@/hooks/useToast"; @@ -195,10 +196,10 @@ export default function CronPage() { {/* Jobs list */}
-

+

{t.cron.scheduledJobs} ({jobs.length}) -

+ {jobs.length === 0 && ( diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index bd79d0d61..19e3ef475 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { FileText, RefreshCw, ChevronRight } from "lucide-react"; +import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -104,7 +105,7 @@ export default function LogsPage() {
-

{t.logs.title}

+

{t.logs.title}

{loading && (
)} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 31b21e518..cb04fcffc 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -13,6 +13,7 @@ import { Hash, X, } from "lucide-react"; +import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api"; import { timeAgo } from "@/lib/utils"; @@ -383,7 +384,7 @@ export default function SessionsPage() {
-

{t.sessions.title}

+

{t.sessions.title}

{total} diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index 3fc462b10..52daeef2c 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -15,6 +15,7 @@ import { Code, Zap, } from "lucide-react"; +import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { SkillInfo, ToolsetInfo } from "@/lib/api"; import { useToast } from "@/hooks/useToast"; @@ -193,7 +194,7 @@ export default function SkillsPage() {
-

{t.skills.title}

+

{t.skills.title}

{t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))} diff --git a/web/src/pages/StatusPage.tsx b/web/src/pages/StatusPage.tsx index 863594542..6f2418fac 100644 --- a/web/src/pages/StatusPage.tsx +++ b/web/src/pages/StatusPage.tsx @@ -9,6 +9,7 @@ import { Wifi, WifiOff, } from "lucide-react"; +import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index"; import { api } from "@/lib/api"; import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api"; import { timeAgo, isoTimeAgo } from "@/lib/utils"; @@ -23,8 +24,14 @@ export default function StatusPage() { useEffect(() => { const load = () => { - api.getStatus().then(setStatus).catch(() => {}); - api.getSessions(50).then((resp) => setSessions(resp.sessions)).catch(() => {}); + api + .getStatus() + .then(setStatus) + .catch(() => {}); + api + .getSessions(50) + .then((resp) => setSessions(resp.sessions)) + .catch(() => {}); }; load(); const interval = setInterval(load, 5000); @@ -39,13 +46,19 @@ export default function StatusPage() { ); } - const PLATFORM_STATE_BADGE: Record = { + const PLATFORM_STATE_BADGE: Record< + string, + { variant: "success" | "warning" | "destructive"; label: string } + > = { connected: { variant: "success", label: t.status.connected }, disconnected: { variant: "warning", label: t.status.disconnected }, fatal: { variant: "destructive", label: t.status.error }, }; - const GATEWAY_STATE_DISPLAY: Record = { + const GATEWAY_STATE_DISPLAY: Record< + string, + { badge: "success" | "warning" | "destructive" | "outline"; label: string } + > = { running: { badge: "success", label: t.status.running }, starting: { badge: "warning", label: t.status.starting }, startup_failed: { badge: "destructive", label: t.status.failed }, @@ -53,15 +66,19 @@ export default function StatusPage() { }; function gatewayValue(): string { - if (status!.gateway_running && status!.gateway_health_url) return status!.gateway_health_url; - if (status!.gateway_running && status!.gateway_pid) return `${t.status.pid} ${status!.gateway_pid}`; + if (status!.gateway_running && status!.gateway_health_url) + return status!.gateway_health_url; + if (status!.gateway_running && status!.gateway_pid) + return `${t.status.pid} ${status!.gateway_pid}`; if (status!.gateway_running) return t.status.runningRemote; if (status!.gateway_state === "startup_failed") return t.status.startFailed; return t.status.notRunning; } function gatewayBadge() { - const info = status!.gateway_state ? GATEWAY_STATE_DISPLAY[status!.gateway_state] : null; + const info = status!.gateway_state + ? GATEWAY_STATE_DISPLAY[status!.gateway_state] + : null; if (info) return info; return status!.gateway_running ? { badge: "success" as const, label: t.status.running } @@ -88,9 +105,14 @@ export default function StatusPage() { { icon: Activity, label: t.status.activeSessions, - value: status.active_sessions > 0 ? `${status.active_sessions} ${t.status.running.toLowerCase()}` : t.status.noneRunning, + value: + status.active_sessions > 0 + ? `${status.active_sessions} ${t.status.running.toLowerCase()}` + : t.status.noneRunning, badgeText: status.active_sessions > 0 ? t.common.live : t.common.off, - badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline", + badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as + | "success" + | "outline", }, ]; @@ -106,9 +128,14 @@ export default function StatusPage() { detail: status.gateway_exit_reason ?? undefined, }); } - const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected"); + const failedPlatforms = platforms.filter( + ([, info]) => info.state === "fatal" || info.state === "disconnected", + ); for (const [name, info] of failedPlatforms) { - const stateLabel = info.state === "fatal" ? t.status.platformError : t.status.platformDisconnected; + const stateLabel = + info.state === "fatal" + ? t.status.platformError + : t.status.platformDisconnected; alerts.push({ message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`, detail: info.error_message ?? undefined, @@ -117,7 +144,6 @@ export default function StatusPage() { return (
- {/* Alert banner β€” breaks grid monotony for critical states */} {alerts.length > 0 && (
@@ -125,9 +151,13 @@ export default function StatusPage() {
{alerts.map((alert, i) => (
-

{alert.message}

+

+ {alert.message} +

{alert.detail && ( -

{alert.detail}

+

+ {alert.detail} +

)}
))} @@ -136,32 +166,41 @@ export default function StatusPage() {
)} -
+ {items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => ( - - + +
{label} - +
- -
{value}
+
+ {value} +
- {badgeText && ( - - {badgeVariant === "success" && ( - - )} - {badgeText} - - )} -
-
+ {badgeText && ( + + {badgeVariant === "success" && ( + + )} + {badgeText} + + )} + ))} -
+ {platforms.length > 0 && ( - + )} {activeSessions.length > 0 && ( @@ -169,7 +208,9 @@ export default function StatusPage() {
- {t.status.activeSessions} + + {t.status.activeSessions} +
@@ -181,7 +222,9 @@ export default function StatusPage() { >
- {s.title ?? t.common.untitled} + + {s.title ?? t.common.untitled} + @@ -190,7 +233,11 @@ export default function StatusPage() {
- {(s.model ?? t.common.unknown).split("/").pop()} Β· {s.message_count} {t.common.msgs} Β· {timeAgo(s.last_active)} + + {(s.model ?? t.common.unknown).split("/").pop()} + {" "} + Β· {s.message_count} {t.common.msgs} Β·{" "} + {timeAgo(s.last_active)}
@@ -204,7 +251,9 @@ export default function StatusPage() {
- {t.status.recentSessions} + + {t.status.recentSessions} +
@@ -215,10 +264,16 @@ export default function StatusPage() { className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full" >
- {s.title ?? t.common.untitled} + + {s.title ?? t.common.untitled} + - {(s.model ?? t.common.unknown).split("/").pop()} Β· {s.message_count} {t.common.msgs} Β· {timeAgo(s.last_active)} + + {(s.model ?? t.common.unknown).split("/").pop()} + {" "} + Β· {s.message_count} {t.common.msgs} Β·{" "} + {timeAgo(s.last_active)} {s.preview && ( @@ -228,7 +283,10 @@ export default function StatusPage() { )}
- + {s.source ?? "local"} @@ -249,7 +307,9 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
- {t.status.connectedPlatforms} + + {t.status.connectedPlatforms} +
@@ -259,7 +319,12 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) { variant: "outline" as const, label: info.state, }; - const IconComponent = info.state === "connected" ? Wifi : info.state === "fatal" ? AlertTriangle : WifiOff; + const IconComponent = + info.state === "connected" + ? Wifi + : info.state === "fatal" + ? AlertTriangle + : WifiOff; return (
- +
- {name} + + {name} + {info.error_message && ( - {info.error_message} + + {info.error_message} + )} {info.updated_at && ( @@ -290,7 +361,10 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
- + {display.variant === "success" && ( )} @@ -306,5 +380,8 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) { interface PlatformsCardProps { platforms: [string, PlatformStatus][]; - platformStateBadge: Record; + platformStateBadge: Record< + string, + { variant: "success" | "warning" | "destructive"; label: string } + >; }