MVP: Pulse, Memory, History modules

- Mobile-first PWA with dark glassmorphic theme
- Pulse: status card, recent sessions, memory snapshot
- Memory: view/edit agent + user memory with usage bars
- History: session list, platform filter, search, session replay
- TanStack Query hooks for all Hermes WebAPI endpoints
- Bottom tab navigation, safe-area support
This commit is contained in:
Hermes
2026-04-09 20:41:13 -05:00
parent 65325d5f68
commit 517583d1c4
38 changed files with 8088 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
const BASE = process.env.NEXT_PUBLIC_HERMES_API || "http://localhost:8787";
async function get<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function patch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function del<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
import type {
Session, Message, MemoryResponse, SkillSummary, SkillDetail,
CronJob, ConfigResponse, SearchResult,
} from "./types";
// Sessions
export const fetchSessions = (limit = 50, offset = 0, source?: string) =>
get<{ items: Session[]; total: number }>(
`/api/sessions?limit=${limit}&offset=${offset}${source ? `&source=${source}` : ""}`
);
export const fetchSession = (id: string) =>
get<{ session: Session }>(`/api/sessions/${id}`);
export const fetchMessages = (sessionId: string, limit = 200, offset = 0) =>
get<{ items: Message[]; total: number }>(
`/api/sessions/${sessionId}/messages?limit=${limit}&offset=${offset}`
);
export const searchSessions = (q: string, limit = 20) =>
get<{ query: string; count: number; results: SearchResult[] }>(
`/api/sessions/search?q=${encodeURIComponent(q)}&limit=${limit}`
);
// Memory
export const fetchMemory = () => get<MemoryResponse>("/api/memory");
export const addMemory = (target: string, content: string) =>
post<{ success: boolean }>("/api/memory", { target, content });
export const patchMemory = (target: string, old_text: string, content: string) =>
patch<{ success: boolean }>("/api/memory", { target, old_text, content });
export const deleteMemory = (target: string, old_text: string) =>
del<{ success: boolean }>("/api/memory", { target, old_text });
// Skills
export const fetchSkills = (category?: string) =>
get<{ success: boolean; skills: SkillSummary[] }>(
`/api/skills${category ? `?category=${category}` : ""}`
);
export const fetchSkill = (name: string) =>
get<SkillDetail>(`/api/skills/${name}`);
// Config
export const fetchConfig = () => get<ConfigResponse>("/api/config");
// Health
export const fetchHealth = () => get<{ status: string }>("/api/health");
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as api from "./api";
// Sessions
export function useSessions(limit = 20, source?: string) {
return useQuery({
queryKey: ["sessions", limit, source],
queryFn: () => api.fetchSessions(limit, 0, source),
});
}
export function useSession(id: string) {
return useQuery({
queryKey: ["session", id],
queryFn: () => api.fetchSession(id),
enabled: !!id,
});
}
export function useMessages(sessionId: string) {
return useQuery({
queryKey: ["messages", sessionId],
queryFn: () => api.fetchMessages(sessionId),
enabled: !!sessionId,
});
}
export function useSessionSearch(query: string) {
return useQuery({
queryKey: ["search", query],
queryFn: () => api.searchSessions(query),
enabled: query.length >= 2,
});
}
// Memory
export function useMemory() {
return useQuery({
queryKey: ["memory"],
queryFn: api.fetchMemory,
});
}
export function useAddMemory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ target, content }: { target: string; content: string }) =>
api.addMemory(target, content),
onSuccess: () => qc.invalidateQueries({ queryKey: ["memory"] }),
});
}
export function usePatchMemory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ target, old_text, content }: { target: string; old_text: string; content: string }) =>
api.patchMemory(target, old_text, content),
onSuccess: () => qc.invalidateQueries({ queryKey: ["memory"] }),
});
}
export function useDeleteMemory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ target, old_text }: { target: string; old_text: string }) =>
api.deleteMemory(target, old_text),
onSuccess: () => qc.invalidateQueries({ queryKey: ["memory"] }),
});
}
// Skills
export function useSkills(category?: string) {
return useQuery({
queryKey: ["skills", category],
queryFn: () => api.fetchSkills(category),
});
}
export function useSkill(name: string) {
return useQuery({
queryKey: ["skill", name],
queryFn: () => api.fetchSkill(name),
enabled: !!name,
});
}
// Config
export function useConfig() {
return useQuery({
queryKey: ["config"],
queryFn: api.fetchConfig,
});
}
// Health
export function useHealth() {
return useQuery({
queryKey: ["health"],
queryFn: api.fetchHealth,
refetchInterval: 30_000,
});
}
+107
View File
@@ -0,0 +1,107 @@
// Hermes WebAPI response types
export interface Session {
id: string;
source: string;
user_id: string | null;
model: string | null;
started_at: number;
ended_at: number | null;
end_reason: string | null;
message_count: number;
tool_call_count: number;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
reasoning_tokens: number;
estimated_cost_usd: number | null;
title: string | null;
parent_session_id: string | null;
}
export interface Message {
id: number;
session_id: string;
role: "user" | "assistant" | "tool" | "system";
content: string | null;
tool_call_id: string | null;
tool_calls: string | null;
tool_name: string | null;
timestamp: number;
token_count: number | null;
finish_reason: string | null;
reasoning: string | null;
}
export interface ToolCall {
id: string;
function: { name: string; arguments: string };
}
export interface MemoryTarget {
target: "memory" | "user";
entries: string[];
usage: string;
entry_count: number;
}
export interface MemoryResponse {
targets: MemoryTarget[];
}
export interface SkillSummary {
name: string;
description: string;
category?: string;
}
export interface SkillDetail {
name: string;
content: string;
linked_files?: Record<string, string[]>;
}
export interface CronJob {
id: string;
name?: string;
prompt: string;
schedule: string;
status: string;
deliver: string;
skill?: string;
skills?: string[];
model?: string;
provider?: string;
repeat?: number;
run_count: number;
last_run?: string;
next_run?: string;
created_at: string;
}
export interface GatewayState {
pid: number;
kind: string;
gateway_state: string;
platforms: Record<string, { state: string; updated_at: string }>;
updated_at: string;
}
export interface ConfigResponse {
model: string;
provider: string | null;
api_mode: string | null;
base_url: string | null;
config: Record<string, unknown>;
}
export interface SearchResult {
session_id: string;
message_id: number;
role: string;
content: string;
timestamp: number;
session_title: string | null;
session_source: string;
}
+39
View File
@@ -0,0 +1,39 @@
export function timeAgo(ts: number): string {
const now = Date.now() / 1000;
const diff = now - ts;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return new Date(ts * 1000).toLocaleDateString();
}
export function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
export function platformColor(source: string): string {
const map: Record<string, string> = {
cli: "#f59e0b",
telegram: "#38bdf8",
discord: "#8b5cf6",
whatsapp: "#22c55e",
web: "#a78bfa",
webapi: "#a78bfa",
};
return map[source] || "#6b7280";
}
export function platformBadgeClass(source: string): string {
const map: Record<string, string> = {
cli: "badge-cli",
telegram: "badge-telegram",
discord: "badge-discord",
whatsapp: "badge-whatsapp",
web: "badge-web",
webapi: "badge-web",
};
return map[source] || "badge-cli";
}