feat: v1.0.0 — profiles, knowledge browser, MCP settings, skills hub upgrade, eslint, security contact update
New features: - Multi-profile management (create, switch, rename, delete) - Knowledge browser with document viewer - MCP server settings screen - Skills hub with marketplace search fallback - Context usage tracking and display Improvements: - eslint added and auto-fixed (69 issues resolved) - Settings dialog restructured (Agent, Smart Routing, Voice, Display sections) - Navigation updated with Profiles tab across desktop/mobile - Security contact updated to GitHub advisories + X DM - .gitignore hardened (.runtime/, internal dev docs) - Version bumped to 1.0.0 Build: clean | TypeScript: 0 errors | Tests: 4/4 passing
This commit is contained in:
1
.github/workflows/security.yml
vendored
1
.github/workflows/security.yml
vendored
@@ -68,4 +68,3 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✅ No obvious secret patterns found"
|
echo "✅ No obvious secret patterns found"
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -136,3 +136,5 @@ __pycache__/
|
|||||||
|
|
||||||
.env.docker
|
.env.docker
|
||||||
.env.bak
|
.env.bak
|
||||||
|
.runtime/
|
||||||
|
workspace-final-markdown-review.md
|
||||||
|
|||||||
@@ -50,4 +50,3 @@ See `.env.example` for all options. Key ones:
|
|||||||
- **Describe what you changed** — clear PR title + description
|
- **Describe what you changed** — clear PR title + description
|
||||||
- **No secrets** — never commit API keys, tokens, or passwords
|
- **No secrets** — never commit API keys, tokens, or passwords
|
||||||
- **Follow existing patterns** — match the code style you see
|
- **Follow existing patterns** — match the code style you see
|
||||||
|
|
||||||
|
|||||||
735
FEATURES-INVENTORY.md
Normal file
735
FEATURES-INVENTORY.md
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
# Hermes Workspace — Comprehensive Features Inventory
|
||||||
|
|
||||||
|
> **Version:** 1.0.0 | **Stack:** React 19 + TanStack Start/Router + Vite 7 + Tailwind CSS 4 + Zustand + xterm.js + Monaco Editor
|
||||||
|
> **Description:** Desktop workspace for Hermes Agent — chat, orchestration, and multi-agent coding pipelines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Frontend Screens & Features](#1-frontend-screens--features)
|
||||||
|
2. [Backend API Endpoints](#2-backend-api-endpoints)
|
||||||
|
3. [UI Components Library](#3-ui-components-library)
|
||||||
|
4. [Configuration & Settings](#4-configuration--settings)
|
||||||
|
5. [Server-Side Architecture](#5-server-side-architecture)
|
||||||
|
6. [Integrations & Provider Support](#6-integrations--provider-support)
|
||||||
|
7. [UX Features & Interactions](#7-ux-features--interactions)
|
||||||
|
8. [Security Features](#8-security-features)
|
||||||
|
9. [Mobile & PWA Features](#9-mobile--pwa-features)
|
||||||
|
10. [Deployment Options](#10-deployment-options)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Frontend Screens & Features
|
||||||
|
|
||||||
|
### 1.1 Chat Screen (`/chat`, `/chat/$sessionKey`)
|
||||||
|
|
||||||
|
- **Real-time SSE streaming** with tool call rendering
|
||||||
|
- **Multi-session management** — create, rename, delete, fork sessions
|
||||||
|
- **Dual chat backend modes:**
|
||||||
|
- **Enhanced Hermes** — full session API with persistent history via Hermes gateway
|
||||||
|
- **Portable** — OpenAI-compatible `/v1/chat/completions` (works with Ollama, LM Studio, vLLM, etc.)
|
||||||
|
- **Chat sidebar** — session list with search, pin, rename, delete dialogs
|
||||||
|
- **Message rendering:**
|
||||||
|
- Markdown with GFM support (`react-markdown` + `remark-gfm` + `remark-breaks`)
|
||||||
|
- Syntax highlighting via Shiki
|
||||||
|
- Tool call pill rendering with expandable details
|
||||||
|
- Thinking/reasoning content display
|
||||||
|
- Message timestamps
|
||||||
|
- Message actions bar (copy, etc.)
|
||||||
|
- **Chat composer** — multi-line input with:
|
||||||
|
- Slash command menu (`/new`, `/clear`, `/model`, `/save`, `/skills`, `/skin`, `/help`)
|
||||||
|
- File attachment support (images via base64, multimodal content)
|
||||||
|
- Voice input (Web Speech API)
|
||||||
|
- Context meter showing token usage percentage
|
||||||
|
- **Session management features:**
|
||||||
|
- Auto-generated session titles
|
||||||
|
- Session forking
|
||||||
|
- Session search across history
|
||||||
|
- Pinned sessions
|
||||||
|
- Session tombstones for deleted session cleanup
|
||||||
|
- **Inspector panel** — sidebar showing session activity, memory, and skills
|
||||||
|
- **Research card** — embedded research display
|
||||||
|
- **Connection status messaging** — real-time gateway connectivity indicators
|
||||||
|
- **Scroll-to-bottom button** for long conversations
|
||||||
|
- **Chat empty state** — onboarding content when no messages exist
|
||||||
|
- **Provider selection dialog** — model/provider chooser inline in chat
|
||||||
|
- **Smooth streaming text** — progressive text reveal for streaming responses
|
||||||
|
- **Context alert system** — warnings when approaching token limits
|
||||||
|
|
||||||
|
### 1.2 Dashboard Screen (`/dashboard`)
|
||||||
|
|
||||||
|
- Overview dashboard for workspace metrics
|
||||||
|
- Dashboard overflow panel for expanded views
|
||||||
|
|
||||||
|
### 1.3 Files Screen (`/files`)
|
||||||
|
|
||||||
|
- **Full workspace file browser** with directory tree navigation
|
||||||
|
- **File preview dialog** — inline file viewing
|
||||||
|
- **Monaco Editor integration** — full code editing
|
||||||
|
- **File operations:** create, read, write, rename, delete, mkdir
|
||||||
|
- **File upload** — multipart form upload support
|
||||||
|
- **Image preview** — base64 rendering for image files
|
||||||
|
- **Glob pattern support** — filter files by pattern
|
||||||
|
- **Path traversal prevention** — sandboxed to workspace root
|
||||||
|
- **Ignored directories:** `node_modules`, `.git`, `.next`, `.turbo`, `.cache`, `__pycache__`, `.venv`, `dist`
|
||||||
|
- **Max depth/entries limits** — configurable tree depth (default 3), max 20K entries
|
||||||
|
|
||||||
|
### 1.4 Terminal Screen (`/terminal`)
|
||||||
|
|
||||||
|
- **Full PTY terminal** via Python pty-helper
|
||||||
|
- **xterm.js** with addons: fit, search, web-links
|
||||||
|
- **256-color support** (TERM=xterm-256color, COLORTERM=truecolor)
|
||||||
|
- **Persistent shell sessions** — create, input, resize, close
|
||||||
|
- **SSE-based terminal streaming** — real-time output
|
||||||
|
- **Keepalive pings** every 8 seconds
|
||||||
|
- **Terminal workspace** component with debug panel
|
||||||
|
- **Mobile terminal input** — adapted for touch devices
|
||||||
|
- **Platform-aware default shell:** zsh (macOS), bash (Linux), PowerShell (Windows)
|
||||||
|
|
||||||
|
### 1.5 Memory Browser Screen (`/memory`)
|
||||||
|
|
||||||
|
- **Browse agent memory files** in `~/.hermes/` (MEMORY.md, memory/, memories/)
|
||||||
|
- **Search across memory entries** — text search with line-level results (max 200 matches)
|
||||||
|
- **Markdown preview** with live editing via MemoryEditor
|
||||||
|
- **Memory file list** — sorted with MEMORY.md first, daily files by date
|
||||||
|
- **Memory components:** MemoryFileList, MemorySearch, MemoryEditor, MemoryPreview
|
||||||
|
|
||||||
|
### 1.6 Skills Browser Screen (`/skills`)
|
||||||
|
|
||||||
|
- **Browse 2,000+ skills** from the Hermes skill registry
|
||||||
|
- **Tabbed view:** Installed, Marketplace, Featured
|
||||||
|
- **Skill categories:** All, Web & Frontend, Coding Agents, Git & GitHub, DevOps & Cloud, Browser & Automation, Image & Video, Search & Research, AI & LLMs, Productivity, Marketing & Sales, Communication, Data & Analytics, Finance & Crypto
|
||||||
|
- **Search and filter** — by name, description, author, tags, triggers
|
||||||
|
- **Sort options:** by name, by category
|
||||||
|
- **Featured skills** curation with groups (Most Popular, New This Week, Developer Tools, Productivity)
|
||||||
|
- **Security risk display** — safe/low/medium/high levels with flags and scores
|
||||||
|
- **Workspace skills screen** — per-session skill management
|
||||||
|
|
||||||
|
### 1.7 Jobs Screen (`/jobs`)
|
||||||
|
|
||||||
|
- **Scheduled job management** — cron-style agent automation
|
||||||
|
- **Create job dialog** — schedule, prompt, name, delivery, skills, repeat config
|
||||||
|
- **Edit job dialog** — modify existing jobs
|
||||||
|
- **Job operations:** create, update, delete, pause, resume, trigger
|
||||||
|
- **Job output viewer** — view execution results
|
||||||
|
- **Job state tracking** — enabled/disabled, next/last run, success status
|
||||||
|
|
||||||
|
### 1.8 Settings Screens (`/settings`, `/settings/providers`)
|
||||||
|
|
||||||
|
- **Settings dialog** — centralized configuration panel
|
||||||
|
- **Providers screen** — manage AI provider connections
|
||||||
|
- **Provider wizard** — guided setup for new providers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Backend API Endpoints
|
||||||
|
|
||||||
|
### 2.1 Chat & Messaging
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| -------------------- | ------ | -------------------------------------------------------------------------------- |
|
||||||
|
| `/api/send-stream` | POST | Main streaming chat endpoint — routes to enhanced Hermes or portable OpenAI mode |
|
||||||
|
| `/api/send` | POST | Non-streaming chat send |
|
||||||
|
| `/api/sessions/send` | POST | Session-specific send |
|
||||||
|
| `/api/chat-events` | GET | SSE chat event stream |
|
||||||
|
| `/api/events` | GET | Global SSE event bus (keepalive, real-time updates) |
|
||||||
|
| `/api/history` | GET | Chat history retrieval |
|
||||||
|
|
||||||
|
### 2.2 Sessions
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| -------------------------------------- | ------ | ------------------------------------- |
|
||||||
|
| `/api/sessions` | GET | List all sessions (paginated, max 50) |
|
||||||
|
| `/api/sessions` | POST | Create new session |
|
||||||
|
| `/api/sessions` | PATCH | Update session (rename) |
|
||||||
|
| `/api/sessions` | DELETE | Delete session |
|
||||||
|
| `/api/sessions/$sessionKey/status` | GET | Session status |
|
||||||
|
| `/api/sessions/$sessionKey/active-run` | GET | Active run for session |
|
||||||
|
| `/api/session-status` | GET | Session connection status |
|
||||||
|
|
||||||
|
### 2.3 Files
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| ---------------------------- | ------ | ------------------------------------------- |
|
||||||
|
| `/api/files?action=list` | GET | List directory tree with depth/entry limits |
|
||||||
|
| `/api/files?action=read` | GET | Read file content (text or base64 image) |
|
||||||
|
| `/api/files?action=download` | GET | Download file with Content-Disposition |
|
||||||
|
| `/api/files` | POST | Write/upload/mkdir/rename/delete files |
|
||||||
|
| `/api/paths` | GET | Path resolution and workspace info |
|
||||||
|
|
||||||
|
### 2.4 Memory
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| -------------------- | ------ | -------------------------------- |
|
||||||
|
| `/api/memory` | GET | Get memory from Hermes gateway |
|
||||||
|
| `/api/memory/list` | GET | List local memory markdown files |
|
||||||
|
| `/api/memory/read` | GET | Read specific memory file |
|
||||||
|
| `/api/memory/search` | GET | Search across memory files |
|
||||||
|
| `/api/memory/write` | POST | Write/update memory file |
|
||||||
|
|
||||||
|
### 2.5 Skills
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| ------------- | ------ | ----------------------------------------- |
|
||||||
|
| `/api/skills` | GET | List skills (paginated, filtered, sorted) |
|
||||||
|
| `/api/skills` | POST | Skill installation (currently disabled) |
|
||||||
|
|
||||||
|
### 2.6 Models & Config
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| -------------------- | ------ | -------------------------------------------- |
|
||||||
|
| `/api/models` | GET | List available models (gateway + auth store) |
|
||||||
|
| `/api/hermes-config` | GET | Read Hermes config.yaml and .env |
|
||||||
|
| `/api/hermes-config` | PATCH | Update config.yaml and .env |
|
||||||
|
| `/api/context-usage` | GET | Token/context usage for a session |
|
||||||
|
|
||||||
|
### 2.7 Jobs
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| ------------------------- | --------------------- | ---------------------------------------------- |
|
||||||
|
| `/api/hermes-jobs` | GET | List all jobs |
|
||||||
|
| `/api/hermes-jobs` | POST | Create new job |
|
||||||
|
| `/api/hermes-jobs/$jobId` | GET/POST/PATCH/DELETE | Job CRUD and actions (pause/resume/run/output) |
|
||||||
|
|
||||||
|
### 2.8 Terminal
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| ---------------------- | ------ | ---------------------------------------- |
|
||||||
|
| `/api/terminal-stream` | POST | Create PTY session and stream SSE output |
|
||||||
|
| `/api/terminal-input` | POST | Send input to terminal session |
|
||||||
|
| `/api/terminal-resize` | POST | Resize terminal dimensions |
|
||||||
|
| `/api/terminal-close` | POST | Close terminal session |
|
||||||
|
|
||||||
|
### 2.9 Auth & Infrastructure
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| ------------------------ | ------ | --------------------------------------------- |
|
||||||
|
| `/api/auth` | POST | Password authentication (rate-limited: 5/min) |
|
||||||
|
| `/api/auth-check` | GET | Check authentication status |
|
||||||
|
| `/api/ping` | GET | Server ping/health |
|
||||||
|
| `/api/connection-status` | GET | Gateway connection status with capabilities |
|
||||||
|
| `/api/gateway-status` | GET | Detailed gateway capabilities |
|
||||||
|
| `/api/start-agent` | POST | Auto-start Hermes agent process |
|
||||||
|
| `/api/start-hermes` | POST | Start Hermes gateway |
|
||||||
|
| `/api/workspace` | GET | Workspace auto-detection |
|
||||||
|
|
||||||
|
### 2.10 OAuth
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| ------------------------ | ------ | ------------------------------- |
|
||||||
|
| `/api/oauth/device-code` | POST | Device code flow (Nous Portal) |
|
||||||
|
| `/api/oauth/poll-token` | POST | Poll for OAuth token completion |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UI Components Library
|
||||||
|
|
||||||
|
### 3.1 Core UI Primitives (`src/components/ui/`)
|
||||||
|
|
||||||
|
- **alert-dialog** — confirmation dialogs
|
||||||
|
- **autocomplete** — filterable autocomplete input
|
||||||
|
- **braille-spinner** — loading indicator with braille animation
|
||||||
|
- **button** — button variants (class-variance-authority)
|
||||||
|
- **collapsible** — expandable/collapsible sections
|
||||||
|
- **command** — command palette UI (cmdk-style)
|
||||||
|
- **dialog** — modal dialogs
|
||||||
|
- **input** — text input fields
|
||||||
|
- **menu** — dropdown menus
|
||||||
|
- **preview-card** — content preview cards
|
||||||
|
- **scroll-area** — custom scrollable areas
|
||||||
|
- **switch** — toggle switches
|
||||||
|
- **tabs** — tabbed interfaces
|
||||||
|
- **three-dots-spinner** — loading animation
|
||||||
|
- **toast** — notification toasts
|
||||||
|
- **tooltip** — hover tooltips
|
||||||
|
|
||||||
|
### 3.2 Prompt Kit (`src/components/prompt-kit/`)
|
||||||
|
|
||||||
|
- **chat-container** — main chat layout wrapper
|
||||||
|
- **message** — individual message rendering
|
||||||
|
- **markdown** — rich markdown rendering
|
||||||
|
- **code-block** — syntax-highlighted code with copy
|
||||||
|
- **prompt-input** — chat input component
|
||||||
|
- **tool** — tool call display
|
||||||
|
- **tool-indicator** — tool execution status
|
||||||
|
- **thinking** — thinking/reasoning block
|
||||||
|
- **thinking-indicator** — animated thinking state
|
||||||
|
- **typing-indicator** — typing animation
|
||||||
|
- **text-shimmer** — text loading shimmer effect
|
||||||
|
- **scroll-button** — scroll-to-bottom control
|
||||||
|
|
||||||
|
### 3.3 Feature Components
|
||||||
|
|
||||||
|
- **workspace-shell** — main app layout shell
|
||||||
|
- **chat-panel** — persistent side chat panel
|
||||||
|
- **chat-panel-toggle** — show/hide chat panel
|
||||||
|
- **command-palette** — global `⌘K` command palette
|
||||||
|
- **slash-command-menu** — `/` command autocomplete in chat
|
||||||
|
- **attachment-button** — file attachment trigger
|
||||||
|
- **attachment-preview** — attached file preview
|
||||||
|
- **export-menu** — export chat as Markdown/JSON/Text
|
||||||
|
- **context-meter** — token usage visualization
|
||||||
|
- **mode-selector** — preset mode selection
|
||||||
|
- **save-mode-dialog** / **apply-mode-dialog** / **rename-mode-dialog** / **manage-modes-modal** — full mode management UI
|
||||||
|
- **model-suggestion-toast** — smart model recommendations
|
||||||
|
- **keyboard-shortcuts-modal** — keyboard shortcut reference
|
||||||
|
- **global-shortcut-listener** — system-wide keyboard shortcuts
|
||||||
|
- **terminal-shortcut-listener** — terminal-specific shortcuts
|
||||||
|
- **connection-overlay** — full-screen connection status
|
||||||
|
- **connection-startup-screen** — initial loading/connection screen
|
||||||
|
- **backend-unavailable-state** — offline fallback UI
|
||||||
|
- **hermes-health-banner** — gateway health indicator
|
||||||
|
- **hermes-reconnect-banner** — reconnection prompt
|
||||||
|
- **error-boundary** — React error boundary
|
||||||
|
- **error-toast** — error notification
|
||||||
|
- **loading-indicator** — generic loading state
|
||||||
|
- **logo-loader** — branded loading animation
|
||||||
|
- **status-indicator** — colored status dots
|
||||||
|
- **empty-state** — empty content placeholder
|
||||||
|
- **theme-toggle** — light/dark theme switcher
|
||||||
|
|
||||||
|
### 3.4 Navigation & Layout
|
||||||
|
|
||||||
|
- **mobile-tab-bar** — bottom navigation for mobile
|
||||||
|
- **mobile-hamburger-menu** — hamburger menu for mobile
|
||||||
|
- **mobile-sessions-panel** — mobile session browser
|
||||||
|
- **mobile-page-header** — mobile header bar
|
||||||
|
- **mobile-prompt** — MobileSetupModal, MobilePromptTrigger
|
||||||
|
|
||||||
|
### 3.5 Specialized Components
|
||||||
|
|
||||||
|
- **memory-viewer/** — MemoryFileList, MemorySearch, MemoryEditor, MemoryPreview
|
||||||
|
- **file-explorer/** — file-explorer-sidebar, file-preview-dialog
|
||||||
|
- **terminal/** — terminal-panel, terminal-workspace, debug-panel, mobile-terminal-input
|
||||||
|
- **inspector/** — inspector-panel, activity-store
|
||||||
|
- **usage-meter/** — usage-meter, usage-meter-compact, usage-details-modal, context-alert-modal
|
||||||
|
- **search/** — search-modal, search-input, search-results, search-result-item, quick-actions
|
||||||
|
- **settings-dialog/** — settings-dialog
|
||||||
|
- **onboarding/** — hermes-onboarding, onboarding-wizard, onboarding-tour, tour-steps, setup-step-content, provider-select-step
|
||||||
|
- **agent-chat/** — AgentChatModal, AgentChatHeader, AgentChatInput, AgentChatMessages
|
||||||
|
- **avatars/** — user-avatar, assistant-avatar
|
||||||
|
- **auth/** — login-screen
|
||||||
|
|
||||||
|
### 3.6 Provider & Model Components
|
||||||
|
|
||||||
|
- **provider-logo** — provider brand logos
|
||||||
|
- **provider-model-icon** — model-specific icons
|
||||||
|
- **agent-avatar** — AI agent avatar
|
||||||
|
- **agent-card** — agent info card
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Configuration & Settings
|
||||||
|
|
||||||
|
### 4.1 User Settings (persisted via Zustand + localStorage)
|
||||||
|
|
||||||
|
| Setting | Type | Default | Description |
|
||||||
|
| ------------------------- | ------------------------------- | -------- | --------------------------- |
|
||||||
|
| `hermesUrl` | string | `''` | Hermes API URL |
|
||||||
|
| `hermesToken` | string | `''` | Bearer token |
|
||||||
|
| `theme` | `system\|light\|dark` | `system` | Color mode |
|
||||||
|
| `accentColor` | `orange\|purple\|blue\|green` | `blue` | Accent color |
|
||||||
|
| `editorFontSize` | number | `13` | Monaco editor font size |
|
||||||
|
| `editorWordWrap` | boolean | `true` | Editor word wrap |
|
||||||
|
| `editorMinimap` | boolean | `false` | Editor minimap |
|
||||||
|
| `notificationsEnabled` | boolean | `true` | Sound/notifications |
|
||||||
|
| `usageThreshold` | number | `80` | Context usage warning % |
|
||||||
|
| `smartSuggestionsEnabled` | boolean | `false` | Smart model suggestions |
|
||||||
|
| `preferredBudgetModel` | string | `''` | Preferred cheap model |
|
||||||
|
| `preferredPremiumModel` | string | `''` | Preferred premium model |
|
||||||
|
| `onlySuggestCheaper` | boolean | `false` | Only suggest cheaper models |
|
||||||
|
| `showSystemMetricsFooter` | boolean | `false` | System metrics display |
|
||||||
|
| `mobileChatNavMode` | `dock\|integrated\|scroll-hide` | `dock` | Mobile nav behavior |
|
||||||
|
|
||||||
|
### 4.2 Theme System — 8 Themes
|
||||||
|
|
||||||
|
| Theme | Description | Mode |
|
||||||
|
| --------------------- | ------------------------------- | ----- |
|
||||||
|
| Hermes Official | Navy and indigo flagship | Dark |
|
||||||
|
| Hermes Official Light | Soft indigo light palette | Light |
|
||||||
|
| Hermes Classic | Bronze accents on dark charcoal | Dark |
|
||||||
|
| Classic Light | Warm parchment with bronze | Light |
|
||||||
|
| Slate | Cool blue developer theme | Dark |
|
||||||
|
| Slate Light | GitHub-light with blue accents | Light |
|
||||||
|
| Mono | Clean monochrome grayscale | Dark |
|
||||||
|
| Mono Light | Bright monochrome grayscale | Light |
|
||||||
|
|
||||||
|
### 4.3 Workspace State (Zustand, persisted)
|
||||||
|
|
||||||
|
- Sidebar collapsed/expanded
|
||||||
|
- File explorer collapsed/expanded
|
||||||
|
- Chat focus mode
|
||||||
|
- Active sub-page route
|
||||||
|
- Chat panel open/closed + session key
|
||||||
|
- Mobile keyboard state
|
||||||
|
|
||||||
|
### 4.4 Modes System
|
||||||
|
|
||||||
|
- **Custom presets** — save/load named configurations
|
||||||
|
- Each mode stores: name, preferred model, smart suggestions toggle, budget/premium model prefs
|
||||||
|
- Drift detection — alerts when settings diverge from applied mode
|
||||||
|
|
||||||
|
### 4.5 Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ---------------------- | -------------------------------------------------- |
|
||||||
|
| `HERMES_API_URL` | Backend API URL (default: `http://127.0.0.1:8642`) |
|
||||||
|
| `HERMES_PASSWORD` | Optional password protection for web UI |
|
||||||
|
| `HERMES_WORKSPACE_DIR` | Workspace root directory (default: `~/.hermes`) |
|
||||||
|
| `HERMES_AGENT_PATH` | Path to hermes-agent directory |
|
||||||
|
| `HERMES_DEFAULT_MODEL` | Default model override |
|
||||||
|
| `HERMES_ALLOWED_HOSTS` | Allowed hosts (default: `.ts.net`) |
|
||||||
|
| `ANTHROPIC_API_KEY` | Anthropic API key passthrough |
|
||||||
|
| `BEARER_TOKEN` | Bearer token for backend auth |
|
||||||
|
| `PORT` | Server port (default: 3002 dev, 3000 prod) |
|
||||||
|
|
||||||
|
### 4.6 Hermes Config Management
|
||||||
|
|
||||||
|
- **Read/write `~/.hermes/config.yaml`** — YAML config via web UI
|
||||||
|
- **Read/write `~/.hermes/.env`** — environment variables
|
||||||
|
- **Provider status** with masked API keys
|
||||||
|
- **Auth store integration** — reads from `~/.hermes/auth-profiles.json` and `~/.openclaw/agents/main/agent/auth-profiles.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Server-Side Architecture
|
||||||
|
|
||||||
|
### 5.1 Gateway Capability Probing
|
||||||
|
|
||||||
|
- **Two-tier capability model:**
|
||||||
|
- **Core:** health, chatCompletions, models, streaming
|
||||||
|
- **Enhanced:** sessions, skills, memory, config, jobs
|
||||||
|
- **Three chat modes:**
|
||||||
|
- `enhanced-hermes` — full Hermes session API
|
||||||
|
- `portable` — OpenAI-compatible /v1/chat/completions
|
||||||
|
- `disconnected` — no usable backend
|
||||||
|
- **Auto-detection** with port fallback (8642 → 8643)
|
||||||
|
- **Probe TTL** — 30 second cache, periodic refresh
|
||||||
|
- **Feature gates** — graceful degradation per capability
|
||||||
|
|
||||||
|
### 5.2 Chat Event Bus
|
||||||
|
|
||||||
|
- Server-side event bus for real-time updates
|
||||||
|
- SSE broadcasting to all connected clients
|
||||||
|
- Chat events: chunk, done, error, thinking, tool calls
|
||||||
|
|
||||||
|
### 5.3 Run Store (Persistence)
|
||||||
|
|
||||||
|
- Persisted run state at `~/.hermes/webui-mvp/runs/`
|
||||||
|
- Run lifecycle: accepted → active → handoff → stalled → complete → error
|
||||||
|
- Tool call tracking with phase management
|
||||||
|
- Lifecycle event logging (max 40 per run)
|
||||||
|
- Run timeout: 15 minutes
|
||||||
|
|
||||||
|
### 5.4 Terminal Sessions
|
||||||
|
|
||||||
|
- Python PTY helper (`pty-helper.py`) — real PTY without native node-pty addon
|
||||||
|
- Session management: create, input, resize (SIGWINCH), close (SIGTERM → SIGKILL)
|
||||||
|
- Event emitter pattern with early buffer for pre-listener output
|
||||||
|
|
||||||
|
### 5.5 Memory Browser (Server)
|
||||||
|
|
||||||
|
- Filesystem-based memory browsing in `~/.hermes/`
|
||||||
|
- File filters: MEMORY.md, memory/_, memories/_
|
||||||
|
- Markdown-only restriction
|
||||||
|
- Path traversal prevention
|
||||||
|
- Sort: MEMORY.md first, daily files by date descending, then by modification time
|
||||||
|
|
||||||
|
### 5.6 OpenAI-Compatible API Client
|
||||||
|
|
||||||
|
- Streaming parser for `/v1/chat/completions` SSE
|
||||||
|
- Support for reasoning/thinking content (DeepSeek, QwQ, etc.)
|
||||||
|
- Automatic default model detection from `/v1/models`
|
||||||
|
- Multimodal support (image_url content parts)
|
||||||
|
|
||||||
|
### 5.7 Hermes Agent Auto-Start
|
||||||
|
|
||||||
|
- Auto-detects sibling `hermes-agent/` directory
|
||||||
|
- Resolves Python virtualenv (`.venv`, `venv`, system `python3`)
|
||||||
|
- Spawns uvicorn with health polling (15 attempts, 1s interval)
|
||||||
|
- Reads `~/.hermes/.env` for agent configuration
|
||||||
|
|
||||||
|
### 5.8 Workspace Daemon (Optional)
|
||||||
|
|
||||||
|
- Separate workspace daemon process on port 3099
|
||||||
|
- Auto-restart with exponential backoff (max 20 retries)
|
||||||
|
- Provides workspace-level APIs (checkpoints, agents, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Integrations & Provider Support
|
||||||
|
|
||||||
|
### 6.1 AI Providers (Provider Catalog)
|
||||||
|
|
||||||
|
| Provider | Auth Types | Description |
|
||||||
|
| ---------- | ------------------ | ----------------------------------- |
|
||||||
|
| Anthropic | API Key, CLI Token | Claude models — Haiku, Sonnet, Opus |
|
||||||
|
| OpenAI | API Key | GPT and reasoning models |
|
||||||
|
| Google | API Key, OAuth | Gemini models |
|
||||||
|
| OpenRouter | API Key | Unified multi-provider access |
|
||||||
|
| MiniMax | API Key | Foundation models |
|
||||||
|
| Ollama | Local (no auth) | Local models |
|
||||||
|
| Custom | API Key | Any OpenAI-compatible server |
|
||||||
|
|
||||||
|
### 6.2 Known Gateway Providers (Hermes Config)
|
||||||
|
|
||||||
|
- Nous Portal (OAuth device code flow)
|
||||||
|
- OpenAI Codex (OAuth)
|
||||||
|
- Anthropic (API key)
|
||||||
|
- OpenRouter (API key)
|
||||||
|
- Z.AI / GLM (API key)
|
||||||
|
- Kimi / Moonshot (API key)
|
||||||
|
- MiniMax / MiniMax CN (API key)
|
||||||
|
- Ollama (local, no auth)
|
||||||
|
- Custom OpenAI-compatible
|
||||||
|
|
||||||
|
### 6.3 Well-Known Models
|
||||||
|
|
||||||
|
- **Anthropic:** Claude Sonnet 4, Claude Opus 4
|
||||||
|
- **OpenAI:** GPT-4o
|
||||||
|
- **xAI:** Grok 3
|
||||||
|
- **Context window database:** Claude 4 (1M), Claude 3.x (200K), GPT-4o (128K), Gemini 2.x (1M), Qwen (32K–131K), Llama 3 (8K–128K), Mistral (32K–128K), DeepSeek (64K–128K)
|
||||||
|
|
||||||
|
### 6.4 OAuth Integration
|
||||||
|
|
||||||
|
- **Device code flow** for Nous Portal
|
||||||
|
- Token polling mechanism
|
||||||
|
- Auth profile storage in `~/.hermes/auth-profiles.json`
|
||||||
|
|
||||||
|
### 6.5 Workspace Agents
|
||||||
|
|
||||||
|
- Multi-agent directory with capabilities tracking
|
||||||
|
- Agent properties: model, provider, status (online/away/offline), avatar, system prompt
|
||||||
|
- Agent capabilities: repo write, shell commands, git operations, browser, network
|
||||||
|
- Agent stats: runs/tokens/cost today, success rate, avg response time
|
||||||
|
|
||||||
|
### 6.6 Workspace Checkpoints
|
||||||
|
|
||||||
|
- Code review checkpoint system
|
||||||
|
- Review actions: approve, approve-and-commit, approve-and-pr, approve-and-merge, reject, revise
|
||||||
|
- Diff viewing with file-level additions/deletions
|
||||||
|
- Verification checks: TypeScript (tsc), tests, lint, e2e
|
||||||
|
- Run event timeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UX Features & Interactions
|
||||||
|
|
||||||
|
### 7.1 Sound Notification System
|
||||||
|
|
||||||
|
Web Audio API synthesized sounds (no audio files):
|
||||||
|
|
||||||
|
- **Agent Spawned** — ascending C5→E5 chime
|
||||||
|
- **Agent Complete** — satisfying G5 ding
|
||||||
|
- **Agent Failed** — low C3→A2 error tone
|
||||||
|
- **Chat Notification** — soft E5 ping
|
||||||
|
- **Chat Complete** — gentle E5→C5 descend
|
||||||
|
- **Alert** — attention-grab A4→E5→A4
|
||||||
|
- **Thinking** — subtle C6 tick
|
||||||
|
- Configurable volume (0–1) and enable/disable
|
||||||
|
|
||||||
|
### 7.2 Keyboard Shortcuts
|
||||||
|
|
||||||
|
- **⌘K** — Command palette
|
||||||
|
- **Global shortcuts** via `global-shortcut-listener`
|
||||||
|
- **Terminal shortcuts** via `terminal-shortcut-listener`
|
||||||
|
- **Session shortcuts** — navigate between sessions
|
||||||
|
- **Keyboard shortcuts modal** — discoverable reference
|
||||||
|
|
||||||
|
### 7.3 Voice Input
|
||||||
|
|
||||||
|
- Web Speech API integration
|
||||||
|
- Languages: configurable (default: en-US)
|
||||||
|
- States: idle, listening, processing, error
|
||||||
|
- Interim (partial) results support
|
||||||
|
- Toggle on/off
|
||||||
|
|
||||||
|
### 7.4 Haptic Feedback
|
||||||
|
|
||||||
|
- `navigator.vibrate(8)` for mobile tap feedback
|
||||||
|
|
||||||
|
### 7.5 Search
|
||||||
|
|
||||||
|
- **Global search modal** with quick actions
|
||||||
|
- **Search input** with keyboard navigation
|
||||||
|
- **Search results** with highlighted matches
|
||||||
|
- **Session search** across all chat history
|
||||||
|
|
||||||
|
### 7.6 Onboarding
|
||||||
|
|
||||||
|
- **Onboarding wizard** — first-run setup flow
|
||||||
|
- **Onboarding tour** — interactive guided tour (react-joyride)
|
||||||
|
- **Setup steps** — provider selection, connection verification
|
||||||
|
- **Tour steps** — feature highlights
|
||||||
|
|
||||||
|
### 7.7 Export
|
||||||
|
|
||||||
|
- Export conversations as Markdown, JSON, or Plain Text
|
||||||
|
|
||||||
|
### 7.8 Auto-Generated Session Titles
|
||||||
|
|
||||||
|
- Automatic title generation from conversation content
|
||||||
|
|
||||||
|
### 7.9 Pinned Sessions & Models
|
||||||
|
|
||||||
|
- Pin frequently used sessions for quick access
|
||||||
|
- Pin preferred models
|
||||||
|
|
||||||
|
### 7.10 Smart Model Suggestions
|
||||||
|
|
||||||
|
- Automatic model recommendations based on task
|
||||||
|
- Budget vs premium model preferences
|
||||||
|
- Model suggestion toast notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security Features
|
||||||
|
|
||||||
|
### 8.1 Authentication
|
||||||
|
|
||||||
|
- Optional password protection via `HERMES_PASSWORD` env var
|
||||||
|
- Timing-safe password comparison
|
||||||
|
- Cryptographic session tokens (32 bytes hex)
|
||||||
|
- HTTP-only, SameSite=Strict cookies (30-day expiry)
|
||||||
|
- Rate-limited login: 5 attempts/minute per IP
|
||||||
|
- 1-second delay on failed auth (brute force prevention)
|
||||||
|
|
||||||
|
### 8.2 Authorization
|
||||||
|
|
||||||
|
- Auth middleware on all API routes
|
||||||
|
- Local request detection (127.0.0.1, ::1, Tailscale 100.x, LAN 192.168.x, 10.x)
|
||||||
|
- `requireLocalOrAuth` for sensitive operations (file delete, terminal)
|
||||||
|
|
||||||
|
### 8.3 Input Validation
|
||||||
|
|
||||||
|
- CSRF protection via `Content-Type: application/json` requirement
|
||||||
|
- Path traversal prevention on file and memory routes
|
||||||
|
- Zod schema validation on auth endpoints
|
||||||
|
- Input sanitization on all user inputs
|
||||||
|
|
||||||
|
### 8.4 Rate Limiting
|
||||||
|
|
||||||
|
- Sliding window rate limiter (in-memory, no external deps)
|
||||||
|
- Per-endpoint limits: auth (5/min), files (30/min), terminal (10/min)
|
||||||
|
- Auto-cleanup every 5 minutes
|
||||||
|
- 429 Too Many Requests responses
|
||||||
|
|
||||||
|
### 8.5 Error Handling
|
||||||
|
|
||||||
|
- Safe error messages in production (hides internals)
|
||||||
|
- Error boundaries in React
|
||||||
|
- Graceful degradation on gateway unavailability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Mobile & PWA Features
|
||||||
|
|
||||||
|
### 9.1 Progressive Web App
|
||||||
|
|
||||||
|
- Full PWA with install prompts
|
||||||
|
- iOS Safari "Add to Home Screen" support
|
||||||
|
- Android Chrome install support
|
||||||
|
- Desktop Chrome/Edge install support
|
||||||
|
|
||||||
|
### 9.2 Mobile-Specific Components
|
||||||
|
|
||||||
|
- Mobile tab bar (bottom navigation)
|
||||||
|
- Mobile hamburger menu
|
||||||
|
- Mobile sessions panel
|
||||||
|
- Mobile page header
|
||||||
|
- Mobile terminal input
|
||||||
|
- Mobile setup modal & prompt trigger
|
||||||
|
- Mobile keyboard handling & inset tracking
|
||||||
|
- Swipe navigation
|
||||||
|
|
||||||
|
### 9.3 Mobile Chat Nav Modes
|
||||||
|
|
||||||
|
- **Dock** — iMessage-style (no nav in chat)
|
||||||
|
- **Integrated** — chat input in nav pill
|
||||||
|
- **Scroll-hide** — nav shows on scroll up
|
||||||
|
|
||||||
|
### 9.4 Tailscale Integration
|
||||||
|
|
||||||
|
- First-class support for Tailscale remote access
|
||||||
|
- Default allowed hosts include `.ts.net`
|
||||||
|
- End-to-end encrypted mobile access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Deployment Options
|
||||||
|
|
||||||
|
### 10.1 Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # Vite dev server with HMR on port 3002
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Production Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build && pnpm start # Node.js server on port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Stable Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start:stable # Background process via scripts/start-stable.sh
|
||||||
|
pnpm stop:stable # Stop via scripts/stop-stable.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 Docker Compose
|
||||||
|
|
||||||
|
- **hermes-agent** container — Python FastAPI gateway on port 8642
|
||||||
|
- **hermes-workspace** container — Node.js web UI on port 3000
|
||||||
|
- Health checks with retries
|
||||||
|
- Environment file passthrough
|
||||||
|
|
||||||
|
### 10.5 Auto-Start Features
|
||||||
|
|
||||||
|
- Hermes agent auto-start from sibling directory
|
||||||
|
- Workspace daemon auto-start with crash recovery
|
||||||
|
- Port fallback detection (8642 → 8643)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File/Directory Statistics
|
||||||
|
|
||||||
|
| Category | Count |
|
||||||
|
| ------------------ | ----- |
|
||||||
|
| Total source files | ~287 |
|
||||||
|
| API route files | ~35 |
|
||||||
|
| React components | ~100+ |
|
||||||
|
| Custom hooks | ~25 |
|
||||||
|
| Server modules | ~12 |
|
||||||
|
| Library utilities | ~20 |
|
||||||
|
| Store files | 3 |
|
||||||
|
| Screen files | 8 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| ------------- | ------------------------------- |
|
||||||
|
| Framework | TanStack Start (React 19 + SSR) |
|
||||||
|
| Routing | TanStack Router (file-based) |
|
||||||
|
| Build | Vite 7 |
|
||||||
|
| Styling | Tailwind CSS 4 |
|
||||||
|
| State | Zustand 5 (persisted) |
|
||||||
|
| Data Fetching | TanStack React Query 5 |
|
||||||
|
| Terminal | xterm.js 5 + Python PTY |
|
||||||
|
| Editor | Monaco Editor |
|
||||||
|
| Markdown | react-markdown + Shiki |
|
||||||
|
| Charts | Recharts 3 |
|
||||||
|
| Animation | Motion (Framer Motion) |
|
||||||
|
| Validation | Zod |
|
||||||
|
| Icons | Hugeicons + Lobehub Icons |
|
||||||
|
| Tour | react-joyride |
|
||||||
|
| WebSocket | ws library |
|
||||||
|
| Config | YAML parser |
|
||||||
|
| Testing | Vitest + Testing Library |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated from codebase analysis of `/Users/aurora/hermes-workspace/`_
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# FUTURE-FEATURES.md — Post-Roadmap Development
|
# FUTURE-FEATURES.md — Post-Roadmap Development
|
||||||
|
|
||||||
_Added: 2026-03-09 | Source: Framework research (Anthropic Skills guide, OpenAI Agents SDK, Google ADK)_
|
_Added: 2026-03-09 | Source: Framework research (Anthropic Skills guide, OpenAI Agents SDK, Google ADK)_
|
||||||
|
|
||||||
These features are NOT part of the initial roadmap. Build them AFTER the v4 mockup is 100% complete and verified.
|
These features are NOT part of the initial roadmap. Build them AFTER the v4 mockup is 100% complete and verified.
|
||||||
@@ -8,44 +9,51 @@ These features are NOT part of the initial roadmap. Build them AFTER the v4 mock
|
|||||||
## 🔴 High Priority (unlocks "App Factory" overnight runs)
|
## 🔴 High Priority (unlocks "App Factory" overnight runs)
|
||||||
|
|
||||||
### 1. Iterative Refinement Loop
|
### 1. Iterative Refinement Loop
|
||||||
|
|
||||||
**What:** Verification doesn't stop at one tsc pass. Loop: run tsc → errors? → send back to agent → fix → re-run. Max 3 iterations before escalating to human review.
|
**What:** Verification doesn't stop at one tsc pass. Loop: run tsc → errors? → send back to agent → fix → re-run. Max 3 iterations before escalating to human review.
|
||||||
**Why:** Anthropic explicitly identifies this as the pattern that makes agents reliable. Current single-pass fails silently.
|
**Why:** Anthropic explicitly identifies this as the pattern that makes agents reliable. Current single-pass fails silently.
|
||||||
**Where:** `workspace-daemon/src/verification.ts` + `checkpoint-builder.ts`
|
**Where:** `workspace-daemon/src/verification.ts` + `checkpoint-builder.ts`
|
||||||
**Pattern source:** Anthropic Skills Guide — "Iterative Refinement" design pattern
|
**Pattern source:** Anthropic Skills Guide — "Iterative Refinement" design pattern
|
||||||
|
|
||||||
### 2. Agent Handoffs (Context Passing Between Agents)
|
### 2. Agent Handoffs (Context Passing Between Agents)
|
||||||
|
|
||||||
**What:** When one agent finishes a wave, it passes structured context (git diff, error log, what it built, what it skipped) to the next agent. No more blind starts.
|
**What:** When one agent finishes a wave, it passes structured context (git diff, error log, what it built, what it skipped) to the next agent. No more blind starts.
|
||||||
**Why:** Current agents start each task cold. Handoffs are first-class in OpenAI Agents SDK — explicit control transfer with context. This is what keeps overnight runs coherent.
|
**Why:** Current agents start each task cold. Handoffs are first-class in OpenAI Agents SDK — explicit control transfer with context. This is what keeps overnight runs coherent.
|
||||||
**Where:** New `workspace-daemon/src/handoff.ts`, update adapter interfaces
|
**Where:** New `workspace-daemon/src/handoff.ts`, update adapter interfaces
|
||||||
**Pattern source:** OpenAI Agents SDK — "Handoffs" primitive
|
**Pattern source:** OpenAI Agents SDK — "Handoffs" primitive
|
||||||
|
|
||||||
### 3. Specialized Agent Roles
|
### 3. Specialized Agent Roles
|
||||||
|
|
||||||
**What:** Replace generic Codex adapter with role-specific agents:
|
**What:** Replace generic Codex adapter with role-specific agents:
|
||||||
|
|
||||||
- **Researcher** — reads codebase, produces spec/context doc
|
- **Researcher** — reads codebase, produces spec/context doc
|
||||||
- **Planner** — takes spec, produces task breakdown with deps
|
- **Planner** — takes spec, produces task breakdown with deps
|
||||||
- **Builder** — executes tasks (Codex)
|
- **Builder** — executes tasks (Codex)
|
||||||
- **Validator** — runs tsc, tests, reviews diff
|
- **Validator** — runs tsc, tests, reviews diff
|
||||||
- **Deployer** — git ops, PR creation, notifications
|
- **Deployer** — git ops, PR creation, notifications
|
||||||
**Why:** The "App Factory" screenshot runs specialized roles. Generic agents miss domain context.
|
**Why:** The "App Factory" screenshot runs specialized roles. Generic agents miss domain context.
|
||||||
**Where:** `workspace-daemon/src/adapters/` — one file per role
|
**Where:** `workspace-daemon/src/adapters/` — one file per role
|
||||||
**Pattern source:** Anthropic Skills — "Domain-specific intelligence" + App Factory pattern
|
**Pattern source:** Anthropic Skills — "Domain-specific intelligence" + App Factory pattern
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🟡 Medium Priority
|
## 🟡 Medium Priority
|
||||||
|
|
||||||
### 4. Parallel Guardrails (tsc watcher during agent run)
|
### 4. Parallel Guardrails (tsc watcher during agent run)
|
||||||
|
|
||||||
**What:** Run tsc in watch mode alongside Codex, not just after. Flag errors in real-time without waiting for checkpoint.
|
**What:** Run tsc in watch mode alongside Codex, not just after. Flag errors in real-time without waiting for checkpoint.
|
||||||
**Why:** OpenAI SDK runs guardrails in parallel with the agent — catches issues without blocking the main flow.
|
**Why:** OpenAI SDK runs guardrails in parallel with the agent — catches issues without blocking the main flow.
|
||||||
**Where:** New process spawned alongside agent in `agent-runner.ts`
|
**Where:** New process spawned alongside agent in `agent-runner.ts`
|
||||||
**Pattern source:** OpenAI Agents SDK — "Guardrails" primitive
|
**Pattern source:** OpenAI Agents SDK — "Guardrails" primitive
|
||||||
|
|
||||||
### 5. Rollback on Checkpoint Rejection
|
### 5. Rollback on Checkpoint Rejection
|
||||||
|
|
||||||
**What:** When a checkpoint is rejected, auto-revert to pre-task git state rather than leaving dirty code in tree.
|
**What:** When a checkpoint is rejected, auto-revert to pre-task git state rather than leaving dirty code in tree.
|
||||||
**Why:** Currently a rejection leaves broken code that the next agent inherits.
|
**Why:** Currently a rejection leaves broken code that the next agent inherits.
|
||||||
**Where:** `workspace-daemon/src/git-ops.ts` — add `revertToCheckpoint()` method
|
**Where:** `workspace-daemon/src/git-ops.ts` — add `revertToCheckpoint()` method
|
||||||
|
|
||||||
### 6. Context-Aware Tool Selection
|
### 6. Context-Aware Tool Selection
|
||||||
|
|
||||||
**What:** Agent routing logic that picks different tools based on file size, task type, and context. Large refactors → Codex. Small surgical fixes → Claude ACP session. Research tasks → Claude with web search.
|
**What:** Agent routing logic that picks different tools based on file size, task type, and context. Large refactors → Codex. Small surgical fixes → Claude ACP session. Research tasks → Claude with web search.
|
||||||
**Pattern source:** Anthropic Skills — "Context-aware tool selection" pattern
|
**Pattern source:** Anthropic Skills — "Context-aware tool selection" pattern
|
||||||
|
|
||||||
@@ -54,15 +62,18 @@ These features are NOT part of the initial roadmap. Build them AFTER the v4 mock
|
|||||||
## 🔵 Lower Priority (Enterprise / Scale)
|
## 🔵 Lower Priority (Enterprise / Scale)
|
||||||
|
|
||||||
### 7. Session Persistence Surfaced to Agents
|
### 7. Session Persistence Surfaced to Agents
|
||||||
|
|
||||||
**What:** Pass previous run context (what worked, what failed, git history) to agent at start of each task. Agents currently start blind even when re-running.
|
**What:** Pass previous run context (what worked, what failed, git history) to agent at start of each task. Agents currently start blind even when re-running.
|
||||||
**Where:** Update adapter `buildPrompt()` to include run history from SQLite
|
**Where:** Update adapter `buildPrompt()` to include run history from SQLite
|
||||||
|
|
||||||
### 8. Progressive Skill Loading for Agent Prompts
|
### 8. Progressive Skill Loading for Agent Prompts
|
||||||
|
|
||||||
**What:** Agent system prompts use Anthropic's 3-level progressive disclosure — minimal header always loaded, full instructions only when triggered, reference docs on demand.
|
**What:** Agent system prompts use Anthropic's 3-level progressive disclosure — minimal header always loaded, full instructions only when triggered, reference docs on demand.
|
||||||
**Why:** Keeps context lean when running many agents in parallel.
|
**Why:** Keeps context lean when running many agents in parallel.
|
||||||
**Pattern source:** Anthropic Skills Guide — core architecture
|
**Pattern source:** Anthropic Skills Guide — core architecture
|
||||||
|
|
||||||
### 9. Skills Marketplace / Agent Skill Definitions
|
### 9. Skills Marketplace / Agent Skill Definitions
|
||||||
|
|
||||||
**What:** Define agent "skills" as portable SKILL.md-style files that can be shared, versioned, and swapped. A "React Builder" skill vs "Python API Builder" skill.
|
**What:** Define agent "skills" as portable SKILL.md-style files that can be shared, versioned, and swapped. A "React Builder" skill vs "Python API Builder" skill.
|
||||||
**Pattern source:** Anthropic agentskills.io open standard
|
**Pattern source:** Anthropic agentskills.io open standard
|
||||||
|
|
||||||
@@ -71,7 +82,7 @@ These features are NOT part of the initial roadmap. Build them AFTER the v4 mock
|
|||||||
## Summary Table
|
## Summary Table
|
||||||
|
|
||||||
| Feature | Impact | Effort | Priority |
|
| Feature | Impact | Effort | Priority |
|
||||||
|---------|--------|--------|----------|
|
| ---------------------------- | ------- | ------ | ----------- |
|
||||||
| Iterative refinement loop | 🔥 High | Low | Do first |
|
| Iterative refinement loop | 🔥 High | Low | Do first |
|
||||||
| Agent handoffs | 🔥 High | Med | Do second |
|
| Agent handoffs | 🔥 High | Med | Do second |
|
||||||
| Specialized agent roles | 🔥 High | High | Do third |
|
| Specialized agent roles | 🔥 High | High | Do third |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
**Your AI agent's command center — chat, files, memory, skills, and terminal in one place.**
|
**Your AI agent's command center — chat, files, memory, skills, and terminal in one place.**
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://nodejs.org/)
|
[](https://nodejs.org/)
|
||||||
[](CONTRIBUTING.md)
|
[](CONTRIBUTING.md)
|
||||||
|
|||||||
10
SECURITY.md
10
SECURITY.md
@@ -6,7 +6,7 @@ If you discover a security vulnerability in Hermes Workspace, please report it r
|
|||||||
|
|
||||||
**Do NOT open a public GitHub issue for security vulnerabilities.**
|
**Do NOT open a public GitHub issue for security vulnerabilities.**
|
||||||
|
|
||||||
Instead, email: **security@hermesworkspace.app**
|
Instead, report via [GitHub Security Advisories](https://github.com/outsourc-e/hermes-workspace/security/advisories) or DM [@ericousodev on X](https://x.com/ericousodev).
|
||||||
|
|
||||||
We will acknowledge your report within 48 hours and aim to provide a fix within 7 days for critical issues.
|
We will acknowledge your report within 48 hours and aim to provide a fix within 7 days for critical issues.
|
||||||
|
|
||||||
@@ -28,17 +28,20 @@ We will acknowledge your report within 48 hours and aim to provide a fix within
|
|||||||
## Security Measures (v3.0.0+)
|
## Security Measures (v3.0.0+)
|
||||||
|
|
||||||
**Authentication**
|
**Authentication**
|
||||||
|
|
||||||
- All API routes require authentication as of v3.0.0
|
- All API routes require authentication as of v3.0.0
|
||||||
- Session tokens use timing-safe comparison to prevent timing attacks
|
- Session tokens use timing-safe comparison to prevent timing attacks
|
||||||
- httpOnly + SameSite=Strict cookies
|
- httpOnly + SameSite=Strict cookies
|
||||||
- Token revocation on logout
|
- Token revocation on logout
|
||||||
|
|
||||||
**Network**
|
**Network**
|
||||||
|
|
||||||
- `Access-Control-Allow-Origin` restricted to localhost — no wildcard CORS
|
- `Access-Control-Allow-Origin` restricted to localhost — no wildcard CORS
|
||||||
- Browser proxy and screenshot endpoints locked to same-origin only
|
- Browser proxy and screenshot endpoints locked to same-origin only
|
||||||
- Rate limiting on high-risk endpoints (file access, debug, exec)
|
- Rate limiting on high-risk endpoints (file access, debug, exec)
|
||||||
|
|
||||||
**Data & File Access**
|
**Data & File Access**
|
||||||
|
|
||||||
- Path traversal prevention on all file and memory routes (`ensureWorkspacePath()`)
|
- Path traversal prevention on all file and memory routes (`ensureWorkspacePath()`)
|
||||||
- `.md`-only restriction on memory write routes
|
- `.md`-only restriction on memory write routes
|
||||||
- No API keys or secrets ever exposed to client-side code
|
- No API keys or secrets ever exposed to client-side code
|
||||||
@@ -46,10 +49,12 @@ We will acknowledge your report within 48 hours and aim to provide a fix within
|
|||||||
- Diagnostic output scrubbed of sensitive data
|
- Diagnostic output scrubbed of sensitive data
|
||||||
|
|
||||||
**Agent Safety**
|
**Agent Safety**
|
||||||
|
|
||||||
- Exec approval workflow — sensitive Hermes exec commands require explicit human approval via in-UI modal
|
- Exec approval workflow — sensitive Hermes exec commands require explicit human approval via in-UI modal
|
||||||
- Skills security scanning — every skill from the marketplace is scanned for suspicious patterns before install
|
- Skills security scanning — every skill from the marketplace is scanned for suspicious patterns before install
|
||||||
|
|
||||||
**Configuration**
|
**Configuration**
|
||||||
|
|
||||||
- Environment files are gitignored
|
- Environment files are gitignored
|
||||||
- Config endpoints redact credentials in responses
|
- Config endpoints redact credentials in responses
|
||||||
- Example configs use placeholder keys only
|
- Example configs use placeholder keys only
|
||||||
@@ -70,8 +75,7 @@ We will acknowledge your report within 48 hours and aim to provide a fix within
|
|||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|---------|-----------|
|
| ----------- | ---------------------- |
|
||||||
| v3.x (main) | ✅ Active |
|
| v3.x (main) | ✅ Active |
|
||||||
| v2.x | ⚠️ Security fixes only |
|
| v2.x | ⚠️ Security fixes only |
|
||||||
| < v2.0 | ❌ Unsupported |
|
| < v2.0 | ❌ Unsupported |
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ RUN pip install --no-cache-dir -e .
|
|||||||
|
|
||||||
EXPOSE 8642
|
EXPOSE 8642
|
||||||
|
|
||||||
CMD ["hermes", "--gateway"]
|
CMD ["hermes", "gateway", "run"]
|
||||||
|
|||||||
@@ -8,5 +8,3 @@ export default [
|
|||||||
ignores: ['eslint.config.js', 'prettier.config.js', 'vite.config.ts'],
|
ignores: ['eslint.config.js', 'prettier.config.js', 'vite.config.ts'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-workspace",
|
"name": "hermes-workspace",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"description": "Desktop workspace for Hermes Agent — chat, orchestration, and multi-agent coding pipelines",
|
"description": "Desktop workspace for Hermes Agent — chat, orchestration, and multi-agent coding pipelines",
|
||||||
"author": "Eric (https://github.com/outsourc-e)",
|
"author": "Eric (https://github.com/outsourc-e)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"eslint": "^10.2.0",
|
||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
@@ -76,4 +77,3 @@
|
|||||||
"web-vitals": "^5.1.0"
|
"web-vitals": "^5.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
290
pnpm-lock.yaml
generated
290
pnpm-lock.yaml
generated
@@ -131,7 +131,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@tanstack/eslint-config':
|
'@tanstack/eslint-config':
|
||||||
specifier: ^0.3.0
|
specifier: ^0.3.0
|
||||||
version: 0.3.4(@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
version: 0.3.4(@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@testing-library/dom':
|
'@testing-library/dom':
|
||||||
specifier: ^10.4.0
|
specifier: ^10.4.0
|
||||||
version: 10.4.1
|
version: 10.4.1
|
||||||
@@ -150,6 +150,9 @@ importers:
|
|||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.0.4
|
specifier: ^5.0.4
|
||||||
version: 5.2.0(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
version: 5.2.0(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
|
eslint:
|
||||||
|
specifier: ^10.2.0
|
||||||
|
version: 10.2.0(jiti@2.6.1)
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^27.0.0
|
specifier: ^27.0.0
|
||||||
version: 27.4.0
|
version: 27.4.0
|
||||||
@@ -683,33 +686,29 @@ packages:
|
|||||||
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
|
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
|
||||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||||
|
|
||||||
'@eslint/config-array@0.21.2':
|
'@eslint/config-array@0.23.5':
|
||||||
resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
|
resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
|
|
||||||
'@eslint/config-helpers@0.4.2':
|
'@eslint/config-helpers@0.5.5':
|
||||||
resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
|
resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
|
|
||||||
'@eslint/core@0.17.0':
|
'@eslint/core@1.2.1':
|
||||||
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
|
resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
|
|
||||||
'@eslint/eslintrc@3.3.5':
|
|
||||||
resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
|
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
|
||||||
|
|
||||||
'@eslint/js@9.39.4':
|
'@eslint/js@9.39.4':
|
||||||
resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
|
resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/object-schema@2.1.7':
|
'@eslint/object-schema@3.0.5':
|
||||||
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
|
resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
|
|
||||||
'@eslint/plugin-kit@0.4.1':
|
'@eslint/plugin-kit@0.7.1':
|
||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
|
|
||||||
'@exodus/bytes@1.15.0':
|
'@exodus/bytes@1.15.0':
|
||||||
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
|
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
|
||||||
@@ -1480,66 +1479,79 @@ packages:
|
|||||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||||
@@ -1653,24 +1665,28 @@ packages:
|
|||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
||||||
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
||||||
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
||||||
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
||||||
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
||||||
@@ -2161,41 +2177,49 @@ packages:
|
|||||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||||
@@ -2290,10 +2314,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
ansi-styles@4.3.0:
|
|
||||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
ansi-styles@5.2.0:
|
ansi-styles@5.2.0:
|
||||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2414,10 +2434,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
chalk@4.1.2:
|
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
|
||||||
engines: {node: '>=10'}
|
|
||||||
|
|
||||||
character-entities-html4@2.1.0:
|
character-entities-html4@2.1.0:
|
||||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||||
|
|
||||||
@@ -2473,13 +2489,6 @@ packages:
|
|||||||
collapse-white-space@2.1.0:
|
collapse-white-space@2.1.0:
|
||||||
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
|
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
|
||||||
engines: {node: '>=7.0.0'}
|
|
||||||
|
|
||||||
color-name@1.1.4:
|
|
||||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
|
||||||
|
|
||||||
colord@2.9.3:
|
colord@2.9.3:
|
||||||
resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
|
resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
|
||||||
|
|
||||||
@@ -2888,10 +2897,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '>=8.23.0'
|
eslint: '>=8.23.0'
|
||||||
|
|
||||||
eslint-scope@8.4.0:
|
|
||||||
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
|
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
|
||||||
|
|
||||||
eslint-scope@9.1.2:
|
eslint-scope@9.1.2:
|
||||||
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
|
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
@@ -2908,9 +2913,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
|
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
|
|
||||||
eslint@9.39.4:
|
eslint@10.2.0:
|
||||||
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
|
resolution: {integrity: sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
jiti: '*'
|
jiti: '*'
|
||||||
@@ -3113,10 +3118,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||||
|
|
||||||
globals@14.0.0:
|
|
||||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
globals@15.15.0:
|
globals@15.15.0:
|
||||||
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
|
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3149,10 +3150,6 @@ packages:
|
|||||||
hachure-fill@0.5.2:
|
hachure-fill@0.5.2:
|
||||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||||
|
|
||||||
has-flag@4.0.0:
|
|
||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
hasown@2.0.2:
|
hasown@2.0.2:
|
||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3483,24 +3480,28 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.31.1:
|
lightningcss-linux-arm64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.31.1:
|
lightningcss-linux-x64-gnu@1.31.1:
|
||||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.31.1:
|
lightningcss-linux-x64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.31.1:
|
lightningcss-win32-arm64-msvc@1.31.1:
|
||||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||||
@@ -3537,9 +3538,6 @@ packages:
|
|||||||
lodash-es@4.17.23:
|
lodash-es@4.17.23:
|
||||||
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
|
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
|
||||||
|
|
||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
@@ -4546,10 +4544,6 @@ packages:
|
|||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||||
|
|
||||||
strip-json-comments@3.1.1:
|
|
||||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
@@ -4565,10 +4559,6 @@ packages:
|
|||||||
stylis@4.3.6:
|
stylis@4.3.6:
|
||||||
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
|
||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0:
|
supports-preserve-symlinks-flag@1.0.0:
|
||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5603,50 +5593,36 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.27.4':
|
'@esbuild/win32-x64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
|
'@eslint-community/eslint-utils@4.9.1(eslint@10.2.0(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
|
|
||||||
'@eslint-community/regexpp@4.12.2': {}
|
'@eslint-community/regexpp@4.12.2': {}
|
||||||
|
|
||||||
'@eslint/config-array@0.21.2':
|
'@eslint/config-array@0.23.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/object-schema': 2.1.7
|
'@eslint/object-schema': 3.0.5
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
minimatch: 3.1.5
|
minimatch: 10.2.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@eslint/config-helpers@0.4.2':
|
'@eslint/config-helpers@0.5.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 1.2.1
|
||||||
|
|
||||||
'@eslint/core@0.17.0':
|
'@eslint/core@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/json-schema': 7.0.15
|
'@types/json-schema': 7.0.15
|
||||||
|
|
||||||
'@eslint/eslintrc@3.3.5':
|
|
||||||
dependencies:
|
|
||||||
ajv: 6.14.0
|
|
||||||
debug: 4.4.3
|
|
||||||
espree: 10.4.0
|
|
||||||
globals: 14.0.0
|
|
||||||
ignore: 5.3.2
|
|
||||||
import-fresh: 3.3.1
|
|
||||||
js-yaml: 4.1.1
|
|
||||||
minimatch: 3.1.5
|
|
||||||
strip-json-comments: 3.1.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@eslint/js@9.39.4': {}
|
'@eslint/js@9.39.4': {}
|
||||||
|
|
||||||
'@eslint/object-schema@2.1.7': {}
|
'@eslint/object-schema@3.0.5': {}
|
||||||
|
|
||||||
'@eslint/plugin-kit@0.4.1':
|
'@eslint/plugin-kit@0.7.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 1.2.1
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
'@exodus/bytes@1.15.0': {}
|
'@exodus/bytes@1.15.0': {}
|
||||||
@@ -6656,11 +6632,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
'@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1))':
|
'@stylistic/eslint-plugin@5.10.0(eslint@10.2.0(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1))
|
||||||
'@typescript-eslint/types': 8.57.0
|
'@typescript-eslint/types': 8.57.0
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 4.2.1
|
||||||
espree: 10.4.0
|
espree: 10.4.0
|
||||||
estraverse: 5.3.0
|
estraverse: 5.3.0
|
||||||
@@ -6734,16 +6710,16 @@ snapshots:
|
|||||||
tailwindcss: 4.2.1
|
tailwindcss: 4.2.1
|
||||||
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
'@tanstack/eslint-config@0.3.4(@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
'@tanstack/eslint-config@0.3.4(@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/js': 9.39.4
|
'@eslint/js': 9.39.4
|
||||||
'@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(jiti@2.6.1))
|
'@stylistic/eslint-plugin': 5.10.0(eslint@10.2.0(jiti@2.6.1))
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))
|
eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))
|
||||||
eslint-plugin-n: 17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
eslint-plugin-n: 17.24.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
globals: 16.5.0
|
globals: 16.5.0
|
||||||
typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
typescript-eslint: 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1))
|
vue-eslint-parser: 10.4.0(eslint@10.2.0(jiti@2.6.1))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@typescript-eslint/utils'
|
- '@typescript-eslint/utils'
|
||||||
- eslint-import-resolver-node
|
- eslint-import-resolver-node
|
||||||
@@ -7207,15 +7183,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/use-sync-external-store@0.0.6': {}
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
'@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/scope-manager': 8.57.0
|
'@typescript-eslint/scope-manager': 8.57.0
|
||||||
'@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/type-utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.57.0
|
'@typescript-eslint/visitor-keys': 8.57.0
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
ignore: 7.0.5
|
ignore: 7.0.5
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||||
@@ -7223,14 +7199,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/parser@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/scope-manager': 8.57.0
|
'@typescript-eslint/scope-manager': 8.57.0
|
||||||
'@typescript-eslint/types': 8.57.0
|
'@typescript-eslint/types': 8.57.0
|
||||||
'@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.57.0
|
'@typescript-eslint/visitor-keys': 8.57.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -7253,13 +7229,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/type-utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/types': 8.57.0
|
'@typescript-eslint/types': 8.57.0
|
||||||
'@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -7282,13 +7258,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1))
|
||||||
'@typescript-eslint/scope-manager': 8.57.0
|
'@typescript-eslint/scope-manager': 8.57.0
|
||||||
'@typescript-eslint/types': 8.57.0
|
'@typescript-eslint/types': 8.57.0
|
||||||
'@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -7457,10 +7433,6 @@ snapshots:
|
|||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
ansi-styles@4.3.0:
|
|
||||||
dependencies:
|
|
||||||
color-convert: 2.0.1
|
|
||||||
|
|
||||||
ansi-styles@5.2.0: {}
|
ansi-styles@5.2.0: {}
|
||||||
|
|
||||||
ansis@4.2.0: {}
|
ansis@4.2.0: {}
|
||||||
@@ -7632,11 +7604,6 @@ snapshots:
|
|||||||
loupe: 3.2.1
|
loupe: 3.2.1
|
||||||
pathval: 2.0.1
|
pathval: 2.0.1
|
||||||
|
|
||||||
chalk@4.1.2:
|
|
||||||
dependencies:
|
|
||||||
ansi-styles: 4.3.0
|
|
||||||
supports-color: 7.2.0
|
|
||||||
|
|
||||||
character-entities-html4@2.1.0: {}
|
character-entities-html4@2.1.0: {}
|
||||||
|
|
||||||
character-entities-legacy@3.0.0: {}
|
character-entities-legacy@3.0.0: {}
|
||||||
@@ -7716,12 +7683,6 @@ snapshots:
|
|||||||
|
|
||||||
collapse-white-space@2.1.0: {}
|
collapse-white-space@2.1.0: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
|
||||||
dependencies:
|
|
||||||
color-name: 1.1.4
|
|
||||||
|
|
||||||
color-name@1.1.4: {}
|
|
||||||
|
|
||||||
colord@2.9.3: {}
|
colord@2.9.3: {}
|
||||||
|
|
||||||
comma-separated-tokens@2.0.3: {}
|
comma-separated-tokens@2.0.3: {}
|
||||||
@@ -8124,9 +8085,9 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@5.0.0: {}
|
escape-string-regexp@5.0.0: {}
|
||||||
|
|
||||||
eslint-compat-utils@0.5.1(eslint@9.39.4(jiti@2.6.1)):
|
eslint-compat-utils@0.5.1(eslint@10.2.0(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
|
|
||||||
eslint-import-context@0.1.9(unrs-resolver@1.11.1):
|
eslint-import-context@0.1.9(unrs-resolver@1.11.1):
|
||||||
@@ -8136,20 +8097,20 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
|
|
||||||
eslint-plugin-es-x@7.8.0(eslint@9.39.4(jiti@2.6.1)):
|
eslint-plugin-es-x@7.8.0(eslint@10.2.0(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1))
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1))
|
eslint-compat-utils: 0.5.1(eslint@10.2.0(jiti@2.6.1))
|
||||||
|
|
||||||
eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)):
|
eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@package-json/types': 0.0.12
|
'@package-json/types': 0.0.12
|
||||||
'@typescript-eslint/types': 8.57.0
|
'@typescript-eslint/types': 8.57.0
|
||||||
comment-parser: 1.4.5
|
comment-parser: 1.4.5
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
|
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
minimatch: 10.2.4
|
minimatch: 10.2.4
|
||||||
@@ -8157,16 +8118,16 @@ snapshots:
|
|||||||
stable-hash-x: 0.2.0
|
stable-hash-x: 0.2.0
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-n@17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
|
eslint-plugin-n@17.24.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1))
|
||||||
enhanced-resolve: 5.20.0
|
enhanced-resolve: 5.20.0
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
eslint-plugin-es-x: 7.8.0(eslint@9.39.4(jiti@2.6.1))
|
eslint-plugin-es-x: 7.8.0(eslint@10.2.0(jiti@2.6.1))
|
||||||
get-tsconfig: 4.13.6
|
get-tsconfig: 4.13.6
|
||||||
globals: 15.15.0
|
globals: 15.15.0
|
||||||
globrex: 0.1.2
|
globrex: 0.1.2
|
||||||
@@ -8176,11 +8137,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
eslint-scope@8.4.0:
|
|
||||||
dependencies:
|
|
||||||
esrecurse: 4.3.0
|
|
||||||
estraverse: 5.3.0
|
|
||||||
|
|
||||||
eslint-scope@9.1.2:
|
eslint-scope@9.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/esrecurse': 4.3.1
|
'@types/esrecurse': 4.3.1
|
||||||
@@ -8194,28 +8150,25 @@ snapshots:
|
|||||||
|
|
||||||
eslint-visitor-keys@5.0.1: {}
|
eslint-visitor-keys@5.0.1: {}
|
||||||
|
|
||||||
eslint@9.39.4(jiti@2.6.1):
|
eslint@10.2.0(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1))
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
'@eslint/config-array': 0.21.2
|
'@eslint/config-array': 0.23.5
|
||||||
'@eslint/config-helpers': 0.4.2
|
'@eslint/config-helpers': 0.5.5
|
||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 1.2.1
|
||||||
'@eslint/eslintrc': 3.3.5
|
'@eslint/plugin-kit': 0.7.1
|
||||||
'@eslint/js': 9.39.4
|
|
||||||
'@eslint/plugin-kit': 0.4.1
|
|
||||||
'@humanfs/node': 0.16.7
|
'@humanfs/node': 0.16.7
|
||||||
'@humanwhocodes/module-importer': 1.0.1
|
'@humanwhocodes/module-importer': 1.0.1
|
||||||
'@humanwhocodes/retry': 0.4.3
|
'@humanwhocodes/retry': 0.4.3
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
ajv: 6.14.0
|
ajv: 6.14.0
|
||||||
chalk: 4.1.2
|
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
escape-string-regexp: 4.0.0
|
escape-string-regexp: 4.0.0
|
||||||
eslint-scope: 8.4.0
|
eslint-scope: 9.1.2
|
||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 5.0.1
|
||||||
espree: 10.4.0
|
espree: 11.2.0
|
||||||
esquery: 1.7.0
|
esquery: 1.7.0
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
@@ -8226,8 +8179,7 @@ snapshots:
|
|||||||
imurmurhash: 0.1.4
|
imurmurhash: 0.1.4
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
json-stable-stringify-without-jsonify: 1.0.1
|
json-stable-stringify-without-jsonify: 1.0.1
|
||||||
lodash.merge: 4.6.2
|
minimatch: 10.2.4
|
||||||
minimatch: 3.1.5
|
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
optionator: 0.9.4
|
optionator: 0.9.4
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -8414,8 +8366,6 @@ snapshots:
|
|||||||
once: 1.4.0
|
once: 1.4.0
|
||||||
path-is-absolute: 1.0.1
|
path-is-absolute: 1.0.1
|
||||||
|
|
||||||
globals@14.0.0: {}
|
|
||||||
|
|
||||||
globals@15.15.0: {}
|
globals@15.15.0: {}
|
||||||
|
|
||||||
globals@16.5.0: {}
|
globals@16.5.0: {}
|
||||||
@@ -8435,8 +8385,6 @@ snapshots:
|
|||||||
|
|
||||||
hachure-fill@0.5.2: {}
|
hachure-fill@0.5.2: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
|
||||||
|
|
||||||
hasown@2.0.2:
|
hasown@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
@@ -8901,8 +8849,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash-es@4.17.23: {}
|
lodash-es@4.17.23: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
|
||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
longest-streak@3.1.0: {}
|
longest-streak@3.1.0: {}
|
||||||
@@ -10311,8 +10257,6 @@ snapshots:
|
|||||||
character-entities-html4: 2.1.0
|
character-entities-html4: 2.1.0
|
||||||
character-entities-legacy: 3.0.0
|
character-entities-legacy: 3.0.0
|
||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
|
||||||
|
|
||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
@@ -10329,10 +10273,6 @@ snapshots:
|
|||||||
|
|
||||||
stylis@4.3.6: {}
|
stylis@4.3.6: {}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
|
||||||
dependencies:
|
|
||||||
has-flag: 4.0.0
|
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
swr@2.4.1(react@19.2.4):
|
swr@2.4.1(react@19.2.4):
|
||||||
@@ -10444,13 +10384,13 @@ snapshots:
|
|||||||
|
|
||||||
type-fest@4.41.0: {}
|
type-fest@4.41.0: {}
|
||||||
|
|
||||||
typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
|
typescript-eslint@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.57.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -10717,10 +10657,10 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)):
|
vue-eslint-parser@10.4.0(eslint@10.2.0(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
eslint-scope: 9.1.2
|
eslint-scope: 9.1.2
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
espree: 11.2.0
|
espree: 11.2.0
|
||||||
|
|||||||
@@ -8,5 +8,3 @@ const config = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0A0E1A",
|
"background_color": "#0A0E1A",
|
||||||
"theme_color": "#6366F1",
|
"theme_color": "#6366F1",
|
||||||
"categories": [
|
"categories": ["productivity", "utilities"],
|
||||||
"productivity",
|
|
||||||
"utilities"
|
|
||||||
],
|
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/hermes-icon-192.png",
|
"src": "/hermes-icon-192.png",
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ self.addEventListener('install', () => {
|
|||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((names) =>
|
caches
|
||||||
Promise.all(names.map((name) => caches.delete(name)))
|
.keys()
|
||||||
).then(() => self.clients.claim())
|
.then((names) => Promise.all(names.map((name) => caches.delete(name))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Tell all open tabs to reload so they get fresh assets
|
// Tell all open tabs to reload so they get fresh assets
|
||||||
self.clients.matchAll({ type: 'window' }).then((clients) => {
|
self.clients.matchAll({ type: 'window' }).then((clients) => {
|
||||||
clients.forEach((client) => client.navigate(client.url))
|
clients.forEach((client) => client.navigate(client.url))
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,82 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head><title>Streaming Test</title></head>
|
<head>
|
||||||
<body style="font-family: monospace; padding: 20px; background: #1a1a1a; color: #eee;">
|
<title>Streaming Test</title>
|
||||||
<h3>Direct Streaming Test</h3>
|
</head>
|
||||||
<input id="msg" value="say hello" style="width: 300px; padding: 8px;">
|
<body
|
||||||
<button onclick="send()">Send</button>
|
style="
|
||||||
<div id="thinking" style="color: #888; margin-top: 10px;"></div>
|
font-family: monospace;
|
||||||
<div id="response" style="margin-top: 10px; white-space: pre-wrap;"></div>
|
padding: 20px;
|
||||||
<div id="log" style="margin-top: 20px; font-size: 11px; color: #666;"></div>
|
background: #1a1a1a;
|
||||||
<script>
|
color: #eee;
|
||||||
async function send() {
|
"
|
||||||
const msg = document.getElementById('msg').value;
|
>
|
||||||
document.getElementById('response').textContent = '';
|
<h3>Direct Streaming Test</h3>
|
||||||
document.getElementById('thinking').textContent = 'Thinking...';
|
<input id="msg" value="say hello" style="width: 300px; padding: 8px" />
|
||||||
document.getElementById('log').textContent = '';
|
<button onclick="send()">Send</button>
|
||||||
|
<div id="thinking" style="color: #888; margin-top: 10px"></div>
|
||||||
|
<div id="response" style="margin-top: 10px; white-space: pre-wrap"></div>
|
||||||
|
<div id="log" style="margin-top: 20px; font-size: 11px; color: #666"></div>
|
||||||
|
<script>
|
||||||
|
async function send() {
|
||||||
|
const msg = document.getElementById('msg').value
|
||||||
|
document.getElementById('response').textContent = ''
|
||||||
|
document.getElementById('thinking').textContent = 'Thinking...'
|
||||||
|
document.getElementById('log').textContent = ''
|
||||||
|
|
||||||
const res = await fetch('/api/send-stream', {
|
const res = await fetch('/api/send-stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionKey: 'main', message: msg })
|
body: JSON.stringify({ sessionKey: 'main', message: msg }),
|
||||||
});
|
})
|
||||||
|
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader()
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder()
|
||||||
let buffer = '';
|
let buffer = ''
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read()
|
||||||
if (done) break;
|
if (done) break
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true })
|
||||||
const events = buffer.split('\n\n');
|
const events = buffer.split('\n\n')
|
||||||
buffer = events.pop();
|
buffer = events.pop()
|
||||||
|
|
||||||
for (const block of events) {
|
for (const block of events) {
|
||||||
if (!block.trim()) continue;
|
if (!block.trim()) continue
|
||||||
let event = '', data = '';
|
let event = '',
|
||||||
|
data = ''
|
||||||
for (const line of block.split('\n')) {
|
for (const line of block.split('\n')) {
|
||||||
if (line.startsWith('event: ')) event = line.slice(7);
|
if (line.startsWith('event: ')) event = line.slice(7)
|
||||||
if (line.startsWith('data: ')) data += line.slice(6);
|
if (line.startsWith('data: ')) data += line.slice(6)
|
||||||
}
|
}
|
||||||
if (!event || !data) continue;
|
if (!event || !data) continue
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data)
|
||||||
document.getElementById('log').textContent += event + ': ' + JSON.stringify(parsed).slice(0,100) + '\n';
|
document.getElementById('log').textContent +=
|
||||||
|
event + ': ' + JSON.stringify(parsed).slice(0, 100) + '\n'
|
||||||
|
|
||||||
if (event === 'thinking') {
|
if (event === 'thinking') {
|
||||||
document.getElementById('thinking').textContent = 'Thinking: ' + (parsed.text || '').slice(-80);
|
document.getElementById('thinking').textContent =
|
||||||
|
'Thinking: ' + (parsed.text || '').slice(-80)
|
||||||
}
|
}
|
||||||
if (event === 'chunk') {
|
if (event === 'chunk') {
|
||||||
document.getElementById('thinking').textContent = '';
|
document.getElementById('thinking').textContent = ''
|
||||||
document.getElementById('response').textContent = parsed.text || '';
|
document.getElementById('response').textContent =
|
||||||
|
parsed.text || ''
|
||||||
}
|
}
|
||||||
if (event === 'done') {
|
if (event === 'done') {
|
||||||
document.getElementById('thinking').textContent = '';
|
document.getElementById('thinking').textContent = ''
|
||||||
const msg = parsed.message;
|
const msg = parsed.message
|
||||||
if (msg && msg.content) {
|
if (msg && msg.content) {
|
||||||
const text = msg.content.filter(c => c.type === 'text').map(c => c.text).join('');
|
const text = msg.content
|
||||||
if (text) document.getElementById('response').textContent = text;
|
.filter((c) => c.type === 'text')
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join('')
|
||||||
|
if (text) document.getElementById('response').textContent = text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -47,4 +47,3 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch(console.error)
|
main().catch(console.error)
|
||||||
|
|
||||||
|
|||||||
63
scripts/start-stable.sh
Executable file
63
scripts/start-stable.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
PORT="${PORT:-3002}"
|
||||||
|
RUNTIME_DIR="$ROOT/.runtime"
|
||||||
|
PID_FILE="$RUNTIME_DIR/hermes-workspace.pid"
|
||||||
|
LOG_FILE="$RUNTIME_DIR/hermes-workspace.log"
|
||||||
|
BUILD_LOG_FILE="$RUNTIME_DIR/hermes-workspace.build.log"
|
||||||
|
mkdir -p "$RUNTIME_DIR"
|
||||||
|
|
||||||
|
stop_pid() {
|
||||||
|
local pid="$1"
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
for _ in {1..20}; do
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -f "$PID_FILE" ]]; then
|
||||||
|
old_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$old_pid" ]]; then
|
||||||
|
stop_pid "$old_pid"
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for pid in $(lsof -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true); do
|
||||||
|
stop_pid "$pid"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[stable] building Hermes Workspace..."
|
||||||
|
pnpm build >"$BUILD_LOG_FILE" 2>&1
|
||||||
|
|
||||||
|
echo "[stable] starting Hermes Workspace on port $PORT..."
|
||||||
|
nohup env PORT="$PORT" NODE_OPTIONS="--max-old-space-size=2048" node server-entry.js >>"$LOG_FILE" 2>&1 &
|
||||||
|
new_pid=$!
|
||||||
|
echo "$new_pid" >"$PID_FILE"
|
||||||
|
|
||||||
|
for _ in {1..40}; do
|
||||||
|
if curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then
|
||||||
|
echo "[stable] up on http://127.0.0.1:$PORT"
|
||||||
|
echo "[stable] pid=$new_pid"
|
||||||
|
echo "[stable] log=$LOG_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$new_pid" 2>/dev/null; then
|
||||||
|
echo "[stable] failed to start, see $LOG_FILE and $BUILD_LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[stable] timed out waiting for startup, see $LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
36
scripts/stop-stable.sh
Executable file
36
scripts/stop-stable.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
PORT="${PORT:-3002}"
|
||||||
|
PID_FILE="$ROOT/.runtime/hermes-workspace.pid"
|
||||||
|
|
||||||
|
stop_pid() {
|
||||||
|
local pid="$1"
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
for _ in {1..20}; do
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -f "$PID_FILE" ]]; then
|
||||||
|
pid="$(cat "$PID_FILE" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$pid" ]]; then
|
||||||
|
stop_pid "$pid"
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for pid in $(lsof -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true); do
|
||||||
|
stop_pid "$pid"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[stable] stopped Hermes Workspace on port $PORT"
|
||||||
@@ -34,7 +34,10 @@ const MIME_TYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function tryServeStatic(req, res) {
|
async function tryServeStatic(req, res) {
|
||||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
|
const url = new URL(
|
||||||
|
req.url || '/',
|
||||||
|
`http://${req.headers.host || 'localhost'}`,
|
||||||
|
)
|
||||||
const pathname = decodeURIComponent(url.pathname)
|
const pathname = decodeURIComponent(url.pathname)
|
||||||
|
|
||||||
// Prevent directory traversal
|
// Prevent directory traversal
|
||||||
@@ -141,4 +144,3 @@ const httpServer = createServer(async (req, res) => {
|
|||||||
httpServer.listen(port, host, () => {
|
httpServer.listen(port, host, () => {
|
||||||
console.log(`Hermes Workspace running at http://${host}:${port}`)
|
console.log(`Hermes Workspace running at http://${host}:${port}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,15 @@ function AgentAvatar({
|
|||||||
🦞
|
🦞
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<img src="/hermes-avatar.webp" alt="Hermes" className={cn(getLogoSizeClassName(size), iconClassName, 'rounded-xl')} />
|
<img
|
||||||
|
src="/hermes-avatar.webp"
|
||||||
|
alt="Hermes"
|
||||||
|
className={cn(
|
||||||
|
getLogoSizeClassName(size),
|
||||||
|
iconClassName,
|
||||||
|
'rounded-xl',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">Click to switch avatar</TooltipContent>
|
<TooltipContent side="top">Click to switch avatar</TooltipContent>
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ function StatusIndicator({ status }: { status: AgentCardStatus }) {
|
|||||||
if (status === 'completed') {
|
if (status === 'completed') {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 text-emerald-300">
|
<span className="inline-flex items-center gap-1 text-emerald-300">
|
||||||
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={14} strokeWidth={1.8} />
|
<HugeiconsIcon
|
||||||
|
icon={CheckmarkCircle01Icon}
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
<span className="text-[11px] font-medium text-emerald-300">Done</span>
|
<span className="text-[11px] font-medium text-emerald-300">Done</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -112,8 +116,14 @@ export function AgentCard({
|
|||||||
}: AgentCardProps) {
|
}: AgentCardProps) {
|
||||||
const avatar = detectProviderAvatar(model)
|
const avatar = detectProviderAvatar(model)
|
||||||
const resolvedRuntime =
|
const resolvedRuntime =
|
||||||
runtimeLabel ?? (typeof runtimeSeconds === 'number' ? formatRuntimeCompact(runtimeSeconds) : '')
|
runtimeLabel ??
|
||||||
const hasTokens = typeof tokenCount === 'number' && Number.isFinite(tokenCount) && tokenCount > 0
|
(typeof runtimeSeconds === 'number'
|
||||||
|
? formatRuntimeCompact(runtimeSeconds)
|
||||||
|
: '')
|
||||||
|
const hasTokens =
|
||||||
|
typeof tokenCount === 'number' &&
|
||||||
|
Number.isFinite(tokenCount) &&
|
||||||
|
tokenCount > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { ArrowUp01Icon } from '@hugeicons/core-free-icons'
|
import { ArrowUp01Icon } from '@hugeicons/core-free-icons'
|
||||||
import type {FormEvent, KeyboardEvent} from 'react';
|
import type { FormEvent, KeyboardEvent } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ function readMessageText(message: ChatMessage): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function toChatMessages(
|
function toChatMessages(messages: Array<ChatMessage>): Array<AgentChatMessage> {
|
||||||
messages: Array<ChatMessage>,
|
|
||||||
): Array<AgentChatMessage> {
|
|
||||||
return messages
|
return messages
|
||||||
.map(function mapMessage(message, index) {
|
.map(function mapMessage(message, index) {
|
||||||
const text = readMessageText(message)
|
const text = readMessageText(message)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react'
|
||||||
|
|
||||||
export function LoginScreen() {
|
export function LoginScreen() {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ function UserAvatarComponent({
|
|||||||
fill="#E6EAF2"
|
fill="#E6EAF2"
|
||||||
/>
|
/>
|
||||||
{/* Collar detail */}
|
{/* Collar detail */}
|
||||||
<path d="M 44 55 L 50 62 L 56 55" stroke="#1A2340" strokeWidth="1.5" fill="none" />
|
<path
|
||||||
|
d="M 44 55 L 50 62 L 56 55"
|
||||||
|
stroke="#1A2340"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ type Props = {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackendUnavailableState({
|
export function BackendUnavailableState({ feature, description }: Props) {
|
||||||
feature,
|
|
||||||
description,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[320px] items-center justify-center p-6">
|
<div className="flex h-full min-h-[320px] items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md rounded-2xl border border-primary-200 bg-primary-50/70 p-8 text-center shadow-sm backdrop-blur-sm">
|
<div className="w-full max-w-md rounded-2xl border border-primary-200 bg-primary-50/70 p-8 text-center shadow-sm backdrop-blur-sm">
|
||||||
@@ -19,8 +16,8 @@ export function BackendUnavailableState({
|
|||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<h2 className="text-lg font-semibold text-primary-900">{feature}</h2>
|
<h2 className="text-lg font-semibold text-primary-900">{feature}</h2>
|
||||||
<p className="text-sm leading-6 text-primary-600">
|
<p className="text-sm leading-6 text-primary-600">
|
||||||
Not available on this backend. Connect to a Hermes gateway to
|
Not available on this backend. Connect to a Hermes gateway to unlock{' '}
|
||||||
unlock {feature}.
|
{feature}.
|
||||||
</p>
|
</p>
|
||||||
{description ? (
|
{description ? (
|
||||||
<p className="text-xs leading-5 text-primary-500">{description}</p>
|
<p className="text-xs leading-5 text-primary-500">{description}</p>
|
||||||
|
|||||||
@@ -143,7 +143,10 @@ export function ChatPanel() {
|
|||||||
exit={{ x: '100%', opacity: 0 }}
|
exit={{ x: '100%', opacity: 0 }}
|
||||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||||
className="fixed right-0 bottom-0 top-[var(--titlebar-h,0px)] h-[calc(100dvh-var(--titlebar-h,0px))] max-h-[calc(100dvh-var(--titlebar-h,0px))] w-[420px] max-w-[100vw] border-l overflow-hidden flex flex-col z-20 shadow-xl"
|
className="fixed right-0 bottom-0 top-[var(--titlebar-h,0px)] h-[calc(100dvh-var(--titlebar-h,0px))] max-h-[calc(100dvh-var(--titlebar-h,0px))] w-[420px] max-w-[100vw] border-l overflow-hidden flex flex-col z-20 shadow-xl"
|
||||||
style={{ background: 'var(--theme-bg)', borderColor: 'var(--theme-border)' }}
|
style={{
|
||||||
|
background: 'var(--theme-bg)',
|
||||||
|
borderColor: 'var(--theme-border)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Panel header */}
|
{/* Panel header */}
|
||||||
<div className="flex items-center justify-between h-10 px-3 border-b border-primary-200 shrink-0">
|
<div className="flex items-center justify-between h-10 px-3 border-b border-primary-200 shrink-0">
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ type CommandAction = {
|
|||||||
label: string
|
label: string
|
||||||
keywords: string
|
keywords: string
|
||||||
shortcut?: string
|
shortcut?: string
|
||||||
icon: React.ComponentProps<typeof import('@hugeicons/react').HugeiconsIcon>['icon']
|
icon: React.ComponentProps<
|
||||||
|
typeof import('@hugeicons/react').HugeiconsIcon
|
||||||
|
>['icon']
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +56,11 @@ type ScoredAction = CommandAction & {
|
|||||||
score: number
|
score: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCREEN_GROUP_ORDER = ['Screens', 'Recent Sessions', 'Slash Commands'] as const
|
const SCREEN_GROUP_ORDER = [
|
||||||
|
'Screens',
|
||||||
|
'Recent Sessions',
|
||||||
|
'Slash Commands',
|
||||||
|
] as const
|
||||||
|
|
||||||
function getSessionLabel(session: SessionMeta) {
|
function getSessionLabel(session: SessionMeta) {
|
||||||
return (
|
return (
|
||||||
@@ -73,14 +79,20 @@ function scoreCommandAction(action: CommandAction, query: string) {
|
|||||||
const haystack = `${action.label} ${action.keywords}`.toLowerCase()
|
const haystack = `${action.label} ${action.keywords}`.toLowerCase()
|
||||||
const directIndex = haystack.indexOf(normalizedQuery)
|
const directIndex = haystack.indexOf(normalizedQuery)
|
||||||
if (directIndex >= 0) {
|
if (directIndex >= 0) {
|
||||||
return 400 - directIndex - Math.max(0, haystack.length - normalizedQuery.length)
|
return (
|
||||||
|
400 - directIndex - Math.max(0, haystack.length - normalizedQuery.length)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let queryIndex = 0
|
let queryIndex = 0
|
||||||
let gaps = 0
|
let gaps = 0
|
||||||
let lastMatch = -1
|
let lastMatch = -1
|
||||||
|
|
||||||
for (let i = 0; i < haystack.length && queryIndex < normalizedQuery.length; i += 1) {
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < haystack.length && queryIndex < normalizedQuery.length;
|
||||||
|
i += 1
|
||||||
|
) {
|
||||||
if (haystack[i] !== normalizedQuery[queryIndex]) continue
|
if (haystack[i] !== normalizedQuery[queryIndex]) continue
|
||||||
if (lastMatch >= 0) gaps += Math.max(0, i - lastMatch - 1)
|
if (lastMatch >= 0) gaps += Math.max(0, i - lastMatch - 1)
|
||||||
lastMatch = i
|
lastMatch = i
|
||||||
@@ -91,10 +103,7 @@ function scoreCommandAction(action: CommandAction, query: string) {
|
|||||||
return 180 - gaps - Math.max(0, haystack.length - normalizedQuery.length)
|
return 180 - gaps - Math.max(0, haystack.length - normalizedQuery.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({
|
export function CommandPalette({ pathname, sessions }: CommandPaletteProps) {
|
||||||
pathname,
|
|
||||||
sessions,
|
|
||||||
}: CommandPaletteProps) {
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
@@ -301,9 +310,15 @@ export function CommandPalette({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
.map((action) => ({ ...action, score: scoreCommandAction(action, normalizedQuery) }))
|
.map((action) => ({
|
||||||
|
...action,
|
||||||
|
score: scoreCommandAction(action, normalizedQuery),
|
||||||
|
}))
|
||||||
.filter((action) => action.score > 0)
|
.filter((action) => action.score > 0)
|
||||||
.sort((left, right) => right.score - left.score || left.label.localeCompare(right.label))
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.score - left.score || left.label.localeCompare(right.label),
|
||||||
|
)
|
||||||
}, [actions, query])
|
}, [actions, query])
|
||||||
|
|
||||||
const groupedActions = useMemo(
|
const groupedActions = useMemo(
|
||||||
@@ -372,7 +387,8 @@ export function CommandPalette({
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (filteredActions.length === 0) return
|
if (filteredActions.length === 0) return
|
||||||
setSelectedIndex(
|
setSelectedIndex(
|
||||||
(current) => (current - 1 + filteredActions.length) % filteredActions.length,
|
(current) =>
|
||||||
|
(current - 1 + filteredActions.length) % filteredActions.length,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -438,7 +454,11 @@ export function CommandPalette({
|
|||||||
isSelected && 'bg-primary-100 text-primary-900',
|
isSelected && 'bg-primary-100 text-primary-900',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={action.icon} size={18} strokeWidth={1.6} />
|
<HugeiconsIcon
|
||||||
|
icon={action.icon}
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium">
|
<div className="truncate text-sm font-medium">
|
||||||
{action.label}
|
{action.label}
|
||||||
@@ -453,7 +473,9 @@ export function CommandPalette({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{groupIndex < groupedActions.length - 1 ? <CommandSeparator /> : null}
|
{groupIndex < groupedActions.length - 1 ? (
|
||||||
|
<CommandSeparator />
|
||||||
|
) : null}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
@@ -463,8 +485,16 @@ export function CommandPalette({
|
|||||||
<div className="flex items-center gap-4 text-primary-700">
|
<div className="flex items-center gap-4 text-primary-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="inline-flex items-center gap-1 rounded-md border border-primary-200 bg-surface px-2 py-1 text-[11px] font-medium text-primary-700">
|
<span className="inline-flex items-center gap-1 rounded-md border border-primary-200 bg-surface px-2 py-1 text-[11px] font-medium text-primary-700">
|
||||||
<HugeiconsIcon icon={ArrowUp01Icon} size={14} strokeWidth={1.5} />
|
<HugeiconsIcon
|
||||||
<HugeiconsIcon icon={ArrowDown01Icon} size={14} strokeWidth={1.5} />
|
icon={ArrowUp01Icon}
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={ArrowDown01Icon}
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span>Navigate</span>
|
<span>Navigate</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
// Stub — connection overlay (not used in Hermes Workspace)
|
// Stub — connection overlay (not used in Hermes Workspace)
|
||||||
export function useConnectionRestart() {
|
export function useConnectionRestart() {
|
||||||
return {
|
return {
|
||||||
triggerRestart: async (fn: () => Promise<void>) => { await fn() },
|
triggerRestart: async (fn: () => Promise<void>) => {
|
||||||
|
await fn()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectionProvider({ children }: { children: React.ReactNode }) {
|
export function ConnectionProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ type ContextMeterProps = {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextMeter({ variant = 'mobile', className }: ContextMeterProps) {
|
export function ContextMeter({
|
||||||
|
variant = 'mobile',
|
||||||
|
className,
|
||||||
|
}: ContextMeterProps) {
|
||||||
const [pct, setPct] = useState(0)
|
const [pct, setPct] = useState(0)
|
||||||
const [warning, setWarning] = useState<string | null>(null)
|
const [warning, setWarning] = useState<string | null>(null)
|
||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
@@ -40,7 +43,10 @@ export function ContextMeter({ variant = 'mobile', className }: ContextMeterProp
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/context-usage')
|
const res = await fetch('/api/context-usage')
|
||||||
if (!res.ok || cancelled) return
|
if (!res.ok || cancelled) return
|
||||||
const data = (await res.json()) as { ok?: boolean; contextPercent?: unknown }
|
const data = (await res.json()) as {
|
||||||
|
ok?: boolean
|
||||||
|
contextPercent?: unknown
|
||||||
|
}
|
||||||
if (!data?.ok || cancelled) return
|
if (!data?.ok || cancelled) return
|
||||||
const next = readPercent(data.contextPercent)
|
const next = readPercent(data.contextPercent)
|
||||||
prevPctRef.current = next
|
prevPctRef.current = next
|
||||||
@@ -73,18 +79,28 @@ export function ContextMeter({ variant = 'mobile', className }: ContextMeterProp
|
|||||||
return (
|
return (
|
||||||
<div className={cn('w-full flex flex-col', className)}>
|
<div className={cn('w-full flex flex-col', className)}>
|
||||||
{/* Thin progress bar */}
|
{/* Thin progress bar */}
|
||||||
<div className="w-full h-[3px]" style={{ background: 'var(--color-border, rgba(0,0,0,0.1))' }}>
|
|
||||||
<div
|
<div
|
||||||
className={cn('h-full transition-all duration-700', getBarColor(pct))}
|
className="w-full h-[3px]"
|
||||||
|
style={{ background: 'var(--color-border, rgba(0,0,0,0.1))' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full transition-all duration-700',
|
||||||
|
getBarColor(pct),
|
||||||
|
)}
|
||||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Warning banner at 75%+ */}
|
{/* Warning banner at 75%+ */}
|
||||||
{warning && pct >= 75 && (
|
{warning && pct >= 75 && (
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
'w-full text-[11px] px-3 py-0.5 text-center',
|
'w-full text-[11px] px-3 py-0.5 text-center',
|
||||||
pct >= 90 ? 'bg-red-500/10 text-red-600' : 'bg-orange-500/10 text-orange-600'
|
pct >= 90
|
||||||
)}>
|
? 'bg-red-500/10 text-red-600'
|
||||||
|
: 'bg-orange-500/10 text-orange-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
⚠ {warning}
|
⚠ {warning}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -100,13 +116,22 @@ export function ContextMeter({ variant = 'mobile', className }: ContextMeterProp
|
|||||||
{pct >= 90 ? '⚠ Context full' : '⚠ Context high'}
|
{pct >= 90 ? '⚠ Context full' : '⚠ Context high'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="w-20 h-1.5 rounded-full overflow-hidden shrink-0" style={{ background: 'var(--color-border, rgba(0,0,0,0.1))' }}>
|
|
||||||
<div
|
<div
|
||||||
className={cn('h-full rounded-full transition-all duration-700', getBarColor(pct))}
|
className="w-20 h-1.5 rounded-full overflow-hidden shrink-0"
|
||||||
|
style={{ background: 'var(--color-border, rgba(0,0,0,0.1))' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all duration-700',
|
||||||
|
getBarColor(pct),
|
||||||
|
)}
|
||||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] shrink-0 tabular-nums" style={{ color: 'var(--color-muted, #888)' }}>
|
<span
|
||||||
|
className="text-[10px] shrink-0 tabular-nums"
|
||||||
|
style={{ color: 'var(--color-muted, #888)' }}
|
||||||
|
>
|
||||||
{Math.round(pct)}%
|
{Math.round(pct)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,12 +12,9 @@ import {
|
|||||||
Sun02Icon,
|
Sun02Icon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
} from '@hugeicons/core-free-icons'
|
} from '@hugeicons/core-free-icons'
|
||||||
import type {SettingsThemeMode} from '@/hooks/use-settings';
|
import type { SettingsThemeMode } from '@/hooks/use-settings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import { useSettingsStore } from '@/hooks/use-settings'
|
||||||
|
|
||||||
useSettingsStore
|
|
||||||
} from '@/hooks/use-settings'
|
|
||||||
|
|
||||||
type OverflowItem = {
|
type OverflowItem = {
|
||||||
icon: typeof File01Icon
|
icon: typeof File01Icon
|
||||||
@@ -111,11 +108,7 @@ export function DashboardOverflowPanel({ open, onClose }: Props) {
|
|||||||
document.documentElement.classList.contains('dark'))
|
document.documentElement.classList.contains('dark'))
|
||||||
const themeIcon = resolvedDarkMode ? Moon02Icon : Sun02Icon
|
const themeIcon = resolvedDarkMode ? Moon02Icon : Sun02Icon
|
||||||
const themeLabel =
|
const themeLabel =
|
||||||
theme === 'system'
|
theme === 'system' ? 'System' : theme === 'dark' ? 'Dark' : 'Light'
|
||||||
? 'System'
|
|
||||||
: theme === 'dark'
|
|
||||||
? 'Dark'
|
|
||||||
: 'Light'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[80] no-swipe md:hidden">
|
<div className="fixed inset-0 z-[80] no-swipe md:hidden">
|
||||||
@@ -149,8 +142,16 @@ export function DashboardOverflowPanel({ open, onClose }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<OverflowGrid title="System" items={SYSTEM_ITEMS} onSelect={handleSelect} />
|
<OverflowGrid
|
||||||
<OverflowGrid title="Hermes" items={HERMES_ITEMS} onSelect={handleSelect} />
|
title="System"
|
||||||
|
items={SYSTEM_ITEMS}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
<OverflowGrid
|
||||||
|
title="Hermes"
|
||||||
|
items={HERMES_ITEMS}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component } from 'react'
|
import { Component } from 'react'
|
||||||
import type {ErrorInfo, ReactNode} from 'react';
|
import type { ErrorInfo, ReactNode } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,26 @@ type ErrorEntry = {
|
|||||||
|
|
||||||
function classifyError(raw: string): string {
|
function classifyError(raw: string): string {
|
||||||
const lower = raw.toLowerCase()
|
const lower = raw.toLowerCase()
|
||||||
if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many')) {
|
if (
|
||||||
|
lower.includes('429') ||
|
||||||
|
lower.includes('rate limit') ||
|
||||||
|
lower.includes('too many')
|
||||||
|
) {
|
||||||
return 'Rate limited — try again in a moment'
|
return 'Rate limited — try again in a moment'
|
||||||
}
|
}
|
||||||
if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('auth')) {
|
if (
|
||||||
|
lower.includes('401') ||
|
||||||
|
lower.includes('403') ||
|
||||||
|
lower.includes('unauthorized') ||
|
||||||
|
lower.includes('auth')
|
||||||
|
) {
|
||||||
return 'Authentication error — check your API key in Settings'
|
return 'Authentication error — check your API key in Settings'
|
||||||
}
|
}
|
||||||
if (lower.includes('500') || lower.includes('server error') || lower.includes('model error')) {
|
if (
|
||||||
|
lower.includes('500') ||
|
||||||
|
lower.includes('server error') ||
|
||||||
|
lower.includes('model error')
|
||||||
|
) {
|
||||||
return 'Model error — the provider is having issues'
|
return 'Model error — the provider is having issues'
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -67,7 +80,9 @@ function ToastItem({ entry, onDismiss }: ToastItemProps) {
|
|||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<span className="text-red-500 text-base shrink-0 mt-0.5">⚠</span>
|
<span className="text-red-500 text-base shrink-0 mt-0.5">⚠</span>
|
||||||
<span className="flex-1 text-[13px] text-ink leading-snug">{entry.message}</span>
|
<span className="flex-1 text-[13px] text-ink leading-snug">
|
||||||
|
{entry.message}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDismiss(entry.id)}
|
onClick={() => onDismiss(entry.id)}
|
||||||
|
|||||||
@@ -56,9 +56,7 @@ export function HermesHealthBanner({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-white/60 animate-pulse" />
|
<span className="inline-block h-2 w-2 rounded-full bg-white/60 animate-pulse" />
|
||||||
<span>
|
<span>Hermes Agent unreachable{lastError ? ` — ${lastError}` : ''}</span>
|
||||||
Hermes Agent unreachable{lastError ? ` — ${lastError}` : ''}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -70,7 +68,9 @@ export function HermesHealthBanner({
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
setLastError(err instanceof Error ? err.message : 'Connection failed')
|
setLastError(
|
||||||
|
err instanceof Error ? err.message : 'Connection failed',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className="ml-2 rounded px-2 py-0.5 text-xs font-semibold transition-opacity hover:opacity-80"
|
className="ml-2 rounded px-2 py-0.5 text-xs font-semibold transition-opacity hover:opacity-80"
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ async function probeHermesHealth(): Promise<boolean> {
|
|||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
if (response.ok) return true
|
if (response.ok) return true
|
||||||
} catch { /* fall through */ }
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
// Fallback to direct health proxy
|
// Fallback to direct health proxy
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/hermes-proxy/health', {
|
const response = await fetch('/api/hermes-proxy/health', {
|
||||||
@@ -39,7 +41,9 @@ export function HermesReconnectBanner({
|
|||||||
|
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
const inFlightProbeRef = useRef<Promise<boolean> | null>(null)
|
const inFlightProbeRef = useRef<Promise<boolean> | null>(null)
|
||||||
const probeNowRef = useRef<((showSpinner: boolean) => Promise<boolean>) | null>(null)
|
const probeNowRef = useRef<
|
||||||
|
((showSpinner: boolean) => Promise<boolean>) | null
|
||||||
|
>(null)
|
||||||
const wasDisconnectedRef = useRef(false)
|
const wasDisconnectedRef = useRef(false)
|
||||||
const flashTimerRef = useRef<number | null>(null)
|
const flashTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
@@ -111,7 +115,9 @@ export function HermesReconnectBanner({
|
|||||||
if (!cancelled && mountedRef.current) {
|
if (!cancelled && mountedRef.current) {
|
||||||
wasDisconnectedRef.current = true
|
wasDisconnectedRef.current = true
|
||||||
setBannerState('disconnected')
|
setBannerState('disconnected')
|
||||||
setMessage(error instanceof Error ? error.message : 'Connection failed')
|
setMessage(
|
||||||
|
error instanceof Error ? error.message : 'Connection failed',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -173,7 +179,9 @@ export function HermesReconnectBanner({
|
|||||||
: 'Starting Hermes agent…',
|
: 'Starting Hermes agent…',
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : 'Failed to start Hermes agent')
|
setMessage(
|
||||||
|
error instanceof Error ? error.message : 'Failed to start Hermes agent',
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setIsStarting(false)
|
setIsStarting(false)
|
||||||
await probeNowRef.current?.(true)
|
await probeNowRef.current?.(true)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { useActivityStore } from './activity-store'
|
import { useActivityStore } from './activity-store'
|
||||||
import type {ActivityEvent} from './activity-store';
|
import type { ActivityEvent } from './activity-store'
|
||||||
import { getUnavailableReason } from '@/lib/feature-gates'
|
import { getUnavailableReason } from '@/lib/feature-gates'
|
||||||
import { useFeatureAvailable } from '@/hooks/use-feature-available'
|
import { useFeatureAvailable } from '@/hooks/use-feature-available'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -43,9 +43,14 @@ function LoadingState({ text }: { text: string }) {
|
|||||||
<div className="flex items-center gap-2 p-4">
|
<div className="flex items-center gap-2 p-4">
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 animate-spin rounded-full border-2 border-t-transparent"
|
className="h-3 w-3 animate-spin rounded-full border-2 border-t-transparent"
|
||||||
style={{ borderColor: 'var(--theme-accent)', borderTopColor: 'transparent' }}
|
style={{
|
||||||
|
borderColor: 'var(--theme-accent)',
|
||||||
|
borderTopColor: 'transparent',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs" style={{ color: 'var(--theme-muted)' }}>{text}</span>
|
<span className="text-xs" style={{ color: 'var(--theme-muted)' }}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -53,7 +58,9 @@ function LoadingState({ text }: { text: string }) {
|
|||||||
function ErrorState({ text }: { text: string }) {
|
function ErrorState({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<span className="text-xs" style={{ color: 'var(--theme-danger)' }}>{text}</span>
|
<span className="text-xs" style={{ color: 'var(--theme-danger)' }}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -61,7 +68,9 @@ function ErrorState({ text }: { text: string }) {
|
|||||||
function EmptyState({ text }: { text: string }) {
|
function EmptyState({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<span className="text-xs" style={{ color: 'var(--theme-muted)' }}>{text}</span>
|
<span className="text-xs" style={{ color: 'var(--theme-muted)' }}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -81,18 +90,26 @@ function ActivityTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scrollRef} className="space-y-1 p-3 overflow-auto max-h-[calc(100vh-140px)]">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="space-y-1 p-3 overflow-auto max-h-[calc(100vh-140px)]"
|
||||||
|
>
|
||||||
{events.map((event: ActivityEvent, i: number) => (
|
{events.map((event: ActivityEvent, i: number) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-start gap-2 rounded-md px-2 py-1.5 text-xs"
|
className="flex items-start gap-2 rounded-md px-2 py-1.5 text-xs"
|
||||||
style={{ background: 'var(--theme-card2)' }}
|
style={{ background: 'var(--theme-card2)' }}
|
||||||
>
|
>
|
||||||
<span style={{ color: 'var(--theme-accent)', fontFamily: 'monospace' }}>
|
<span
|
||||||
|
style={{ color: 'var(--theme-accent)', fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
{event.time}
|
{event.time}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: 'var(--theme-muted)' }}>{event.type}</span>
|
<span style={{ color: 'var(--theme-muted)' }}>{event.type}</span>
|
||||||
<span className="ml-auto truncate" style={{ color: 'var(--theme-text)' }}>
|
<span
|
||||||
|
className="ml-auto truncate"
|
||||||
|
style={{ color: 'var(--theme-text)' }}
|
||||||
|
>
|
||||||
{event.text}
|
{event.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,14 +127,21 @@ function FilesTab() {
|
|||||||
const files = Array.from(
|
const files = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
events
|
events
|
||||||
.filter((e: ActivityEvent) => e.type === 'tool_call' || e.type === 'file_read' || e.type === 'file_write')
|
.filter(
|
||||||
.map((e: ActivityEvent) => e.text)
|
(e: ActivityEvent) =>
|
||||||
.filter(Boolean)
|
e.type === 'tool_call' ||
|
||||||
|
e.type === 'file_read' ||
|
||||||
|
e.type === 'file_write',
|
||||||
)
|
)
|
||||||
|
.map((e: ActivityEvent) => e.text)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return <EmptyState text="No files touched yet — activity will appear during chat" />
|
return (
|
||||||
|
<EmptyState text="No files touched yet — activity will appear during chat" />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,7 +153,10 @@ function FilesTab() {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded px-2 py-1 text-xs font-mono truncate"
|
className="rounded px-2 py-1 text-xs font-mono truncate"
|
||||||
style={{ color: 'var(--theme-text)', background: 'var(--theme-card2)' }}
|
style={{
|
||||||
|
color: 'var(--theme-text)',
|
||||||
|
background: 'var(--theme-card2)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{file}
|
{file}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +168,10 @@ function FilesTab() {
|
|||||||
// ── Memory Tab ────────────────────────────────────────────────────────────────
|
// ── Memory Tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function MemoryTab() {
|
function MemoryTab() {
|
||||||
const [files, setFiles] = useState<Array<{ path: string; name: string }> | null>(null)
|
const [files, setFiles] = useState<Array<{
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
}> | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -170,12 +200,15 @@ function MemoryTab() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) return <LoadingState text="Loading memory…" />
|
if (loading) return <LoadingState text="Loading memory…" />
|
||||||
if (error) return <ErrorState text={`Memory: ${error}`} />
|
if (error) return <ErrorState text={`Memory: ${error}`} />
|
||||||
if (!files || files.length === 0) return <EmptyState text="No memory files available" />
|
if (!files || files.length === 0)
|
||||||
|
return <EmptyState text="No memory files available" />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 p-3 overflow-auto max-h-[calc(100vh-140px)]">
|
<div className="space-y-2 p-3 overflow-auto max-h-[calc(100vh-140px)]">
|
||||||
@@ -186,7 +219,11 @@ function MemoryTab() {
|
|||||||
<div
|
<div
|
||||||
key={`${file.path}-${index}`}
|
key={`${file.path}-${index}`}
|
||||||
className="rounded-lg px-3 py-2 text-xs leading-relaxed"
|
className="rounded-lg px-3 py-2 text-xs leading-relaxed"
|
||||||
style={{ backgroundColor: 'var(--theme-card)', border: '1px solid var(--theme-border)', color: 'var(--theme-text)' }}
|
style={{
|
||||||
|
backgroundColor: 'var(--theme-card)',
|
||||||
|
border: '1px solid var(--theme-border)',
|
||||||
|
color: 'var(--theme-text)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{file.name}</div>
|
<div className="font-medium">{file.name}</div>
|
||||||
<div style={{ color: 'var(--theme-muted)' }}>{file.path}</div>
|
<div style={{ color: 'var(--theme-muted)' }}>{file.path}</div>
|
||||||
@@ -220,7 +257,9 @@ function SkillsTab() {
|
|||||||
.then((json) => {
|
.then((json) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
// Handle array of skills or object with skills property
|
// Handle array of skills or object with skills property
|
||||||
const list = Array.isArray(json) ? json : (json.skills || json.data || [])
|
const list = Array.isArray(json)
|
||||||
|
? json
|
||||||
|
: json.skills || json.data || []
|
||||||
setSkills(list)
|
setSkills(list)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -231,7 +270,9 @@ function SkillsTab() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) return <LoadingState text="Loading skills…" />
|
if (loading) return <LoadingState text="Loading skills…" />
|
||||||
@@ -253,17 +294,25 @@ function SkillsTab() {
|
|||||||
</p>
|
</p>
|
||||||
{Object.entries(grouped).map(([category, items]) => (
|
{Object.entries(grouped).map(([category, items]) => (
|
||||||
<div key={category}>
|
<div key={category}>
|
||||||
<p className="text-[10px] uppercase tracking-wider mb-1 font-semibold" style={{ color: 'var(--theme-accent)' }}>
|
<p
|
||||||
|
className="text-[10px] uppercase tracking-wider mb-1 font-semibold"
|
||||||
|
style={{ color: 'var(--theme-accent)' }}
|
||||||
|
>
|
||||||
{category}
|
{category}
|
||||||
</p>
|
</p>
|
||||||
{items.map((skill) => (
|
{items.map((skill) => (
|
||||||
<button
|
<button
|
||||||
key={skill.name}
|
key={skill.name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded(expanded === skill.name ? null : skill.name)}
|
onClick={() =>
|
||||||
|
setExpanded(expanded === skill.name ? null : skill.name)
|
||||||
|
}
|
||||||
className="w-full text-left rounded px-2 py-1.5 text-xs mb-0.5 transition-colors"
|
className="w-full text-left rounded px-2 py-1.5 text-xs mb-0.5 transition-colors"
|
||||||
style={{
|
style={{
|
||||||
background: expanded === skill.name ? 'var(--theme-card2)' : 'transparent',
|
background:
|
||||||
|
expanded === skill.name
|
||||||
|
? 'var(--theme-card2)'
|
||||||
|
: 'transparent',
|
||||||
color: 'var(--theme-text)',
|
color: 'var(--theme-text)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -272,7 +321,10 @@ function SkillsTab() {
|
|||||||
<span>{skill.name}</span>
|
<span>{skill.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{expanded === skill.name && skill.description && (
|
{expanded === skill.name && skill.description && (
|
||||||
<p className="mt-1 pl-5 text-[11px]" style={{ color: 'var(--theme-muted)' }}>
|
<p
|
||||||
|
className="mt-1 pl-5 text-[11px]"
|
||||||
|
style={{ color: 'var(--theme-muted)' }}
|
||||||
|
>
|
||||||
{skill.description}
|
{skill.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -312,7 +364,10 @@ function LogsTab() {
|
|||||||
<pre
|
<pre
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="text-xs rounded p-2 overflow-auto max-h-[400px] font-mono"
|
className="text-xs rounded p-2 overflow-auto max-h-[400px] font-mono"
|
||||||
style={{ background: 'var(--theme-card2)', color: 'var(--theme-muted)' }}
|
style={{
|
||||||
|
background: 'var(--theme-card2)',
|
||||||
|
color: 'var(--theme-muted)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{events.map((e: ActivityEvent) => JSON.stringify(e)).join('\n')}
|
{events.map((e: ActivityEvent) => JSON.stringify(e)).join('\n')}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -356,7 +411,10 @@ export function InspectorPanel() {
|
|||||||
className="flex items-center justify-between px-4 py-3 shrink-0"
|
className="flex items-center justify-between px-4 py-3 shrink-0"
|
||||||
style={{ borderBottom: '1px solid var(--theme-border)' }}
|
style={{ borderBottom: '1px solid var(--theme-border)' }}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--theme-text)' }}>
|
<span
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: 'var(--theme-text)' }}
|
||||||
|
>
|
||||||
Inspector
|
Inspector
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -375,7 +433,7 @@ export function InspectorPanel() {
|
|||||||
className="flex shrink-0 overflow-x-auto"
|
className="flex shrink-0 overflow-x-auto"
|
||||||
style={{ borderBottom: '1px solid var(--theme-border)' }}
|
style={{ borderBottom: '1px solid var(--theme-border)' }}
|
||||||
>
|
>
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) =>
|
||||||
(() => {
|
(() => {
|
||||||
const available =
|
const available =
|
||||||
tab.feature === 'memory'
|
tab.feature === 'memory'
|
||||||
@@ -394,14 +452,18 @@ export function InspectorPanel() {
|
|||||||
disabled={!available}
|
disabled={!available}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3 py-2 text-xs font-medium shrink-0 transition-colors',
|
'px-3 py-2 text-xs font-medium shrink-0 transition-colors',
|
||||||
activeTab === tab.id
|
activeTab === tab.id ? 'border-b-2' : 'hover:opacity-80',
|
||||||
? 'border-b-2'
|
|
||||||
: 'hover:opacity-80',
|
|
||||||
!available && 'cursor-not-allowed opacity-50',
|
!available && 'cursor-not-allowed opacity-50',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
color: activeTab === tab.id ? 'var(--theme-accent)' : 'var(--theme-muted)',
|
color:
|
||||||
borderBottomColor: activeTab === tab.id ? 'var(--theme-accent)' : 'transparent',
|
activeTab === tab.id
|
||||||
|
? 'var(--theme-accent)'
|
||||||
|
: 'var(--theme-muted)',
|
||||||
|
borderBottomColor:
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'var(--theme-accent)'
|
||||||
|
: 'transparent',
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
!available && tab.feature
|
!available && tab.feature
|
||||||
@@ -417,8 +479,8 @@ export function InspectorPanel() {
|
|||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})()
|
})(),
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export type LogoLoaderProps = {
|
|||||||
function LogoLoader({ className }: LogoLoaderProps) {
|
function LogoLoader({ className }: LogoLoaderProps) {
|
||||||
return (
|
return (
|
||||||
<span className="logo-loader-track" aria-hidden="true">
|
<span className="logo-loader-track" aria-hidden="true">
|
||||||
<img src="/hermes-avatar.webp" alt="" className={cn('logo-loader-icon size-4 rounded', className)} />
|
<img
|
||||||
|
src="/hermes-avatar.webp"
|
||||||
|
alt=""
|
||||||
|
className={cn('logo-loader-icon size-4 rounded', className)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,17 +23,57 @@ import {
|
|||||||
} from '@/hooks/use-chat-settings'
|
} from '@/hooks/use-chat-settings'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ id: 'chat', label: 'Chat', icon: Chat01Icon, to: '/chat/main', match: (p: string) => p.startsWith('/chat') || p === '/new' || p === '/' },
|
{
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: DashboardSquare01Icon, to: '/dashboard', match: (p: string) => p.startsWith('/dashboard') },
|
id: 'chat',
|
||||||
{ id: 'terminal', label: 'Terminal', icon: CommandLineIcon, to: '/terminal', match: (p: string) => p.startsWith('/terminal') },
|
label: 'Chat',
|
||||||
{ id: 'jobs', label: 'Jobs', icon: Clock01Icon, to: '/jobs', match: (p: string) => p.startsWith('/jobs') },
|
icon: Chat01Icon,
|
||||||
{ id: 'memory', label: 'Memory', icon: BrainIcon, to: '/memory', match: (p: string) => p.startsWith('/memory') },
|
to: '/chat/main',
|
||||||
{ id: 'skills', label: 'Skills', icon: PuzzleIcon, to: '/skills', match: (p: string) => p.startsWith('/skills') },
|
match: (p: string) => p.startsWith('/chat') || p === '/new' || p === '/',
|
||||||
{ id: 'profiles', label: 'Profiles', icon: UserGroupIcon, to: '/profiles', match: (p: string) => p.startsWith('/profiles') },
|
},
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
icon: DashboardSquare01Icon,
|
||||||
|
to: '/dashboard',
|
||||||
|
match: (p: string) => p.startsWith('/dashboard'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'terminal',
|
||||||
|
label: 'Terminal',
|
||||||
|
icon: CommandLineIcon,
|
||||||
|
to: '/terminal',
|
||||||
|
match: (p: string) => p.startsWith('/terminal'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jobs',
|
||||||
|
label: 'Jobs',
|
||||||
|
icon: Clock01Icon,
|
||||||
|
to: '/jobs',
|
||||||
|
match: (p: string) => p.startsWith('/jobs'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'memory',
|
||||||
|
label: 'Memory',
|
||||||
|
icon: BrainIcon,
|
||||||
|
to: '/memory',
|
||||||
|
match: (p: string) => p.startsWith('/memory'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'skills',
|
||||||
|
label: 'Skills',
|
||||||
|
icon: PuzzleIcon,
|
||||||
|
to: '/skills',
|
||||||
|
match: (p: string) => p.startsWith('/skills'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'profiles',
|
||||||
|
label: 'Profiles',
|
||||||
|
icon: UserGroupIcon,
|
||||||
|
to: '/profiles',
|
||||||
|
match: (p: string) => p.startsWith('/profiles'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** Shared drawer state — used by both the trigger button and the drawer itself */
|
/** Shared drawer state — used by both the trigger button and the drawer itself */
|
||||||
let _setOpen: ((v: boolean) => void) | null = null
|
let _setOpen: ((v: boolean) => void) | null = null
|
||||||
|
|
||||||
@@ -70,13 +110,16 @@ export function MobileHamburgerMenu() {
|
|||||||
// Add/remove body class to push main content
|
// Add/remove body class to push main content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.toggle('nav-drawer-open', open)
|
document.body.classList.toggle('nav-drawer-open', open)
|
||||||
return () => { document.body.classList.remove('nav-drawer-open') }
|
return () => {
|
||||||
|
document.body.classList.remove('nav-drawer-open')
|
||||||
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
const profileDisplayName = useChatSettingsStore(selectChatProfileDisplayName)
|
const profileDisplayName = useChatSettingsStore(selectChatProfileDisplayName)
|
||||||
const isChatRoute = pathname.startsWith('/chat') || pathname === '/new' || pathname === '/'
|
const isChatRoute =
|
||||||
|
pathname.startsWith('/chat') || pathname === '/new' || pathname === '/'
|
||||||
|
|
||||||
function handleNav(to: string) {
|
function handleNav(to: string) {
|
||||||
hapticTap()
|
hapticTap()
|
||||||
@@ -100,7 +143,9 @@ export function MobileHamburgerMenu() {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-all duration-300 ease-in-out',
|
'absolute inset-0 transition-all duration-300 ease-in-out',
|
||||||
open ? 'translate-x-72 opacity-40 scale-[0.92] rounded-2xl overflow-hidden' : 'translate-x-0 opacity-100 scale-100',
|
open
|
||||||
|
? 'translate-x-72 opacity-40 scale-[0.92] rounded-2xl overflow-hidden'
|
||||||
|
: 'translate-x-0 opacity-100 scale-100',
|
||||||
)}
|
)}
|
||||||
onClick={() => open && setOpen(false)}
|
onClick={() => open && setOpen(false)}
|
||||||
style={open ? { transformOrigin: 'left center' } : undefined}
|
style={open ? { transformOrigin: 'left center' } : undefined}
|
||||||
@@ -116,15 +161,35 @@ export function MobileHamburgerMenu() {
|
|||||||
'transition-transform duration-300 ease-in-out',
|
'transition-transform duration-300 ease-in-out',
|
||||||
open ? 'translate-x-0' : '-translate-x-full',
|
open ? 'translate-x-0' : '-translate-x-full',
|
||||||
)}
|
)}
|
||||||
style={{ background: 'var(--color-surface, #fff)', borderColor: 'var(--color-border, #e5e7eb)' }}
|
style={{
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
borderColor: 'var(--color-border, #e5e7eb)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 pb-4" style={{ borderBottom: '1px solid var(--color-border, #e5e7eb)' }}>
|
<div
|
||||||
|
className="flex items-center justify-between px-4 pb-4"
|
||||||
|
style={{ borderBottom: '1px solid var(--color-border, #e5e7eb)' }}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<img src="/hermes-avatar.webp" alt="Hermes" className="size-8 rounded-xl shrink-0" />
|
<img
|
||||||
|
src="/hermes-avatar.webp"
|
||||||
|
alt="Hermes"
|
||||||
|
className="size-8 rounded-xl shrink-0"
|
||||||
|
/>
|
||||||
<div className="flex flex-col leading-tight">
|
<div className="flex flex-col leading-tight">
|
||||||
<span className="font-bold text-[15px] tracking-tight" style={{ color: 'var(--color-ink, #111)' }}>Hermes</span>
|
<span
|
||||||
<span className="text-[11px]" style={{ color: 'var(--color-muted, #888)' }}>Workspace</span>
|
className="font-bold text-[15px] tracking-tight"
|
||||||
|
style={{ color: 'var(--color-ink, #111)' }}
|
||||||
|
>
|
||||||
|
Hermes
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[11px]"
|
||||||
|
style={{ color: 'var(--color-muted, #888)' }}
|
||||||
|
>
|
||||||
|
Workspace
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -151,12 +216,21 @@ export function MobileHamburgerMenu() {
|
|||||||
'flex items-center gap-3 px-3 py-3 rounded-xl text-left w-full',
|
'flex items-center gap-3 px-3 py-3 rounded-xl text-left w-full',
|
||||||
'transition-all duration-150 active:scale-[0.98]',
|
'transition-all duration-150 active:scale-[0.98]',
|
||||||
)}
|
)}
|
||||||
style={isActive
|
style={
|
||||||
? { background: 'var(--color-accent-muted, rgba(99,102,241,0.12))', color: 'var(--color-accent, #6366f1)' }
|
isActive
|
||||||
|
? {
|
||||||
|
background:
|
||||||
|
'var(--color-accent-muted, rgba(99,102,241,0.12))',
|
||||||
|
color: 'var(--color-accent, #6366f1)',
|
||||||
|
}
|
||||||
: { color: 'var(--color-ink-muted, #555)' }
|
: { color: 'var(--color-ink-muted, #555)' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={item.icon} size={20} strokeWidth={isActive ? 2 : 1.6} />
|
<HugeiconsIcon
|
||||||
|
icon={item.icon}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={isActive ? 2 : 1.6}
|
||||||
|
/>
|
||||||
<span className="text-[15px] font-medium">{item.label}</span>
|
<span className="text-[15px] font-medium">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -164,16 +238,37 @@ export function MobileHamburgerMenu() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Bottom — user profile + settings + theme toggle */}
|
{/* Bottom — user profile + settings + theme toggle */}
|
||||||
<div className="px-3 pb-2 pt-3" style={{ borderTop: '1px solid var(--color-border, #e5e7eb)' }}>
|
<div
|
||||||
|
className="px-3 pb-2 pt-3"
|
||||||
|
style={{ borderTop: '1px solid var(--color-border, #e5e7eb)' }}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3 px-2">
|
<div className="flex items-center gap-3 px-2">
|
||||||
{/* User avatar + name + status dot */}
|
{/* User avatar + name + status dot */}
|
||||||
<div className="size-9 rounded-xl shrink-0 flex items-center justify-center" style={{ background: 'var(--color-accent-muted, rgba(99,102,241,0.15))' }}>
|
<div
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-accent, #6366f1)' }}>
|
className="size-9 rounded-xl shrink-0 flex items-center justify-center"
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
style={{
|
||||||
<circle cx="12" cy="7" r="4"/>
|
background: 'var(--color-accent-muted, rgba(99,102,241,0.15))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{ color: 'var(--color-accent, #6366f1)' }}
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[15px] font-semibold truncate" style={{ color: 'var(--color-ink, #111)' }}>
|
<span
|
||||||
|
className="text-[15px] font-semibold truncate"
|
||||||
|
style={{ color: 'var(--color-ink, #111)' }}
|
||||||
|
>
|
||||||
{profileDisplayName}
|
{profileDisplayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="size-2.5 rounded-full bg-green-500 shrink-0" />
|
<span className="size-2.5 rounded-full bg-green-500 shrink-0" />
|
||||||
@@ -188,7 +283,11 @@ export function MobileHamburgerMenu() {
|
|||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
style={{ color: 'var(--color-ink-muted, #888)' }}
|
style={{ color: 'var(--color-ink-muted, #888)' }}
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={Settings01Icon} size={20} strokeWidth={1.5} />
|
<HugeiconsIcon
|
||||||
|
icon={Settings01Icon}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Theme toggle — sun/moon */}
|
{/* Theme toggle — sun/moon */}
|
||||||
@@ -204,8 +303,17 @@ export function MobileHamburgerMenu() {
|
|||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
style={{ color: 'var(--color-ink-muted, #888)' }}
|
style={{ color: 'var(--color-ink-muted, #888)' }}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ type MobilePageHeaderProps = {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobilePageHeader({ title, right, className }: MobilePageHeaderProps) {
|
export function MobilePageHeader({
|
||||||
|
title,
|
||||||
|
right,
|
||||||
|
className,
|
||||||
|
}: MobilePageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -20,7 +24,10 @@ export function MobilePageHeader({ title, right, className }: MobilePageHeaderPr
|
|||||||
'border-b bg-surface',
|
'border-b bg-surface',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ borderColor: 'var(--color-border, #e5e7eb)', paddingTop: 'env(safe-area-inset-top, 0px)' }}
|
style={{
|
||||||
|
borderColor: 'var(--color-border, #e5e7eb)',
|
||||||
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -28,16 +35,29 @@ export function MobilePageHeader({ title, right, className }: MobilePageHeaderPr
|
|||||||
onClick={openHamburgerMenu}
|
onClick={openHamburgerMenu}
|
||||||
className="shrink-0 flex items-center justify-center w-11 h-11 rounded-xl active:bg-white/10 transition-colors touch-manipulation z-10"
|
className="shrink-0 flex items-center justify-center w-11 h-11 rounded-xl active:bg-white/10 transition-colors touch-manipulation z-10"
|
||||||
>
|
>
|
||||||
<svg width="20" height="16" viewBox="0 0 20 16" fill="none" className="opacity-70" style={{ color: 'var(--color-ink, #111)' }}>
|
<svg
|
||||||
<path d="M1 1.5H19M1 8H19M1 14.5H13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
|
width="20"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 20 16"
|
||||||
|
fill="none"
|
||||||
|
className="opacity-70"
|
||||||
|
style={{ color: 'var(--color-ink, #111)' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1 1.5H19M1 8H19M1 14.5H13"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span className="flex-1 text-center text-[15px] font-semibold truncate -ml-11" style={{ color: 'var(--color-ink, #111)' }}>
|
<span
|
||||||
|
className="flex-1 text-center text-[15px] font-semibold truncate -ml-11"
|
||||||
|
style={{ color: 'var(--color-ink, #111)' }}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
<div className="shrink-0 w-9">
|
<div className="shrink-0 w-9">{right ?? null}</div>
|
||||||
{right ?? null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,84 @@
|
|||||||
'use client';
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
|
||||||
import { HugeiconsIcon } from '@hugeicons/react';
|
|
||||||
import { Cancel01Icon } from '@hugeicons/core-free-icons';
|
|
||||||
import { MobileSetupModal } from './MobileSetupModal';
|
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { Cancel01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { MobileSetupModal } from './MobileSetupModal'
|
||||||
|
|
||||||
export function MobilePromptTrigger() {
|
export function MobilePromptTrigger() {
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
const [showPrompt, setShowPrompt] = useState(false)
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||||
const [isDismissedForSession, setIsDismissedForSession] = useState(false);
|
const [isDismissedForSession, setIsDismissedForSession] = useState(false)
|
||||||
const mountTimeRef = useRef<number | null>(null);
|
const mountTimeRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountTimeRef.current = Date.now();
|
mountTimeRef.current = Date.now()
|
||||||
|
|
||||||
// ?mobile-preview forces modal open immediately (dev/review only)
|
// ?mobile-preview forces modal open immediately (dev/review only)
|
||||||
// Strip the param from URL so navigation doesn't re-trigger it
|
// Strip the param from URL so navigation doesn't re-trigger it
|
||||||
if (new URLSearchParams(window.location.search).get('mobile-preview') === '1') {
|
if (
|
||||||
const url = new URL(window.location.href);
|
new URLSearchParams(window.location.search).get('mobile-preview') === '1'
|
||||||
url.searchParams.delete('mobile-preview');
|
) {
|
||||||
window.history.replaceState({}, '', url.toString());
|
const url = new URL(window.location.href)
|
||||||
setIsModalOpen(true);
|
url.searchParams.delete('mobile-preview')
|
||||||
return;
|
window.history.replaceState({}, '', url.toString())
|
||||||
|
setIsModalOpen(true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDismissed =
|
const isDismissed =
|
||||||
localStorage.getItem('hermes-mobile-access-dismissed') === 'true' ||
|
localStorage.getItem('hermes-mobile-access-dismissed') === 'true' ||
|
||||||
localStorage.getItem('hermes-mobile-prompt-dismissed') === 'true';
|
localStorage.getItem('hermes-mobile-prompt-dismissed') === 'true'
|
||||||
const isSetup = localStorage.getItem('hermes-mobile-setup-seen') === 'true';
|
const isSetup = localStorage.getItem('hermes-mobile-setup-seen') === 'true'
|
||||||
|
|
||||||
if (isDismissed || isSetup) {
|
if (isDismissed || isSetup) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkPrompt = () => {
|
const checkPrompt = () => {
|
||||||
if (!mountTimeRef.current) {
|
if (!mountTimeRef.current) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsedTime = Date.now() - mountTimeRef.current;
|
const elapsedTime = Date.now() - mountTimeRef.current
|
||||||
const isDesktop = window.innerWidth > 768;
|
const isDesktop = window.innerWidth > 768
|
||||||
const hasBeenOnPageLongEnough = elapsedTime >= 45_000;
|
const hasBeenOnPageLongEnough = elapsedTime >= 45_000
|
||||||
|
|
||||||
if (isDesktop && hasBeenOnPageLongEnough && !isDismissedForSession) {
|
if (isDesktop && hasBeenOnPageLongEnough && !isDismissedForSession) {
|
||||||
setShowPrompt(true);
|
setShowPrompt(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
checkPrompt();
|
checkPrompt()
|
||||||
const interval = window.setInterval(checkPrompt, 5_000);
|
const interval = window.setInterval(checkPrompt, 5_000)
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval)
|
||||||
}, [isDismissedForSession]);
|
}, [isDismissedForSession])
|
||||||
|
|
||||||
const persistDismissalPreference = () => {
|
const persistDismissalPreference = () => {
|
||||||
if (dontShowAgain) {
|
if (dontShowAgain) {
|
||||||
localStorage.setItem('hermes-mobile-access-dismissed', 'true');
|
localStorage.setItem('hermes-mobile-access-dismissed', 'true')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const dismissPrompt = () => {
|
const dismissPrompt = () => {
|
||||||
persistDismissalPreference();
|
persistDismissalPreference()
|
||||||
setIsDismissedForSession(true);
|
setIsDismissedForSession(true)
|
||||||
setShowPrompt(false);
|
setShowPrompt(false)
|
||||||
};
|
}
|
||||||
|
|
||||||
const openSetup = () => {
|
const openSetup = () => {
|
||||||
persistDismissalPreference();
|
persistDismissalPreference()
|
||||||
setIsDismissedForSession(true);
|
setIsDismissedForSession(true)
|
||||||
setShowPrompt(false);
|
setShowPrompt(false)
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
const closeSetup = () => {
|
const closeSetup = () => {
|
||||||
persistDismissalPreference();
|
persistDismissalPreference()
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false)
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -99,27 +100,88 @@ export function MobilePromptTrigger() {
|
|||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<img src="/hermes-avatar.webp" alt="Hermes" className="size-8 rounded-lg" />
|
<img
|
||||||
|
src="/hermes-avatar.webp"
|
||||||
|
alt="Hermes"
|
||||||
|
className="size-8 rounded-lg"
|
||||||
|
/>
|
||||||
<span className="text-xs text-primary-600">+</span>
|
<span className="text-xs text-primary-600">+</span>
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[#232b3b]">
|
<div className="flex size-8 items-center justify-center rounded-lg bg-[#232b3b]">
|
||||||
<svg viewBox="0 0 100 100" className="size-5">
|
<svg viewBox="0 0 100 100" className="size-5">
|
||||||
<circle cx="50" cy="10" r="10" fill="#fff" opacity="0.9" />
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="10"
|
||||||
|
r="10"
|
||||||
|
fill="#fff"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
<circle cx="50" cy="50" r="10" fill="#fff" />
|
<circle cx="50" cy="50" r="10" fill="#fff" />
|
||||||
<circle cx="50" cy="90" r="10" fill="#fff" opacity="0.9" />
|
<circle
|
||||||
<circle cx="10" cy="30" r="10" fill="#fff" opacity="0.6" />
|
cx="50"
|
||||||
<circle cx="90" cy="30" r="10" fill="#fff" opacity="0.6" />
|
cy="90"
|
||||||
<circle cx="10" cy="70" r="10" fill="#fff" opacity="0.6" />
|
r="10"
|
||||||
<circle cx="90" cy="70" r="10" fill="#fff" opacity="0.6" />
|
fill="#fff"
|
||||||
<circle cx="10" cy="50" r="10" fill="#fff" opacity="0.3" />
|
opacity="0.9"
|
||||||
<circle cx="90" cy="50" r="10" fill="#fff" opacity="0.3" />
|
/>
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="30"
|
||||||
|
r="10"
|
||||||
|
fill="#fff"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="90"
|
||||||
|
cy="30"
|
||||||
|
r="10"
|
||||||
|
fill="#fff"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="70"
|
||||||
|
r="10"
|
||||||
|
fill="#fff"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="90"
|
||||||
|
cy="70"
|
||||||
|
r="10"
|
||||||
|
fill="#fff"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="50"
|
||||||
|
r="10"
|
||||||
|
fill="#fff"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="90"
|
||||||
|
cy="50"
|
||||||
|
r="10"
|
||||||
|
fill="#fff"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 text-center">
|
<div className="min-w-0 flex-1 text-center">
|
||||||
<p className="text-sm font-semibold" style={{ color: 'var(--theme-text)' }}>Set up mobile access</p>
|
<p
|
||||||
<p className="text-xs" style={{ color: 'var(--theme-muted)' }}>
|
className="text-sm font-semibold"
|
||||||
Connect your phone to this Hermes Workspace instance in a few steps.
|
style={{ color: 'var(--theme-text)' }}
|
||||||
|
>
|
||||||
|
Set up mobile access
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: 'var(--theme-muted)' }}
|
||||||
|
>
|
||||||
|
Connect your phone to this Hermes Workspace instance in a
|
||||||
|
few steps.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,18 +201,28 @@ export function MobilePromptTrigger() {
|
|||||||
style={{ color: 'var(--theme-muted)' }}
|
style={{ color: 'var(--theme-muted)' }}
|
||||||
aria-label="Dismiss mobile setup prompt"
|
aria-label="Dismiss mobile setup prompt"
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={Cancel01Icon} size={16} strokeWidth={2} />
|
<HugeiconsIcon
|
||||||
|
icon={Cancel01Icon}
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="mt-3 flex items-center gap-2 text-xs" style={{ color: 'var(--theme-muted)' }}>
|
<label
|
||||||
|
className="mt-3 flex items-center gap-2 text-xs"
|
||||||
|
style={{ color: 'var(--theme-muted)' }}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={dontShowAgain}
|
checked={dontShowAgain}
|
||||||
onChange={(event) => setDontShowAgain(event.target.checked)}
|
onChange={(event) => setDontShowAgain(event.target.checked)}
|
||||||
className="size-3.5 rounded"
|
className="size-3.5 rounded"
|
||||||
style={{ border: '1px solid var(--theme-border)', background: 'var(--theme-card2)' }}
|
style={{
|
||||||
|
border: '1px solid var(--theme-border)',
|
||||||
|
background: 'var(--theme-card2)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span>Don't show this again</span>
|
<span>Don't show this again</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -161,5 +233,5 @@ export function MobilePromptTrigger() {
|
|||||||
|
|
||||||
<MobileSetupModal isOpen={isModalOpen} onClose={closeSetup} />
|
<MobileSetupModal isOpen={isModalOpen} onClose={closeSetup} />
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ export function MobileSetupModal({ isOpen, onClose }: MobileSetupModalProps) {
|
|||||||
action: (
|
action: (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => networkUrl && writeTextToClipboard(networkUrl.url).catch(() => {})}
|
onClick={() =>
|
||||||
|
networkUrl && writeTextToClipboard(networkUrl.url).catch(() => {})
|
||||||
|
}
|
||||||
className="group flex w-full items-center justify-between rounded-lg border border-primary-700 bg-primary-950 px-4 py-3 transition-colors hover:border-accent-500/50"
|
className="group flex w-full items-center justify-between rounded-lg border border-primary-700 bg-primary-950 px-4 py-3 transition-colors hover:border-accent-500/50"
|
||||||
>
|
>
|
||||||
<span className="break-all font-mono text-sm text-accent-300">
|
<span className="break-all font-mono text-sm text-accent-300">
|
||||||
|
|||||||
@@ -87,7 +87,13 @@ export function MobileSessionsPanel({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<aside className="no-swipe absolute inset-y-0 left-0 w-[80vw] max-w-sm border-r shadow-2xl animate-in slide-in-from-left-8 duration-200" style={{ background: 'var(--color-surface, #fff)', borderColor: 'var(--color-border, #e5e7eb)' }}>
|
<aside
|
||||||
|
className="no-swipe absolute inset-y-0 left-0 w-[80vw] max-w-sm border-r shadow-2xl animate-in slide-in-from-left-8 duration-200"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
borderColor: 'var(--color-border, #e5e7eb)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between border-b border-primary-200 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-primary-200 px-4 py-3">
|
||||||
<h2 className="text-sm font-semibold text-ink">Sessions</h2>
|
<h2 className="text-sm font-semibold text-ink">Sessions</h2>
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import {
|
|||||||
Settings01Icon,
|
Settings01Icon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
} from '@hugeicons/core-free-icons'
|
} from '@hugeicons/core-free-icons'
|
||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import type { TouchEvent } from 'react'
|
import type { TouchEvent } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { hapticTap } from '@/lib/haptics'
|
import { hapticTap } from '@/lib/haptics'
|
||||||
@@ -114,29 +120,24 @@ export function MobileTabBar() {
|
|||||||
|
|
||||||
const { settings } = useSettings()
|
const { settings } = useSettings()
|
||||||
void settings.mobileChatNavMode // reserved for future use
|
void settings.mobileChatNavMode // reserved for future use
|
||||||
const isOnChat = pathname.startsWith('/chat') || pathname === '/new' || pathname === '/'
|
const isOnChat =
|
||||||
|
pathname.startsWith('/chat') || pathname === '/new' || pathname === '/'
|
||||||
|
|
||||||
// Always hide tab bar on chat routes — iMessage/Telegram pattern
|
// Always hide tab bar on chat routes — iMessage/Telegram pattern
|
||||||
const isChatRoute = isOnChat
|
const isChatRoute = isOnChat
|
||||||
|
|
||||||
// Drag-to-switch: horizontal swipe across pill switches tabs
|
// Drag-to-switch: horizontal swipe across pill switches tabs
|
||||||
const handlePillTouchStart = useCallback(
|
const handlePillTouchStart = useCallback((event: TouchEvent<HTMLElement>) => {
|
||||||
(event: TouchEvent<HTMLElement>) => {
|
|
||||||
dragStartXRef.current = event.touches[0]?.clientX ?? null
|
dragStartXRef.current = event.touches[0]?.clientX ?? null
|
||||||
dragStartTimeRef.current = Date.now()
|
dragStartTimeRef.current = Date.now()
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
},
|
}, [])
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handlePillTouchMove = useCallback(
|
const handlePillTouchMove = useCallback((_event: TouchEvent<HTMLElement>) => {
|
||||||
(_event: TouchEvent<HTMLElement>) => {
|
|
||||||
if (dragStartXRef.current !== null) {
|
if (dragStartXRef.current !== null) {
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
}
|
}
|
||||||
},
|
}, [])
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handlePillTouchEnd = useCallback(
|
const handlePillTouchEnd = useCallback(
|
||||||
(event: TouchEvent<HTMLElement>) => {
|
(event: TouchEvent<HTMLElement>) => {
|
||||||
@@ -177,7 +178,8 @@ export function MobileTabBar() {
|
|||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
if (rect.height <= 0) return
|
if (rect.height <= 0) return
|
||||||
// pill height + its bottom margin (safe-area + 8px) + 12px breathing room
|
// pill height + its bottom margin (safe-area + 8px) + 12px breathing room
|
||||||
const safeArea = window.innerHeight - document.documentElement.clientHeight || 0
|
const safeArea =
|
||||||
|
window.innerHeight - document.documentElement.clientHeight || 0
|
||||||
const bottomInset = Math.max(safeArea, 16) + 8
|
const bottomInset = Math.max(safeArea, 16) + 8
|
||||||
const total = Math.ceil(rect.height) + bottomInset + 12
|
const total = Math.ceil(rect.height) + bottomInset + 12
|
||||||
root.style.setProperty('--tabbar-h', `${total}px`)
|
root.style.setProperty('--tabbar-h', `${total}px`)
|
||||||
@@ -205,9 +207,13 @@ export function MobileTabBar() {
|
|||||||
if (el) {
|
if (el) {
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
if (rect.height > 0) {
|
if (rect.height > 0) {
|
||||||
const safeArea2 = window.innerHeight - document.documentElement.clientHeight || 0
|
const safeArea2 =
|
||||||
|
window.innerHeight - document.documentElement.clientHeight || 0
|
||||||
const bInset = Math.max(safeArea2, 16) + 8
|
const bInset = Math.max(safeArea2, 16) + 8
|
||||||
root.style.setProperty('--tabbar-h', `${Math.ceil(rect.height) + bInset + 12}px`)
|
root.style.setProperty(
|
||||||
|
'--tabbar-h',
|
||||||
|
`${Math.ceil(rect.height) + bInset + 12}px`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +250,8 @@ export function MobileTabBar() {
|
|||||||
{TABS.map((tab, idx) => {
|
{TABS.map((tab, idx) => {
|
||||||
const isActive = tab.match(pathname)
|
const isActive = tab.match(pathname)
|
||||||
const isCenter = tab.id === 'chat'
|
const isCenter = tab.id === 'chat'
|
||||||
const circleSize = isCenter && isActive ? 'size-10' : isActive ? 'size-9' : 'size-10'
|
const circleSize =
|
||||||
|
isCenter && isActive ? 'size-10' : isActive ? 'size-9' : 'size-10'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -15,4 +15,3 @@ describe('onboarding tour completion logic', () => {
|
|||||||
expect(shouldCompleteOnboardingTour('next', 'running')).toBe(false)
|
expect(shouldCompleteOnboardingTour('next', 'running')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Joyride, { ACTIONS, STATUS } from 'react-joyride'
|
import Joyride, { ACTIONS, STATUS } from 'react-joyride'
|
||||||
import { tourSteps } from './tour-steps'
|
import { tourSteps } from './tour-steps'
|
||||||
import type { CallBackProps, Styles } from 'react-joyride';
|
import type { CallBackProps, Styles } from 'react-joyride'
|
||||||
import { useSettingsStore } from '@/hooks/use-settings'
|
import { useSettingsStore } from '@/hooks/use-settings'
|
||||||
import { useResolvedTheme } from '@/hooks/use-chat-settings'
|
import { useResolvedTheme } from '@/hooks/use-chat-settings'
|
||||||
|
|
||||||
@@ -47,7 +47,8 @@ export function OnboardingTour() {
|
|||||||
// Wait for setup wizard to finish before starting tour
|
// Wait for setup wizard to finish before starting tour
|
||||||
const HERMES_SETUP_KEY = 'hermes-configured'
|
const HERMES_SETUP_KEY = 'hermes-configured'
|
||||||
const checkAndStart = () => {
|
const checkAndStart = () => {
|
||||||
const hermesConfigured = localStorage.getItem(HERMES_SETUP_KEY) === 'true'
|
const hermesConfigured =
|
||||||
|
localStorage.getItem(HERMES_SETUP_KEY) === 'true'
|
||||||
if (hermesConfigured) {
|
if (hermesConfigured) {
|
||||||
setRun(true)
|
setRun(true)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -65,7 +65,16 @@ export function OnboardingWizard() {
|
|||||||
prevStep()
|
prevStep()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[canProceed, isOpen, isLastStep, isFirstStep, skip, handleComplete, nextStep, prevStep],
|
[
|
||||||
|
canProceed,
|
||||||
|
isOpen,
|
||||||
|
isLastStep,
|
||||||
|
isFirstStep,
|
||||||
|
skip,
|
||||||
|
handleComplete,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,7 +131,11 @@ export function OnboardingWizard() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.8 }}
|
initial={{ scale: 0.8 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
transition={{ type: 'spring', damping: 15, stiffness: 300 }}
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
damping: 15,
|
||||||
|
stiffness: 300,
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mb-6 flex items-center justify-center shadow-lg',
|
'mb-6 flex items-center justify-center shadow-lg',
|
||||||
step.id === 'welcome'
|
step.id === 'welcome'
|
||||||
@@ -132,7 +145,11 @@ export function OnboardingWizard() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{step.id === 'welcome' ? (
|
{step.id === 'welcome' ? (
|
||||||
<img src="/hermes-avatar.webp" alt="Hermes" className="size-16 rounded-2xl" />
|
<img
|
||||||
|
src="/hermes-avatar.webp"
|
||||||
|
alt="Hermes"
|
||||||
|
className="size-16 rounded-2xl"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon
|
||||||
icon={step.icon}
|
icon={step.icon}
|
||||||
|
|||||||
@@ -36,7 +36,15 @@ function AnthropicLogo({ className }: { className?: string }) {
|
|||||||
|
|
||||||
function OpenRouterLogo({ className }: { className?: string }) {
|
function OpenRouterLogo({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="M8 3h8l4 4v8l-4 4H8l-4-4V7l4-4z" />
|
<path d="M8 3h8l4 4v8l-4 4H8l-4-4V7l4-4z" />
|
||||||
<path d="M12 8v8M8 12h8" />
|
<path d="M12 8v8M8 12h8" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -46,10 +54,22 @@ function OpenRouterLogo({ className }: { className?: string }) {
|
|||||||
function GoogleLogo({ className }: { className?: string }) {
|
function GoogleLogo({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" className={className}>
|
<svg viewBox="0 0 24 24" className={className}>
|
||||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
<path
|
||||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
fill="#4285F4"
|
||||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -66,7 +86,8 @@ const PROVIDERS: Array<Provider> = [
|
|||||||
{
|
{
|
||||||
id: 'anthropic',
|
id: 'anthropic',
|
||||||
name: 'Anthropic (Claude)',
|
name: 'Anthropic (Claude)',
|
||||||
description: 'Best for complex reasoning, long-form writing and precise instructions',
|
description:
|
||||||
|
'Best for complex reasoning, long-form writing and precise instructions',
|
||||||
badge: 'Recommended',
|
badge: 'Recommended',
|
||||||
logo: <AnthropicLogo className="size-8" />,
|
logo: <AnthropicLogo className="size-8" />,
|
||||||
placeholder: 'sk-ant-...',
|
placeholder: 'sk-ant-...',
|
||||||
@@ -76,7 +97,8 @@ const PROVIDERS: Array<Provider> = [
|
|||||||
{
|
{
|
||||||
id: 'openrouter',
|
id: 'openrouter',
|
||||||
name: 'OpenRouter',
|
name: 'OpenRouter',
|
||||||
description: 'One Hermes connection to 200+ AI models. Ideal for flexibility and experimentation',
|
description:
|
||||||
|
'One Hermes connection to 200+ AI models. Ideal for flexibility and experimentation',
|
||||||
badge: 'Popular',
|
badge: 'Popular',
|
||||||
logo: <OpenRouterLogo className="size-8" />,
|
logo: <OpenRouterLogo className="size-8" />,
|
||||||
placeholder: 'sk-or-v1-...',
|
placeholder: 'sk-or-v1-...',
|
||||||
@@ -110,7 +132,10 @@ type ProviderSelectStepProps = {
|
|||||||
onSkip?: () => void
|
onSkip?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderSelectStep({ onComplete, onSkip }: ProviderSelectStepProps) {
|
export function ProviderSelectStep({
|
||||||
|
onComplete,
|
||||||
|
onSkip,
|
||||||
|
}: ProviderSelectStepProps) {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [apiKey, setApiKey] = useState('')
|
const [apiKey, setApiKey] = useState('')
|
||||||
const [showKey, setShowKey] = useState(false)
|
const [showKey, setShowKey] = useState(false)
|
||||||
@@ -175,7 +200,8 @@ export function ProviderSelectStep({ onComplete, onSkip }: ProviderSelectStepPro
|
|||||||
Choose AI Provider
|
Choose AI Provider
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-primary-600">
|
<p className="text-sm text-primary-600">
|
||||||
Pick the AI provider you want to start with. You can switch or add more providers later.
|
Pick the AI provider you want to start with. You can switch or add
|
||||||
|
more providers later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,9 +235,7 @@ export function ProviderSelectStep({ onComplete, onSkip }: ProviderSelectStepPro
|
|||||||
: 'border-primary-300',
|
: 'border-primary-300',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{isSelected && <div className="size-2 rounded-full bg-white" />}
|
||||||
<div className="size-2 rounded-full bg-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -318,7 +342,11 @@ export function ProviderSelectStep({ onComplete, onSkip }: ProviderSelectStepPro
|
|||||||
{/* Validation feedback */}
|
{/* Validation feedback */}
|
||||||
{validated === true && (
|
{validated === true && (
|
||||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-green-700">
|
<div className="mt-2 flex items-center gap-1.5 text-xs text-green-700">
|
||||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} size={14} strokeWidth={2} />
|
<HugeiconsIcon
|
||||||
|
icon={CheckmarkCircle02Icon}
|
||||||
|
size={14}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
<span>API key is valid!</span>
|
<span>API key is valid!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -115,13 +115,17 @@ export function ConnectionCheckStep({
|
|||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-red-700 mb-1">1. Enable the API server in <code>~/.hermes/.env</code>:</p>
|
<p className="text-xs font-medium text-red-700 mb-1">
|
||||||
|
1. Enable the API server in <code>~/.hermes/.env</code>:
|
||||||
|
</p>
|
||||||
<code className="block overflow-x-auto rounded-lg bg-red-100 px-3 py-2 text-xs text-red-900">
|
<code className="block overflow-x-auto rounded-lg bg-red-100 px-3 py-2 text-xs text-red-900">
|
||||||
API_SERVER_ENABLED=true
|
API_SERVER_ENABLED=true
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-red-700 mb-1">2. Restart the gateway:</p>
|
<p className="text-xs font-medium text-red-700 mb-1">
|
||||||
|
2. Restart the gateway:
|
||||||
|
</p>
|
||||||
<code className="block overflow-x-auto rounded-lg bg-red-100 px-3 py-2 text-xs text-red-900">
|
<code className="block overflow-x-auto rounded-lg bg-red-100 px-3 py-2 text-xs text-red-900">
|
||||||
cd hermes-agent && hermes --gateway
|
cd hermes-agent && hermes --gateway
|
||||||
</code>
|
</code>
|
||||||
|
|||||||
@@ -7,10 +7,22 @@ export const tourSteps: Array<Step> = [
|
|||||||
placement: 'center',
|
placement: 'center',
|
||||||
title: 'Welcome to Hermes Workspace! ⚕',
|
title: 'Welcome to Hermes Workspace! ⚕',
|
||||||
content: (
|
content: (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
<div
|
||||||
<img src="/hermes-avatar.webp" alt="Hermes" style={{ width: 48, height: 48, borderRadius: 12 }} />
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/hermes-avatar.webp"
|
||||||
|
alt="Hermes"
|
||||||
|
style={{ width: 48, height: 48, borderRadius: 12 }}
|
||||||
|
/>
|
||||||
<p style={{ textAlign: 'center', margin: 0 }}>
|
<p style={{ textAlign: 'center', margin: 0 }}>
|
||||||
Your AI-powered command center for managing agents, chats, files, and more. Let's take a quick tour!
|
Your AI-powered command center for managing agents, chats, files, and
|
||||||
|
more. Let's take a quick tour!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ function ChatContainerContent({
|
|||||||
...props
|
...props
|
||||||
}: ChatContainerContentProps) {
|
}: ChatContainerContentProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex w-full flex-col min-h-full', className)} {...props}>
|
<div
|
||||||
|
className={cn('flex w-full flex-col min-h-full', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="mx-auto w-full px-3 sm:px-5 flex flex-col"
|
className="mx-auto w-full px-3 sm:px-5 flex flex-col"
|
||||||
style={{ maxWidth: 'min(768px, 100%)' }}
|
style={{ maxWidth: 'min(768px, 100%)' }}
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ function PromptInputTextarea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
|
function handlePaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
|
||||||
|
|
||||||
const hasFiles = Array.from(e.clipboardData.items).some(
|
const hasFiles = Array.from(e.clipboardData.items).some(
|
||||||
(item) => item.kind === 'file',
|
(item) => item.kind === 'file',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ function useIsLightTheme(): boolean {
|
|||||||
}
|
}
|
||||||
check()
|
check()
|
||||||
const observer = new MutationObserver(check)
|
const observer = new MutationObserver(check)
|
||||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme'],
|
||||||
|
})
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
return light
|
return light
|
||||||
@@ -54,7 +57,10 @@ export function ProviderLogo({
|
|||||||
if (!file) {
|
if (!file) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('flex items-center justify-center rounded-lg bg-neutral-600 text-white text-xs font-bold', className)}
|
className={cn(
|
||||||
|
'flex items-center justify-center rounded-lg bg-neutral-600 text-white text-xs font-bold',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
>
|
>
|
||||||
{(provider || 'C')[0].toUpperCase()}
|
{(provider || 'C')[0].toUpperCase()}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Uses @lobehub/icons for real provider logos.
|
* Uses @lobehub/icons for real provider logos.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {CSSProperties} from 'react';
|
import type { CSSProperties } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type ProviderModelIconProps = {
|
type ProviderModelIconProps = {
|
||||||
@@ -22,18 +22,38 @@ type ProviderModelIconProps = {
|
|||||||
function detectProvider(model: string): string {
|
function detectProvider(model: string): string {
|
||||||
const m = model.toLowerCase()
|
const m = model.toLowerCase()
|
||||||
if (m.includes('anthropic') || m.includes('claude')) return 'anthropic'
|
if (m.includes('anthropic') || m.includes('claude')) return 'anthropic'
|
||||||
if (m.includes('openai') || m.includes('gpt') || m.includes('codex') || m.includes('o1') || m.includes('o3')) return 'openai'
|
if (
|
||||||
if (m.includes('google') || m.includes('gemini') || m.includes('antigravity')) return 'google'
|
m.includes('openai') ||
|
||||||
|
m.includes('gpt') ||
|
||||||
|
m.includes('codex') ||
|
||||||
|
m.includes('o1') ||
|
||||||
|
m.includes('o3')
|
||||||
|
)
|
||||||
|
return 'openai'
|
||||||
|
if (m.includes('google') || m.includes('gemini') || m.includes('antigravity'))
|
||||||
|
return 'google'
|
||||||
if (m.includes('minimax')) return 'minimax'
|
if (m.includes('minimax')) return 'minimax'
|
||||||
if (m.includes('mistral') || m.includes('devstral')) return 'mistral'
|
if (m.includes('mistral') || m.includes('devstral')) return 'mistral'
|
||||||
if (m.includes('deepseek')) return 'deepseek'
|
if (m.includes('deepseek')) return 'deepseek'
|
||||||
if (m.includes('ollama') || m.includes('qwen') || m.includes('llama') || m.includes('pc1') || m.includes('pc2')) return 'ollama'
|
if (
|
||||||
|
m.includes('ollama') ||
|
||||||
|
m.includes('qwen') ||
|
||||||
|
m.includes('llama') ||
|
||||||
|
m.includes('pc1') ||
|
||||||
|
m.includes('pc2')
|
||||||
|
)
|
||||||
|
return 'ollama'
|
||||||
if (m.includes('openrouter')) return 'openrouter'
|
if (m.includes('openrouter')) return 'openrouter'
|
||||||
if (m.includes('nvidia') || m.includes('nemotron')) return 'nvidia'
|
if (m.includes('nvidia') || m.includes('nemotron')) return 'nvidia'
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderModelIcon({ model, size = 12, className, style }: ProviderModelIconProps) {
|
export function ProviderModelIcon({
|
||||||
|
model,
|
||||||
|
size = 12,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: ProviderModelIconProps) {
|
||||||
const provider = detectProvider(model)
|
const provider = detectProvider(model)
|
||||||
|
|
||||||
// Use light variant (dark logos on transparent) for light mode
|
// Use light variant (dark logos on transparent) for light mode
|
||||||
@@ -58,7 +78,10 @@ export function ProviderModelIcon({ model, size = 12, className, style }: Provid
|
|||||||
// Fallback: first letter of provider
|
// Fallback: first letter of provider
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn('inline-flex items-center justify-center rounded-sm bg-primary-200 text-primary-600 font-mono font-bold', className)}
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-sm bg-primary-200 text-primary-600 font-mono font-bold',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
style={{ width: size, height: size, fontSize: size * 0.6, ...style }}
|
style={{ width: size, height: size, fontSize: size * 0.6, ...style }}
|
||||||
>
|
>
|
||||||
{provider[0]?.toUpperCase() ?? '?'}
|
{provider[0]?.toUpperCase() ?? '?'}
|
||||||
|
|||||||
@@ -252,11 +252,7 @@ export function SearchModal() {
|
|||||||
scope: 'actions',
|
scope: 'actions',
|
||||||
icon: (
|
icon: (
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon
|
||||||
icon={
|
icon={entry.id === 'qa-logs' ? ListViewIcon : FlashIcon}
|
||||||
entry.id === 'qa-logs'
|
|
||||||
? ListViewIcon
|
|
||||||
: FlashIcon
|
|
||||||
}
|
|
||||||
size={20}
|
size={20}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import type { Ref } from 'react'
|
import type { Ref } from 'react'
|
||||||
|
|
||||||
import { useAutocompleteFilter } from '@/components/ui/autocomplete'
|
import { useAutocompleteFilter } from '@/components/ui/autocomplete'
|
||||||
@@ -95,7 +101,12 @@ const SlashCommandMenu = forwardRef(function SlashCommandMenu(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-x-2 bottom-[calc(100%+0.5rem)] z-[70]">
|
<div className="pointer-events-none absolute inset-x-2 bottom-[calc(100%+0.5rem)] z-[70]">
|
||||||
<div className="pointer-events-auto overflow-hidden rounded-xl border border-primary-200 shadow-lg" style={{ background: 'var(--color-surface, var(--theme-card, #1a1f2e))' }}>
|
<div
|
||||||
|
className="pointer-events-auto overflow-hidden rounded-xl border border-primary-200 shadow-lg"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface, var(--theme-card, #1a1f2e))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Command
|
<Command
|
||||||
items={filteredCommands}
|
items={filteredCommands}
|
||||||
value={query}
|
value={query}
|
||||||
|
|||||||
@@ -64,8 +64,14 @@ export function MobileTerminalInput() {
|
|||||||
type="text"
|
type="text"
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') { e.preventDefault(); send() }
|
if (e.key === 'Enter') {
|
||||||
if (e.key === 'Tab') { e.preventDefault(); void sendToActiveTab('\t') }
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
void sendToActiveTab('\t')
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Type command…"
|
placeholder="Type command…"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ export function TerminalPanel({ isMobile }: TerminalPanelProps) {
|
|||||||
|
|
||||||
window.addEventListener('mousemove', handleMove)
|
window.addEventListener('mousemove', handleMove)
|
||||||
window.addEventListener('mouseup', handleUp)
|
window.addEventListener('mouseup', handleUp)
|
||||||
|
|
||||||
},
|
},
|
||||||
[activeTab?.id, height],
|
[activeTab?.id, height],
|
||||||
)
|
)
|
||||||
@@ -377,7 +376,6 @@ export function TerminalPanel({ isMobile }: TerminalPanelProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
|
||||||
<div className="flex items-center gap-2 border-b border-primary-200 px-3 py-2">
|
<div className="flex items-center gap-2 border-b border-primary-200 px-3 py-2">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto">
|
<div className="flex items-center gap-2 overflow-x-auto">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
@@ -425,7 +423,6 @@ export function TerminalPanel({ isMobile }: TerminalPanelProps) {
|
|||||||
placeholder="Search output"
|
placeholder="Search output"
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
|
|
||||||
handleSearch(
|
handleSearch(
|
||||||
activeTab?.id ?? '',
|
activeTab?.id ?? '',
|
||||||
event.currentTarget.value,
|
event.currentTarget.value,
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ export function TerminalWorkspace({
|
|||||||
const [debugLoading, setDebugLoading] = useState(false)
|
const [debugLoading, setDebugLoading] = useState(false)
|
||||||
const [showDebugPanel, setShowDebugPanel] = useState(false)
|
const [showDebugPanel, setShowDebugPanel] = useState(false)
|
||||||
|
|
||||||
|
|
||||||
const containerMapRef = useRef(new Map<string, HTMLDivElement>())
|
const containerMapRef = useRef(new Map<string, HTMLDivElement>())
|
||||||
const terminalMapRef = useRef(new Map<string, Terminal>())
|
const terminalMapRef = useRef(new Map<string, Terminal>())
|
||||||
const fitMapRef = useRef(new Map<string, FitAddon>())
|
const fitMapRef = useRef(new Map<string, FitAddon>())
|
||||||
@@ -277,8 +276,6 @@ export function TerminalWorkspace({
|
|||||||
[activeTab],
|
[activeTab],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const closeTabResources = useCallback(async function closeTabResources(
|
const closeTabResources = useCallback(async function closeTabResources(
|
||||||
tabId: string,
|
tabId: string,
|
||||||
sessionId: string | null,
|
sessionId: string | null,
|
||||||
@@ -400,7 +397,8 @@ export function TerminalWorkspace({
|
|||||||
|
|
||||||
for (let _bi = 0; _bi < blocks.length; _bi++) {
|
for (let _bi = 0; _bi < blocks.length; _bi++) {
|
||||||
// Yield every 10 blocks to let input events through
|
// Yield every 10 blocks to let input events through
|
||||||
if (_bi > 0 && _bi % 10 === 0) await new Promise((r) => setTimeout(r, 0))
|
if (_bi > 0 && _bi % 10 === 0)
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
const block = blocks[_bi]
|
const block = blocks[_bi]
|
||||||
if (!block.trim()) continue
|
if (!block.trim()) continue
|
||||||
const lines = block.split('\n')
|
const lines = block.split('\n')
|
||||||
@@ -596,7 +594,11 @@ export function TerminalWorkspace({
|
|||||||
// Refit all terminals when becoming visible (e.g. navigating back to terminal route)
|
// Refit all terminals when becoming visible (e.g. navigating back to terminal route)
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
for (const fitAddon of fitMapRef.current.values()) {
|
for (const fitAddon of fitMapRef.current.values()) {
|
||||||
try { fitAddon.fit() } catch { /* ignore */ }
|
try {
|
||||||
|
fitAddon.fit()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const snapshot = useTerminalPanelStore.getState().tabs
|
const snapshot = useTerminalPanelStore.getState().tabs
|
||||||
for (const tab of snapshot) {
|
for (const tab of snapshot) {
|
||||||
@@ -613,7 +615,11 @@ export function TerminalWorkspace({
|
|||||||
function fitOnResize() {
|
function fitOnResize() {
|
||||||
function refitAll() {
|
function refitAll() {
|
||||||
for (const fitAddon of fitMapRef.current.values()) {
|
for (const fitAddon of fitMapRef.current.values()) {
|
||||||
try { fitAddon.fit() } catch { /* */ }
|
try {
|
||||||
|
fitAddon.fit()
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const snapshot = useTerminalPanelStore.getState().tabs
|
const snapshot = useTerminalPanelStore.getState().tabs
|
||||||
for (const tab of snapshot) {
|
for (const tab of snapshot) {
|
||||||
@@ -668,7 +674,11 @@ export function TerminalWorkspace({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex min-h-0 flex-col bg-primary-50"
|
className="relative flex min-h-0 flex-col bg-primary-50"
|
||||||
style={termHeight ? { height: termHeight, maxHeight: termHeight } : { height: '100%' }}
|
style={
|
||||||
|
termHeight
|
||||||
|
? { height: termHeight, maxHeight: termHeight }
|
||||||
|
: { height: '100%' }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* fullscreen header removed — tab bar handles everything */}
|
{/* fullscreen header removed — tab bar handles everything */}
|
||||||
|
|
||||||
@@ -776,14 +786,41 @@ export function TerminalWorkspace({
|
|||||||
</Button>
|
</Button>
|
||||||
{mode === 'panel' ? (
|
{mode === 'panel' ? (
|
||||||
<>
|
<>
|
||||||
<Button size="icon-sm" variant="ghost" onClick={onMinimizePanel} aria-label="Minimize">
|
<Button
|
||||||
<HugeiconsIcon icon={SidebarLeft01Icon} size={20} strokeWidth={1.5} />
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onMinimizePanel}
|
||||||
|
aria-label="Minimize"
|
||||||
|
>
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={SidebarLeft01Icon}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="icon-sm" variant="ghost" onClick={onMaximizePanel} aria-label="Maximize">
|
<Button
|
||||||
<HugeiconsIcon icon={ArrowRight01Icon} size={20} strokeWidth={1.5} />
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onMaximizePanel}
|
||||||
|
aria-label="Maximize"
|
||||||
|
>
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={ArrowRight01Icon}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="icon-sm" variant="ghost" onClick={handleClosePanel} aria-label="Close">
|
<Button
|
||||||
<HugeiconsIcon icon={Cancel01Icon} size={20} strokeWidth={1.5} />
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClosePanel}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={Cancel01Icon}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { ComputerIcon, Moon01Icon, Sun01Icon } from '@hugeicons/core-free-icons'
|
import { ComputerIcon, Moon01Icon, Sun01Icon } from '@hugeicons/core-free-icons'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import type {SettingsThemeMode} from '@/hooks/use-settings';
|
import type { SettingsThemeMode } from '@/hooks/use-settings'
|
||||||
import {
|
import { applyTheme, useSettingsStore } from '@/hooks/use-settings'
|
||||||
|
|
||||||
applyTheme,
|
|
||||||
useSettingsStore
|
|
||||||
} from '@/hooks/use-settings'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function resolvedIsDark(): boolean {
|
function resolvedIsDark(): boolean {
|
||||||
|
|||||||
@@ -183,10 +183,7 @@ function AutocompleteGroupLabel({
|
|||||||
}: AutocompletePrimitive.GroupLabel.Props) {
|
}: AutocompletePrimitive.GroupLabel.Props) {
|
||||||
return (
|
return (
|
||||||
<AutocompletePrimitive.GroupLabel
|
<AutocompletePrimitive.GroupLabel
|
||||||
className={cn(
|
className={cn('px-2 py-1.5 font-medium text-xs', className)}
|
||||||
'px-2 py-1.5 font-medium text-xs',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
data-slot="autocomplete-group-label"
|
data-slot="autocomplete-group-label"
|
||||||
style={{ color: 'var(--theme-muted)' }}
|
style={{ color: 'var(--theme-muted)' }}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ const buttonVariants = cva(
|
|||||||
'bg-primary-50 text-primary-950 hover:bg-primary-200 outline outline-primary-900/10 shadow-2xs',
|
'bg-primary-50 text-primary-950 hover:bg-primary-200 outline outline-primary-900/10 shadow-2xs',
|
||||||
outline:
|
outline:
|
||||||
'border-primary-200 bg-transparent text-primary-900 hover:bg-primary-50 shadow-2xs outline outline-primary-900/10',
|
'border-primary-200 bg-transparent text-primary-900 hover:bg-primary-50 shadow-2xs outline outline-primary-900/10',
|
||||||
ghost:
|
ghost: 'text-primary-900 hover:bg-primary-200 hover:text-primary-950',
|
||||||
'text-primary-900 hover:bg-primary-200 hover:text-primary-950',
|
|
||||||
destructive: 'bg-red-600 text-primary-50 hover:bg-red-700 shadow-sm',
|
destructive: 'bg-red-600 text-primary-50 hover:bg-red-700 shadow-sm',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ type DialogContentProps = {
|
|||||||
function DialogContent({ className, children }: DialogContentProps) {
|
function DialogContent({ className, children }: DialogContentProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Backdrop className="fixed inset-0 transition-all duration-150 data-[state=open]:opacity-100 data-[state=closed]:opacity-0" style={{ background: 'rgba(0,0,0,0.5)' }} />
|
<Dialog.Backdrop
|
||||||
|
className="fixed inset-0 transition-all duration-150 data-[state=open]:opacity-100 data-[state=closed]:opacity-0"
|
||||||
|
style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||||
|
/>
|
||||||
<Dialog.Popup
|
<Dialog.Popup
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||||
'w-[min(400px,92vw)] rounded-[20px] p-0',
|
'w-[min(400px,92vw)] max-h-[90vh] rounded-[20px] p-0 overflow-hidden flex flex-col',
|
||||||
'transition-all duration-150',
|
'transition-all duration-150',
|
||||||
'data-[state=open]:opacity-100 data-[state=closed]:opacity-0',
|
'data-[state=open]:opacity-100 data-[state=closed]:opacity-0',
|
||||||
'data-[state=open]:scale-100 data-[state=closed]:scale-95',
|
'data-[state=open]:scale-100 data-[state=closed]:scale-95',
|
||||||
|
|||||||
@@ -66,8 +66,13 @@ function MenuItem({ className, ...props }: MenuItemProps) {
|
|||||||
style={{
|
style={{
|
||||||
color: 'var(--theme-text)',
|
color: 'var(--theme-text)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--theme-card2)' }}
|
onMouseEnter={(e) => {
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent' }}
|
;(e.currentTarget as HTMLElement).style.background =
|
||||||
|
'var(--theme-card2)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.background = 'transparent'
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export function Toaster() {
|
|||||||
const addToast = useCallback((item: ToastItem) => {
|
const addToast = useCallback((item: ToastItem) => {
|
||||||
setToasts((prev) => {
|
setToasts((prev) => {
|
||||||
// Dedupe: skip if same message + type already visible
|
// Dedupe: skip if same message + type already visible
|
||||||
if (prev.some((t) => t.message === item.message && t.type === item.type)) {
|
if (
|
||||||
|
prev.some((t) => t.message === item.message && t.type === item.type)
|
||||||
|
) {
|
||||||
return prev
|
return prev
|
||||||
}
|
}
|
||||||
return [...prev.slice(-4), item] // max 5
|
return [...prev.slice(-4), item] // max 5
|
||||||
|
|||||||
@@ -38,10 +38,7 @@ function TooltipContent({
|
|||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
<Tooltip.Positioner side={side}>
|
<Tooltip.Positioner side={side}>
|
||||||
<Tooltip.Popup
|
<Tooltip.Popup
|
||||||
className={cn(
|
className={cn('rounded-md px-2 py-1 text-xs shadow-sm', className)}
|
||||||
'rounded-md px-2 py-1 text-xs shadow-sm',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--theme-card)',
|
background: 'var(--theme-card)',
|
||||||
color: 'var(--theme-text)',
|
color: 'var(--theme-text)',
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ function ContextAlertModalComponent({
|
|||||||
? "Your conversation history is nearly at the model's limit. Responses may become less accurate as the model loses access to earlier context. You should start a new chat soon."
|
? "Your conversation history is nearly at the model's limit. Responses may become less accurate as the model loses access to earlier context. You should start a new chat soon."
|
||||||
: isDanger
|
: isDanger
|
||||||
? 'Your conversation is getting long. The model may start forgetting earlier messages. Consider starting a new chat for best results.'
|
? 'Your conversation is getting long. The model may start forgetting earlier messages. Consider starting a new chat for best results.'
|
||||||
: "Hermes will auto-compact your context soon (it triggers at ~40% usage). Older messages will be summarized. Consider writing a handoff or starting a new chat to preserve full context."}
|
: 'Hermes will auto-compact your context soon (it triggers at ~40% usage). Older messages will be summarized. Consider writing a handoff or starting a new chat to preserve full context.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -387,7 +387,8 @@ export function UsageDetailsModal({
|
|||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{usage.models.length === 0 ? (
|
{usage.models.length === 0 ? (
|
||||||
<div className="text-sm text-primary-500">
|
<div className="text-sm text-primary-500">
|
||||||
No model usage reported yet. Send a message to start tracking usage here.
|
No model usage reported yet. Send a message to start
|
||||||
|
tracking usage here.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
usage.models.map((model) => (
|
usage.models.map((model) => (
|
||||||
@@ -418,7 +419,8 @@ export function UsageDetailsModal({
|
|||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{usage.sessions.length === 0 ? (
|
{usage.sessions.length === 0 ? (
|
||||||
<div className="text-sm text-primary-500">
|
<div className="text-sm text-primary-500">
|
||||||
No sessions reported yet. Start a chat to see session history here.
|
No sessions reported yet. Start a chat to see session
|
||||||
|
history here.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
usage.sessions.map((session) => (
|
usage.sessions.map((session) => (
|
||||||
@@ -487,10 +489,12 @@ export function UsageDetailsModal({
|
|||||||
{providerUsage.length === 0 ? (
|
{providerUsage.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-primary-200 bg-white/70 p-6 text-center">
|
<div className="rounded-2xl border border-primary-200 bg-white/70 p-6 text-center">
|
||||||
<div className="text-sm font-medium text-primary-700">
|
<div className="text-sm font-medium text-primary-700">
|
||||||
No providers connected. Add a provider in Settings to start chatting.
|
No providers connected. Add a provider in Settings to start
|
||||||
|
chatting.
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-primary-500">
|
<div className="mt-1 text-xs text-primary-500">
|
||||||
Open Settings -{'>'} Providers to connect Claude CLI or add an API key.
|
Open Settings -{'>'} Providers to connect Claude CLI or add
|
||||||
|
an API key.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ function readPercent(value: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseContextPercent(payload: unknown): number {
|
function parseContextPercent(payload: unknown): number {
|
||||||
const root = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
const root =
|
||||||
|
payload && typeof payload === 'object'
|
||||||
|
? (payload as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
const usage =
|
const usage =
|
||||||
(root.today as Record<string, unknown> | undefined) ??
|
(root.today as Record<string, unknown> | undefined) ??
|
||||||
(root.usage as Record<string, unknown> | undefined) ??
|
(root.usage as Record<string, unknown> | undefined) ??
|
||||||
@@ -78,9 +81,9 @@ function parseContextPercent(payload: unknown): number {
|
|||||||
(root.totals as Record<string, unknown> | undefined) ??
|
(root.totals as Record<string, unknown> | undefined) ??
|
||||||
root
|
root
|
||||||
return readPercent(
|
return readPercent(
|
||||||
(usage)?.contextPercent ??
|
usage?.contextPercent ??
|
||||||
(usage)?.context_percent ??
|
usage?.context_percent ??
|
||||||
(usage)?.context ??
|
usage?.context ??
|
||||||
root?.contextPercent ??
|
root?.contextPercent ??
|
||||||
root?.context_percent,
|
root?.context_percent,
|
||||||
)
|
)
|
||||||
@@ -125,8 +128,12 @@ export function UsageMeterCompact() {
|
|||||||
const [contextPct, setContextPct] = useState<number | null>(null)
|
const [contextPct, setContextPct] = useState<number | null>(null)
|
||||||
const [progressRows, setProgressRows] = useState<Array<UsageRow>>([])
|
const [progressRows, setProgressRows] = useState<Array<UsageRow>>([])
|
||||||
const [providerLabel, setProviderLabel] = useState<string | null>(null)
|
const [providerLabel, setProviderLabel] = useState<string | null>(null)
|
||||||
const [preferredProvider, setPreferredProvider] = useState<string | null>(getStoredPreferredProvider)
|
const [preferredProvider, setPreferredProvider] = useState<string | null>(
|
||||||
const [allProviders, setAllProviders] = useState<Array<ProviderUsageEntry>>([])
|
getStoredPreferredProvider,
|
||||||
|
)
|
||||||
|
const [allProviders, setAllProviders] = useState<Array<ProviderUsageEntry>>(
|
||||||
|
[],
|
||||||
|
)
|
||||||
const [expanded, setExpanded] = useState(true)
|
const [expanded, setExpanded] = useState(true)
|
||||||
// Flash state: animate provider name on change
|
// Flash state: animate provider name on change
|
||||||
const [providerFlash, setProviderFlash] = useState(false)
|
const [providerFlash, setProviderFlash] = useState(false)
|
||||||
@@ -138,11 +145,14 @@ export function UsageMeterCompact() {
|
|||||||
(providers: Array<ProviderUsageEntry>, preferred: string | null) => {
|
(providers: Array<ProviderUsageEntry>, preferred: string | null) => {
|
||||||
if (preferred) {
|
if (preferred) {
|
||||||
const match = providers.find(
|
const match = providers.find(
|
||||||
(p) => p.provider === preferred && p.status === 'ok' && p.lines.length > 0,
|
(p) =>
|
||||||
|
p.provider === preferred && p.status === 'ok' && p.lines.length > 0,
|
||||||
)
|
)
|
||||||
if (match) return match
|
if (match) return match
|
||||||
}
|
}
|
||||||
return providers.find((p) => p.status === 'ok' && p.lines.length > 0) ?? null
|
return (
|
||||||
|
providers.find((p) => p.status === 'ok' && p.lines.length > 0) ?? null
|
||||||
|
)
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -150,9 +160,13 @@ export function UsageMeterCompact() {
|
|||||||
// ── Cycle to next provider ───────────────────────────────────────────────
|
// ── Cycle to next provider ───────────────────────────────────────────────
|
||||||
|
|
||||||
const cycleProvider = useCallback(() => {
|
const cycleProvider = useCallback(() => {
|
||||||
const okProviders = allProviders.filter((p) => p.status === 'ok' && p.lines.length > 0)
|
const okProviders = allProviders.filter(
|
||||||
|
(p) => p.status === 'ok' && p.lines.length > 0,
|
||||||
|
)
|
||||||
if (okProviders.length < 2) return
|
if (okProviders.length < 2) return
|
||||||
const currentIdx = okProviders.findIndex((p) => p.provider === preferredProvider)
|
const currentIdx = okProviders.findIndex(
|
||||||
|
(p) => p.provider === preferredProvider,
|
||||||
|
)
|
||||||
const nextIdx = (currentIdx + 1) % okProviders.length
|
const nextIdx = (currentIdx + 1) % okProviders.length
|
||||||
const next = okProviders[nextIdx]
|
const next = okProviders[nextIdx]
|
||||||
if (!next) return
|
if (!next) return
|
||||||
@@ -243,7 +257,10 @@ export function UsageMeterCompact() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchProvider(preferredProvider)
|
void fetchProvider(preferredProvider)
|
||||||
const id = window.setInterval(() => fetchProvider(preferredProvider), POLL_INTERVAL_MS)
|
const id = window.setInterval(
|
||||||
|
() => fetchProvider(preferredProvider),
|
||||||
|
POLL_INTERVAL_MS,
|
||||||
|
)
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fetchProvider])
|
}, [fetchProvider])
|
||||||
@@ -262,12 +279,12 @@ export function UsageMeterCompact() {
|
|||||||
// Build the rows to display: session context row + all provider progress rows
|
// Build the rows to display: session context row + all provider progress rows
|
||||||
const ctxRow: UsageRow = { label: 'Ctx', pct: contextPct, resetHint: null }
|
const ctxRow: UsageRow = { label: 'Ctx', pct: contextPct, resetHint: null }
|
||||||
const allRows: Array<UsageRow> =
|
const allRows: Array<UsageRow> =
|
||||||
progressRows.length > 0
|
progressRows.length > 0 ? progressRows : [ctxRow]
|
||||||
? progressRows
|
|
||||||
: [ctxRow]
|
|
||||||
|
|
||||||
const headerLabel = providerLabel ? `Usage · ${providerLabel}` : 'Usage'
|
const headerLabel = providerLabel ? `Usage · ${providerLabel}` : 'Usage'
|
||||||
const canCycle = allProviders.filter((p) => p.status === 'ok' && p.lines.length > 0).length > 1
|
const canCycle =
|
||||||
|
allProviders.filter((p) => p.status === 'ok' && p.lines.length > 0).length >
|
||||||
|
1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0 px-1">
|
<div className="space-y-0 px-1">
|
||||||
@@ -288,9 +305,7 @@ export function UsageMeterCompact() {
|
|||||||
aria-label={canCycle ? 'Cycle provider' : undefined}
|
aria-label={canCycle ? 'Cycle provider' : undefined}
|
||||||
>
|
>
|
||||||
<span>{headerLabel}</span>
|
<span>{headerLabel}</span>
|
||||||
{canCycle && (
|
{canCycle && <span className="text-[8px] opacity-60">↻</span>}
|
||||||
<span className="text-[8px] opacity-60">↻</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Collapse chevron */}
|
{/* Collapse chevron */}
|
||||||
@@ -322,7 +337,10 @@ export function UsageMeterCompact() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-1 flex-1 rounded-full bg-neutral-200 dark:bg-neutral-700">
|
<div className="h-1 flex-1 rounded-full bg-neutral-200 dark:bg-neutral-700">
|
||||||
<div
|
<div
|
||||||
className={cn('h-full rounded-full transition-all', barColor(row.pct))}
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all',
|
||||||
|
barColor(row.pct),
|
||||||
|
)}
|
||||||
style={{ width: `${row.pct}%` }}
|
style={{ width: `${row.pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ describe('workspace shell sidebar backdrop', () => {
|
|||||||
expect(DESKTOP_SIDEBAR_BACKDROP_CLASS).not.toContain('inset-0')
|
expect(DESKTOP_SIDEBAR_BACKDROP_CLASS).not.toContain('inset-0')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ export function WorkspaceShell() {
|
|||||||
|
|
||||||
if (prevIdx !== -1 && currentIdx !== -1 && currentIdx !== prevIdx) {
|
if (prevIdx !== -1 && currentIdx !== -1 && currentIdx !== prevIdx) {
|
||||||
// Navigate right (higher index) = slide left; left = slide right
|
// Navigate right (higher index) = slide left; left = slide right
|
||||||
const direction = currentIdx > prevIdx ? 'slide-enter-left' : 'slide-enter-right'
|
const direction =
|
||||||
|
currentIdx > prevIdx ? 'slide-enter-left' : 'slide-enter-right'
|
||||||
setSlideClass(direction)
|
setSlideClass(direction)
|
||||||
// Remove class after animation completes
|
// Remove class after animation completes
|
||||||
const timer = setTimeout(() => setSlideClass(''), 250)
|
const timer = setTimeout(() => setSlideClass(''), 250)
|
||||||
@@ -265,13 +266,23 @@ export function WorkspaceShell() {
|
|||||||
{isElectron && (
|
{isElectron && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 top-0 flex h-10 items-center border-b border-primary-200 z-40"
|
className="absolute inset-x-0 top-0 flex h-10 items-center border-b border-primary-200 z-40"
|
||||||
style={{ WebkitAppRegion: 'drag', background: 'var(--theme-sidebar)' } as React.CSSProperties}
|
style={
|
||||||
|
{
|
||||||
|
WebkitAppRegion: 'drag',
|
||||||
|
background: 'var(--theme-sidebar)',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* Traffic light spacer (left ~78px for macOS buttons) */}
|
{/* Traffic light spacer (left ~78px for macOS buttons) */}
|
||||||
<div className="w-[78px] shrink-0" />
|
<div className="w-[78px] shrink-0" />
|
||||||
{/* Centered title */}
|
{/* Centered title */}
|
||||||
<div className="flex-1 text-center">
|
<div className="flex-1 text-center">
|
||||||
<span className="text-[13px] font-medium select-none" style={{ color: 'var(--theme-accent, #B98A44)' }}>Hermes</span>
|
<span
|
||||||
|
className="text-[13px] font-medium select-none"
|
||||||
|
style={{ color: 'var(--theme-accent, #B98A44)' }}
|
||||||
|
>
|
||||||
|
Hermes
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Right spacer to balance */}
|
{/* Right spacer to balance */}
|
||||||
<div className="w-[78px] shrink-0" />
|
<div className="w-[78px] shrink-0" />
|
||||||
@@ -338,17 +349,29 @@ export function WorkspaceShell() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<TerminalWorkspace mode="fullscreen" panelVisible={isOnTerminalRoute} />
|
<TerminalWorkspace
|
||||||
|
mode="fullscreen"
|
||||||
|
panelVisible={isOnTerminalRoute}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
{/* Mobile input bar — sibling to terminal, NOT a child, so SSE re-renders don't freeze it */}
|
{/* Mobile input bar — sibling to terminal, NOT a child, so SSE re-renders don't freeze it */}
|
||||||
{isMobile && <MobileTerminalInput />}
|
{isMobile && <MobileTerminalInput />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={['page-transition h-full flex flex-col', slideClass, isOnTerminalRoute ? 'hidden' : ''].filter(Boolean).join(' ')}>
|
<div
|
||||||
{isMobile && !isOnChatRoute && !isOnTerminalRoute && mobilePageTitle && (
|
className={[
|
||||||
<MobilePageHeader title={mobilePageTitle} />
|
'page-transition h-full flex flex-col',
|
||||||
)}
|
slideClass,
|
||||||
|
isOnTerminalRoute ? 'hidden' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{isMobile &&
|
||||||
|
!isOnChatRoute &&
|
||||||
|
!isOnTerminalRoute &&
|
||||||
|
mobilePageTitle && <MobilePageHeader title={mobilePageTitle} />}
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
className="h-full min-h-0 flex-1"
|
className="h-full min-h-0 flex-1"
|
||||||
title="Something went wrong"
|
title="Something went wrong"
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ const CLOSE_THRESHOLD = 12
|
|||||||
const CLOSE_DEBOUNCE_MS = 200
|
const CLOSE_DEBOUNCE_MS = 200
|
||||||
|
|
||||||
export function useMobileKeyboard() {
|
export function useMobileKeyboard() {
|
||||||
const setMobileKeyboardInset = useWorkspaceStore((s) => s.setMobileKeyboardInset)
|
const setMobileKeyboardInset = useWorkspaceStore(
|
||||||
|
(s) => s.setMobileKeyboardInset,
|
||||||
|
)
|
||||||
const setMobileKeyboardOpen = useWorkspaceStore(
|
const setMobileKeyboardOpen = useWorkspaceStore(
|
||||||
(s) => s.setMobileKeyboardOpen,
|
(s) => s.setMobileKeyboardOpen,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export const defaultStudioSettings: StudioSettings = {
|
|||||||
mobileChatNavMode: 'dock',
|
mobileChatNavMode: 'dock',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsState>()(
|
export const useSettingsStore = create<SettingsState>()(
|
||||||
persist(
|
persist(
|
||||||
function createSettingsStore(set) {
|
function createSettingsStore(set) {
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import { useNavigate, useRouterState } from '@tanstack/react-router'
|
|||||||
import type { TouchEvent } from 'react'
|
import type { TouchEvent } from 'react'
|
||||||
import { useWorkspaceStore } from '@/stores/workspace-store'
|
import { useWorkspaceStore } from '@/stores/workspace-store'
|
||||||
|
|
||||||
const TAB_ORDER = [
|
const TAB_ORDER = ['/chat/main', '/files', '/jobs', '/settings'] as const
|
||||||
'/chat/main',
|
|
||||||
'/files',
|
|
||||||
'/jobs',
|
|
||||||
'/settings',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const EDGE_ZONE = 24
|
const EDGE_ZONE = 24
|
||||||
const LOCK_THRESHOLD = 12
|
const LOCK_THRESHOLD = 12
|
||||||
@@ -59,7 +54,9 @@ function triggerHaptic() {
|
|||||||
|
|
||||||
export function useSwipeNavigation() {
|
export function useSwipeNavigation() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const pathname = useRouterState({ select: (state) => state.location.pathname })
|
const pathname = useRouterState({
|
||||||
|
select: (state) => state.location.pathname,
|
||||||
|
})
|
||||||
const gestureRef = useRef<GestureState | null>(null)
|
const gestureRef = useRef<GestureState | null>(null)
|
||||||
|
|
||||||
const onTouchStart = useCallback((event: TouchEvent<HTMLElement>) => {
|
const onTouchStart = useCallback((event: TouchEvent<HTMLElement>) => {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function useTapDebug(
|
|||||||
eventTarget instanceof Element
|
eventTarget instanceof Element
|
||||||
? eventTarget
|
? eventTarget
|
||||||
: eventTarget instanceof Node
|
: eventTarget instanceof Node
|
||||||
? (eventTarget).parentElement
|
? eventTarget.parentElement
|
||||||
: null
|
: null
|
||||||
|
|
||||||
console.debug(`[tap-debug:${label}]`, {
|
console.debug(`[tap-debug:${label}]`, {
|
||||||
@@ -142,11 +142,7 @@ export function useTapDebug(
|
|||||||
function handleTouchStart(event: TouchEvent) {
|
function handleTouchStart(event: TouchEvent) {
|
||||||
const touch = event.touches[0]
|
const touch = event.touches[0]
|
||||||
if (!touch) return
|
if (!touch) return
|
||||||
logTap(
|
logTap({ x: touch.clientX, y: touch.clientY }, 'touchstart', event.target)
|
||||||
{ x: touch.clientX, y: touch.clientY },
|
|
||||||
'touchstart',
|
|
||||||
event.target,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
'use client';
|
'use client'
|
||||||
|
|
||||||
const STORAGE_KEY = 'hermes-session-pinged';
|
const STORAGE_KEY = 'hermes-session-pinged'
|
||||||
|
|
||||||
export async function generateFingerprint(): Promise<string> {
|
export async function generateFingerprint(): Promise<string> {
|
||||||
const data = `${navigator.userAgent}${window.screen.width}${navigator.language}`;
|
const data = `${navigator.userAgent}${window.screen.width}${navigator.language}`
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder()
|
||||||
const dataBuffer = encoder.encode(data);
|
const dataBuffer = encoder.encode(data)
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
|
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
return hashHex.slice(0, 16);
|
return hashHex.slice(0, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAlreadyPingedToday(): boolean {
|
function isAlreadyPingedToday(): boolean {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedDate = localStorage.getItem(STORAGE_KEY);
|
const storedDate = localStorage.getItem(STORAGE_KEY)
|
||||||
if (!storedDate) {
|
if (!storedDate) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0]
|
||||||
return storedDate === today;
|
return storedDate === today
|
||||||
}
|
}
|
||||||
|
|
||||||
function markAsPingedToday(): void {
|
function markAsPingedToday(): void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0]
|
||||||
localStorage.setItem(STORAGE_KEY, today);
|
localStorage.setItem(STORAGE_KEY, today)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPing(fingerprint: string): Promise<void> {
|
async function sendPing(fingerprint: string): Promise<void> {
|
||||||
const pingUrl = process.env.NEXT_PUBLIC_PING_URL;
|
const pingUrl = process.env.NEXT_PUBLIC_PING_URL
|
||||||
if (!pingUrl) {
|
if (!pingUrl) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -46,7 +46,7 @@ async function sendPing(fingerprint: string): Promise<void> {
|
|||||||
version: process.env.NEXT_PUBLIC_APP_VERSION ?? '3.1.0',
|
version: process.env.NEXT_PUBLIC_APP_VERSION ?? '3.1.0',
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
mobile: window.innerWidth < 768,
|
mobile: window.innerWidth < 768,
|
||||||
};
|
}
|
||||||
|
|
||||||
await fetch(pingUrl, {
|
await fetch(pingUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -54,22 +54,22 @@ async function sendPing(fingerprint: string): Promise<void> {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pingActiveUser(): Promise<void> {
|
export async function pingActiveUser(): Promise<void> {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAlreadyPingedToday()) {
|
if (isAlreadyPingedToday()) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fingerprint = await generateFingerprint();
|
const fingerprint = await generateFingerprint()
|
||||||
await sendPing(fingerprint);
|
await sendPing(fingerprint)
|
||||||
markAsPingedToday();
|
markAsPingedToday()
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore telemetry failures.
|
// Ignore telemetry failures.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export interface ApprovalRequest {
|
|||||||
resolvedAt?: number
|
resolvedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addApproval(_approval: Record<string, unknown>): ApprovalRequest | null {
|
export function addApproval(
|
||||||
|
_approval: Record<string, unknown>,
|
||||||
|
): ApprovalRequest | null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,4 +28,7 @@ export function loadApprovals(): Array<ApprovalRequest> {
|
|||||||
|
|
||||||
export function saveApprovals(_approvals?: Array<ApprovalRequest>): void {}
|
export function saveApprovals(_approvals?: Array<ApprovalRequest>): void {}
|
||||||
|
|
||||||
export function respondToApproval(_id: string, _status: 'approved' | 'denied'): void {}
|
export function respondToApproval(
|
||||||
|
_id: string,
|
||||||
|
_status: 'approved' | 'denied',
|
||||||
|
): void {}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { getCapabilities } from '../server/gateway-capabilities'
|
import { getCapabilities } from '../server/gateway-capabilities'
|
||||||
|
|
||||||
export type EnhancedFeature = 'sessions' | 'skills' | 'memory' | 'config' | 'jobs'
|
export type EnhancedFeature =
|
||||||
|
| 'sessions'
|
||||||
|
| 'skills'
|
||||||
|
| 'memory'
|
||||||
|
| 'config'
|
||||||
|
| 'jobs'
|
||||||
|
|
||||||
const FEATURE_LABELS: Record<EnhancedFeature, string> = {
|
const FEATURE_LABELS: Record<EnhancedFeature, string> = {
|
||||||
sessions: 'Sessions',
|
sessions: 'Sessions',
|
||||||
@@ -10,7 +15,9 @@ const FEATURE_LABELS: Record<EnhancedFeature, string> = {
|
|||||||
jobs: 'Jobs',
|
jobs: 'Jobs',
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFeature(feature: EnhancedFeature | string): EnhancedFeature | null {
|
function normalizeFeature(
|
||||||
|
feature: EnhancedFeature | string,
|
||||||
|
): EnhancedFeature | null {
|
||||||
const normalized = feature.trim().toLowerCase()
|
const normalized = feature.trim().toLowerCase()
|
||||||
if (
|
if (
|
||||||
normalized === 'sessions' ||
|
normalized === 'sessions' ||
|
||||||
@@ -25,9 +32,7 @@ function normalizeFeature(feature: EnhancedFeature | string): EnhancedFeature |
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isFeatureAvailable(
|
export function isFeatureAvailable(feature: EnhancedFeature): boolean {
|
||||||
feature: EnhancedFeature,
|
|
||||||
): boolean {
|
|
||||||
const caps = getCapabilities()
|
const caps = getCapabilities()
|
||||||
return caps[feature] === true
|
return caps[feature] === true
|
||||||
}
|
}
|
||||||
@@ -38,7 +43,9 @@ export function getFeatureLabel(feature: EnhancedFeature | string): string {
|
|||||||
return FEATURE_LABELS[normalized]
|
return FEATURE_LABELS[normalized]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUnavailableReason(feature: EnhancedFeature | string): string {
|
export function getUnavailableReason(
|
||||||
|
feature: EnhancedFeature | string,
|
||||||
|
): string {
|
||||||
return `${getFeatureLabel(feature)} requires a Hermes gateway with enhanced API support.`
|
return `${getFeatureLabel(feature)} requires a Hermes gateway with enhanced API support.`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ const MODEL_MAP: Record<string, string> = {
|
|||||||
'gpt-4-turbo': 'GPT-4 Turbo',
|
'gpt-4-turbo': 'GPT-4 Turbo',
|
||||||
'gpt-5.4': 'GPT-5.4',
|
'gpt-5.4': 'GPT-5.4',
|
||||||
'gpt-5.3-codex': 'Codex (GPT-5.3)',
|
'gpt-5.3-codex': 'Codex (GPT-5.3)',
|
||||||
'o1': 'o1',
|
o1: 'o1',
|
||||||
'o1-mini': 'o1 Mini',
|
'o1-mini': 'o1 Mini',
|
||||||
'o1-pro': 'o1 Pro',
|
'o1-pro': 'o1 Pro',
|
||||||
'o3': 'o3',
|
o3: 'o3',
|
||||||
'o3-mini': 'o3 Mini',
|
'o3-mini': 'o3 Mini',
|
||||||
'o3-pro': 'o3 Pro',
|
'o3-pro': 'o3 Pro',
|
||||||
'o4-mini': 'o4 Mini',
|
'o4-mini': 'o4 Mini',
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ export function formatSessionKey(key: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: last meaningful segment
|
// Fallback: last meaningful segment
|
||||||
const lastMeaningful = parts.filter((p) => p.length > 8 ? false : true).pop()
|
const lastMeaningful = parts
|
||||||
|
.filter((p) => (p.length > 8 ? false : true))
|
||||||
|
.pop()
|
||||||
return lastMeaningful ? titleCase(lastMeaningful) : key
|
return lastMeaningful ? titleCase(lastMeaningful) : key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,19 +76,25 @@ export async function deleteJob(jobId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pauseJob(jobId: string): Promise<HermesJob> {
|
export async function pauseJob(jobId: string): Promise<HermesJob> {
|
||||||
const res = await fetch(`${HERMES_API}/${jobId}?action=pause`, { method: 'POST' })
|
const res = await fetch(`${HERMES_API}/${jobId}?action=pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error(`Failed to pause job: ${res.status}`)
|
if (!res.ok) throw new Error(`Failed to pause job: ${res.status}`)
|
||||||
return (await res.json()).job
|
return (await res.json()).job
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resumeJob(jobId: string): Promise<HermesJob> {
|
export async function resumeJob(jobId: string): Promise<HermesJob> {
|
||||||
const res = await fetch(`${HERMES_API}/${jobId}?action=resume`, { method: 'POST' })
|
const res = await fetch(`${HERMES_API}/${jobId}?action=resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error(`Failed to resume job: ${res.status}`)
|
if (!res.ok) throw new Error(`Failed to resume job: ${res.status}`)
|
||||||
return (await res.json()).job
|
return (await res.json()).job
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function triggerJob(jobId: string): Promise<HermesJob> {
|
export async function triggerJob(jobId: string): Promise<HermesJob> {
|
||||||
const res = await fetch(`${HERMES_API}/${jobId}?action=run`, { method: 'POST' })
|
const res = await fetch(`${HERMES_API}/${jobId}?action=run`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error(`Failed to trigger job: ${res.status}`)
|
if (!res.ok) throw new Error(`Failed to trigger job: ${res.status}`)
|
||||||
return (await res.json()).job
|
return (await res.json()).job
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type LocalThread = {
|
|||||||
label: string
|
label: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
messages: LocalThreadMessage[]
|
messages: Array<LocalThreadMessage>
|
||||||
}
|
}
|
||||||
|
|
||||||
const threads = new Map<string, LocalThread>()
|
const threads = new Map<string, LocalThread>()
|
||||||
@@ -24,7 +24,8 @@ function nextThreadLabel(): string {
|
|||||||
|
|
||||||
function createThread(id?: string): LocalThread {
|
function createThread(id?: string): LocalThread {
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
const threadId = id && id.trim() ? id.trim() : `portable-${crypto.randomUUID()}`
|
const threadId =
|
||||||
|
id && id.trim() ? id.trim() : `portable-${crypto.randomUUID()}`
|
||||||
const thread: LocalThread = {
|
const thread: LocalThread = {
|
||||||
id: threadId,
|
id: threadId,
|
||||||
label: nextThreadLabel(),
|
label: nextThreadLabel(),
|
||||||
@@ -55,7 +56,7 @@ export function getThread(id: string): LocalThread | undefined {
|
|||||||
return threads.get(id)
|
return threads.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listThreads(): LocalThread[] {
|
export function listThreads(): Array<LocalThread> {
|
||||||
return [...threads.values()].sort((a, b) => b.updatedAt - a.updatedAt)
|
return [...threads.values()].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const QUEUED_WRAPPER_MARKER = '[Queued messages while agent was busy]'
|
const QUEUED_WRAPPER_MARKER = '[Queued messages while agent was busy]'
|
||||||
const QUEUED_HEADER_REGEX = /---\s*\n?Queued #\d+\s*\n/g
|
const QUEUED_HEADER_REGEX = /---\s*\n?Queued #\d+\s*\n/g
|
||||||
const QUEUED_MARKER_REGEX =
|
const QUEUED_MARKER_REGEX = /^\[Queued messages while agent was busy\]\s*\n?/g
|
||||||
/^\[Queued messages while agent was busy\]\s*\n?/g
|
|
||||||
|
|
||||||
export function stripQueuedWrapper(text: string): string {
|
export function stripQueuedWrapper(text: string): string {
|
||||||
if (!text.includes(QUEUED_WRAPPER_MARKER)) return text
|
if (!text.includes(QUEUED_WRAPPER_MARKER)) return text
|
||||||
|
|||||||
@@ -67,13 +67,19 @@ export const THEMES: Array<{
|
|||||||
const STORAGE_KEY = 'hermes-theme'
|
const STORAGE_KEY = 'hermes-theme'
|
||||||
const DEFAULT_THEME: ThemeId = 'hermes-official'
|
const DEFAULT_THEME: ThemeId = 'hermes-official'
|
||||||
const THEME_SET = new Set<ThemeId>(THEMES.map((theme) => theme.id))
|
const THEME_SET = new Set<ThemeId>(THEMES.map((theme) => theme.id))
|
||||||
const LIGHT_THEME_MAP: Record<Exclude<ThemeId, `${string}-light`>, Extract<ThemeId, `${string}-light`>> = {
|
const LIGHT_THEME_MAP: Record<
|
||||||
|
Exclude<ThemeId, `${string}-light`>,
|
||||||
|
Extract<ThemeId, `${string}-light`>
|
||||||
|
> = {
|
||||||
'hermes-official': 'hermes-official-light',
|
'hermes-official': 'hermes-official-light',
|
||||||
'hermes-classic': 'hermes-classic-light',
|
'hermes-classic': 'hermes-classic-light',
|
||||||
'hermes-slate': 'hermes-slate-light',
|
'hermes-slate': 'hermes-slate-light',
|
||||||
'hermes-mono': 'hermes-mono-light',
|
'hermes-mono': 'hermes-mono-light',
|
||||||
}
|
}
|
||||||
const DARK_THEME_MAP: Record<Extract<ThemeId, `${string}-light`>, Exclude<ThemeId, `${string}-light`>> = {
|
const DARK_THEME_MAP: Record<
|
||||||
|
Extract<ThemeId, `${string}-light`>,
|
||||||
|
Exclude<ThemeId, `${string}-light`>
|
||||||
|
> = {
|
||||||
'hermes-official-light': 'hermes-official',
|
'hermes-official-light': 'hermes-official',
|
||||||
'hermes-classic-light': 'hermes-classic',
|
'hermes-classic-light': 'hermes-classic',
|
||||||
'hermes-slate-light': 'hermes-slate',
|
'hermes-slate-light': 'hermes-slate',
|
||||||
@@ -87,7 +93,9 @@ const LIGHT_THEMES = new Set<ThemeId>([
|
|||||||
'hermes-mono-light',
|
'hermes-mono-light',
|
||||||
])
|
])
|
||||||
|
|
||||||
export function isValidTheme(value: string | null | undefined): value is ThemeId {
|
export function isValidTheme(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): value is ThemeId {
|
||||||
return typeof value === 'string' && THEME_SET.has(value as ThemeId)
|
return typeof value === 'string' && THEME_SET.has(value as ThemeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +103,10 @@ export function isDarkTheme(theme: ThemeId): boolean {
|
|||||||
return !LIGHT_THEMES.has(theme)
|
return !LIGHT_THEMES.has(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getThemeVariant(theme: ThemeId, mode: 'light' | 'dark'): ThemeId {
|
export function getThemeVariant(
|
||||||
|
theme: ThemeId,
|
||||||
|
mode: 'light' | 'dark',
|
||||||
|
): ThemeId {
|
||||||
if (mode === 'light') {
|
if (mode === 'light') {
|
||||||
return isDarkTheme(theme)
|
return isDarkTheme(theme)
|
||||||
? LIGHT_THEME_MAP[theme as keyof typeof LIGHT_THEME_MAP]
|
? LIGHT_THEME_MAP[theme as keyof typeof LIGHT_THEME_MAP]
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ function asBoolean(value: unknown): boolean {
|
|||||||
|
|
||||||
function asStringArray(value: unknown): Array<string> {
|
function asStringArray(value: unknown): Array<string> {
|
||||||
return Array.isArray(value)
|
return Array.isArray(value)
|
||||||
? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
? value.filter(
|
||||||
|
(item): item is string =>
|
||||||
|
typeof item === 'string' && item.trim().length > 0,
|
||||||
|
)
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +100,8 @@ function normalizeAgent(value: unknown): WorkspaceAgentDirectory | null {
|
|||||||
: 'primary',
|
: 'primary',
|
||||||
description: asString(record?.description) ?? '',
|
description: asString(record?.description) ?? '',
|
||||||
system_prompt: asString(record?.system_prompt) ?? '',
|
system_prompt: asString(record?.system_prompt) ?? '',
|
||||||
prompt_updated_at: asString(record?.prompt_updated_at) ?? new Date().toISOString(),
|
prompt_updated_at:
|
||||||
|
asString(record?.prompt_updated_at) ?? new Date().toISOString(),
|
||||||
limits: {
|
limits: {
|
||||||
max_tokens: asNumber(limits?.max_tokens),
|
max_tokens: asNumber(limits?.max_tokens),
|
||||||
cost_label: asString(limits?.cost_label) ?? 'Unknown',
|
cost_label: asString(limits?.cost_label) ?? 'Unknown',
|
||||||
@@ -116,9 +120,13 @@ function normalizeAgent(value: unknown): WorkspaceAgentDirectory | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractWorkspaceAgents(payload: unknown): Array<WorkspaceAgentDirectory> {
|
export function extractWorkspaceAgents(
|
||||||
|
payload: unknown,
|
||||||
|
): Array<WorkspaceAgentDirectory> {
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
return payload.map(normalizeAgent).filter((value): value is WorkspaceAgentDirectory => Boolean(value))
|
return payload
|
||||||
|
.map(normalizeAgent)
|
||||||
|
.filter((value): value is WorkspaceAgentDirectory => Boolean(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = asRecord(payload)
|
const record = asRecord(payload)
|
||||||
@@ -133,7 +141,9 @@ export function extractWorkspaceAgents(payload: unknown): Array<WorkspaceAgentDi
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeWorkspaceAgentStats(payload: unknown): WorkspaceAgentStats {
|
export function normalizeWorkspaceAgentStats(
|
||||||
|
payload: unknown,
|
||||||
|
): WorkspaceAgentStats {
|
||||||
const record = asRecord(payload)
|
const record = asRecord(payload)
|
||||||
const stats = asRecord(record?.stats) ?? record
|
const stats = asRecord(record?.stats) ?? record
|
||||||
return {
|
return {
|
||||||
@@ -143,13 +153,16 @@ export function normalizeWorkspaceAgentStats(payload: unknown): WorkspaceAgentSt
|
|||||||
cost_cents_today: asNumber(stats?.cost_cents_today),
|
cost_cents_today: asNumber(stats?.cost_cents_today),
|
||||||
success_rate: asNumber(stats?.success_rate),
|
success_rate: asNumber(stats?.success_rate),
|
||||||
avg_response_ms:
|
avg_response_ms:
|
||||||
typeof stats?.avg_response_ms === 'number' && Number.isFinite(stats.avg_response_ms)
|
typeof stats?.avg_response_ms === 'number' &&
|
||||||
|
Number.isFinite(stats.avg_response_ms)
|
||||||
? stats.avg_response_ms
|
? stats.avg_response_ms
|
||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorkspaceAgents(): Promise<Array<WorkspaceAgentDirectory>> {
|
export async function listWorkspaceAgents(): Promise<
|
||||||
|
Array<WorkspaceAgentDirectory>
|
||||||
|
> {
|
||||||
const payload = await workspaceRequestJson('/api/workspace/agents')
|
const payload = await workspaceRequestJson('/api/workspace/agents')
|
||||||
return extractWorkspaceAgents(payload)
|
return extractWorkspaceAgents(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export type WorkspaceCheckpointVerificationItem = {
|
|||||||
checked_at: string | null
|
checked_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceCheckpointVerificationKey = 'tsc' | 'tests' | 'lint' | 'e2e'
|
export type WorkspaceCheckpointVerificationKey =
|
||||||
|
| 'tsc'
|
||||||
|
| 'tests'
|
||||||
|
| 'lint'
|
||||||
|
| 'e2e'
|
||||||
|
|
||||||
export type WorkspaceCheckpointVerificationMap = Record<
|
export type WorkspaceCheckpointVerificationMap = Record<
|
||||||
WorkspaceCheckpointVerificationKey,
|
WorkspaceCheckpointVerificationKey,
|
||||||
@@ -172,7 +176,9 @@ function normalizeRunEvent(value: unknown): WorkspaceCheckpointRunEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeVerificationItem(value: unknown): WorkspaceCheckpointVerificationItem {
|
function normalizeVerificationItem(
|
||||||
|
value: unknown,
|
||||||
|
): WorkspaceCheckpointVerificationItem {
|
||||||
const record = asRecord(value)
|
const record = asRecord(value)
|
||||||
const status = asString(record?.status)
|
const status = asString(record?.status)
|
||||||
return {
|
return {
|
||||||
@@ -295,7 +301,8 @@ export async function getWorkspaceCheckpointDetail(
|
|||||||
|
|
||||||
const detailRecord = asRecord(record.checkpoint) ?? record
|
const detailRecord = asRecord(record.checkpoint) ?? record
|
||||||
const parsedDiffStat = asRecord(record.parsed_diff_stat)
|
const parsedDiffStat = asRecord(record.parsed_diff_stat)
|
||||||
const verificationRecord = asRecord(record.verification) ?? asRecord(detailRecord.verification)
|
const verificationRecord =
|
||||||
|
asRecord(record.verification) ?? asRecord(detailRecord.verification)
|
||||||
const fileDiffs = Array.isArray(record.file_diffs)
|
const fileDiffs = Array.isArray(record.file_diffs)
|
||||||
? record.file_diffs.map((entry) => {
|
? record.file_diffs.map((entry) => {
|
||||||
const item = asRecord(entry)
|
const item = asRecord(entry)
|
||||||
@@ -304,7 +311,7 @@ export async function getWorkspaceCheckpointDetail(
|
|||||||
patch:
|
patch:
|
||||||
typeof item?.diff === 'string'
|
typeof item?.diff === 'string'
|
||||||
? item.diff
|
? item.diff
|
||||||
: asString(item?.patch) ?? '',
|
: (asString(item?.patch) ?? ''),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
: Array.isArray(detailRecord.diff_files)
|
: Array.isArray(detailRecord.diff_files)
|
||||||
@@ -317,7 +324,8 @@ export async function getWorkspaceCheckpointDetail(
|
|||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const rawDiffStat = typeof parsedDiffStat?.raw === 'string' ? parsedDiffStat.raw : ''
|
const rawDiffStat =
|
||||||
|
typeof parsedDiffStat?.raw === 'string' ? parsedDiffStat.raw : ''
|
||||||
const checkpoint = normalizeCheckpoint(detailRecord)
|
const checkpoint = normalizeCheckpoint(detailRecord)
|
||||||
return {
|
return {
|
||||||
...checkpoint,
|
...checkpoint,
|
||||||
@@ -331,7 +339,8 @@ export async function getWorkspaceCheckpointDetail(
|
|||||||
typeof detailRecord.task_run_attempt === 'number'
|
typeof detailRecord.task_run_attempt === 'number'
|
||||||
? detailRecord.task_run_attempt
|
? detailRecord.task_run_attempt
|
||||||
: null,
|
: null,
|
||||||
task_run_workspace_path: asString(detailRecord.task_run_workspace_path) ?? null,
|
task_run_workspace_path:
|
||||||
|
asString(detailRecord.task_run_workspace_path) ?? null,
|
||||||
task_run_started_at: asString(detailRecord.task_run_started_at) ?? null,
|
task_run_started_at: asString(detailRecord.task_run_started_at) ?? null,
|
||||||
task_run_completed_at: asString(detailRecord.task_run_completed_at) ?? null,
|
task_run_completed_at: asString(detailRecord.task_run_completed_at) ?? null,
|
||||||
task_run_error: asString(detailRecord.task_run_error) ?? null,
|
task_run_error: asString(detailRecord.task_run_error) ?? null,
|
||||||
@@ -394,7 +403,11 @@ function parseDiffLineTotals(
|
|||||||
const line = raw
|
const line = raw
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((entry) => entry.trimEnd())
|
.map((entry) => entry.trimEnd())
|
||||||
.find((entry) => entry.trimStart().startsWith(filePath) || entry.includes(` ${filePath} `))
|
.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.trimStart().startsWith(filePath) ||
|
||||||
|
entry.includes(` ${filePath} `),
|
||||||
|
)
|
||||||
|
|
||||||
if (!line) return null
|
if (!line) return null
|
||||||
const match = line.match(/^(.*?)\s+\|\s+(\d+)\s+([+\-]+)$/)
|
const match = line.match(/^(.*?)\s+\|\s+(\d+)\s+([+\-]+)$/)
|
||||||
@@ -510,7 +523,8 @@ export function getCheckpointActionButtonClass(
|
|||||||
|
|
||||||
/** SQLite timestamps come as "2026-03-10 21:40:00" (no tz) — treat as UTC */
|
/** SQLite timestamps come as "2026-03-10 21:40:00" (no tz) — treat as UTC */
|
||||||
export function parseUtcTimestamp(value: string): Date {
|
export function parseUtcTimestamp(value: string): Date {
|
||||||
const normalized = value.includes('T') || value.endsWith('Z')
|
const normalized =
|
||||||
|
value.includes('T') || value.endsWith('Z')
|
||||||
? value
|
? value
|
||||||
: value.replace(' ', 'T') + 'Z'
|
: value.replace(' ', 'T') + 'Z'
|
||||||
return new Date(normalized)
|
return new Date(normalized)
|
||||||
@@ -534,13 +548,18 @@ export function matchesCheckpointProject(
|
|||||||
return checkpoint.project_name === projectName
|
return checkpoint.project_name === projectName
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCheckpointSummary(checkpoint: WorkspaceCheckpoint, maxLength = 200): string {
|
export function getCheckpointSummary(
|
||||||
|
checkpoint: WorkspaceCheckpoint,
|
||||||
|
maxLength = 200,
|
||||||
|
): string {
|
||||||
const raw = checkpoint.summary?.trim() || 'No checkpoint summary provided.'
|
const raw = checkpoint.summary?.trim() || 'No checkpoint summary provided.'
|
||||||
if (raw.length <= maxLength) return raw
|
if (raw.length <= maxLength) return raw
|
||||||
return raw.slice(0, maxLength).trimEnd() + '…'
|
return raw.slice(0, maxLength).trimEnd() + '…'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCheckpointFullSummary(checkpoint: WorkspaceCheckpoint): string {
|
export function getCheckpointFullSummary(
|
||||||
|
checkpoint: WorkspaceCheckpoint,
|
||||||
|
): string {
|
||||||
return checkpoint.summary?.trim() || 'No checkpoint summary provided.'
|
return checkpoint.summary?.trim() || 'No checkpoint summary provided.'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,14 +569,19 @@ export interface ParsedDiffStat {
|
|||||||
filesChanged: number
|
filesChanged: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCheckpointDiffStatParsed(checkpoint: WorkspaceCheckpoint): ParsedDiffStat | null {
|
export function getCheckpointDiffStatParsed(
|
||||||
|
checkpoint: WorkspaceCheckpoint,
|
||||||
|
): ParsedDiffStat | null {
|
||||||
if (!checkpoint.diff_stat) return null
|
if (!checkpoint.diff_stat) return null
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(checkpoint.diff_stat) as Record<string, unknown>
|
const parsed = JSON.parse(checkpoint.diff_stat) as Record<string, unknown>
|
||||||
return {
|
return {
|
||||||
raw: typeof parsed.raw === 'string' ? parsed.raw : '',
|
raw: typeof parsed.raw === 'string' ? parsed.raw : '',
|
||||||
changedFiles: Array.isArray(parsed.changed_files) ? (parsed.changed_files as Array<string>) : [],
|
changedFiles: Array.isArray(parsed.changed_files)
|
||||||
filesChanged: typeof parsed.files_changed === 'number' ? parsed.files_changed : 0,
|
? (parsed.changed_files as Array<string>)
|
||||||
|
: [],
|
||||||
|
filesChanged:
|
||||||
|
typeof parsed.files_changed === 'number' ? parsed.files_changed : 0,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -583,7 +607,8 @@ export function getCheckpointReviewSuccessMessage(
|
|||||||
action: CheckpointReviewAction,
|
action: CheckpointReviewAction,
|
||||||
): string {
|
): string {
|
||||||
if (action === 'approve') return 'Checkpoint approved'
|
if (action === 'approve') return 'Checkpoint approved'
|
||||||
if (action === 'approve-and-commit') return 'Checkpoint approved and committed'
|
if (action === 'approve-and-commit')
|
||||||
|
return 'Checkpoint approved and committed'
|
||||||
if (action === 'approve-and-pr') return 'Checkpoint approved and PR opened'
|
if (action === 'approve-and-pr') return 'Checkpoint approved and PR opened'
|
||||||
if (action === 'approve-and-merge') return 'Checkpoint approved and merged'
|
if (action === 'approve-and-merge') return 'Checkpoint approved and merged'
|
||||||
if (action === 'revise') return 'Checkpoint sent back for revision'
|
if (action === 'revise') return 'Checkpoint sent back for revision'
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import { Route as ApiKnowledgeSearchRouteImport } from './routes/api/knowledge/s
|
|||||||
import { Route as ApiKnowledgeReadRouteImport } from './routes/api/knowledge/read'
|
import { Route as ApiKnowledgeReadRouteImport } from './routes/api/knowledge/read'
|
||||||
import { Route as ApiKnowledgeListRouteImport } from './routes/api/knowledge/list'
|
import { Route as ApiKnowledgeListRouteImport } from './routes/api/knowledge/list'
|
||||||
import { Route as ApiKnowledgeGraphRouteImport } from './routes/api/knowledge/graph'
|
import { Route as ApiKnowledgeGraphRouteImport } from './routes/api/knowledge/graph'
|
||||||
|
import { Route as ApiHermesProxySplatRouteImport } from './routes/api/hermes-proxy/$'
|
||||||
import { Route as ApiHermesJobsJobIdRouteImport } from './routes/api/hermes-jobs.$jobId'
|
import { Route as ApiHermesJobsJobIdRouteImport } from './routes/api/hermes-jobs.$jobId'
|
||||||
import { Route as ApiSessionsSessionKeyStatusRouteImport } from './routes/api/sessions/$sessionKey.status'
|
import { Route as ApiSessionsSessionKeyStatusRouteImport } from './routes/api/sessions/$sessionKey.status'
|
||||||
import { Route as ApiSessionsSessionKeyActiveRunRouteImport } from './routes/api/sessions/$sessionKey.active-run'
|
import { Route as ApiSessionsSessionKeyActiveRunRouteImport } from './routes/api/sessions/$sessionKey.active-run'
|
||||||
@@ -397,6 +398,11 @@ const ApiKnowledgeGraphRoute = ApiKnowledgeGraphRouteImport.update({
|
|||||||
path: '/api/knowledge/graph',
|
path: '/api/knowledge/graph',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiHermesProxySplatRoute = ApiHermesProxySplatRouteImport.update({
|
||||||
|
id: '/api/hermes-proxy/$',
|
||||||
|
path: '/api/hermes-proxy/$',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiHermesJobsJobIdRoute = ApiHermesJobsJobIdRouteImport.update({
|
const ApiHermesJobsJobIdRoute = ApiHermesJobsJobIdRouteImport.update({
|
||||||
id: '/$jobId',
|
id: '/$jobId',
|
||||||
path: '/$jobId',
|
path: '/$jobId',
|
||||||
@@ -459,6 +465,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/chat/': typeof ChatIndexRoute
|
'/chat/': typeof ChatIndexRoute
|
||||||
'/settings/': typeof SettingsIndexRoute
|
'/settings/': typeof SettingsIndexRoute
|
||||||
'/api/hermes-jobs/$jobId': typeof ApiHermesJobsJobIdRoute
|
'/api/hermes-jobs/$jobId': typeof ApiHermesJobsJobIdRoute
|
||||||
|
'/api/hermes-proxy/$': typeof ApiHermesProxySplatRoute
|
||||||
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
|
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
|
||||||
'/api/knowledge/list': typeof ApiKnowledgeListRoute
|
'/api/knowledge/list': typeof ApiKnowledgeListRoute
|
||||||
'/api/knowledge/read': typeof ApiKnowledgeReadRoute
|
'/api/knowledge/read': typeof ApiKnowledgeReadRoute
|
||||||
@@ -527,6 +534,7 @@ export interface FileRoutesByTo {
|
|||||||
'/chat': typeof ChatIndexRoute
|
'/chat': typeof ChatIndexRoute
|
||||||
'/settings': typeof SettingsIndexRoute
|
'/settings': typeof SettingsIndexRoute
|
||||||
'/api/hermes-jobs/$jobId': typeof ApiHermesJobsJobIdRoute
|
'/api/hermes-jobs/$jobId': typeof ApiHermesJobsJobIdRoute
|
||||||
|
'/api/hermes-proxy/$': typeof ApiHermesProxySplatRoute
|
||||||
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
|
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
|
||||||
'/api/knowledge/list': typeof ApiKnowledgeListRoute
|
'/api/knowledge/list': typeof ApiKnowledgeListRoute
|
||||||
'/api/knowledge/read': typeof ApiKnowledgeReadRoute
|
'/api/knowledge/read': typeof ApiKnowledgeReadRoute
|
||||||
@@ -597,6 +605,7 @@ export interface FileRoutesById {
|
|||||||
'/chat/': typeof ChatIndexRoute
|
'/chat/': typeof ChatIndexRoute
|
||||||
'/settings/': typeof SettingsIndexRoute
|
'/settings/': typeof SettingsIndexRoute
|
||||||
'/api/hermes-jobs/$jobId': typeof ApiHermesJobsJobIdRoute
|
'/api/hermes-jobs/$jobId': typeof ApiHermesJobsJobIdRoute
|
||||||
|
'/api/hermes-proxy/$': typeof ApiHermesProxySplatRoute
|
||||||
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
|
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
|
||||||
'/api/knowledge/list': typeof ApiKnowledgeListRoute
|
'/api/knowledge/list': typeof ApiKnowledgeListRoute
|
||||||
'/api/knowledge/read': typeof ApiKnowledgeReadRoute
|
'/api/knowledge/read': typeof ApiKnowledgeReadRoute
|
||||||
@@ -668,6 +677,7 @@ export interface FileRouteTypes {
|
|||||||
| '/chat/'
|
| '/chat/'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
| '/api/hermes-jobs/$jobId'
|
| '/api/hermes-jobs/$jobId'
|
||||||
|
| '/api/hermes-proxy/$'
|
||||||
| '/api/knowledge/graph'
|
| '/api/knowledge/graph'
|
||||||
| '/api/knowledge/list'
|
| '/api/knowledge/list'
|
||||||
| '/api/knowledge/read'
|
| '/api/knowledge/read'
|
||||||
@@ -736,6 +746,7 @@ export interface FileRouteTypes {
|
|||||||
| '/chat'
|
| '/chat'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/api/hermes-jobs/$jobId'
|
| '/api/hermes-jobs/$jobId'
|
||||||
|
| '/api/hermes-proxy/$'
|
||||||
| '/api/knowledge/graph'
|
| '/api/knowledge/graph'
|
||||||
| '/api/knowledge/list'
|
| '/api/knowledge/list'
|
||||||
| '/api/knowledge/read'
|
| '/api/knowledge/read'
|
||||||
@@ -805,6 +816,7 @@ export interface FileRouteTypes {
|
|||||||
| '/chat/'
|
| '/chat/'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
| '/api/hermes-jobs/$jobId'
|
| '/api/hermes-jobs/$jobId'
|
||||||
|
| '/api/hermes-proxy/$'
|
||||||
| '/api/knowledge/graph'
|
| '/api/knowledge/graph'
|
||||||
| '/api/knowledge/list'
|
| '/api/knowledge/list'
|
||||||
| '/api/knowledge/read'
|
| '/api/knowledge/read'
|
||||||
@@ -871,6 +883,7 @@ export interface RootRouteChildren {
|
|||||||
ApiWorkspaceRoute: typeof ApiWorkspaceRoute
|
ApiWorkspaceRoute: typeof ApiWorkspaceRoute
|
||||||
ChatSessionKeyRoute: typeof ChatSessionKeyRoute
|
ChatSessionKeyRoute: typeof ChatSessionKeyRoute
|
||||||
ChatIndexRoute: typeof ChatIndexRoute
|
ChatIndexRoute: typeof ChatIndexRoute
|
||||||
|
ApiHermesProxySplatRoute: typeof ApiHermesProxySplatRoute
|
||||||
ApiKnowledgeGraphRoute: typeof ApiKnowledgeGraphRoute
|
ApiKnowledgeGraphRoute: typeof ApiKnowledgeGraphRoute
|
||||||
ApiKnowledgeListRoute: typeof ApiKnowledgeListRoute
|
ApiKnowledgeListRoute: typeof ApiKnowledgeListRoute
|
||||||
ApiKnowledgeReadRoute: typeof ApiKnowledgeReadRoute
|
ApiKnowledgeReadRoute: typeof ApiKnowledgeReadRoute
|
||||||
@@ -1337,6 +1350,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiKnowledgeGraphRouteImport
|
preLoaderRoute: typeof ApiKnowledgeGraphRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/hermes-proxy/$': {
|
||||||
|
id: '/api/hermes-proxy/$'
|
||||||
|
path: '/api/hermes-proxy/$'
|
||||||
|
fullPath: '/api/hermes-proxy/$'
|
||||||
|
preLoaderRoute: typeof ApiHermesProxySplatRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/hermes-jobs/$jobId': {
|
'/api/hermes-jobs/$jobId': {
|
||||||
id: '/api/hermes-jobs/$jobId'
|
id: '/api/hermes-jobs/$jobId'
|
||||||
path: '/$jobId'
|
path: '/$jobId'
|
||||||
@@ -1479,6 +1499,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiWorkspaceRoute: ApiWorkspaceRoute,
|
ApiWorkspaceRoute: ApiWorkspaceRoute,
|
||||||
ChatSessionKeyRoute: ChatSessionKeyRoute,
|
ChatSessionKeyRoute: ChatSessionKeyRoute,
|
||||||
ChatIndexRoute: ChatIndexRoute,
|
ChatIndexRoute: ChatIndexRoute,
|
||||||
|
ApiHermesProxySplatRoute: ApiHermesProxySplatRoute,
|
||||||
ApiKnowledgeGraphRoute: ApiKnowledgeGraphRoute,
|
ApiKnowledgeGraphRoute: ApiKnowledgeGraphRoute,
|
||||||
ApiKnowledgeListRoute: ApiKnowledgeListRoute,
|
ApiKnowledgeListRoute: ApiKnowledgeListRoute,
|
||||||
ApiKnowledgeReadRoute: ApiKnowledgeReadRoute,
|
ApiKnowledgeReadRoute: ApiKnowledgeReadRoute,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function NotFoundPage() {
|
|||||||
Go Back
|
Go Back
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to={"/chat" as string}
|
to={'/chat' as string}
|
||||||
className={buttonVariants({ variant: 'default', size: 'default' })}
|
className={buttonVariants({ variant: 'default', size: 'default' })}
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={Home01Icon} size={18} strokeWidth={1.5} />
|
<HugeiconsIcon icon={Home01Icon} size={18} strokeWidth={1.5} />
|
||||||
|
|||||||
@@ -236,7 +236,9 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta httpEquiv="Content-Security-Policy" content={APP_CSP} />
|
<meta httpEquiv="Content-Security-Policy" content={APP_CSP} />
|
||||||
<script dangerouslySetInnerHTML={{ __html: `
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
// Polyfill crypto.randomUUID for non-secure contexts (HTTP access via LAN IP)
|
// Polyfill crypto.randomUUID for non-secure contexts (HTTP access via LAN IP)
|
||||||
if (typeof crypto !== 'undefined' && !crypto.randomUUID) {
|
if (typeof crypto !== 'undefined' && !crypto.randomUUID) {
|
||||||
crypto.randomUUID = function() {
|
crypto.randomUUID = function() {
|
||||||
@@ -245,13 +247,17 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
` }} />
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeColorScript }} />
|
<script dangerouslySetInnerHTML={{ __html: themeColorScript }} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script dangerouslySetInnerHTML={{ __html: `
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
(function(){
|
(function(){
|
||||||
if (document.getElementById('splash-screen')) return;
|
if (document.getElementById('splash-screen')) return;
|
||||||
var bg = '#0A0E1A', txt = '#E6EAF2', muted = '#9AA5BD', accent = '#6366F1';
|
var bg = '#0A0E1A', txt = '#E6EAF2', muted = '#9AA5BD', accent = '#6366F1';
|
||||||
@@ -335,10 +341,14 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
})()
|
})()
|
||||||
`}} />
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="root">{children}</div>
|
<div className="root">{children}</div>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
<script dangerouslySetInnerHTML={{ __html: `
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
(function(){
|
(function(){
|
||||||
var start = Date.now();
|
var start = Date.now();
|
||||||
function check() {
|
function check() {
|
||||||
@@ -349,7 +359,9 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
setTimeout(check, 2500);
|
setTimeout(check, 2500);
|
||||||
})()
|
})()
|
||||||
`}} />
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { isAuthenticated } from '../../server/auth-middleware'
|
import { isAuthenticated } from '../../server/auth-middleware'
|
||||||
import { ensureBusStarted, subscribeToChatEvents } from '../../server/chat-event-bus'
|
import {
|
||||||
|
ensureBusStarted,
|
||||||
|
subscribeToChatEvents,
|
||||||
|
} from '../../server/chat-event-bus'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE endpoint for chat events.
|
* SSE endpoint for chat events.
|
||||||
@@ -21,7 +24,8 @@ export const Route = createFileRoute('/api/chat-events')({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
const sessionKeyParam = url.searchParams.get('sessionKey')?.trim() || undefined
|
const sessionKeyParam =
|
||||||
|
url.searchParams.get('sessionKey')?.trim() || undefined
|
||||||
|
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
let streamClosed = false
|
let streamClosed = false
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
* Connection status endpoint — returns a summary of portable chat readiness
|
* Connection status endpoint — returns a summary of portable chat readiness
|
||||||
* plus whether Hermes gateway enhancements are available.
|
* plus whether Hermes gateway enhancements are available.
|
||||||
*/
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { isAuthenticated } from '../../server/auth-middleware'
|
import YAML from 'yaml'
|
||||||
import {
|
import {
|
||||||
HERMES_API,
|
HERMES_API,
|
||||||
ensureGatewayProbed,
|
ensureGatewayProbed,
|
||||||
getChatMode,
|
getChatMode,
|
||||||
} from '../../server/gateway-capabilities'
|
} from '../../server/gateway-capabilities'
|
||||||
import fs from 'node:fs'
|
import { isAuthenticated } from '../../server/auth-middleware'
|
||||||
import path from 'node:path'
|
|
||||||
import os from 'node:os'
|
|
||||||
import YAML from 'yaml'
|
|
||||||
|
|
||||||
const CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml')
|
const CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml')
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,47 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { json } from '@tanstack/react-start'
|
import { json } from '@tanstack/react-start'
|
||||||
import { isAuthenticated } from '@/server/auth-middleware'
|
import { isAuthenticated } from '@/server/auth-middleware'
|
||||||
import { HERMES_API } from '@/server/gateway-capabilities'
|
import { BEARER_TOKEN, HERMES_API } from '@/server/gateway-capabilities'
|
||||||
|
|
||||||
|
const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
||||||
|
'claude-opus-4-6': 200_000,
|
||||||
|
'claude-opus-4-5': 200_000,
|
||||||
|
'claude-sonnet-4-6': 200_000,
|
||||||
|
'claude-sonnet-4-5': 200_000,
|
||||||
|
'claude-sonnet-4': 200_000,
|
||||||
|
'claude-3-5-sonnet': 200_000,
|
||||||
|
'claude-3-opus': 200_000,
|
||||||
|
'claude-haiku-3.5': 200_000,
|
||||||
|
'gpt-5.4': 1_000_000,
|
||||||
|
'gpt-5.2-codex': 1_000_000,
|
||||||
|
'gpt-4.1': 1_000_000,
|
||||||
|
'gpt-4.1-mini': 1_000_000,
|
||||||
|
'gpt-4o': 128_000,
|
||||||
|
'gpt-4o-mini': 128_000,
|
||||||
|
'gpt-4-turbo': 128_000,
|
||||||
|
o1: 200_000,
|
||||||
|
'o3-mini': 200_000,
|
||||||
|
'gemini-2.5-flash': 1_000_000,
|
||||||
|
'gemini-2.5-pro': 1_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContextWindow(model: string): number {
|
||||||
|
if (MODEL_CONTEXT_WINDOWS[model]) return MODEL_CONTEXT_WINDOWS[model]
|
||||||
|
for (const [key, value] of Object.entries(MODEL_CONTEXT_WINDOWS)) {
|
||||||
|
if (
|
||||||
|
model.toLowerCase().includes(key.toLowerCase()) ||
|
||||||
|
key.toLowerCase().includes(model.toLowerCase())
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return 200_000
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
return BEARER_TOKEN ? { Authorization: `Bearer ${BEARER_TOKEN}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHARS_PER_TOKEN = 3.5
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/context-usage')({
|
export const Route = createFileRoute('/api/context-usage')({
|
||||||
server: {
|
server: {
|
||||||
@@ -15,87 +55,133 @@ export const Route = createFileRoute('/api/context-usage')({
|
|||||||
const sessionId = url.searchParams.get('sessionId') || ''
|
const sessionId = url.searchParams.get('sessionId') || ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to get session token usage from Hermes
|
// Step 1: Get session data from Hermes
|
||||||
let usedTokens = 0
|
|
||||||
let maxTokens = 200000 // default context window
|
|
||||||
let model = ''
|
|
||||||
|
|
||||||
// Known context window sizes for common models
|
|
||||||
const MODEL_CONTEXT: Record<string, number> = {
|
|
||||||
'claude-opus-4-6': 1000000,
|
|
||||||
'claude-sonnet-4-6': 1000000,
|
|
||||||
'claude-opus-4-5': 1000000,
|
|
||||||
'claude-sonnet-4-5': 1000000,
|
|
||||||
'claude-sonnet-4': 200000,
|
|
||||||
'claude-opus-4': 200000,
|
|
||||||
'claude-3-5-sonnet': 200000,
|
|
||||||
'claude-3-opus': 200000,
|
|
||||||
'gpt-5.4': 1000000,
|
|
||||||
'gpt-4o': 128000,
|
|
||||||
'gpt-4-turbo': 128000,
|
|
||||||
'gpt-4.1': 1000000,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find session data — first by exact ID, then by listing
|
|
||||||
let sessionData: Record<string, unknown> | null = null
|
let sessionData: Record<string, unknown> | null = null
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const res = await fetch(`${HERMES_API}/api/sessions/${sessionId}`, {
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${HERMES_API}/api/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
headers: authHeaders(),
|
||||||
signal: AbortSignal.timeout(3000),
|
signal: AbortSignal.timeout(3000),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = (await res.json()) as { session?: Record<string, unknown> }
|
const data = (await res.json()) as {
|
||||||
|
session?: Record<string, unknown>
|
||||||
|
}
|
||||||
if (data.session) sessionData = data.session
|
if (data.session) sessionData = data.session
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: if no session found by ID, try the most recent active session
|
// Fallback: most recent active session
|
||||||
if (!sessionData) {
|
if (!sessionData) {
|
||||||
try {
|
try {
|
||||||
const listRes = await fetch(`${HERMES_API}/api/sessions?limit=1`, {
|
const listRes = await fetch(
|
||||||
|
`${HERMES_API}/api/sessions?limit=1`,
|
||||||
|
{
|
||||||
|
headers: authHeaders(),
|
||||||
signal: AbortSignal.timeout(3000),
|
signal: AbortSignal.timeout(3000),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
if (listRes.ok) {
|
if (listRes.ok) {
|
||||||
const listData = (await listRes.json()) as { items?: Array<Record<string, unknown>> }
|
const listData = (await listRes.json()) as {
|
||||||
|
items?: Array<Record<string, unknown>>
|
||||||
|
}
|
||||||
if (listData.items && listData.items.length > 0) {
|
if (listData.items && listData.items.length > 0) {
|
||||||
sessionData = listData.items[0]
|
sessionData = listData.items[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionData) {
|
if (!sessionData) {
|
||||||
// Active context = input + output tokens (what's in the conversation window)
|
return json({
|
||||||
// Cache tokens are NOT additional context — they represent tokens served
|
ok: true,
|
||||||
// from cache instead of being reprocessed, so they don't add to window usage
|
contextPercent: 0,
|
||||||
usedTokens = (Number(sessionData.input_tokens) || 0)
|
maxTokens: 0,
|
||||||
+ (Number(sessionData.output_tokens) || 0)
|
usedTokens: 0,
|
||||||
model = String(sessionData.model || '')
|
model: '',
|
||||||
|
staticTokens: 0,
|
||||||
// Set max based on model
|
conversationTokens: 0,
|
||||||
const modelKey = Object.keys(MODEL_CONTEXT).find(
|
|
||||||
(k) => model.toLowerCase().includes(k.toLowerCase()),
|
|
||||||
)
|
|
||||||
if (modelKey) maxTokens = MODEL_CONTEXT[modelKey]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try /v1/models for context_length
|
|
||||||
if (maxTokens === 200000 && !model) {
|
|
||||||
try {
|
|
||||||
const modelsRes = await fetch(`${HERMES_API}/v1/models`, {
|
|
||||||
signal: AbortSignal.timeout(3000),
|
|
||||||
})
|
})
|
||||||
if (modelsRes.ok) {
|
|
||||||
const modelsData = (await modelsRes.json()) as { data?: Array<{ context_length?: number }> }
|
|
||||||
const firstModel = modelsData.data?.[0]
|
|
||||||
if (firstModel?.context_length) {
|
|
||||||
maxTokens = firstModel.context_length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* use default */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextPercent = maxTokens > 0 ? Math.round((usedTokens / maxTokens) * 100) : 0
|
const model = String(sessionData.model || '')
|
||||||
|
const maxTokens = getContextWindow(model)
|
||||||
|
const inputTokens = Number(sessionData.input_tokens) || 0
|
||||||
|
const outputTokens = Number(sessionData.output_tokens) || 0
|
||||||
|
const cacheReadTokens = Number(sessionData.cache_read_tokens) || 0
|
||||||
|
const messageCount = Number(sessionData.message_count) || 0
|
||||||
|
const toolCallCount = Number(sessionData.tool_call_count) || 0
|
||||||
|
|
||||||
|
// Step 2: Estimate actual context window usage
|
||||||
|
// The key insight: input_tokens and output_tokens from the session are
|
||||||
|
// CUMULATIVE totals across all turns. We need the current context size.
|
||||||
|
//
|
||||||
|
// Strategy (matching ControlSuite's approach):
|
||||||
|
// 1. If cache_read_tokens > 0, use it to estimate context size.
|
||||||
|
// cache_read ≈ tokens that were re-read from cache on each turn,
|
||||||
|
// which approximates the conversation context.
|
||||||
|
// Divide by assistant turn count and multiply by 1.2 for overhead.
|
||||||
|
// 2. Otherwise, fall back to estimating from message content size.
|
||||||
|
|
||||||
|
let usedTokens = 0
|
||||||
|
|
||||||
|
// Count assistant turns (each assistant message = one API call)
|
||||||
|
const assistantTurns = Math.max(1, Math.ceil(messageCount / 2))
|
||||||
|
|
||||||
|
if (cacheReadTokens > 0 && assistantTurns > 0) {
|
||||||
|
// Cache-based estimation (most accurate when available)
|
||||||
|
// cache_read per turn ≈ the context that was served from cache
|
||||||
|
// Multiply by 1.2 to account for non-cached parts (new messages, tool results)
|
||||||
|
usedTokens = Math.ceil((cacheReadTokens / assistantTurns) * 1.2)
|
||||||
|
} else if (messageCount > 0) {
|
||||||
|
// Fallback: fetch messages and estimate from content length
|
||||||
|
try {
|
||||||
|
const targetSessionId = sessionId || String(sessionData.id || '')
|
||||||
|
if (targetSessionId) {
|
||||||
|
const msgRes = await fetch(
|
||||||
|
`${HERMES_API}/api/sessions/${encodeURIComponent(targetSessionId)}/messages`,
|
||||||
|
{
|
||||||
|
headers: authHeaders(),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (msgRes.ok) {
|
||||||
|
const msgData = (await msgRes.json()) as {
|
||||||
|
items?: Array<{
|
||||||
|
content?: string
|
||||||
|
tool_calls?: unknown
|
||||||
|
reasoning?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const messages = msgData.items || []
|
||||||
|
let totalChars = 0
|
||||||
|
for (const msg of messages) {
|
||||||
|
totalChars += (msg.content || '').length
|
||||||
|
if (msg.reasoning) totalChars += msg.reasoning.length
|
||||||
|
if (msg.tool_calls)
|
||||||
|
totalChars += JSON.stringify(msg.tool_calls).length
|
||||||
|
}
|
||||||
|
usedTokens = Math.ceil(totalChars / CHARS_PER_TOKEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore - use zero */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to maxTokens
|
||||||
|
usedTokens = Math.min(usedTokens, maxTokens)
|
||||||
|
const contextPercent =
|
||||||
|
maxTokens > 0 ? Math.round((usedTokens / maxTokens) * 1000) / 10 : 0
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -110,7 +196,7 @@ export const Route = createFileRoute('/api/context-usage')({
|
|||||||
return json({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
contextPercent: 0,
|
contextPercent: 0,
|
||||||
maxTokens: 128000,
|
maxTokens: 128_000,
|
||||||
usedTokens: 0,
|
usedTokens: 0,
|
||||||
model: '',
|
model: '',
|
||||||
staticTokens: 0,
|
staticTokens: 0,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { subscribeToChatEvents, ensureBusStarted } from '../../server/chat-event-bus'
|
import {
|
||||||
|
ensureBusStarted,
|
||||||
|
subscribeToChatEvents,
|
||||||
|
} from '../../server/chat-event-bus'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/events')({
|
export const Route = createFileRoute('/api/events')({
|
||||||
server: {
|
server: {
|
||||||
@@ -15,14 +18,18 @@ export const Route = createFileRoute('/api/events')({
|
|||||||
start(controller) {
|
start(controller) {
|
||||||
// Send connected event immediately
|
// Send connected event immediately
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(`event: connected\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`),
|
encoder.encode(
|
||||||
|
`event: connected\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Subscribe to chat event bus
|
// Subscribe to chat event bus
|
||||||
unsubscribe = subscribeToChatEvents((event) => {
|
unsubscribe = subscribeToChatEvents((event) => {
|
||||||
try {
|
try {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`),
|
encoder.encode(
|
||||||
|
`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// Stream closed
|
// Stream closed
|
||||||
@@ -49,7 +56,7 @@ export const Route = createFileRoute('/api/events')({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache, no-store',
|
'Cache-Control': 'no-cache, no-store',
|
||||||
'Connection': 'keep-alive',
|
Connection: 'keep-alive',
|
||||||
'X-Accel-Buffering': 'no',
|
'X-Accel-Buffering': 'no',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import path from 'node:path'
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import YAML from 'yaml'
|
import YAML from 'yaml'
|
||||||
import { createCapabilityUnavailablePayload } from '@/lib/feature-gates'
|
|
||||||
import { isAuthenticated } from '../../server/auth-middleware'
|
import { isAuthenticated } from '../../server/auth-middleware'
|
||||||
import {
|
import {
|
||||||
ensureGatewayProbed,
|
ensureGatewayProbed,
|
||||||
getCapabilities,
|
getCapabilities,
|
||||||
} from '../../server/gateway-capabilities'
|
} from '../../server/gateway-capabilities'
|
||||||
|
import { createCapabilityUnavailablePayload } from '@/lib/feature-gates'
|
||||||
|
|
||||||
type AuthResult = Response | true
|
type AuthResult = Response | true
|
||||||
|
|
||||||
@@ -24,14 +24,49 @@ const ENV_PATH = path.join(HERMES_HOME, '.env')
|
|||||||
const PROVIDERS = [
|
const PROVIDERS = [
|
||||||
{ id: 'nous', name: 'Nous Portal', authType: 'oauth', envKeys: [] },
|
{ id: 'nous', name: 'Nous Portal', authType: 'oauth', envKeys: [] },
|
||||||
{ id: 'openai-codex', name: 'OpenAI Codex', authType: 'oauth', envKeys: [] },
|
{ id: 'openai-codex', name: 'OpenAI Codex', authType: 'oauth', envKeys: [] },
|
||||||
{ id: 'anthropic', name: 'Anthropic', authType: 'api_key', envKeys: ['ANTHROPIC_API_KEY'] },
|
{
|
||||||
{ id: 'openrouter', name: 'OpenRouter', authType: 'api_key', envKeys: ['OPENROUTER_API_KEY'] },
|
id: 'anthropic',
|
||||||
{ id: 'zai', name: 'Z.AI / GLM', authType: 'api_key', envKeys: ['GLM_API_KEY'] },
|
name: 'Anthropic',
|
||||||
{ id: 'kimi-coding', name: 'Kimi / Moonshot', authType: 'api_key', envKeys: ['KIMI_API_KEY'] },
|
authType: 'api_key',
|
||||||
{ id: 'minimax', name: 'MiniMax', authType: 'api_key', envKeys: ['MINIMAX_API_KEY'] },
|
envKeys: ['ANTHROPIC_API_KEY'],
|
||||||
{ id: 'minimax-cn', name: 'MiniMax (China)', authType: 'api_key', envKeys: ['MINIMAX_CN_API_KEY'] },
|
},
|
||||||
|
{
|
||||||
|
id: 'openrouter',
|
||||||
|
name: 'OpenRouter',
|
||||||
|
authType: 'api_key',
|
||||||
|
envKeys: ['OPENROUTER_API_KEY'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zai',
|
||||||
|
name: 'Z.AI / GLM',
|
||||||
|
authType: 'api_key',
|
||||||
|
envKeys: ['GLM_API_KEY'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kimi-coding',
|
||||||
|
name: 'Kimi / Moonshot',
|
||||||
|
authType: 'api_key',
|
||||||
|
envKeys: ['KIMI_API_KEY'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'minimax',
|
||||||
|
name: 'MiniMax',
|
||||||
|
authType: 'api_key',
|
||||||
|
envKeys: ['MINIMAX_API_KEY'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'minimax-cn',
|
||||||
|
name: 'MiniMax (China)',
|
||||||
|
authType: 'api_key',
|
||||||
|
envKeys: ['MINIMAX_CN_API_KEY'],
|
||||||
|
},
|
||||||
{ id: 'ollama', name: 'Ollama (Local)', authType: 'none', envKeys: [] },
|
{ id: 'ollama', name: 'Ollama (Local)', authType: 'none', envKeys: [] },
|
||||||
{ id: 'custom', name: 'Custom OpenAI-compatible', authType: 'api_key', envKeys: [] },
|
{
|
||||||
|
id: 'custom',
|
||||||
|
name: 'Custom OpenAI-compatible',
|
||||||
|
authType: 'api_key',
|
||||||
|
envKeys: [],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function readConfig(): Record<string, unknown> {
|
function readConfig(): Record<string, unknown> {
|
||||||
@@ -60,7 +95,10 @@ function readEnv(): Record<string, string> {
|
|||||||
const key = trimmed.slice(0, eqIdx).trim()
|
const key = trimmed.slice(0, eqIdx).trim()
|
||||||
let value = trimmed.slice(eqIdx + 1).trim()
|
let value = trimmed.slice(eqIdx + 1).trim()
|
||||||
// Strip quotes
|
// Strip quotes
|
||||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
value = value.slice(1, -1)
|
value = value.slice(1, -1)
|
||||||
}
|
}
|
||||||
env[key] = value
|
env[key] = value
|
||||||
@@ -83,11 +121,22 @@ function maskKey(key: string): string {
|
|||||||
return key.slice(0, 4) + '...' + key.slice(-4)
|
return key.slice(0, 4) + '...' + key.slice(-4)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAuthStore(providerId: string): { hasToken: boolean; source: string; maskedKey?: string } {
|
function checkAuthStore(providerId: string): {
|
||||||
|
hasToken: boolean
|
||||||
|
source: string
|
||||||
|
maskedKey?: string
|
||||||
|
} {
|
||||||
// Check Hermes auth store
|
// Check Hermes auth store
|
||||||
for (const storePath of [
|
for (const storePath of [
|
||||||
path.join(os.homedir(), '.hermes', 'auth-profiles.json'),
|
path.join(os.homedir(), '.hermes', 'auth-profiles.json'),
|
||||||
path.join(os.homedir(), '.openclaw', 'agents', 'main', 'agent', 'auth-profiles.json'),
|
path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'.openclaw',
|
||||||
|
'agents',
|
||||||
|
'main',
|
||||||
|
'agent',
|
||||||
|
'auth-profiles.json',
|
||||||
|
),
|
||||||
]) {
|
]) {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(storePath)) continue
|
if (!fs.existsSync(storePath)) continue
|
||||||
@@ -99,7 +148,9 @@ function checkAuthStore(providerId: string): { hasToken: boolean; source: string
|
|||||||
const p = value as Record<string, unknown>
|
const p = value as Record<string, unknown>
|
||||||
const token = String(p.token || p.key || p.access || '').trim()
|
const token = String(p.token || p.key || p.access || '').trim()
|
||||||
if (token) {
|
if (token) {
|
||||||
const source = storePath.includes('.hermes') ? 'hermes-auth-store' : 'openclaw-auth-store'
|
const source = storePath.includes('.hermes')
|
||||||
|
? 'hermes-auth-store'
|
||||||
|
: 'openclaw-auth-store'
|
||||||
return { hasToken: true, source, maskedKey: maskKey(token) }
|
return { hasToken: true, source, maskedKey: maskKey(token) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,9 +182,11 @@ export const Route = createFileRoute('/api/hermes-config')({
|
|||||||
|
|
||||||
// Build provider status
|
// Build provider status
|
||||||
const providerStatus = PROVIDERS.map((p) => {
|
const providerStatus = PROVIDERS.map((p) => {
|
||||||
const hasEnvKey = p.envKeys.length === 0 || p.envKeys.some((k) => !!env[k])
|
const hasEnvKey =
|
||||||
|
p.envKeys.length === 0 || p.envKeys.some((k) => !!env[k])
|
||||||
const authStoreCheck = checkAuthStore(p.id)
|
const authStoreCheck = checkAuthStore(p.id)
|
||||||
const hasKey = hasEnvKey || authStoreCheck.hasToken || p.authType === 'none'
|
const hasKey =
|
||||||
|
hasEnvKey || authStoreCheck.hasToken || p.authType === 'none'
|
||||||
const maskedKeys: Record<string, string> = {}
|
const maskedKeys: Record<string, string> = {}
|
||||||
for (const k of p.envKeys) {
|
for (const k of p.envKeys) {
|
||||||
if (env[k]) maskedKeys[k] = maskKey(env[k])
|
if (env[k]) maskedKeys[k] = maskKey(env[k])
|
||||||
@@ -144,7 +197,11 @@ export const Route = createFileRoute('/api/hermes-config')({
|
|||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
configured: hasKey,
|
configured: hasKey,
|
||||||
authSource: authStoreCheck.hasToken ? authStoreCheck.source : (hasEnvKey ? 'env' : 'none'),
|
authSource: authStoreCheck.hasToken
|
||||||
|
? authStoreCheck.source
|
||||||
|
: hasEnvKey
|
||||||
|
? 'env'
|
||||||
|
: 'none',
|
||||||
maskedKeys,
|
maskedKeys,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -161,7 +218,8 @@ export const Route = createFileRoute('/api/hermes-config')({
|
|||||||
} else if (modelField && typeof modelField === 'object') {
|
} else if (modelField && typeof modelField === 'object') {
|
||||||
const modelObj = modelField as Record<string, unknown>
|
const modelObj = modelField as Record<string, unknown>
|
||||||
activeModel = (modelObj.default as string) || ''
|
activeModel = (modelObj.default as string) || ''
|
||||||
activeProvider = (modelObj.provider as string) || (config.provider as string) || ''
|
activeProvider =
|
||||||
|
(modelObj.provider as string) || (config.provider as string) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
@@ -196,10 +254,22 @@ export const Route = createFileRoute('/api/hermes-config')({
|
|||||||
const updates = body.config as Record<string, unknown>
|
const updates = body.config as Record<string, unknown>
|
||||||
|
|
||||||
// Deep merge
|
// Deep merge
|
||||||
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>) {
|
function deepMerge(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
) {
|
||||||
for (const [key, value] of Object.entries(source)) {
|
for (const [key, value] of Object.entries(source)) {
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value) && target[key] && typeof target[key] === 'object') {
|
if (
|
||||||
deepMerge(target[key] as Record<string, unknown>, value as Record<string, unknown>)
|
value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
target[key] &&
|
||||||
|
typeof target[key] === 'object'
|
||||||
|
) {
|
||||||
|
deepMerge(
|
||||||
|
target[key] as Record<string, unknown>,
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
target[key] = value
|
target[key] = value
|
||||||
}
|
}
|
||||||
@@ -231,7 +301,10 @@ export const Route = createFileRoute('/api/hermes-config')({
|
|||||||
writeEnv(currentEnv)
|
writeEnv(currentEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ ok: true, message: 'Config updated. Restart Hermes to apply changes.' })
|
return Response.json({
|
||||||
|
ok: true,
|
||||||
|
message: 'Config updated. Restart Hermes to apply changes.',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export const Route = createFileRoute('/api/hermes-jobs/$jobId')({
|
|||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request, params }) => {
|
GET: async ({ request, params }) => {
|
||||||
if (!isAuthenticated(request)) {
|
if (!isAuthenticated(request)) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await ensureGatewayProbed()
|
await ensureGatewayProbed()
|
||||||
if (!getCapabilities().jobs) {
|
if (!getCapabilities().jobs) {
|
||||||
@@ -33,11 +35,16 @@ export const Route = createFileRoute('/api/hermes-jobs/$jobId')({
|
|||||||
? `${HERMES_API}/api/jobs/${params.jobId}/${subPath}${url.search}`
|
? `${HERMES_API}/api/jobs/${params.jobId}/${subPath}${url.search}`
|
||||||
: `${HERMES_API}/api/jobs/${params.jobId}`
|
: `${HERMES_API}/api/jobs/${params.jobId}`
|
||||||
const res = await fetch(target)
|
const res = await fetch(target)
|
||||||
return new Response(await res.text(), { status: res.status, headers: { 'Content-Type': 'application/json' } })
|
return new Response(await res.text(), {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
},
|
},
|
||||||
POST: async ({ request, params }) => {
|
POST: async ({ request, params }) => {
|
||||||
if (!isAuthenticated(request)) {
|
if (!isAuthenticated(request)) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await ensureGatewayProbed()
|
await ensureGatewayProbed()
|
||||||
if (!getCapabilities().jobs) {
|
if (!getCapabilities().jobs) {
|
||||||
@@ -59,11 +66,16 @@ export const Route = createFileRoute('/api/hermes-jobs/$jobId')({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: body || undefined,
|
body: body || undefined,
|
||||||
})
|
})
|
||||||
return new Response(await res.text(), { status: res.status, headers: { 'Content-Type': 'application/json' } })
|
return new Response(await res.text(), {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
},
|
},
|
||||||
PATCH: async ({ request, params }) => {
|
PATCH: async ({ request, params }) => {
|
||||||
if (!isAuthenticated(request)) {
|
if (!isAuthenticated(request)) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await ensureGatewayProbed()
|
await ensureGatewayProbed()
|
||||||
if (!getCapabilities().jobs) {
|
if (!getCapabilities().jobs) {
|
||||||
@@ -80,11 +92,16 @@ export const Route = createFileRoute('/api/hermes-jobs/$jobId')({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
return new Response(await res.text(), { status: res.status, headers: { 'Content-Type': 'application/json' } })
|
return new Response(await res.text(), {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
},
|
},
|
||||||
DELETE: async ({ request, params }) => {
|
DELETE: async ({ request, params }) => {
|
||||||
if (!isAuthenticated(request)) {
|
if (!isAuthenticated(request)) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await ensureGatewayProbed()
|
await ensureGatewayProbed()
|
||||||
if (!getCapabilities().jobs) {
|
if (!getCapabilities().jobs) {
|
||||||
@@ -95,8 +112,13 @@ export const Route = createFileRoute('/api/hermes-jobs/$jobId')({
|
|||||||
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const res = await fetch(`${HERMES_API}/api/jobs/${params.jobId}`, { method: 'DELETE' })
|
const res = await fetch(`${HERMES_API}/api/jobs/${params.jobId}`, {
|
||||||
return new Response(await res.text(), { status: res.status, headers: { 'Content-Type': 'application/json' } })
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
return new Response(await res.text(), {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* Jobs API proxy — forwards to Hermes FastAPI /api/jobs
|
* Jobs API proxy — forwards to Hermes FastAPI /api/jobs
|
||||||
*/
|
*/
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { createCapabilityUnavailablePayload } from '@/lib/feature-gates'
|
|
||||||
import { isAuthenticated } from '../../server/auth-middleware'
|
import { isAuthenticated } from '../../server/auth-middleware'
|
||||||
import {
|
import {
|
||||||
HERMES_API,
|
HERMES_API,
|
||||||
@@ -10,13 +9,16 @@ import {
|
|||||||
ensureGatewayProbed,
|
ensureGatewayProbed,
|
||||||
getCapabilities,
|
getCapabilities,
|
||||||
} from '../../server/gateway-capabilities'
|
} from '../../server/gateway-capabilities'
|
||||||
|
import { createCapabilityUnavailablePayload } from '@/lib/feature-gates'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/hermes-jobs')({
|
export const Route = createFileRoute('/api/hermes-jobs')({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request }) => {
|
GET: async ({ request }) => {
|
||||||
if (!isAuthenticated(request)) {
|
if (!isAuthenticated(request)) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await ensureGatewayProbed()
|
await ensureGatewayProbed()
|
||||||
if (!getCapabilities().jobs) {
|
if (!getCapabilities().jobs) {
|
||||||
@@ -33,11 +35,16 @@ export const Route = createFileRoute('/api/hermes-jobs')({
|
|||||||
const params = url.searchParams.toString()
|
const params = url.searchParams.toString()
|
||||||
const target = `${HERMES_API}/api/jobs${params ? `?${params}` : ''}`
|
const target = `${HERMES_API}/api/jobs${params ? `?${params}` : ''}`
|
||||||
const res = await fetch(target)
|
const res = await fetch(target)
|
||||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } })
|
return new Response(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
},
|
},
|
||||||
POST: async ({ request }) => {
|
POST: async ({ request }) => {
|
||||||
if (!isAuthenticated(request)) {
|
if (!isAuthenticated(request)) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await ensureGatewayProbed()
|
await ensureGatewayProbed()
|
||||||
if (!getCapabilities().jobs) {
|
if (!getCapabilities().jobs) {
|
||||||
@@ -56,7 +63,10 @@ export const Route = createFileRoute('/api/hermes-jobs')({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
return new Response(await res.text(), { status: res.status, headers: { 'Content-Type': 'application/json' } })
|
return new Response(await res.text(), {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
77
src/routes/api/hermes-proxy/$.ts
Normal file
77
src/routes/api/hermes-proxy/$.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { HERMES_API } from '../../../server/gateway-capabilities'
|
||||||
|
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||||
|
|
||||||
|
async function proxyRequest(request: Request, splat: string) {
|
||||||
|
const incomingUrl = new URL(request.url)
|
||||||
|
const targetPath = splat.startsWith('/') ? splat : `/${splat}`
|
||||||
|
const targetUrl = new URL(`${HERMES_API}${targetPath}`)
|
||||||
|
targetUrl.search = incomingUrl.search
|
||||||
|
|
||||||
|
const headers = new Headers(request.headers)
|
||||||
|
headers.delete('host')
|
||||||
|
headers.delete('content-length')
|
||||||
|
|
||||||
|
const init: RequestInit = {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
redirect: 'manual',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['GET', 'HEAD'].includes(request.method.toUpperCase())) {
|
||||||
|
init.body = await request.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = await fetch(targetUrl, init)
|
||||||
|
const body = await upstream.text()
|
||||||
|
const responseHeaders = new Headers()
|
||||||
|
const contentType = upstream.headers.get('content-type')
|
||||||
|
if (contentType) responseHeaders.set('content-type', contentType)
|
||||||
|
return new Response(body, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: responseHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/hermes-proxy/$')({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ request, params }) => {
|
||||||
|
if (!isAuthenticated(request)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: 'Unauthorized' }),
|
||||||
|
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return proxyRequest(request, params._splat || '')
|
||||||
|
},
|
||||||
|
POST: async ({ request, params }) => {
|
||||||
|
if (!isAuthenticated(request)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: 'Unauthorized' }),
|
||||||
|
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return proxyRequest(request, params._splat || '')
|
||||||
|
},
|
||||||
|
PATCH: async ({ request, params }) => {
|
||||||
|
if (!isAuthenticated(request)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: 'Unauthorized' }),
|
||||||
|
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return proxyRequest(request, params._splat || '')
|
||||||
|
},
|
||||||
|
DELETE: async ({ request, params }) => {
|
||||||
|
if (!isAuthenticated(request)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: 'Unauthorized' }),
|
||||||
|
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return proxyRequest(request, params._splat || '')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -45,7 +45,11 @@ export const Route = createFileRoute('/api/history')({
|
|||||||
if (sessions.length > 0) {
|
if (sessions.length > 0) {
|
||||||
sessionKey = sessions[0].id
|
sessionKey = sessions[0].id
|
||||||
} else {
|
} else {
|
||||||
return json({ sessionKey: 'new', sessionId: 'new', messages: [] })
|
return json({
|
||||||
|
sessionKey: 'new',
|
||||||
|
sessionId: 'new',
|
||||||
|
messages: [],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return json({ sessionKey: 'new', sessionId: 'new', messages: [] })
|
return json({ sessionKey: 'new', sessionId: 'new', messages: [] })
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ export const Route = createFileRoute('/api/knowledge/graph')({
|
|||||||
return json(buildKnowledgeGraph())
|
return json(buildKnowledgeGraph())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(
|
return json(
|
||||||
{ error: error instanceof Error ? error.message : 'Failed to build knowledge graph' },
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to build knowledge graph',
|
||||||
|
},
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ export const Route = createFileRoute('/api/knowledge/list')({
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(
|
return json(
|
||||||
{ error: error instanceof Error ? error.message : 'Failed to list knowledge pages' },
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to list knowledge pages',
|
||||||
|
},
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user