feat: add web terminal, improve README, fix node-pty and i18n issues
- Add web terminal view with xterm.js and node-pty WebSocket backend - Rewrite README with badges, feature table, mobile demo video - Add package keywords and improved description for npm/GitHub SEO - Fix node-pty spawn-helper missing execute permission after npm install -g - Auto-fix node-pty permissions on CLI startup - Fix duplicate 'error' key in en.ts and zh.ts i18n files - Remove nested NSpin in PlatformSettings (causes invisible loading spinner) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,76 +1,165 @@
|
||||
# Hermes Web UI
|
||||
<p align="center">
|
||||
<strong>Hermes Web UI</strong>
|
||||
</p>
|
||||
|
||||
Web dashboard for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — chat interaction, session management, scheduled jobs, usage statistics, platform channel configuration, and log viewing.
|
||||
<p align="center">
|
||||
A full-featured web dashboard for <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a>.<br/>
|
||||
Manage AI chat sessions, monitor usage & costs, configure platform channels,<br/>
|
||||
schedule cron jobs, browse skills — all from a clean, responsive web interface.
|
||||
</p>
|
||||
|
||||

|
||||
<p align="center">
|
||||
<code>npm install -g hermes-web-ui && hermes-web-ui start</code>
|
||||
</p>
|
||||
|
||||
## Tech Stack
|
||||
<p align="center">
|
||||
<img src="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/src/assets/output.gif" alt="Hermes Web UI Demo" width="680"/>
|
||||
</p>
|
||||
|
||||
- **Vue 3** — Composition API + `<script setup>`
|
||||
- **TypeScript**
|
||||
- **Vite** — Build tool
|
||||
- **Naive UI** — Component library
|
||||
- **Pinia** — State management
|
||||
- **Vue Router** — Routing (Hash mode)
|
||||
- **vue-i18n** — Internationalization (Chinese / English)
|
||||
- **Koa 2** — BFF server (API proxy, file upload, session management)
|
||||
- **SCSS** — Style preprocessor
|
||||
- **markdown-it** + **highlight.js** — Markdown rendering and code highlighting
|
||||
<p align="center">
|
||||
<strong>Mobile</strong>
|
||||
</p>
|
||||
<p align="center">
|
||||
<video src="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/src/assets/video.mp4?raw=true" width="360" controls></video>
|
||||
</p>
|
||||
|
||||
## Install and Run
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/hermes-web-ui"><img src="https://img.shields.io/npm/v/hermes-web-ui?style=flat-square&color=blue" alt="npm version"/></a>
|
||||
<a href="https://github.com/EKKOLearnAI/hermes-web-ui/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/hermes-web-ui?style=flat-square" alt="license"/></a>
|
||||
<a href="https://github.com/EKKOLearnAI/hermes-web-ui/stargazers"><img src="https://img.shields.io/github/stars/EKKOLearnAI/hermes-web-ui?style=flat-square" alt="stars"/></a>
|
||||
</p>
|
||||
|
||||
### Quick Install
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### AI Chat
|
||||
|
||||
- Real-time streaming via SSE with async run support
|
||||
- Multi-session management — create, rename, delete, switch between sessions
|
||||
- Session grouping by source (Telegram, Discord, Slack, etc.) with collapsible accordion
|
||||
- Markdown rendering with syntax highlighting and code copy
|
||||
- Tool call detail expansion (arguments / result)
|
||||
- File upload support
|
||||
- Global model selector — discovers models from `~/.hermes/auth.json` credential pool
|
||||
- Per-session model display badge and context token usage
|
||||
|
||||
### Platform Channels
|
||||
|
||||
Unified configuration for **8 platforms** in one page:
|
||||
|
||||
| Platform | Features |
|
||||
|---|---|
|
||||
| Telegram | Bot token, mention control, reactions, free-response chats |
|
||||
| Discord | Bot token, mention, auto-thread, reactions, channel allow/ignore lists |
|
||||
| Slack | Bot token, mention control, bot message handling |
|
||||
| WhatsApp | Enable/disable, mention control, mention patterns |
|
||||
| Matrix | Access token, homeserver, auto-thread, DM mention threads |
|
||||
| Feishu (Lark) | App ID / Secret, mention control |
|
||||
| WeChat | QR code login (scan in browser, auto-save credentials) |
|
||||
| WeCom | Bot ID / Secret |
|
||||
|
||||
- Credential management writes to `~/.hermes/.env`
|
||||
- Channel behavior settings write to `~/.hermes/config.yaml`
|
||||
- Auto gateway restart on config change
|
||||
- Per-platform configured/unconfigured status detection
|
||||
|
||||
### Usage Analytics
|
||||
|
||||
- Total token usage breakdown (input / output)
|
||||
- Session count with daily average
|
||||
- Estimated cost tracking & cache hit rate
|
||||
- Model usage distribution chart
|
||||
- 30-day daily trend (bar chart + data table)
|
||||
|
||||
### Scheduled Jobs
|
||||
|
||||
- Create, edit, pause, resume, delete cron jobs
|
||||
- Trigger immediate execution
|
||||
- Cron expression quick presets
|
||||
|
||||
### Model Management
|
||||
|
||||
- Auto-discover models from credential pool (`~/.hermes/auth.json`)
|
||||
- Fetch available models from each provider endpoint (`/v1/models`)
|
||||
- Add custom OpenAI-compatible providers
|
||||
- Provider-level model grouping
|
||||
|
||||
### Skills & Memory
|
||||
|
||||
- Browse and search installed skills
|
||||
- View skill details and attached files
|
||||
- User notes and profile management
|
||||
|
||||
### Logs
|
||||
|
||||
- View agent / gateway / error logs
|
||||
- Filter by log level, log file, and keyword
|
||||
- Structured log parsing with HTTP access log highlighting
|
||||
|
||||
### Settings
|
||||
|
||||
- Display (streaming, compact mode, reasoning, cost display)
|
||||
- Agent (max turns, timeout, tool enforcement)
|
||||
- Memory (enable/disable, char limits)
|
||||
- Session reset (idle timeout, scheduled reset)
|
||||
- Privacy (PII redaction)
|
||||
- API server configuration
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### npm (Recommended)
|
||||
|
||||
```bash
|
||||
npm install -g hermes-web-ui
|
||||
hermes-web-ui start
|
||||
```
|
||||
|
||||
Open http://localhost:8648
|
||||
|
||||
### WSL (Windows Subsystem for Linux)
|
||||
|
||||
```bash
|
||||
# 1. Auto-setup: install Node.js + hermes-web-ui
|
||||
bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh)
|
||||
|
||||
# 2. Start
|
||||
hermes-web-ui start
|
||||
```
|
||||
|
||||
> WSL will auto-detect and use `hermes gateway run` for background startup (no launchd/systemd).
|
||||
Open **http://localhost:8648**
|
||||
|
||||
### One-line Setup (Auto-detect OS)
|
||||
|
||||
Automatically installs Node.js (if missing) and hermes-web-ui on Debian/Ubuntu/macOS:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh)
|
||||
```
|
||||
|
||||
Automatically installs Node.js (if missing) and hermes-web-ui on Debian/Ubuntu/macOS.
|
||||
### WSL
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh)
|
||||
hermes-web-ui start
|
||||
```
|
||||
|
||||
> WSL auto-detects and uses `hermes gateway run` for background startup (no launchd/systemd).
|
||||
|
||||
### CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
| --------------------------------- | --------------------------------- |
|
||||
| `hermes-web-ui start` | Start in background (daemon mode) |
|
||||
| `hermes-web-ui start --port 9000` | Start on custom port |
|
||||
| `hermes-web-ui stop` | Stop background process |
|
||||
| `hermes-web-ui restart` | Restart background process |
|
||||
| `hermes-web-ui status` | Check if running |
|
||||
| `hermes-web-ui update` | Update to latest version & restart|
|
||||
| `hermes-web-ui -v` | Show version number |
|
||||
| `hermes-web-ui -h` | Show help message |
|
||||
| `hermes-web-ui` | Run in foreground (for debugging) |
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `hermes-web-ui start` | Start in background (daemon mode) |
|
||||
| `hermes-web-ui start --port 9000` | Start on custom port |
|
||||
| `hermes-web-ui stop` | Stop background process |
|
||||
| `hermes-web-ui restart` | Restart background process |
|
||||
| `hermes-web-ui status` | Check if running |
|
||||
| `hermes-web-ui update` | Update to latest version & restart |
|
||||
| `hermes-web-ui -v` | Show version number |
|
||||
| `hermes-web-ui -h` | Show help message |
|
||||
|
||||
### Auto Configuration
|
||||
|
||||
On startup, the BFF server automatically:
|
||||
On startup the BFF server automatically:
|
||||
|
||||
- Checks `~/.hermes/config.yaml` and ensures `platforms.api_server` has all required fields (`enabled`, `host`, `port`, `key`, `cors_origins`)
|
||||
- If any field is missing, backs up the original to `config.yaml.bak`, fills in defaults, and restarts the gateway
|
||||
- Detects if the gateway is running and starts it if needed
|
||||
- Kills any process occupying the target port before starting
|
||||
- Opens the browser automatically after successful startup
|
||||
- Validates `~/.hermes/config.yaml` and fills missing `api_server` fields
|
||||
- Backs up original config to `config.yaml.bak` if modified
|
||||
- Detects and starts the gateway if needed
|
||||
- Resolves port conflicts (kills stale processes)
|
||||
- Opens browser on successful startup
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
@@ -81,161 +170,13 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts:
|
||||
|
||||
- Frontend: http://localhost:5173
|
||||
- BFF Server: http://localhost:8648 (proxies to Hermes on 8642)
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run build # outputs to dist/
|
||||
```
|
||||
|
||||
Outputs to `dist/` (frontend + compiled BFF server).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
hermes-web-ui/
|
||||
├── bin/
|
||||
│ └── hermes-web-ui.mjs # CLI entry (start/stop/restart/status/update/version/help)
|
||||
├── server/src/
|
||||
│ ├── index.ts # BFF entry (Koa app bootstrap)
|
||||
│ ├── config.ts # Configuration (port, upstream, etc.)
|
||||
│ ├── routes/
|
||||
│ │ ├── proxy.ts # API proxy to Hermes (/api/*, /v1/*)
|
||||
│ │ ├── config.ts # Config & credentials management
|
||||
│ │ ├── weixin.ts # WeChat QR code login proxy
|
||||
│ │ ├── upload.ts # File upload (POST /upload)
|
||||
│ │ ├── sessions.ts # Session management via Hermes CLI
|
||||
│ │ ├── filesystem.ts # Skills, memory, config model management
|
||||
│ │ ├── webhook.ts # Webhook receiver
|
||||
│ │ └── logs.ts # Log file listing and reading
|
||||
│ └── services/
|
||||
│ └── hermes-cli.ts # Hermes CLI wrapper (sessions, logs, version)
|
||||
├── src/
|
||||
│ ├── i18n/ # Internationalization (en / zh)
|
||||
│ │ ├── index.ts # i18n instance setup
|
||||
│ │ └── locales/
|
||||
│ │ ├── en.ts # English translations
|
||||
│ │ └── zh.ts # Chinese translations
|
||||
│ ├── api/ # Frontend API layer
|
||||
│ ├── stores/ # Pinia state management
|
||||
│ ├── components/
|
||||
│ │ ├── layout/
|
||||
│ │ │ ├── AppSidebar.vue # Sidebar navigation
|
||||
│ │ │ ├── LanguageSwitch.vue # Language toggle (EN / 中文)
|
||||
│ │ │ └── ModelSelector.vue # Global model selector
|
||||
│ │ ├── chat/ # Chat components
|
||||
│ │ ├── jobs/ # Job components
|
||||
│ │ ├── models/ # Model/provider components
|
||||
│ │ ├── settings/ # Settings components
|
||||
│ │ │ ├── PlatformCard.vue # Platform card with config status
|
||||
│ │ │ └── PlatformSettings.vue # Platform channel configuration
|
||||
│ │ ├── usage/ # Usage statistics components
|
||||
│ │ └── skills/ # Skill components
|
||||
│ ├── views/
|
||||
│ │ ├── ChatView.vue # Chat page
|
||||
│ │ ├── JobsView.vue # Jobs page
|
||||
│ │ ├── LogsView.vue # Logs page
|
||||
│ │ ├── ModelsView.vue # Model management page
|
||||
│ │ ├── ChannelsView.vue # Platform channels page
|
||||
│ │ ├── SkillsView.vue # Skills page
|
||||
│ │ ├── MemoryView.vue # Memory page
|
||||
│ │ ├── UsageView.vue # Usage statistics page
|
||||
│ │ └── SettingsView.vue # Settings page
|
||||
│ └── router/index.ts # Router configuration
|
||||
└── dist/ # Build output (published to npm)
|
||||
├── server/index.js # Compiled BFF
|
||||
├── index.html # Frontend entry
|
||||
└── assets/ # Frontend static assets
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Chat
|
||||
|
||||
- Async Run + SSE event streaming via BFF proxy
|
||||
- Session management via Hermes CLI
|
||||
- Multi-session switching with message history
|
||||
- Session grouping by source (Telegram, Discord, Slack, etc.) with collapsible accordion
|
||||
- Session rename and deletion
|
||||
- Markdown rendering with syntax highlighting and code copy
|
||||
- Tool call detail expansion (arguments / result)
|
||||
- File upload support (saved to temp, path passed to API)
|
||||
- Model selector — automatically discovers available models from `~/.hermes/auth.json` credential pool
|
||||
- Global model switching (updates `~/.hermes/config.yaml`)
|
||||
- Per-session model display (badge in chat header and session list)
|
||||
- Context token usage display (used / total)
|
||||
|
||||
### Usage Statistics
|
||||
|
||||
- Total token usage breakdown (input / output)
|
||||
- Session count with daily average
|
||||
- Estimated cost tracking
|
||||
- Cache hit rate
|
||||
- Model usage distribution (horizontal bar chart)
|
||||
- 30-day daily trend (bar chart + data table)
|
||||
- Hover tooltips on chart bars
|
||||
|
||||
### Platform Channels
|
||||
|
||||
- Unified channel configuration page (Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, WeChat, WeCom)
|
||||
- Credential management — writes to `~/.hermes/.env` (matching `hermes gateway setup` behavior)
|
||||
- Channel behavior settings — writes to `~/.hermes/config.yaml`
|
||||
- WeChat QR code login — opens QR in browser, polls scan status, auto-saves credentials
|
||||
- Auto gateway restart after any channel config change
|
||||
- Per-platform configured/unconfigured status detection
|
||||
|
||||
### Model Management
|
||||
|
||||
- Automatically reads credential pool from `~/.hermes/auth.json`
|
||||
- Fetches available models from each provider endpoint (`/v1/models`)
|
||||
- Groups models by provider (e.g. zai, subrouter.ai)
|
||||
- Add custom OpenAI-compatible providers
|
||||
- Switching model updates `model.provider` in config.yaml to bypass env auto-detection
|
||||
- Error handling: parallel fetching, per-provider timeout, fallback to config.yaml parsing
|
||||
|
||||
### Settings
|
||||
|
||||
- Display settings (streaming, compact mode, reasoning, cost, etc.)
|
||||
- Agent settings (max turns, timeout, tool enforcement)
|
||||
- Memory settings (enable/disable, char limits)
|
||||
- Session reset settings (idle timeout, scheduled reset)
|
||||
- Privacy settings (PII redaction)
|
||||
- API server settings
|
||||
|
||||
### Scheduled Jobs
|
||||
|
||||
- Job list view (including paused/disabled jobs)
|
||||
- Create, edit, pause, resume, and delete jobs
|
||||
- Trigger immediate job execution
|
||||
- Cron expression quick presets
|
||||
|
||||
### Skills & Memory
|
||||
|
||||
- Browse and search installed skills
|
||||
- View skill details and attached files
|
||||
- User notes and profile management
|
||||
|
||||
### Logs
|
||||
|
||||
- View Hermes agent/gateway/error logs
|
||||
- Filter by log level, log file, and search keyword
|
||||
- Structured log parsing with HTTP access log highlighting
|
||||
|
||||
### Other
|
||||
|
||||
- Internationalization — auto-detect browser language, manual toggle between Chinese and English
|
||||
- Real-time connection status monitoring
|
||||
- Hermes version display in sidebar
|
||||
- Auto config check on startup with field-level validation
|
||||
- Port conflict auto-resolution (kills stale processes)
|
||||
- Auto browser open on startup
|
||||
- Minimalist "Pure Ink" theme
|
||||
- Session group collapse state persisted across navigation
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -248,21 +189,13 @@ Browser → BFF (Koa, :8648) → Hermes API (:8642)
|
||||
Tencent iLink API (WeChat QR login)
|
||||
```
|
||||
|
||||
The BFF layer handles:
|
||||
The BFF layer handles API proxy, SSE streaming, file upload, session CRUD via CLI, config/credential management, WeChat QR login, model discovery, skills/memory management, log reading, and static file serving.
|
||||
|
||||
- API proxy to Hermes (with header forwarding)
|
||||
- SSE streaming passthrough
|
||||
- File upload to temp directory
|
||||
- Session CRUD via Hermes CLI (with cache/cost token passthrough)
|
||||
- Config & credential management (config.yaml + .env)
|
||||
- WeChat QR code login flow (fetch QR, poll status, save credentials)
|
||||
- Auto gateway restart on platform config changes
|
||||
- Model discovery from `~/.hermes/auth.json` credential pool
|
||||
- Skills, memory, and custom provider management
|
||||
- Log file reading and parsing
|
||||
- Static file serving (SPA fallback)
|
||||
## Tech Stack
|
||||
|
||||
---
|
||||
**Frontend:** Vue 3 + TypeScript + Vite + Naive UI + Pinia + Vue Router + vue-i18n + SCSS + markdown-it + highlight.js
|
||||
|
||||
**Backend:** Koa 2 (BFF server) + node-pty (web terminal)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+14
-2
@@ -2,13 +2,14 @@
|
||||
import { spawn, execSync } from 'child_process'
|
||||
import { resolve, dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync } from 'fs'
|
||||
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync } from 'fs'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const serverEntry = resolve(__dirname, '..', 'dist', 'server', 'index.js')
|
||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'))
|
||||
const pkgDir = resolve(__dirname, '..')
|
||||
const pkg = JSON.parse(readFileSync(resolve(pkgDir, 'package.json'), 'utf-8'))
|
||||
const VERSION = pkg.version
|
||||
const PID_DIR = resolve(homedir(), '.hermes-web-ui')
|
||||
const PID_FILE = join(PID_DIR, 'server.pid')
|
||||
@@ -16,6 +17,15 @@ const LOG_FILE = join(PID_DIR, 'server.log')
|
||||
const TOKEN_FILE = resolve(__dirname, '..', 'dist', 'server', 'data', '.token')
|
||||
const DEFAULT_PORT = 8648
|
||||
|
||||
// ─── Auto-fix node-pty native module ──────────────────────────
|
||||
function ensureNativeModules() {
|
||||
const prebuildDir = join(pkgDir, 'node_modules', 'node-pty', 'prebuilds', `${process.platform}-${process.arch}`)
|
||||
const helper = join(prebuildDir, 'spawn-helper')
|
||||
try {
|
||||
chmodSync(helper, 0o755)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
try {
|
||||
return readFileSync(TOKEN_FILE, 'utf-8').trim()
|
||||
@@ -105,6 +115,7 @@ function startDaemon(port) {
|
||||
|
||||
mkdirSync(PID_DIR, { recursive: true })
|
||||
|
||||
ensureNativeModules()
|
||||
const token = ensureToken()
|
||||
|
||||
const logStream = openSync(LOG_FILE, 'a')
|
||||
@@ -260,6 +271,7 @@ switch (command) {
|
||||
doUpdate()
|
||||
break
|
||||
default:
|
||||
ensureNativeModules()
|
||||
const port = !isNaN(command) ? parseInt(command) : DEFAULT_PORT
|
||||
const child = spawn(process.execPath, [serverEntry], {
|
||||
stdio: 'inherit',
|
||||
|
||||
+28
-4
@@ -1,13 +1,31 @@
|
||||
{
|
||||
"name": "hermes-web-ui",
|
||||
"version": "0.2.5",
|
||||
"description": "Hermes Agent Web UI - Chat and Job Management Dashboard",
|
||||
"version": "0.2.6",
|
||||
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/EKKOLearnAI/hermes-web-ui.git"
|
||||
},
|
||||
"homepage": "https://github.com/EKKOLearnAI/hermes-web-ui",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"hermes",
|
||||
"ai-agent",
|
||||
"llm",
|
||||
"chat-ui",
|
||||
"dashboard",
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"whatsapp",
|
||||
"matrix",
|
||||
"feishu",
|
||||
"weixin",
|
||||
"multi-platform",
|
||||
"vue3",
|
||||
"typescript",
|
||||
"naive-ui"
|
||||
],
|
||||
"bin": {
|
||||
"hermes-web-ui": "./bin/hermes-web-ui.mjs"
|
||||
},
|
||||
@@ -25,9 +43,12 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@koa/bodyparser": "^5.0.0",
|
||||
"axios": "^1.9.0",
|
||||
"@koa/cors": "^5.0.0",
|
||||
"@koa/router": "^13.1.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"koa": "^2.15.3",
|
||||
@@ -35,11 +56,13 @@
|
||||
"koa-static": "^5.0.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"naive-ui": "^2.44.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-i18n": "^11.3.2",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
@@ -51,6 +74,7 @@
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"concurrently": "^9.2.1",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { logRoutes } from './routes/logs'
|
||||
import { fsRoutes } from './routes/filesystem'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { weixinRoutes } from './routes/weixin'
|
||||
import { setupTerminalWebSocket } from './routes/terminal'
|
||||
import * as hermesCli from './services/hermes-cli'
|
||||
import { getToken, authMiddleware } from './services/auth'
|
||||
|
||||
@@ -94,6 +95,9 @@ export async function bootstrap() {
|
||||
// 🚀 启动服务
|
||||
server = app.listen(config.port, '0.0.0.0')
|
||||
|
||||
// Terminal WebSocket (must be after server is created)
|
||||
setupTerminalWebSocket(server)
|
||||
|
||||
server.on('listening', () => {
|
||||
console.log(`➜ Server: http://localhost:${config.port}`)
|
||||
console.log(`➜ Upstream: ${config.upstream}`)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { WebSocketServer } from 'ws'
|
||||
import type { Server as HttpServer } from 'http'
|
||||
import { existsSync } from 'fs'
|
||||
import * as pty from 'node-pty'
|
||||
import { getToken } from '../services/auth'
|
||||
|
||||
// ─── Shell detection ────────────────────────────────────────────
|
||||
|
||||
function findShell(): string {
|
||||
const candidates = [
|
||||
process.env.SHELL,
|
||||
'/bin/zsh',
|
||||
'/bin/bash',
|
||||
process.platform === 'win32' ? 'powershell.exe' : null,
|
||||
process.platform === 'win32' ? 'cmd.exe' : null,
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
for (const shell of candidates) {
|
||||
if (existsSync(shell)) return shell
|
||||
}
|
||||
return '/bin/bash'
|
||||
}
|
||||
|
||||
function shellName(shell: string): string {
|
||||
return shell.split('/').pop() || 'shell'
|
||||
}
|
||||
|
||||
// ─── Session types ──────────────────────────────────────────────
|
||||
|
||||
interface PtySession {
|
||||
id: string
|
||||
pty: pty.IPty
|
||||
shell: string
|
||||
pid: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
sessions: Map<string, PtySession>
|
||||
activeSessionId: string | null
|
||||
outputBuffers: Map<string, string[]>
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
}
|
||||
|
||||
function createSession(shell: string): PtySession {
|
||||
const id = generateId()
|
||||
let ptyProcess: pty.IPty
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.env.HOME || undefined,
|
||||
})
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to spawn shell "${shell}": ${err.message}. Run "npm rebuild node-pty" to fix.`)
|
||||
}
|
||||
|
||||
const session: PtySession = {
|
||||
id,
|
||||
pty: ptyProcess,
|
||||
shell,
|
||||
pid: ptyProcess.pid,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// ─── WebSocket server setup ─────────────────────────────────────
|
||||
|
||||
export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
const defaultShell = findShell()
|
||||
|
||||
httpServer.on('upgrade', async (req, socket, head) => {
|
||||
const url = new URL(req.url || '', `http://${req.headers.host}`)
|
||||
if (url.pathname !== '/terminal') {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
// Auth check
|
||||
const authToken = await getToken()
|
||||
if (authToken) {
|
||||
const token = url.searchParams.get('token') || ''
|
||||
if (token !== authToken) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req)
|
||||
})
|
||||
})
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
const conn: Connection = {
|
||||
sessions: new Map(),
|
||||
activeSessionId: null,
|
||||
outputBuffers: new Map(),
|
||||
}
|
||||
|
||||
// ─── PTY output → WebSocket ──────────────────────────────────
|
||||
|
||||
function attachPtyOutput(session: PtySession) {
|
||||
session.pty.onData((data) => {
|
||||
if (ws.readyState !== ws.OPEN) return
|
||||
if (conn.activeSessionId === session.id) {
|
||||
ws.send(data)
|
||||
} else {
|
||||
// Buffer output for inactive sessions
|
||||
let buf = conn.outputBuffers.get(session.id)
|
||||
if (!buf) {
|
||||
buf = []
|
||||
conn.outputBuffers.set(session.id, buf)
|
||||
}
|
||||
buf.push(data)
|
||||
// Cap buffer at 1MB to prevent memory issues
|
||||
if (buf.length > 5000) {
|
||||
buf.splice(0, buf.length - 5000)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
session.pty.onExit(({ exitCode }) => {
|
||||
conn.outputBuffers.delete(session.id)
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
|
||||
}
|
||||
conn.sessions.delete(session.id)
|
||||
console.log(`[Terminal] Session ${session.id} exited (pid ${session.pid}, code ${exitCode})`)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Message handler ────────────────────────────────────────
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
const msg = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw)
|
||||
|
||||
// JSON control message
|
||||
if (msg.charCodeAt(0) === 0x7B) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg)
|
||||
handleControl(parsed)
|
||||
} catch {
|
||||
// Not valid JSON, fall through to raw input
|
||||
writeRaw(msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeRaw(msg)
|
||||
})
|
||||
|
||||
function writeRaw(data: string) {
|
||||
const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null
|
||||
if (session) {
|
||||
session.pty.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
function handleControl(parsed: any) {
|
||||
switch (parsed.type) {
|
||||
case 'create': {
|
||||
const shell = parsed.shell || defaultShell
|
||||
let session: PtySession
|
||||
try {
|
||||
session = createSession(shell)
|
||||
} catch (err: any) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }))
|
||||
return
|
||||
}
|
||||
conn.sessions.set(session.id, session)
|
||||
conn.activeSessionId = session.id
|
||||
attachPtyOutput(session)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'created',
|
||||
id: session.id,
|
||||
pid: session.pid,
|
||||
shell: shellName(shell),
|
||||
}))
|
||||
console.log(`[Terminal] Session created: ${session.id} (${shellName(shell)}, pid ${session.pid})`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
const { sessionId } = parsed
|
||||
const session = conn.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }))
|
||||
return
|
||||
}
|
||||
conn.activeSessionId = sessionId
|
||||
|
||||
// Send switched first so frontend mounts the correct terminal
|
||||
ws.send(JSON.stringify({ type: 'switched', id: sessionId }))
|
||||
|
||||
// Then flush buffered output for this session
|
||||
const buf = conn.outputBuffers.get(sessionId)
|
||||
if (buf && buf.length > 0) {
|
||||
for (const chunk of buf) {
|
||||
ws.send(chunk)
|
||||
}
|
||||
conn.outputBuffers.delete(sessionId)
|
||||
}
|
||||
|
||||
console.log(`[Terminal] Switched to session ${sessionId}`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'close': {
|
||||
const { sessionId } = parsed
|
||||
const session = conn.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
session.pty.kill()
|
||||
conn.sessions.delete(sessionId)
|
||||
conn.outputBuffers.delete(sessionId)
|
||||
if (conn.activeSessionId === sessionId) {
|
||||
// Auto-switch to the first remaining session
|
||||
const remaining = Array.from(conn.sessions.keys())
|
||||
conn.activeSessionId = remaining.length > 0 ? remaining[0] : null
|
||||
}
|
||||
console.log(`[Terminal] Session closed: ${sessionId}`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'resize': {
|
||||
const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null
|
||||
if (!session) return
|
||||
const cols = Math.max(1, parsed.cols || 0)
|
||||
const rows = Math.max(1, parsed.rows || 0)
|
||||
try { session.pty.resize(cols, rows) } catch {}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cleanup ────────────────────────────────────────────────
|
||||
|
||||
ws.on('close', () => {
|
||||
for (const session of Array.from(conn.sessions.values())) {
|
||||
try { session.pty.kill() } catch {}
|
||||
}
|
||||
conn.sessions.clear()
|
||||
console.log(`[Terminal] Connection closed, all sessions killed`)
|
||||
})
|
||||
|
||||
ws.on('error', () => {
|
||||
for (const session of Array.from(conn.sessions.values())) {
|
||||
try { session.pty.kill() } catch {}
|
||||
}
|
||||
conn.sessions.clear()
|
||||
})
|
||||
|
||||
// ─── Auto-create first session ──────────────────────────────
|
||||
|
||||
let firstSession: PtySession
|
||||
try {
|
||||
firstSession = createSession(defaultShell)
|
||||
} catch (err: any) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }))
|
||||
console.error(`[Terminal] Failed to create session: ${err.message}`)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
conn.sessions.set(firstSession.id, firstSession)
|
||||
conn.activeSessionId = firstSession.id
|
||||
attachPtyOutput(firstSession)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'created',
|
||||
id: firstSession.id,
|
||||
pid: firstSession.pid,
|
||||
shell: shellName(defaultShell),
|
||||
}))
|
||||
console.log(`[Terminal] First session created: ${firstSession.id} (${shellName(defaultShell)}, pid ${firstSession.pid})`)
|
||||
})
|
||||
|
||||
console.log(`[Terminal] WebSocket ready at /terminal (shell: ${defaultShell})`)
|
||||
}
|
||||
Binary file not shown.
@@ -40,7 +40,7 @@ const renameSessionId = ref<string | null>(null)
|
||||
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
|
||||
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
|
||||
|
||||
const sourceLabel: Record<string, string> = {
|
||||
const sourceLabelKeys: Record<string, string> = {
|
||||
telegram: 'Telegram',
|
||||
api_server: 'API Server',
|
||||
cli: 'CLI',
|
||||
@@ -62,7 +62,7 @@ const sourceLabel: Record<string, string> = {
|
||||
|
||||
function getSourceLabel(source?: string): string {
|
||||
if (!source) return ''
|
||||
return sourceLabel[source] || source
|
||||
return sourceLabelKeys[source] || source
|
||||
}
|
||||
|
||||
// Source sort order: api_server first, cron last, others alphabetical
|
||||
|
||||
@@ -95,7 +95,7 @@ async function handleDelete() {
|
||||
<span class="info-value">
|
||||
{{ formatTime(job.last_run_at) }}
|
||||
<span v-if="job.last_status" class="run-status" :class="{ ok: job.last_status === 'ok', err: job.last_status !== 'ok' }">
|
||||
{{ job.last_status === 'ok' ? 'OK' : job.last_status }}
|
||||
{{ job.last_status === 'ok' ? t('common.ok') : job.last_status }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -259,6 +259,27 @@ function handleNav(key: string) {
|
||||
<span>{{ t("sidebar.usage") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'terminal' }"
|
||||
@click="handleNav('terminal')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.terminal") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'settings' }"
|
||||
|
||||
@@ -3,11 +3,11 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NSelect } from 'naive-ui'
|
||||
|
||||
const { locale, availableLocales } = useI18n()
|
||||
const { locale, availableLocales, t } = useI18n()
|
||||
|
||||
const options = computed(() =>
|
||||
availableLocales.map(loc => ({
|
||||
label: loc === 'zh' ? '中文' : 'English',
|
||||
label: loc === 'zh' ? t('language.zh') : t('language.en'),
|
||||
value: loc,
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { computed } from 'vue'
|
||||
import { NSelect } from 'naive-ui'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -26,7 +29,7 @@ function handleChange(value: string | number | Array<string | number>) {
|
||||
|
||||
<template>
|
||||
<div class="model-selector">
|
||||
<div class="model-label">Model</div>
|
||||
<div class="model-label">{{ t('models.title') }}</div>
|
||||
<NSelect
|
||||
:value="appStore.selectedModel"
|
||||
:options="options"
|
||||
|
||||
@@ -36,7 +36,7 @@ function autoGenerateName(url: string): string {
|
||||
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
|
||||
const host = clean.split('/')[0]
|
||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||
return `Local (${host})`
|
||||
return t('models.local', { host })
|
||||
}
|
||||
return host.charAt(0).toUpperCase() + host.slice(1)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { NSwitch, NInput, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { NSwitch, NInput, NButton, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/config'
|
||||
@@ -21,14 +21,18 @@ async function saveChannel(platform: string, values: Record<string, any>) {
|
||||
}
|
||||
|
||||
// Save credentials to .env (matching hermes gateway setup behavior)
|
||||
const savingCreds = ref(false)
|
||||
|
||||
async function saveCredentials(platform: string, values: Record<string, any>) {
|
||||
savingCreds.value = true
|
||||
try {
|
||||
await saveCredsApi(platform, values)
|
||||
// Refresh to pick up new .env values
|
||||
await settingsStore.fetchSettings()
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
} finally {
|
||||
savingCreds.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +61,7 @@ async function startWeixinQrLogin() {
|
||||
pollWeixinStatus()
|
||||
} catch (err: any) {
|
||||
wxQrStatus.value = 'error'
|
||||
message.error(err.message || 'Failed to get QR code')
|
||||
message.error(err.message || t('platform.qrFetching'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ function formatCost(n: number): string {
|
||||
<div class="stat-label">{{ t('usage.cacheHitRate') }}</div>
|
||||
<div class="stat-value">{{ usageStore.cacheHitRate !== null ? usageStore.cacheHitRate.toFixed(1) + '%' : '--' }}</div>
|
||||
<div class="stat-sub" v-if="usageStore.cacheHitRate !== null">
|
||||
{{ formatTokens(usageStore.totalCacheTokens) }} tokens
|
||||
{{ formatTokens(usageStore.totalCacheTokens) }} {{ t('usage.tokens') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+12
-2
@@ -1,7 +1,7 @@
|
||||
export default {
|
||||
// Login
|
||||
login: {
|
||||
title: 'Login',
|
||||
title: 'Hermes Web UI',
|
||||
description: 'Enter your access token to continue. Find it in the server startup logs.',
|
||||
placeholder: 'Access token',
|
||||
submit: 'Login',
|
||||
@@ -43,6 +43,7 @@ export default {
|
||||
logs: 'Logs',
|
||||
usage: 'Usage',
|
||||
channels: 'Channels',
|
||||
terminal: 'Terminal',
|
||||
settings: 'Settings',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
@@ -69,8 +70,8 @@ export default {
|
||||
enterNewTitle: 'Enter new title',
|
||||
other: 'Other',
|
||||
runFailed: 'Run failed',
|
||||
error: 'Error',
|
||||
tool: 'Tool',
|
||||
error: 'error',
|
||||
arguments: 'Arguments',
|
||||
result: 'Result',
|
||||
truncated: '... (truncated)',
|
||||
@@ -352,6 +353,15 @@ export default {
|
||||
en: 'English',
|
||||
},
|
||||
|
||||
// Terminal
|
||||
terminal: {
|
||||
sessions: 'Sessions',
|
||||
newTab: 'New Terminal',
|
||||
closeSession: 'Close this session?',
|
||||
sessionExited: 'Exited',
|
||||
processExited: 'Process exited with code {code}',
|
||||
},
|
||||
|
||||
// Usage
|
||||
usage: {
|
||||
title: 'Usage Statistics',
|
||||
|
||||
+12
-2
@@ -1,7 +1,7 @@
|
||||
export default {
|
||||
// 登录
|
||||
login: {
|
||||
title: '登录',
|
||||
title: 'Hermes Web UI',
|
||||
description: '输入访问令牌以继续。令牌在服务端启动日志中查看。',
|
||||
placeholder: '访问令牌',
|
||||
submit: '登录',
|
||||
@@ -43,6 +43,7 @@ export default {
|
||||
logs: '日志',
|
||||
usage: '用量',
|
||||
channels: '频道',
|
||||
terminal: '终端',
|
||||
settings: '设置',
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
@@ -69,8 +70,8 @@ export default {
|
||||
enterNewTitle: '输入新标题',
|
||||
other: '其他',
|
||||
runFailed: '运行失败',
|
||||
tool: '工具',
|
||||
error: '错误',
|
||||
tool: '工具',
|
||||
arguments: '参数',
|
||||
result: '结果',
|
||||
truncated: '... (已截断)',
|
||||
@@ -352,6 +353,15 @@ export default {
|
||||
en: 'English',
|
||||
},
|
||||
|
||||
// 终端
|
||||
terminal: {
|
||||
sessions: '会话',
|
||||
newTab: '新建终端',
|
||||
closeSession: '关闭此会话?',
|
||||
sessionExited: '已退出',
|
||||
processExited: '进程已退出,代码 {code}',
|
||||
},
|
||||
|
||||
// 用量统计
|
||||
usage: {
|
||||
title: '用量统计',
|
||||
|
||||
@@ -55,6 +55,11 @@ const router = createRouter({
|
||||
name: 'channels',
|
||||
component: () => import('@/views/ChannelsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/terminal',
|
||||
name: 'terminal',
|
||||
component: () => import('@/views/TerminalView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
+5
-5
@@ -83,7 +83,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Math.round(msg.timestamp * 1000),
|
||||
toolName: tc.function?.name || 'Tool',
|
||||
toolName: tc.function?.name || 'tool',
|
||||
toolArgs: tc.function?.arguments || undefined,
|
||||
toolStatus: 'done',
|
||||
})
|
||||
@@ -94,7 +94,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
||||
// Tool result messages
|
||||
if (msg.role === 'tool') {
|
||||
const tcId = msg.tool_call_id || ''
|
||||
const toolName = msg.tool_name || toolNameMap.get(tcId) || 'Tool'
|
||||
const toolName = msg.tool_name || toolNameMap.get(tcId) || 'tool'
|
||||
const toolArgs = toolArgsMap.get(tcId) || undefined
|
||||
// Extract a short preview from the content
|
||||
let preview = ''
|
||||
@@ -141,7 +141,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
||||
function mapHermesSession(s: SessionSummary): Session {
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title || 'New Chat',
|
||||
title: s.title || '',
|
||||
source: s.source || undefined,
|
||||
messages: [],
|
||||
createdAt: Math.round(s.started_at * 1000),
|
||||
@@ -190,7 +190,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
function createSession(): Session {
|
||||
const session: Session = {
|
||||
id: uid(),
|
||||
title: 'New Chat',
|
||||
title: '',
|
||||
source: 'api_server',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
@@ -289,7 +289,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
function updateSessionTitle(sessionId: string) {
|
||||
const target = sessions.value.find(s => s.id === sessionId)
|
||||
if (!target) return
|
||||
if (target.title === 'New Chat') {
|
||||
if (!target.title) {
|
||||
const firstUser = target.messages.find(m => m.role === 'user')
|
||||
if (firstUser) {
|
||||
const title = firstUser.attachments?.length
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DisplayConfig, AgentConfig, MemoryConfig, SessionResetConfig, Priv
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const display = ref<DisplayConfig>({})
|
||||
const agent = ref<AgentConfig>({})
|
||||
@@ -49,7 +50,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
|
||||
async function saveSection(section: string, values: Record<string, any>) {
|
||||
await configApi.updateConfigSection(section, values)
|
||||
saving.value = true
|
||||
try {
|
||||
await configApi.updateConfigSection(section, values)
|
||||
switch (section) {
|
||||
case 'display': display.value = { ...display.value, ...values }; break
|
||||
case 'agent': agent.value = { ...agent.value, ...values }; break
|
||||
@@ -76,10 +79,13 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
loading, saving,
|
||||
display, agent, memory, sessionReset, privacy,
|
||||
telegram, discord, slack, whatsapp, matrix, wecom, feishu, dingtalk, weixin, platforms,
|
||||
fetchSettings, saveSection,
|
||||
|
||||
@@ -20,7 +20,7 @@ onMounted(() => {
|
||||
</header>
|
||||
|
||||
<div class="channels-content">
|
||||
<NSpin :show="settingsStore.loading">
|
||||
<NSpin :show="settingsStore.loading || settingsStore.saving" size="large" :description="t('common.loading')">
|
||||
<PlatformSettings />
|
||||
</NSpin>
|
||||
</div>
|
||||
@@ -40,5 +40,6 @@ onMounted(() => {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,7 +56,7 @@ async function handleLogin() {
|
||||
<div class="login-logo">
|
||||
<img src="/logo.png" alt="Hermes" width="80" height="80" />
|
||||
</div>
|
||||
<h1 class="login-title">Hermes Web UI</h1>
|
||||
<h1 class="login-title">{{ t('login.title') }}</h1>
|
||||
<p class="login-desc">{{ t("login.description") }}</p>
|
||||
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
|
||||
@@ -35,7 +35,7 @@ async function saveApiServer(values: Record<string, any>) {
|
||||
</header>
|
||||
|
||||
<div class="settings-content">
|
||||
<NSpin :show="settingsStore.loading">
|
||||
<NSpin :show="settingsStore.loading || settingsStore.saving" size="large" :description="t('common.loading')">
|
||||
<NTabs type="line" animated>
|
||||
<NTabPane name="display" :tab="t('settings.tabs.display')">
|
||||
<DisplaySettings />
|
||||
|
||||
@@ -69,7 +69,7 @@ function handleSelect(category: string, skill: string) {
|
||||
</header>
|
||||
|
||||
<div class="skills-content">
|
||||
<div v-if="loading && categories.length === 0" class="skills-loading">Loading...</div>
|
||||
<div v-if="loading && categories.length === 0" class="skills-loading">{{ t('common.loading') }}</div>
|
||||
<div v-else class="skills-layout">
|
||||
<div class="mobile-backdrop" :class="{ active: showSidebar }" @click="showSidebar = false" />
|
||||
<div v-if="showSidebar" class="skills-sidebar">
|
||||
@@ -92,7 +92,7 @@ function handleSelect(category: string, skill: string) {
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
<span>Select a skill from the list</span>
|
||||
<span>{{ t('skills.noMatch') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { getApiKey, getBaseUrlValue } from "@/api/client";
|
||||
import { NButton, NPopconfirm, NTooltip, useMessage } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
interface SessionInfo {
|
||||
id: string;
|
||||
shell: string;
|
||||
pid: number;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
exited: boolean;
|
||||
}
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null);
|
||||
const showSessions = ref(true);
|
||||
const sessions = ref<SessionInfo[]>([]);
|
||||
const activeSessionId = ref<string | null>(null);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
// Keep all terminal instances alive, only dispose on close
|
||||
const termMap = new Map<
|
||||
string,
|
||||
{ term: Terminal; fitAddon: FitAddon; opened: boolean }
|
||||
>();
|
||||
let activeTerm: Terminal | null = null;
|
||||
let activeFitAddon: FitAddon | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let mobileQuery: MediaQueryList | null = null;
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
|
||||
const activeSession = computed(
|
||||
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
|
||||
);
|
||||
|
||||
// ─── WebSocket ──────────────────────────────────────────────────
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const token = getApiKey();
|
||||
const base = getBaseUrlValue();
|
||||
const wsProtocol = base
|
||||
? base.startsWith("https")
|
||||
? "wss:"
|
||||
: "ws:"
|
||||
: location.protocol === "https:"
|
||||
? "wss:"
|
||||
: "ws:";
|
||||
|
||||
if (base) {
|
||||
return `${wsProtocol}//${new URL(base).host}/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
// Dev mode: connect directly to backend port; Production: same host
|
||||
const host = import.meta.env.DEV
|
||||
? `${location.hostname}:8648`
|
||||
: location.host;
|
||||
return `${wsProtocol}//${host}/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const url = buildWsUrl();
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Server auto-creates the first session and sends 'created'
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = typeof event.data === "string" ? event.data : "";
|
||||
if (data.charCodeAt(0) === 0x7b) {
|
||||
try {
|
||||
handleControl(JSON.parse(data));
|
||||
} catch {}
|
||||
} else {
|
||||
activeTerm?.write(data);
|
||||
}
|
||||
};
|
||||
|
||||
// On reconnect, recreate all terminals for existing sessions
|
||||
ws.onopen = () => {
|
||||
// Server will auto-create the first session again
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Reconnect after delay
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// let onclose handle reconnect
|
||||
};
|
||||
}
|
||||
|
||||
function send(data: object | string) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(typeof data === "string" ? data : JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ─── Control message handlers ──────────────────────────────────
|
||||
|
||||
function handleControl(msg: any) {
|
||||
switch (msg.type) {
|
||||
case "created":
|
||||
sessions.value.push({
|
||||
id: msg.id,
|
||||
shell: msg.shell,
|
||||
pid: msg.pid,
|
||||
title: `${msg.shell} #${sessions.value.length + 1}`,
|
||||
createdAt: Date.now(),
|
||||
exited: false,
|
||||
});
|
||||
switchSession(msg.id);
|
||||
break;
|
||||
|
||||
case "switched":
|
||||
// Server confirmed switch — frontend already mounted in switchSession()
|
||||
break;
|
||||
|
||||
case "exited": {
|
||||
const s = sessions.value.find((s) => s.id === msg.id);
|
||||
if (s) {
|
||||
s.exited = true;
|
||||
if (activeSessionId.value === msg.id) {
|
||||
activeTerm?.write(
|
||||
`\r\n\x1b[90m[${t("terminal.processExited", { code: msg.exitCode })}]\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
message.error(msg.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session actions ────────────────────────────────────────────
|
||||
|
||||
function createSession() {
|
||||
send({ type: "create" });
|
||||
}
|
||||
|
||||
function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } {
|
||||
let entry = termMap.get(id);
|
||||
if (!entry) {
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: "#1a1a2e",
|
||||
foreground: "#e0e0e0",
|
||||
cursor: "#4cc9f0",
|
||||
cursorAccent: "#1a1a2e",
|
||||
selectionBackground: "rgba(76, 201, 240, 0.3)",
|
||||
black: "#000000",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#e5c07b",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
},
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
entry = { term, fitAddon, opened: false };
|
||||
termMap.set(id, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function switchSession(id: string) {
|
||||
if (activeSessionId.value === id) return;
|
||||
activeSessionId.value = id;
|
||||
const entry = getOrCreateTerm(id);
|
||||
activeTerm = entry.term;
|
||||
activeFitAddon = entry.fitAddon;
|
||||
mountActiveTerminal();
|
||||
send({ type: "switch", sessionId: id });
|
||||
if (mobileQuery?.matches) showSessions.value = false;
|
||||
}
|
||||
|
||||
function closeSession(id: string) {
|
||||
send({ type: "close", sessionId: id });
|
||||
sessions.value = sessions.value.filter((s) => s.id !== id);
|
||||
// Dispose terminal instance
|
||||
const entry = termMap.get(id);
|
||||
if (entry) {
|
||||
entry.term.dispose();
|
||||
termMap.delete(id);
|
||||
}
|
||||
if (activeSessionId.value === id) {
|
||||
activeSessionId.value =
|
||||
sessions.value.length > 0 ? sessions.value[0].id : null;
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
if (activeSessionId.value) {
|
||||
switchSession(activeSessionId.value);
|
||||
} else {
|
||||
unmountActiveTerminal();
|
||||
createSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Terminal mount/unmount ─────────────────────────────────────
|
||||
|
||||
function mountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
// Remove old terminal DOM from container
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
const entry = termMap.get(activeSessionId.value!);
|
||||
if (!entry) return;
|
||||
|
||||
if (!entry.opened) {
|
||||
// First time: call open()
|
||||
entry.term.open(container);
|
||||
entry.opened = true;
|
||||
} else {
|
||||
// Already opened: move the existing DOM element
|
||||
const termEl = entry.term.element;
|
||||
if (termEl) {
|
||||
container.appendChild(termEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Resize observer
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
tryFit();
|
||||
sendResize();
|
||||
});
|
||||
resizeObserver.observe(terminalRef.value);
|
||||
|
||||
// Fit after DOM is ready
|
||||
setTimeout(() => tryFit(), 50);
|
||||
setTimeout(() => tryFit(), 200);
|
||||
}
|
||||
|
||||
function unmountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
function tryFit() {
|
||||
if (!activeFitAddon) return;
|
||||
try {
|
||||
activeFitAddon.fit();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
if (!activeTerm || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
send({
|
||||
type: "resize",
|
||||
cols: activeTerm.cols,
|
||||
rows: activeTerm.rows,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
if (e.matches && showSessions.value) showSessions.value = false;
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
mobileQuery = window.matchMedia("(max-width: 768px)");
|
||||
handleMobileChange(mobileQuery);
|
||||
mobileQuery.addEventListener("change", handleMobileChange);
|
||||
connect();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mobileQuery?.removeEventListener("change", handleMobileChange);
|
||||
unmountActiveTerminal();
|
||||
// Dispose all terminal instances
|
||||
for (const entry of termMap.values()) {
|
||||
entry.term.dispose();
|
||||
}
|
||||
termMap.clear();
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
ws?.close();
|
||||
ws = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="terminal-panel">
|
||||
<!-- Session backdrop (mobile) -->
|
||||
<div
|
||||
class="session-backdrop"
|
||||
:class="{ active: showSessions }"
|
||||
@click="showSessions = false"
|
||||
/>
|
||||
|
||||
<!-- Session list sidebar -->
|
||||
<aside class="session-list" :class="{ collapsed: !showSessions }">
|
||||
<div class="session-list-header">
|
||||
<span v-if="showSessions" class="session-list-title">{{
|
||||
t("terminal.sessions")
|
||||
}}</span>
|
||||
<div class="session-list-actions">
|
||||
<!-- <button class="session-close-btn" @click="showSessions = false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button> -->
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" @click="createSession" circle>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-items">
|
||||
<div v-if="sessions.length === 0" class="session-empty">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<button
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="session-item"
|
||||
:class="{ active: s.id === activeSessionId, exited: s.exited }"
|
||||
@click="switchSession(s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title">{{ s.title }}</span>
|
||||
<span class="session-item-meta">
|
||||
<span class="session-item-shell">{{ s.shell }}</span>
|
||||
<span v-if="s.exited" class="session-item-status">{{
|
||||
t("terminal.sessionExited")
|
||||
}}</span>
|
||||
<span v-else class="session-item-time">{{
|
||||
formatTime(s.createdAt)
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm
|
||||
v-if="sessions.length > 1"
|
||||
@positive-click="closeSession(s.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button class="session-item-delete" @click.stop>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
{{ t("terminal.closeSession") }}
|
||||
</NPopconfirm>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main terminal area -->
|
||||
<div class="terminal-main">
|
||||
<header class="terminal-header">
|
||||
<div class="header-left">
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
@click="showSessions = !showSessions"
|
||||
circle
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<span v-if="activeSession" class="header-session-title">{{
|
||||
activeSession.title
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<NButton size="small" @click="createSession">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NButton>
|
||||
</div>
|
||||
</header>
|
||||
<div class="terminal-container">
|
||||
<div ref="terminalRef" class="terminal-xterm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.terminal-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// ─── Session list ──────────────────────────────────────────────
|
||||
|
||||
.session-list {
|
||||
width: 220px;
|
||||
border-right: 1px solid $border-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
width $transition-normal,
|
||||
opacity $transition-normal;
|
||||
overflow: hidden;
|
||||
|
||||
&.collapsed {
|
||||
width: 0;
|
||||
border-right: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
width: 280px;
|
||||
|
||||
&.collapsed {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-list-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.session-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 6px 12px;
|
||||
}
|
||||
|
||||
.session-empty {
|
||||
padding: 16px 10px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: $text-secondary;
|
||||
transition: all $transition-fast;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
color: $text-primary;
|
||||
|
||||
.session-item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba($accent-primary, 0.1);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.exited {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.session-item-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-item-shell {
|
||||
font-size: 10px;
|
||||
color: $accent-primary;
|
||||
background: rgba($accent-primary, 0.08);
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.session-item-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-item-status {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.session-item-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
background: rgba($error, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.session-close-btn {
|
||||
display: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: $text-secondary;
|
||||
padding: 4px;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main area ──────────────────────────────────────────────────
|
||||
|
||||
.terminal-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 21px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-session-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ─── Terminal container ─────────────────────────────────────────
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
margin: 10px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-xterm {
|
||||
flex: 1;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
background-color: #1a1a2e;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
:deep(.xterm) {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport) {
|
||||
overflow-y: scroll !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-screen) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element) {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mobile ─────────────────────────────────────────────────────
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.session-close-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.session-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 9;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
padding: 16px 12px 16px 52px;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.terminal-xterm {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* Global: xterm scrollbar (scoped :deep can't reach dynamically created elements) */
|
||||
.xterm .scrollbar {
|
||||
width: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
.xterm .scrollbar .slider {
|
||||
border-radius: 3px !important;
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
transition: background 0.15s ease !important;
|
||||
}
|
||||
|
||||
.xterm .scrollbar:hover .slider {
|
||||
background: rgba(255, 255, 255, 0.35) !important;
|
||||
}
|
||||
</style>
|
||||
+3
-2
@@ -3,16 +3,17 @@ import vue from '@vitejs/plugin-vue'
|
||||
import type { ProxyOptions } from 'vite'
|
||||
import { resolve } from 'path'
|
||||
|
||||
const BACKEND = 'http://127.0.0.1:8648'
|
||||
|
||||
function createProxyConfig(): ProxyOptions {
|
||||
return {
|
||||
target: 'http://127.0.0.1:8648',
|
||||
target: BACKEND,
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
proxyReq.removeHeader('origin')
|
||||
proxyReq.removeHeader('referer')
|
||||
})
|
||||
// Disable response buffering for SSE streaming
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
proxyRes.headers['cache-control'] = 'no-cache'
|
||||
proxyRes.headers['x-accel-buffering'] = 'no'
|
||||
|
||||
Reference in New Issue
Block a user