mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-25 21:40:13 +00:00
feat: add model selector, skills/memory pages, and config management
- Add model selector in sidebar that discovers models from auth.json credential pool - Add per-session model display (badge in chat header and session list) - Add skills browser page and memory editor page - Add BFF routes for skills, memory, and config model management - Model switching updates config.yaml provider field to bypass env auto-detection - Refactor Settings page, simplify ChatInput with file upload - Add attachment upload support via BFF /upload endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -12,7 +12,7 @@ dist
|
||||
dist-ssr
|
||||
server/dist
|
||||
*.local
|
||||
|
||||
ROADMAP.md
|
||||
# Server data
|
||||
server/data/
|
||||
server/node_modules/
|
||||
|
||||
@@ -72,6 +72,7 @@ hermes-web-ui/
|
||||
│ │ ├── proxy.ts # API proxy to Hermes (/api/*, /v1/*)
|
||||
│ │ ├── 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/
|
||||
@@ -80,13 +81,16 @@ hermes-web-ui/
|
||||
│ ├── api/ # Frontend API layer
|
||||
│ ├── stores/ # Pinia state management
|
||||
│ ├── components/
|
||||
│ │ ├── layout/AppSidebar.vue # Sidebar navigation
|
||||
│ │ ├── layout/
|
||||
│ │ │ ├── AppSidebar.vue # Sidebar navigation
|
||||
│ │ │ └── ModelSelector.vue # Global model selector
|
||||
│ │ ├── chat/ # Chat components
|
||||
│ │ └── jobs/ # Job components
|
||||
│ ├── views/
|
||||
│ │ ├── ChatView.vue # Chat page
|
||||
│ │ ├── JobsView.vue # Jobs page
|
||||
│ │ └── LogsView.vue # Logs page
|
||||
│ │ ├── LogsView.vue # Logs page
|
||||
│ │ └── SettingsView.vue # Settings (model management)
|
||||
│ └── router/index.ts # Router configuration
|
||||
└── dist/ # Build output (published to npm)
|
||||
├── server/index.js # Compiled BFF
|
||||
@@ -102,6 +106,16 @@ hermes-web-ui/
|
||||
- Multi-session switching with message history
|
||||
- Markdown rendering with syntax highlighting and code copy
|
||||
- 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)
|
||||
|
||||
### 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)
|
||||
- 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
|
||||
|
||||
### Scheduled Jobs
|
||||
- Job list view (including paused/disabled jobs)
|
||||
@@ -133,6 +147,9 @@ The BFF layer handles:
|
||||
- SSE streaming passthrough
|
||||
- File upload to temp directory
|
||||
- Session CRUD via Hermes CLI
|
||||
- Model discovery from `~/.hermes/auth.json` credential pool
|
||||
- Config.yaml model switching (reads/writes `~/.hermes/config.yaml`)
|
||||
- Skills, memory, and custom provider management
|
||||
- Log file reading and parsing
|
||||
- Static file serving (SPA fallback)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-web-ui",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "Hermes Agent Web UI - Chat and Job Management Dashboard",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { uploadRoutes } from './routes/upload'
|
||||
import { sessionRoutes } from './routes/sessions'
|
||||
import { webhookRoutes } from './routes/webhook'
|
||||
import { logRoutes } from './routes/logs'
|
||||
import { fsRoutes } from './routes/filesystem'
|
||||
import * as hermesCli from './services/hermes-cli'
|
||||
const { restartGateway } = hermesCli
|
||||
|
||||
@@ -28,6 +29,7 @@ export async function bootstrap() {
|
||||
app.use(logRoutes.routes())
|
||||
app.use(uploadRoutes.routes())
|
||||
app.use(sessionRoutes.routes())
|
||||
app.use(fsRoutes.routes())
|
||||
|
||||
// Health endpoint with version
|
||||
app.use(async (ctx, next) => {
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
import Router from '@koa/router'
|
||||
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
|
||||
import { join, resolve } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
// --- Auth / Credential Pool ---
|
||||
|
||||
interface CredentialPoolEntry {
|
||||
id: string
|
||||
label: string
|
||||
base_url: string
|
||||
access_token: string
|
||||
last_status?: string | null
|
||||
}
|
||||
|
||||
interface AuthJson {
|
||||
credential_pool?: Record<string, CredentialPoolEntry[]>
|
||||
}
|
||||
|
||||
const authPath = resolve(homedir(), '.hermes', 'auth.json')
|
||||
|
||||
async function loadAuthJson(): Promise<AuthJson | null> {
|
||||
try {
|
||||
const raw = await readFile(authPath, 'utf-8')
|
||||
return JSON.parse(raw) as AuthJson
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const url = baseUrl.replace(/\/+$/, '') + '/models'
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
console.error(`[available-models] ${baseUrl} returned ${res.status}`)
|
||||
return []
|
||||
}
|
||||
const data = await res.json() as { data?: Array<{ id: string }> }
|
||||
if (!Array.isArray(data.data)) {
|
||||
console.error(`[available-models] ${baseUrl} returned unexpected format`)
|
||||
return []
|
||||
}
|
||||
return data.data.map(m => m.id).sort()
|
||||
} catch (err: any) {
|
||||
console.error(`[available-models] ${baseUrl} failed: ${err.message}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const fsRoutes = new Router()
|
||||
|
||||
const hermesDir = resolve(homedir(), '.hermes')
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface SkillCategory {
|
||||
name: string
|
||||
description: string
|
||||
skills: SkillInfo[]
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function extractDescription(content: string): string {
|
||||
// SKILL.md format: YAML frontmatter between --- delimiters, then markdown body
|
||||
// Extract first non-empty, non-frontmatter, non-heading line as description
|
||||
const lines = content.split('\n')
|
||||
let inFrontmatter = false
|
||||
let bodyStarted = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (!bodyStarted && line.trim() === '---') {
|
||||
if (!inFrontmatter) {
|
||||
inFrontmatter = true
|
||||
continue
|
||||
} else {
|
||||
inFrontmatter = false
|
||||
bodyStarted = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (inFrontmatter) continue
|
||||
if (line.trim() === '') continue
|
||||
if (line.startsWith('#')) continue
|
||||
// Return first meaningful line, truncated
|
||||
return line.trim().slice(0, 80)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function safeReadFile(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function safeStat(filePath: string): Promise<{ mtime: number } | null> {
|
||||
try {
|
||||
const s = await stat(filePath)
|
||||
return { mtime: Math.round(s.mtimeMs) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Skills Routes ---
|
||||
|
||||
// List all skills grouped by category
|
||||
fsRoutes.get('/api/skills', async (ctx) => {
|
||||
const skillsDir = join(hermesDir, 'skills')
|
||||
|
||||
try {
|
||||
const entries = await readdir(skillsDir, { withFileTypes: true })
|
||||
const categories: SkillCategory[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||
|
||||
const catDir = join(skillsDir, entry.name)
|
||||
const catDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md'))
|
||||
const catDescription = catDesc ? catDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : ''
|
||||
|
||||
const skillEntries = await readdir(catDir, { withFileTypes: true })
|
||||
const skills: SkillInfo[] = []
|
||||
|
||||
for (const se of skillEntries) {
|
||||
if (!se.isDirectory()) continue
|
||||
const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md'))
|
||||
if (skillMd) {
|
||||
skills.push({
|
||||
name: se.name,
|
||||
description: extractDescription(skillMd),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (skills.length > 0) {
|
||||
categories.push({ name: entry.name, description: catDescription, skills })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort categories alphabetically
|
||||
categories.sort((a, b) => a.name.localeCompare(b.name))
|
||||
for (const cat of categories) {
|
||||
cat.skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
ctx.body = { categories }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: `Failed to read skills directory: ${err.message}` }
|
||||
}
|
||||
})
|
||||
|
||||
// List files in a skill directory (for references/templates/scripts)
|
||||
// Must be registered before the wildcard route
|
||||
async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> {
|
||||
const result: { path: string; name: string }[] = []
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...await listFilesRecursive(join(dir, entry.name), relPath))
|
||||
} else {
|
||||
result.push({ path: relPath, name: entry.name })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fsRoutes.get('/api/skills/:category/:skill/files', async (ctx) => {
|
||||
const { category, skill } = ctx.params
|
||||
const skillDir = join(hermesDir, 'skills', category, skill)
|
||||
|
||||
try {
|
||||
const allFiles = await listFilesRecursive(skillDir, '')
|
||||
const files = allFiles.filter(f => f.path !== 'SKILL.md')
|
||||
ctx.body = { files }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Read a specific file under skills/
|
||||
fsRoutes.get('/api/skills/:path(.+)', async (ctx) => {
|
||||
const filePath = ctx.params.path
|
||||
const fullPath = resolve(join(hermesDir, 'skills', filePath))
|
||||
|
||||
// Security: ensure path stays within skills directory
|
||||
if (!fullPath.startsWith(join(hermesDir, 'skills'))) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Access denied' }
|
||||
return
|
||||
}
|
||||
|
||||
const content = await safeReadFile(fullPath)
|
||||
if (content === null) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'File not found' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = { content }
|
||||
})
|
||||
|
||||
// --- Memory Routes ---
|
||||
|
||||
// Read MEMORY.md and USER.md
|
||||
fsRoutes.get('/api/memory', async (ctx) => {
|
||||
const memoryPath = join(hermesDir, 'memories', 'MEMORY.md')
|
||||
const userPath = join(hermesDir, 'memories', 'USER.md')
|
||||
|
||||
const [memory, user, memoryStat, userStat] = await Promise.all([
|
||||
safeReadFile(memoryPath),
|
||||
safeReadFile(userPath),
|
||||
safeStat(memoryPath),
|
||||
safeStat(userPath),
|
||||
])
|
||||
|
||||
ctx.body = {
|
||||
memory: memory || '',
|
||||
user: user || '',
|
||||
memory_mtime: memoryStat?.mtime || null,
|
||||
user_mtime: userStat?.mtime || null,
|
||||
}
|
||||
})
|
||||
|
||||
// Write MEMORY.md or USER.md
|
||||
fsRoutes.post('/api/memory', async (ctx) => {
|
||||
const { section, content } = ctx.request.body as { section: string; content: string }
|
||||
|
||||
if (!section || !content) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing section or content' }
|
||||
return
|
||||
}
|
||||
|
||||
if (section !== 'memory' && section !== 'user') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Section must be "memory" or "user"' }
|
||||
return
|
||||
}
|
||||
|
||||
const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md'
|
||||
const filePath = join(hermesDir, 'memories', fileName)
|
||||
|
||||
try {
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// --- Config Model Routes ---
|
||||
|
||||
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
||||
|
||||
interface ModelInfo {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ModelGroup {
|
||||
provider: string
|
||||
models: ModelInfo[]
|
||||
}
|
||||
|
||||
// Build model list from user's actual config.yaml configuration
|
||||
// Only shows models the user has explicitly configured, not entire provider catalogs
|
||||
function buildModelGroups(yaml: string): { default: string; groups: ModelGroup[] } {
|
||||
let defaultModel = ''
|
||||
let defaultProvider = ''
|
||||
const groups: ModelGroup[] = []
|
||||
const allModelIds = new Set<string>()
|
||||
|
||||
// 1. Extract current model from `model:` section
|
||||
const defaultMatch = yaml.match(/^model:\s*\n\s+default:\s*(.+)/m)
|
||||
if (defaultMatch) defaultModel = defaultMatch[1].trim()
|
||||
const providerMatch = yaml.match(/^model:\s*\n(?:.*\n)*?\s+provider:\s*(.+)/m)
|
||||
if (providerMatch) defaultProvider = providerMatch[1].trim()
|
||||
|
||||
// 2. Extract providers: section (user-defined endpoints)
|
||||
const providersSection = yaml.match(/^providers:\s*\n((?: .+\n(?: .+\n)*)*)/m)
|
||||
if (providersSection) {
|
||||
const entries = providersSection[1].match(/^ (\S+):\s*\n((?: .+\n)*)/gm)
|
||||
if (entries) {
|
||||
for (const entry of entries) {
|
||||
const nameMatch = entry.match(/^ (\S+):/)
|
||||
const baseUrlMatch = entry.match(/base_url:\s*(.+)/)
|
||||
const name = nameMatch?.[1]?.trim()
|
||||
if (name) {
|
||||
// Provider entry itself — mark as available but don't add model yet
|
||||
// (it's an endpoint the user can switch to, models are fetched at runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Extract custom_providers: section
|
||||
const customSection = yaml.match(/^custom_providers:\s*\n((?:\s*- .+\n(?: .+\n)*)*)/m)
|
||||
if (customSection) {
|
||||
const entryBlocks = customSection[1].match(/\s*- name:\s*(.+)\n((?: .+\n)*)/g)
|
||||
if (entryBlocks) {
|
||||
const customModels: ModelInfo[] = []
|
||||
for (const block of entryBlocks) {
|
||||
const cName = block.match(/name:\s*(.+)/)?.[1]?.trim()
|
||||
const cModel = block.match(/model:\s*(.+)/)?.[1]?.trim()
|
||||
if (cName && cModel) {
|
||||
customModels.push({ id: cModel, label: `${cName}: ${cModel}` })
|
||||
allModelIds.add(cModel)
|
||||
}
|
||||
}
|
||||
if (customModels.length > 0) {
|
||||
groups.push({ provider: 'Custom', models: customModels })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add current default model (if not already in custom_providers)
|
||||
if (defaultModel && !allModelIds.has(defaultModel)) {
|
||||
groups.unshift({ provider: 'Current', models: [{ id: defaultModel, label: defaultModel }] })
|
||||
}
|
||||
|
||||
return { default: defaultModel, groups }
|
||||
}
|
||||
|
||||
// GET /api/available-models — fetch models from all credential pool endpoints
|
||||
fsRoutes.get('/api/available-models', async (ctx) => {
|
||||
try {
|
||||
const auth = await loadAuthJson()
|
||||
const pool = auth?.credential_pool || {}
|
||||
|
||||
// Read current default model from config.yaml
|
||||
const yaml = await safeReadFile(configPath) || ''
|
||||
const defaultMatch = yaml.match(/^model:\s*\n\s+default:\s*(.+)/m)
|
||||
const currentDefault = defaultMatch?.[1]?.trim() || ''
|
||||
|
||||
// Collect unique endpoints from credential pool
|
||||
const endpoints: Array<{ key: string; label: string; base_url: string; token: string }> = []
|
||||
const seenUrls = new Set<string>()
|
||||
|
||||
for (const [providerKey, entries] of Object.entries(pool)) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) continue
|
||||
const entry = entries.find(e => e.last_status !== 'exhausted') || entries[0]
|
||||
if (!entry?.base_url || !entry?.access_token) continue
|
||||
const baseUrl = entry.base_url.replace(/\/+$/, '')
|
||||
if (seenUrls.has(baseUrl)) continue
|
||||
seenUrls.add(baseUrl)
|
||||
endpoints.push({
|
||||
key: providerKey,
|
||||
label: providerKey.replace(/^custom:/, '') || entry.label || baseUrl,
|
||||
base_url: baseUrl,
|
||||
token: entry.access_token,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch all provider models in parallel
|
||||
const results = await Promise.allSettled(
|
||||
endpoints.map(async ep => {
|
||||
const models = await fetchProviderModels(ep.base_url, ep.token)
|
||||
return { ...ep, models }
|
||||
}),
|
||||
)
|
||||
|
||||
const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = []
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value.models.length > 0) {
|
||||
const { key, label, base_url, models } = result.value
|
||||
groups.push({ provider: key, label, base_url, models })
|
||||
} else if (result.status === 'rejected') {
|
||||
console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no providers returned models, fall back to config.yaml parsing
|
||||
if (groups.length === 0) {
|
||||
const fallback = buildModelGroups(yaml)
|
||||
ctx.body = fallback
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = { default: currentDefault, groups }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/config/models
|
||||
fsRoutes.get('/api/config/models', async (ctx) => {
|
||||
try {
|
||||
const yaml = await safeReadFile(configPath)
|
||||
ctx.body = yaml ? buildModelGroups(yaml) : { default: '', groups: [] }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config/model
|
||||
fsRoutes.put('/api/config/model', async (ctx) => {
|
||||
const { default: defaultModel, provider: reqProvider } = ctx.request.body as {
|
||||
default: string
|
||||
provider?: string
|
||||
}
|
||||
|
||||
if (!defaultModel) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing default model' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let yaml = await safeReadFile(configPath) || ''
|
||||
|
||||
// Rebuild the model: block
|
||||
const modelBlockMatch = yaml.match(/^(model:\s*\n(?: .+\n)*)/m)
|
||||
if (modelBlockMatch) {
|
||||
const lines = [`model:`, ` default: ${defaultModel}`]
|
||||
|
||||
if (reqProvider) {
|
||||
// Provider from credential pool key (e.g. "zai" or "custom:subrouter.ai")
|
||||
// Hermes resolves base_url/api_key from auth.json automatically
|
||||
lines.push(` provider: ${reqProvider}`)
|
||||
}
|
||||
|
||||
yaml = yaml.replace(modelBlockMatch[1], lines.join('\n') + '\n')
|
||||
}
|
||||
|
||||
await writeFile(configPath, yaml, 'utf-8')
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/config/providers
|
||||
fsRoutes.post('/api/config/providers', async (ctx) => {
|
||||
const { name, base_url, api_key, model } = ctx.request.body as {
|
||||
name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
model: string
|
||||
}
|
||||
|
||||
if (!name || !base_url || !model) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing name, base_url, or model' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let yaml = await safeReadFile(configPath) || ''
|
||||
|
||||
const newEntry = `- name: ${name}\n base_url: ${base_url}\n api_key: ${api_key || ''}\n model: ${model}\n`
|
||||
|
||||
if (/^custom_providers:/m.test(yaml)) {
|
||||
yaml = yaml.replace(/^(custom_providers:)/m, `$1\n${newEntry}`)
|
||||
} else {
|
||||
yaml = yaml.trimEnd() + `\n\ncustom_providers:\n${newEntry}\n`
|
||||
}
|
||||
|
||||
await writeFile(configPath, yaml, 'utf-8')
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/config/providers/:name
|
||||
fsRoutes.delete('/api/config/providers/:name', async (ctx) => {
|
||||
const name = ctx.params.name
|
||||
|
||||
try {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let yaml = await safeReadFile(configPath) || ''
|
||||
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const blockRegex = new RegExp(` - name:\\s*${escaped}\\s*\\n(?: .+\\n)*`, 'g')
|
||||
yaml = yaml.replace(blockRegex, '')
|
||||
|
||||
await writeFile(configPath, yaml, 'utf-8')
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { useAppStore } from '@/stores/app'
|
||||
const appStore = useAppStore()
|
||||
|
||||
onMounted(() => {
|
||||
appStore.loadModels()
|
||||
appStore.startHealthPolling()
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface StartRunRequest {
|
||||
instructions?: string
|
||||
conversation_history?: ChatMessage[]
|
||||
session_id?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface StartRunResponse {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { request } from './client'
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface SkillCategory {
|
||||
name: string
|
||||
description: string
|
||||
skills: SkillInfo[]
|
||||
}
|
||||
|
||||
export interface SkillListResponse {
|
||||
categories: SkillCategory[]
|
||||
}
|
||||
|
||||
export interface SkillFileEntry {
|
||||
path: string
|
||||
name: string
|
||||
isDir: boolean
|
||||
}
|
||||
|
||||
export interface MemoryData {
|
||||
memory: string
|
||||
user: string
|
||||
memory_mtime: number | null
|
||||
user_mtime: number | null
|
||||
}
|
||||
|
||||
export async function fetchSkills(): Promise<SkillCategory[]> {
|
||||
const res = await request<SkillListResponse>('/api/skills')
|
||||
return res.categories
|
||||
}
|
||||
|
||||
export async function fetchSkillContent(skillPath: string): Promise<string> {
|
||||
const res = await request<{ content: string }>(`/api/skills/${skillPath}`)
|
||||
return res.content
|
||||
}
|
||||
|
||||
export async function fetchSkillFiles(category: string, skill: string): Promise<SkillFileEntry[]> {
|
||||
const res = await request<{ files: SkillFileEntry[] }>(`/api/skills/${category}/${skill}/files`)
|
||||
return res.files
|
||||
}
|
||||
|
||||
export async function fetchMemory(): Promise<MemoryData> {
|
||||
return request<MemoryData>('/api/memory')
|
||||
}
|
||||
|
||||
export async function saveMemory(section: 'memory' | 'user', content: string): Promise<void> {
|
||||
await request('/api/memory', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ section, content }),
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,41 @@ export interface ModelsResponse {
|
||||
data: Model[]
|
||||
}
|
||||
|
||||
// Config-based model types
|
||||
export interface ModelInfo {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface ModelGroup {
|
||||
provider: string
|
||||
models: ModelInfo[]
|
||||
}
|
||||
|
||||
export interface ConfigModelsResponse {
|
||||
default: string
|
||||
groups: ModelGroup[]
|
||||
}
|
||||
|
||||
export interface AvailableModelGroup {
|
||||
provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai")
|
||||
label: string // display name (e.g. "zai", "subrouter.ai")
|
||||
base_url: string
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface AvailableModelsResponse {
|
||||
default: string
|
||||
groups: AvailableModelGroup[]
|
||||
}
|
||||
|
||||
export interface CustomProvider {
|
||||
name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export async function checkHealth(): Promise<HealthResponse> {
|
||||
return request<HealthResponse>('/health')
|
||||
}
|
||||
@@ -23,3 +58,36 @@ export async function checkHealth(): Promise<HealthResponse> {
|
||||
export async function fetchModels(): Promise<ModelsResponse> {
|
||||
return request<ModelsResponse>('/v1/models')
|
||||
}
|
||||
|
||||
export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
|
||||
return request<ConfigModelsResponse>('/api/config/models')
|
||||
}
|
||||
|
||||
export async function fetchAvailableModels(): Promise<AvailableModelsResponse> {
|
||||
return request<AvailableModelsResponse>('/api/available-models')
|
||||
}
|
||||
|
||||
export async function updateDefaultModel(data: {
|
||||
default: string
|
||||
provider?: string
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
}): Promise<void> {
|
||||
await request('/api/config/model', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function addCustomProvider(data: CustomProvider): Promise<void> {
|
||||
await request('/api/config/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeCustomProvider(name: string): Promise<void> {
|
||||
await request(`/api/config/providers/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,13 +9,108 @@ const inputText = ref('')
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const isDragging = ref(false)
|
||||
const dragCounter = ref(0)
|
||||
|
||||
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
||||
|
||||
// --- Voice input (Web Speech API) ---
|
||||
// TODO: re-enable when needed — browser-native speech-to-text
|
||||
// const hasSpeechRecognition = ref(false)
|
||||
// let recognition: SpeechRecognition | null = null
|
||||
// let finalTranscript = ''
|
||||
// let prefixText = ''
|
||||
// onMounted(() => {
|
||||
// const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
// if (!SR) return
|
||||
// recognition = new SR()
|
||||
// recognition.continuous = false
|
||||
// recognition.interimResults = true
|
||||
// recognition.lang = 'en-US'
|
||||
// hasSpeechRecognition.value = true
|
||||
// recognition.onresult = (event: SpeechRecognitionEvent) => { ... }
|
||||
// recognition.onend = () => { ... }
|
||||
// recognition.onerror = (event: SpeechRecognitionErrorEvent) => { ... }
|
||||
// })
|
||||
// onUnmounted(() => { if (recognition && isRecording.value) recognition.stop() })
|
||||
|
||||
// --- File attachment helpers ---
|
||||
|
||||
function addFile(file: File) {
|
||||
if (attachments.value.find(a => a.name === file.name)) return
|
||||
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
const url = URL.createObjectURL(file)
|
||||
attachments.value.push({
|
||||
id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
url,
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
function handleAttachClick() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files) return
|
||||
for (const file of input.files) addFile(file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// --- Paste image ---
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
const items = Array.from(e.clipboardData?.items || [])
|
||||
const imageItems = items.filter(i => i.type.startsWith('image/'))
|
||||
if (!imageItems.length) return
|
||||
e.preventDefault()
|
||||
for (const item of imageItems) {
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) continue
|
||||
const ext = item.type.split('/')[1] || 'png'
|
||||
const file = new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type })
|
||||
addFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag and drop ---
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
dragCounter.value++
|
||||
isDragging.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragCounter.value--
|
||||
if (dragCounter.value <= 0) {
|
||||
dragCounter.value = 0
|
||||
isDragging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragCounter.value = 0
|
||||
isDragging.value = false
|
||||
const files = Array.from(e.dataTransfer?.files || [])
|
||||
if (!files.length) return
|
||||
for (const file of files) addFile(file)
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
// --- Send ---
|
||||
|
||||
function handleSend() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text && attachments.value.length === 0) return
|
||||
@@ -24,7 +119,6 @@ function handleSend() {
|
||||
inputText.value = ''
|
||||
attachments.value = []
|
||||
|
||||
// Reset textarea height
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.style.height = 'auto'
|
||||
}
|
||||
@@ -43,28 +137,6 @@ function handleInput(e: Event) {
|
||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files) return
|
||||
|
||||
for (const file of files) {
|
||||
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
const url = URL.createObjectURL(file)
|
||||
attachments.value.push({
|
||||
id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
url,
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
// Reset input so the same file can be re-selected
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function removeAttachment(id: string) {
|
||||
const idx = attachments.value.findIndex(a => a.id === id)
|
||||
if (idx !== -1) {
|
||||
@@ -110,7 +182,14 @@ function isImage(type: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<div
|
||||
class="input-wrapper"
|
||||
:class="{ 'drag-over': isDragging }"
|
||||
@dragover="handleDragOver"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
@@ -126,6 +205,7 @@ function isImage(type: string): boolean {
|
||||
rows="1"
|
||||
@keydown="handleKeydown"
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
></textarea>
|
||||
<div class="input-actions">
|
||||
<NTooltip trigger="hover">
|
||||
@@ -288,4 +368,11 @@ function isImage(type: string): boolean {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Drag-over state
|
||||
.input-wrapper.drag-over {
|
||||
border-color: #4a90d9;
|
||||
border-style: dashed;
|
||||
background-color: rgba(74, 144, 217, 0.04);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,10 @@ const activeSessionLabel = computed(() =>
|
||||
chatStore.activeSession?.id || 'New Chat',
|
||||
)
|
||||
|
||||
const sessionModelLabel = computed(() =>
|
||||
chatStore.activeSession?.model || appStore.selectedModel || '',
|
||||
)
|
||||
|
||||
function handleNewChat() {
|
||||
chatStore.newChat()
|
||||
}
|
||||
@@ -58,7 +62,7 @@ function formatTime(ts: number) {
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-items">
|
||||
<div v-if="chatStore.isLoadingSessions" class="session-loading">Loading...</div>
|
||||
<div v-if="chatStore.isLoadingSessions && sortedSessions.length === 0" class="session-loading">Loading...</div>
|
||||
<div v-else-if="sortedSessions.length === 0" class="session-empty">No sessions</div>
|
||||
<button
|
||||
v-for="s in sortedSessions"
|
||||
@@ -68,8 +72,11 @@ function formatTime(ts: number) {
|
||||
@click="chatStore.switchSession(s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title">{{ s.id }}</span>
|
||||
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
|
||||
<span class="session-item-title">{{ s.title }}</span>
|
||||
<span class="session-item-meta">
|
||||
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
|
||||
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm
|
||||
v-if="s.id !== chatStore.activeSessionId || sortedSessions.length > 1"
|
||||
@@ -96,7 +103,9 @@ function formatTime(ts: number) {
|
||||
</template>
|
||||
</NButton>
|
||||
<span class="header-session-title">{{ activeSessionLabel }}</span>
|
||||
<span class="model-badge">{{ appStore.selectedModel }}</span>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<span v-if="sessionModelLabel" class="model-badge">{{ sessionModelLabel }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<NTooltip trigger="hover">
|
||||
@@ -224,12 +233,31 @@ function formatTime(ts: number) {
|
||||
}
|
||||
|
||||
.session-item-time {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-item-model {
|
||||
font-size: 10px;
|
||||
color: $accent-primary;
|
||||
background: rgba($accent-primary, 0.08);
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
line-height: 16px;
|
||||
flex-shrink: 0;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-item-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
@@ -269,6 +297,14 @@ function formatTime(ts: number) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex-shrink: 0;
|
||||
max-width: 240px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.header-session-title {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import ModelSelector from './ModelSelector.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -47,6 +48,32 @@ function handleNav(key: string) {
|
||||
<span>Jobs</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'skills' }"
|
||||
@click="handleNav('skills')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
<span>Skills</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'memory' }"
|
||||
@click="handleNav('memory')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
|
||||
</svg>
|
||||
<span>Memory</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'logs' }"
|
||||
@@ -63,6 +90,8 @@ function handleNav(key: string) {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<ModelSelector />
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="status-row">
|
||||
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NSelect } from 'naive-ui'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const options = computed(() =>
|
||||
appStore.modelGroups.map(g => ({
|
||||
label: g.label,
|
||||
type: 'group' as const,
|
||||
key: g.provider,
|
||||
children: g.models.map(m => ({
|
||||
label: m,
|
||||
value: m,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
|
||||
function handleChange(value: string | number | Array<string | number>) {
|
||||
if (typeof value === 'string') {
|
||||
appStore.switchModel(value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-selector">
|
||||
<div class="model-label">Model</div>
|
||||
<NSelect
|
||||
:value="appStore.selectedModel"
|
||||
:options="options"
|
||||
size="small"
|
||||
@update:value="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.model-selector {
|
||||
padding: 0 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.model-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue'
|
||||
import { fetchSkillContent, fetchSkillFiles, type SkillFileEntry } from '@/api/skills'
|
||||
|
||||
const props = defineProps<{
|
||||
category: string
|
||||
skill: string
|
||||
}>()
|
||||
|
||||
const content = ref('')
|
||||
const files = ref<SkillFileEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const fileContent = ref('')
|
||||
const viewingFile = ref<string | null>(null)
|
||||
const fileLoading = ref(false)
|
||||
|
||||
async function loadSkill() {
|
||||
loading.value = true
|
||||
viewingFile.value = null
|
||||
fileContent.value = ''
|
||||
files.value = []
|
||||
content.value = ''
|
||||
try {
|
||||
const skillPath = `${props.category}/${props.skill}/SKILL.md`
|
||||
const [skillContent, skillFiles] = await Promise.all([
|
||||
fetchSkillContent(skillPath),
|
||||
fetchSkillFiles(props.category, props.skill),
|
||||
])
|
||||
content.value = skillContent
|
||||
files.value = skillFiles.filter(f => !f.isDir && f.path !== 'SKILL.md')
|
||||
} catch (err: any) {
|
||||
content.value = `Failed to load skill: ${err.message}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function viewFile(filePath: string) {
|
||||
fileLoading.value = true
|
||||
viewingFile.value = filePath
|
||||
try {
|
||||
// filePath might be absolute or relative; normalize to relative under category/skill/
|
||||
const base = `${props.category}/${props.skill}/`
|
||||
let relPath = filePath
|
||||
if (filePath.startsWith('/')) {
|
||||
// Strip absolute prefix to get relative path
|
||||
const segments = filePath.split('/.hermes/skills/')[1]
|
||||
if (segments) {
|
||||
const afterSkillDir = segments.split('/').slice(2).join('/')
|
||||
relPath = afterSkillDir
|
||||
}
|
||||
}
|
||||
fileContent.value = await fetchSkillContent(`${base}${relPath}`)
|
||||
} catch (err: any) {
|
||||
fileContent.value = `Failed to load file: ${err.message}`
|
||||
} finally {
|
||||
fileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToSkill() {
|
||||
viewingFile.value = null
|
||||
fileContent.value = ''
|
||||
}
|
||||
|
||||
watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skill-detail">
|
||||
<!-- Skill title -->
|
||||
<div class="detail-title">
|
||||
<span class="detail-category">{{ category }}</span>
|
||||
<span class="detail-separator">/</span>
|
||||
<span class="detail-name">{{ skill }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !content" class="detail-loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Breadcrumb for file view -->
|
||||
<div v-if="viewingFile" class="detail-breadcrumb">
|
||||
<button class="back-btn" @click="backToSkill">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Back to {{ skill }}
|
||||
</button>
|
||||
<span class="breadcrumb-path">{{ viewingFile }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Skill content -->
|
||||
<div class="detail-content">
|
||||
<MarkdownRenderer v-if="viewingFile" :content="fileContent" />
|
||||
<MarkdownRenderer v-else :content="content" />
|
||||
</div>
|
||||
|
||||
<!-- Attached files -->
|
||||
<div v-if="!viewingFile && files.length > 0" class="detail-files">
|
||||
<div class="files-header">Attached Files</div>
|
||||
<div class="files-list">
|
||||
<button
|
||||
v-for="f in files"
|
||||
:key="f.path"
|
||||
class="file-item"
|
||||
@click="viewFile(f.path)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span>{{ f.path }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.skill-detail {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.detail-category {
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-separator {
|
||||
color: $text-muted;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
color: $text-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.detail-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 12px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $accent-primary;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-path {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding-bottom: 12px;
|
||||
|
||||
:deep(hr) {
|
||||
border: none;
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-files {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid $border-color;
|
||||
padding-top: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.files-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-secondary;
|
||||
color: $text-secondary;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent-primary;
|
||||
color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SkillCategory } from '@/api/skills'
|
||||
|
||||
const props = defineProps<{
|
||||
categories: SkillCategory[]
|
||||
selectedSkill: string | null
|
||||
searchQuery: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [category: string, skill: string]
|
||||
}>()
|
||||
|
||||
const collapsedCategories = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
if (!props.searchQuery) return props.categories
|
||||
const q = props.searchQuery.toLowerCase()
|
||||
return props.categories
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
skills: cat.skills.filter(
|
||||
s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter(cat => cat.skills.length > 0 || cat.name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function toggleCategory(name: string) {
|
||||
if (collapsedCategories.value.has(name)) {
|
||||
collapsedCategories.value.delete(name)
|
||||
} else {
|
||||
collapsedCategories.value.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(category: string, skill: string) {
|
||||
emit('select', category, skill)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skill-list">
|
||||
<div v-if="filteredCategories.length === 0" class="skill-empty">
|
||||
{{ searchQuery ? 'No skills match your search' : 'No skills found' }}
|
||||
</div>
|
||||
<div
|
||||
v-for="cat in filteredCategories"
|
||||
:key="cat.name"
|
||||
class="skill-category"
|
||||
>
|
||||
<button class="category-header" @click="toggleCategory(cat.name)">
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
class="category-arrow"
|
||||
:class="{ collapsed: collapsedCategories.has(cat.name) }"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
<span class="category-name">{{ cat.name }}</span>
|
||||
<span class="category-count">{{ cat.skills.length }}</span>
|
||||
</button>
|
||||
<div v-if="!collapsedCategories.has(cat.name)" class="category-skills">
|
||||
<button
|
||||
v-for="skill in cat.skills"
|
||||
:key="skill.name"
|
||||
class="skill-item"
|
||||
:class="{
|
||||
active: selectedSkill === `${cat.name}/${skill.name}`,
|
||||
}"
|
||||
@click="handleSelect(cat.name, skill.name)"
|
||||
>
|
||||
<span class="skill-name">{{ skill.name }}</span>
|
||||
<span v-if="skill.description" class="skill-desc">{{ skill.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.skill-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.skill-empty {
|
||||
padding: 24px 16px;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skill-category {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-secondary;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.category-arrow {
|
||||
flex-shrink: 0;
|
||||
transition: transform $transition-fast;
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
background: rgba($accent-primary, 0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.category-skills {
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 6px 10px 6px 28px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba($accent-primary, 0.1);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.skill-desc {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
</style>
|
||||
+11
-1
@@ -18,10 +18,20 @@ const router = createRouter({
|
||||
name: 'logs',
|
||||
component: () => import('@/views/LogsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/skills',
|
||||
name: 'skills',
|
||||
component: () => import('@/views/SkillsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/memory',
|
||||
name: 'memory',
|
||||
component: () => import('@/views/MemoryView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
redirect: '/',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
+21
-11
@@ -1,19 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { checkHealth, fetchModels } from '@/api/system'
|
||||
import type { Model } from '@/api/system'
|
||||
import { checkHealth, fetchAvailableModels, updateDefaultModel, type AvailableModelGroup } from '@/api/system'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const connected = ref(false)
|
||||
const serverVersion = ref('')
|
||||
const models = ref<Model[]>([])
|
||||
const modelGroups = ref<AvailableModelGroup[]>([])
|
||||
const selectedModel = ref('')
|
||||
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
|
||||
|
||||
// Settings
|
||||
const streamEnabled = ref(true)
|
||||
const sessionPersistence = ref(true)
|
||||
const maxTokens = ref(4096)
|
||||
const selectedModel = ref('hermes-agent')
|
||||
|
||||
async function checkConnection() {
|
||||
try {
|
||||
@@ -27,16 +26,26 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const res = await fetchModels()
|
||||
models.value = res.data || []
|
||||
if (models.value.length > 0 && !models.value.find(m => m.id === selectedModel.value)) {
|
||||
selectedModel.value = models.value[0].id
|
||||
}
|
||||
const res = await fetchAvailableModels()
|
||||
modelGroups.value = res.groups
|
||||
selectedModel.value = res.default
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function switchModel(modelId: string, providerOverride?: string) {
|
||||
try {
|
||||
// Find the group containing this model to get provider info
|
||||
const group = modelGroups.value.find(g => g.models.includes(modelId))
|
||||
const provider = providerOverride || group?.provider || ''
|
||||
await updateDefaultModel({ default: modelId, provider })
|
||||
selectedModel.value = modelId
|
||||
} catch (err: any) {
|
||||
console.error('Failed to switch model:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function startHealthPolling(interval = 30000) {
|
||||
stopHealthPolling()
|
||||
checkConnection()
|
||||
@@ -53,13 +62,14 @@ export const useAppStore = defineStore('app', () => {
|
||||
return {
|
||||
connected,
|
||||
serverVersion,
|
||||
models,
|
||||
modelGroups,
|
||||
selectedModel,
|
||||
streamEnabled,
|
||||
sessionPersistence,
|
||||
maxTokens,
|
||||
selectedModel,
|
||||
checkConnection,
|
||||
loadModels,
|
||||
switchModel,
|
||||
startHealthPolling,
|
||||
stopHealthPolling,
|
||||
}
|
||||
|
||||
+45
-1
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
|
||||
import { fetchSessions, fetchSession, deleteSession as deleteSessionApi, type SessionSummary, type HermesMessage } from '@/api/sessions'
|
||||
import { useAppStore } from './app'
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
@@ -31,6 +32,7 @@ export interface Session {
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
model?: string
|
||||
provider?: string
|
||||
messageCount?: number
|
||||
}
|
||||
|
||||
@@ -125,6 +127,7 @@ function mapHermesSession(s: SessionSummary): Session {
|
||||
createdAt: Math.round(s.started_at * 1000),
|
||||
updatedAt: Math.round((s.ended_at || s.started_at) * 1000),
|
||||
model: s.model,
|
||||
provider: (s as any).billing_provider || '',
|
||||
messageCount: s.message_count,
|
||||
}
|
||||
}
|
||||
@@ -145,6 +148,22 @@ export const useChatStore = defineStore('chat', () => {
|
||||
try {
|
||||
const list = await fetchSessions('api_server')
|
||||
sessions.value = list.map(mapHermesSession)
|
||||
// Backfill titles from first user message for sessions with null title
|
||||
const nullTitleSessions = sessions.value.filter(s => s.title === 'New Chat')
|
||||
if (nullTitleSessions.length > 0) {
|
||||
await Promise.allSettled(
|
||||
nullTitleSessions.map(async (s) => {
|
||||
const detail = await fetchSession(s.id)
|
||||
if (detail?.messages) {
|
||||
const firstUser = detail.messages.find(m => m.role === 'user')
|
||||
if (firstUser) {
|
||||
const t = firstUser.content.slice(0, 40)
|
||||
s.title = t + (firstUser.content.length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
// Auto-select the most recent session
|
||||
if (!activeSessionId.value && sessions.value.length > 0) {
|
||||
await switchSession(sessions.value[0].id)
|
||||
@@ -180,9 +199,15 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (detail && detail.messages) {
|
||||
const mapped = mapHermesMessages(detail.messages)
|
||||
activeSession.value.messages = mapped
|
||||
// Update title from Hermes data
|
||||
// Update title: use Hermes title, or fallback to first user message
|
||||
if (detail.title) {
|
||||
activeSession.value.title = detail.title
|
||||
} else {
|
||||
const firstUser = mapped.find(m => m.role === 'user')
|
||||
if (firstUser) {
|
||||
const t = firstUser.content.slice(0, 40)
|
||||
activeSession.value.title = t + (firstUser.content.length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -198,9 +223,23 @@ export const useChatStore = defineStore('chat', () => {
|
||||
function newChat() {
|
||||
if (isStreaming.value) return
|
||||
const session = createSession()
|
||||
// Inherit current global model
|
||||
const appStore = useAppStore()
|
||||
session.model = appStore.selectedModel || undefined
|
||||
switchSession(session.id)
|
||||
}
|
||||
|
||||
async function switchSessionModel(modelId: string, provider?: string) {
|
||||
if (!activeSession.value) return
|
||||
activeSession.value.model = modelId
|
||||
activeSession.value.provider = provider || ''
|
||||
// If provider changed, update global config too (Hermes requires it)
|
||||
if (provider) {
|
||||
const { useAppStore } = await import('./app')
|
||||
await useAppStore().switchModel(modelId, provider)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string) {
|
||||
await deleteSessionApi(sessionId)
|
||||
sessions.value = sessions.value.filter(s => s.id !== sessionId)
|
||||
@@ -273,10 +312,14 @@ export const useChatStore = defineStore('chat', () => {
|
||||
inputText = inputText ? inputText + '\n\n' + pathParts.join('\n') : pathParts.join('\n')
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
// Use session-level model if set, otherwise fall back to global
|
||||
const sessionModel = activeSession.value?.model || appStore.selectedModel
|
||||
const run = await startRun({
|
||||
input: inputText,
|
||||
conversation_history: history,
|
||||
session_id: activeSession.value?.id,
|
||||
model: sessionModel || undefined,
|
||||
})
|
||||
|
||||
const runId = (run as any).run_id || (run as any).id
|
||||
@@ -446,6 +489,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
isLoadingMessages,
|
||||
newChat,
|
||||
switchSession,
|
||||
switchSessionModel,
|
||||
deleteSession,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NButton, useMessage } from 'naive-ui'
|
||||
import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue'
|
||||
import { fetchMemory, saveMemory, type MemoryData } from '@/api/skills'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const data = ref<MemoryData | null>(null)
|
||||
const editingSection = ref<'memory' | 'user' | null>(null)
|
||||
const editContent = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(loadMemory)
|
||||
|
||||
async function loadMemory() {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await fetchMemory()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load memory:', err)
|
||||
message.error('Failed to load memory')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(section: 'memory' | 'user') {
|
||||
editingSection.value = section
|
||||
editContent.value = data.value?.[section] || ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingSection.value = null
|
||||
editContent.value = ''
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editingSection.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await saveMemory(editingSection.value, editContent.value)
|
||||
await loadMemory()
|
||||
editingSection.value = null
|
||||
editContent.value = ''
|
||||
message.success('Saved')
|
||||
} catch (err: any) {
|
||||
message.error(`Save failed: ${err.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number | null): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const memoryEmpty = computed(() => !data.value?.memory?.trim())
|
||||
const userEmpty = computed(() => !data.value?.user?.trim())
|
||||
|
||||
const displayMemory = computed(() => (data.value?.memory || '').replace(/§/g, '\n\n'))
|
||||
const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="memory-view">
|
||||
<header class="memory-header">
|
||||
<h2 class="header-title">Memory</h2>
|
||||
<NButton size="small" quaternary @click="loadMemory">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</template>
|
||||
Refresh
|
||||
</NButton>
|
||||
</header>
|
||||
|
||||
<div class="memory-content">
|
||||
<div v-if="loading && !data" class="memory-loading">Loading...</div>
|
||||
<div v-else class="memory-sections">
|
||||
<!-- My Notes -->
|
||||
<div class="memory-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title-row">
|
||||
<span class="section-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="section-title">My Notes</span>
|
||||
<span v-if="data?.memory_mtime" class="section-mtime">{{ formatTime(data.memory_mtime) }}</span>
|
||||
</div>
|
||||
<NButton v-if="editingSection !== 'memory'" size="tiny" quaternary @click="startEdit('memory')">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</template>
|
||||
Edit
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- View mode -->
|
||||
<div v-if="editingSection !== 'memory'" class="section-body">
|
||||
<MarkdownRenderer v-if="!memoryEmpty" :content="displayMemory" />
|
||||
<p v-else class="empty-text">No notes yet.</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else class="section-edit">
|
||||
<textarea
|
||||
v-model="editContent"
|
||||
class="edit-textarea"
|
||||
placeholder="Write your notes..."
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="edit-actions">
|
||||
<NButton size="small" @click="cancelEdit">Cancel</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">Save</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="memory-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title-row">
|
||||
<span class="section-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<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>
|
||||
</span>
|
||||
<span class="section-title">User Profile</span>
|
||||
<span v-if="data?.user_mtime" class="section-mtime">{{ formatTime(data.user_mtime) }}</span>
|
||||
</div>
|
||||
<NButton v-if="editingSection !== 'user'" size="tiny" quaternary @click="startEdit('user')">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</template>
|
||||
Edit
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- View mode -->
|
||||
<div v-if="editingSection !== 'user'" class="section-body">
|
||||
<MarkdownRenderer v-if="!userEmpty" :content="displayUser" />
|
||||
<p v-else class="empty-text">No profile yet.</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else class="section-edit">
|
||||
<textarea
|
||||
v-model="editContent"
|
||||
class="edit-textarea"
|
||||
placeholder="Write your profile..."
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="edit-actions">
|
||||
<NButton size="small" @click="cancelEdit">Cancel</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">Save</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.memory-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memory-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.memory-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memory-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.memory-sections {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.memory-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: $bg-secondary;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
color: $text-secondary;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.section-mtime {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-edit {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-input;
|
||||
color: $text-primary;
|
||||
font-family: $font-code;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
+53
-60
@@ -1,26 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
NButton, NInput, NSwitch, NSlider, NSelect, NDataTable, useMessage,
|
||||
NButton, NSwitch, NSlider, NDataTable, useMessage,
|
||||
} from 'naive-ui'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { setServerUrl, setApiKey, getBaseUrlValue } from '@/api/client'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const message = useMessage()
|
||||
|
||||
const serverUrl = ref(getBaseUrlValue())
|
||||
const apiKey = ref(localStorage.getItem('hermes_api_key') || '')
|
||||
const testingConnection = ref(false)
|
||||
|
||||
const modelOptions = computed(() =>
|
||||
appStore.models.map(m => ({ label: m.id, value: m.id })),
|
||||
)
|
||||
|
||||
async function handleTestConnection() {
|
||||
testingConnection.value = true
|
||||
setServerUrl(serverUrl.value)
|
||||
if (apiKey.value) setApiKey(apiKey.value)
|
||||
try {
|
||||
await appStore.checkConnection()
|
||||
if (appStore.connected) {
|
||||
@@ -35,34 +26,18 @@ async function handleTestConnection() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveApiKey() {
|
||||
setApiKey(apiKey.value)
|
||||
message.success('API key saved')
|
||||
}
|
||||
|
||||
const endpointColumns = [
|
||||
{ title: 'Method', key: 'method', width: 80 },
|
||||
{ title: 'Endpoint', key: 'endpoint' },
|
||||
{ title: 'Description', key: 'description' },
|
||||
const providerColumns = [
|
||||
{ title: 'Provider', key: 'provider' },
|
||||
{ title: 'Models', key: 'models' },
|
||||
{ title: 'Base URL', key: 'base_url' },
|
||||
]
|
||||
|
||||
const endpoints = [
|
||||
{ method: 'GET', endpoint: '/health', description: 'Health Check' },
|
||||
{ method: 'GET', endpoint: '/v1/health', description: 'Health Check (v1)' },
|
||||
{ method: 'GET', endpoint: '/v1/models', description: 'Model List' },
|
||||
{ method: 'POST', endpoint: '/v1/chat/completions', description: 'Chat Completions (OpenAI compatible)' },
|
||||
{ method: 'POST', endpoint: '/v1/responses', description: 'Create Response (stateful)' },
|
||||
{ method: 'GET', endpoint: '/v1/responses/{id}', description: 'Get Stored Response' },
|
||||
{ method: 'DELETE', endpoint: '/v1/responses/{id}', description: 'Delete Response' },
|
||||
{ method: 'POST', endpoint: '/v1/runs', description: 'Start Async Run' },
|
||||
{ method: 'GET', endpoint: '/v1/runs/{id}/events', description: 'SSE Event Stream' },
|
||||
{ method: 'GET', endpoint: '/api/jobs', description: 'List Jobs' },
|
||||
{ method: 'POST', endpoint: '/api/jobs', description: 'Create Job' },
|
||||
{ method: 'GET', endpoint: '/api/jobs/{id}', description: 'Get Job Detail' },
|
||||
{ method: 'PATCH', endpoint: '/api/jobs/{id}', description: 'Update Job' },
|
||||
{ method: 'DELETE', endpoint: '/api/jobs/{id}', description: 'Delete Job' },
|
||||
{ method: 'POST', endpoint: '/api/jobs/{id}/pause', description: 'Pause Job' },
|
||||
{ method: 'POST', endpoint: '/api/jobs/{id}/resume', description: 'Resume Job' },
|
||||
{ method: 'POST', endpoint: '/api/jobs/{id}/run', description: 'Trigger Job Now' },
|
||||
]
|
||||
</script>
|
||||
@@ -77,17 +52,6 @@ const endpoints = [
|
||||
<!-- API Configuration -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-title">API Configuration</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Server URL</label>
|
||||
<NInput v-model:value="serverUrl" placeholder="http://127.0.0.1:8642" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">API Key (optional)</label>
|
||||
<div class="input-with-action">
|
||||
<NInput v-model:value="apiKey" type="password" show-password-on="click" placeholder="Enter API key" />
|
||||
<NButton size="small" @click="handleSaveApiKey">Save</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="connection-status">
|
||||
<span class="status-dot" :class="{ on: appStore.connected, off: !appStore.connected }"></span>
|
||||
@@ -100,17 +64,34 @@ const endpoints = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Model Management -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-title">Model Management</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Current Model</label>
|
||||
<div class="current-model">{{ appStore.selectedModel || 'Not set' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="appStore.modelGroups.length > 0" class="form-group">
|
||||
<label class="form-label">Available Models</label>
|
||||
<p class="form-hint">Models are discovered from ~/.hermes/auth.json credential pool. Use the sidebar selector to switch.</p>
|
||||
<NDataTable
|
||||
:columns="providerColumns"
|
||||
:data="appStore.modelGroups.map(g => ({
|
||||
provider: g.label,
|
||||
models: g.models.join(', '),
|
||||
base_url: g.base_url,
|
||||
}))"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:row-props="() => ({ style: 'cursor: default;' })"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chat Settings -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-title">Chat Settings</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default Model</label>
|
||||
<NSelect
|
||||
v-model:value="appStore.selectedModel"
|
||||
:options="modelOptions"
|
||||
placeholder="Select model"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Stream Responses</label>
|
||||
<NSwitch v-model:value="appStore.streamEnabled" />
|
||||
@@ -130,11 +111,11 @@ const endpoints = [
|
||||
<h3 class="section-title">About</h3>
|
||||
<p class="about-text">
|
||||
Hermes Agent Web UI
|
||||
<br />Version 0.1.0
|
||||
<br />Version 0.1.3
|
||||
</p>
|
||||
<div class="endpoint-table">
|
||||
<NDataTable
|
||||
:columns="endpointColumns"
|
||||
:columns="[{ title: 'Method', key: 'method', width: 80 }, { title: 'Endpoint', key: 'endpoint' }, { title: 'Description', key: 'description' }]"
|
||||
:data="endpoints"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
@@ -202,14 +183,10 @@ const endpoints = [
|
||||
}
|
||||
}
|
||||
|
||||
.input-with-action {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.n-input {
|
||||
flex: 1;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
@@ -241,6 +218,22 @@ const endpoints = [
|
||||
}
|
||||
}
|
||||
|
||||
.current-model {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
padding: 6px 10px;
|
||||
background: $bg-secondary;
|
||||
border-radius: $radius-sm;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NInput } from 'naive-ui'
|
||||
import SkillList from '@/components/skills/SkillList.vue'
|
||||
import SkillDetail from '@/components/skills/SkillDetail.vue'
|
||||
import { fetchSkills, type SkillCategory } from '@/api/skills'
|
||||
|
||||
const categories = ref<SkillCategory[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedCategory = ref('')
|
||||
const selectedSkill = ref('')
|
||||
const searchQuery = ref('')
|
||||
|
||||
onMounted(loadSkills)
|
||||
|
||||
async function loadSkills() {
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = await fetchSkills()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load skills:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(category: string, skill: string) {
|
||||
selectedCategory.value = category
|
||||
selectedSkill.value = skill
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skills-view">
|
||||
<header class="skills-header">
|
||||
<h2 class="header-title">Skills</h2>
|
||||
<NInput
|
||||
v-model:value="searchQuery"
|
||||
placeholder="Search skills..."
|
||||
size="small"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="skills-content">
|
||||
<div v-if="loading && categories.length === 0" class="skills-loading">Loading...</div>
|
||||
<div v-else class="skills-layout">
|
||||
<div class="skills-sidebar">
|
||||
<SkillList
|
||||
:categories="categories"
|
||||
:selected-skill="selectedCategory && selectedSkill ? `${selectedCategory}/${selectedSkill}` : null"
|
||||
:search-query="searchQuery"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
<div class="skills-main">
|
||||
<SkillDetail
|
||||
v-if="selectedCategory && selectedSkill"
|
||||
:category="selectedCategory"
|
||||
:skill="selectedSkill"
|
||||
/>
|
||||
<div v-else class="empty-detail">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.skills-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skills-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.skills-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skills-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.skills-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skills-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skills-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-detail {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user