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
|
dist-ssr
|
||||||
server/dist
|
server/dist
|
||||||
*.local
|
*.local
|
||||||
|
ROADMAP.md
|
||||||
# Server data
|
# Server data
|
||||||
server/data/
|
server/data/
|
||||||
server/node_modules/
|
server/node_modules/
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ hermes-web-ui/
|
|||||||
│ │ ├── proxy.ts # API proxy to Hermes (/api/*, /v1/*)
|
│ │ ├── proxy.ts # API proxy to Hermes (/api/*, /v1/*)
|
||||||
│ │ ├── upload.ts # File upload (POST /upload)
|
│ │ ├── upload.ts # File upload (POST /upload)
|
||||||
│ │ ├── sessions.ts # Session management via Hermes CLI
|
│ │ ├── sessions.ts # Session management via Hermes CLI
|
||||||
|
│ │ ├── filesystem.ts # Skills, memory, config model management
|
||||||
│ │ ├── webhook.ts # Webhook receiver
|
│ │ ├── webhook.ts # Webhook receiver
|
||||||
│ │ └── logs.ts # Log file listing and reading
|
│ │ └── logs.ts # Log file listing and reading
|
||||||
│ └── services/
|
│ └── services/
|
||||||
@@ -80,13 +81,16 @@ hermes-web-ui/
|
|||||||
│ ├── api/ # Frontend API layer
|
│ ├── api/ # Frontend API layer
|
||||||
│ ├── stores/ # Pinia state management
|
│ ├── stores/ # Pinia state management
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── layout/AppSidebar.vue # Sidebar navigation
|
│ │ ├── layout/
|
||||||
|
│ │ │ ├── AppSidebar.vue # Sidebar navigation
|
||||||
|
│ │ │ └── ModelSelector.vue # Global model selector
|
||||||
│ │ ├── chat/ # Chat components
|
│ │ ├── chat/ # Chat components
|
||||||
│ │ └── jobs/ # Job components
|
│ │ └── jobs/ # Job components
|
||||||
│ ├── views/
|
│ ├── views/
|
||||||
│ │ ├── ChatView.vue # Chat page
|
│ │ ├── ChatView.vue # Chat page
|
||||||
│ │ ├── JobsView.vue # Jobs page
|
│ │ ├── JobsView.vue # Jobs page
|
||||||
│ │ └── LogsView.vue # Logs page
|
│ │ ├── LogsView.vue # Logs page
|
||||||
|
│ │ └── SettingsView.vue # Settings (model management)
|
||||||
│ └── router/index.ts # Router configuration
|
│ └── router/index.ts # Router configuration
|
||||||
└── dist/ # Build output (published to npm)
|
└── dist/ # Build output (published to npm)
|
||||||
├── server/index.js # Compiled BFF
|
├── server/index.js # Compiled BFF
|
||||||
@@ -102,6 +106,16 @@ hermes-web-ui/
|
|||||||
- Multi-session switching with message history
|
- Multi-session switching with message history
|
||||||
- Markdown rendering with syntax highlighting and code copy
|
- Markdown rendering with syntax highlighting and code copy
|
||||||
- File upload support (saved to temp, path passed to API)
|
- 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
|
### Scheduled Jobs
|
||||||
- Job list view (including paused/disabled jobs)
|
- Job list view (including paused/disabled jobs)
|
||||||
@@ -133,6 +147,9 @@ The BFF layer handles:
|
|||||||
- SSE streaming passthrough
|
- SSE streaming passthrough
|
||||||
- File upload to temp directory
|
- File upload to temp directory
|
||||||
- Session CRUD via Hermes CLI
|
- 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
|
- Log file reading and parsing
|
||||||
- Static file serving (SPA fallback)
|
- Static file serving (SPA fallback)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-web-ui",
|
"name": "hermes-web-ui",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"description": "Hermes Agent Web UI - Chat and Job Management Dashboard",
|
"description": "Hermes Agent Web UI - Chat and Job Management Dashboard",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { uploadRoutes } from './routes/upload'
|
|||||||
import { sessionRoutes } from './routes/sessions'
|
import { sessionRoutes } from './routes/sessions'
|
||||||
import { webhookRoutes } from './routes/webhook'
|
import { webhookRoutes } from './routes/webhook'
|
||||||
import { logRoutes } from './routes/logs'
|
import { logRoutes } from './routes/logs'
|
||||||
|
import { fsRoutes } from './routes/filesystem'
|
||||||
import * as hermesCli from './services/hermes-cli'
|
import * as hermesCli from './services/hermes-cli'
|
||||||
const { restartGateway } = hermesCli
|
const { restartGateway } = hermesCli
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ export async function bootstrap() {
|
|||||||
app.use(logRoutes.routes())
|
app.use(logRoutes.routes())
|
||||||
app.use(uploadRoutes.routes())
|
app.use(uploadRoutes.routes())
|
||||||
app.use(sessionRoutes.routes())
|
app.use(sessionRoutes.routes())
|
||||||
|
app.use(fsRoutes.routes())
|
||||||
|
|
||||||
// Health endpoint with version
|
// Health endpoint with version
|
||||||
app.use(async (ctx, next) => {
|
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()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
appStore.loadModels()
|
||||||
appStore.startHealthPolling()
|
appStore.startHealthPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface StartRunRequest {
|
|||||||
instructions?: string
|
instructions?: string
|
||||||
conversation_history?: ChatMessage[]
|
conversation_history?: ChatMessage[]
|
||||||
session_id?: string
|
session_id?: string
|
||||||
|
model?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartRunResponse {
|
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[]
|
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> {
|
export async function checkHealth(): Promise<HealthResponse> {
|
||||||
return request<HealthResponse>('/health')
|
return request<HealthResponse>('/health')
|
||||||
}
|
}
|
||||||
@@ -23,3 +58,36 @@ export async function checkHealth(): Promise<HealthResponse> {
|
|||||||
export async function fetchModels(): Promise<ModelsResponse> {
|
export async function fetchModels(): Promise<ModelsResponse> {
|
||||||
return request<ModelsResponse>('/v1/models')
|
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 textareaRef = ref<HTMLTextAreaElement>()
|
||||||
const fileInputRef = ref<HTMLInputElement>()
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
const attachments = ref<Attachment[]>([])
|
const attachments = ref<Attachment[]>([])
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragCounter = ref(0)
|
||||||
|
|
||||||
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 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() {
|
function handleAttachClick() {
|
||||||
fileInputRef.value?.click()
|
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() {
|
function handleSend() {
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
if (!text && attachments.value.length === 0) return
|
if (!text && attachments.value.length === 0) return
|
||||||
@@ -24,7 +119,6 @@ function handleSend() {
|
|||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
attachments.value = []
|
attachments.value = []
|
||||||
|
|
||||||
// Reset textarea height
|
|
||||||
if (textareaRef.value) {
|
if (textareaRef.value) {
|
||||||
textareaRef.value.style.height = 'auto'
|
textareaRef.value.style.height = 'auto'
|
||||||
}
|
}
|
||||||
@@ -43,28 +137,6 @@ function handleInput(e: Event) {
|
|||||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
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) {
|
function removeAttachment(id: string) {
|
||||||
const idx = attachments.value.findIndex(a => a.id === id)
|
const idx = attachments.value.findIndex(a => a.id === id)
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
@@ -110,7 +182,14 @@ function isImage(type: string): boolean {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-wrapper">
|
<div
|
||||||
|
class="input-wrapper"
|
||||||
|
:class="{ 'drag-over': isDragging }"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@dragenter="handleDragEnter"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -126,6 +205,7 @@ function isImage(type: string): boolean {
|
|||||||
rows="1"
|
rows="1"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
|
@paste="handlePaste"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="input-actions">
|
<div class="input-actions">
|
||||||
<NTooltip trigger="hover">
|
<NTooltip trigger="hover">
|
||||||
@@ -288,4 +368,11 @@ function isImage(type: string): boolean {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-items: center;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ const activeSessionLabel = computed(() =>
|
|||||||
chatStore.activeSession?.id || 'New Chat',
|
chatStore.activeSession?.id || 'New Chat',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sessionModelLabel = computed(() =>
|
||||||
|
chatStore.activeSession?.model || appStore.selectedModel || '',
|
||||||
|
)
|
||||||
|
|
||||||
function handleNewChat() {
|
function handleNewChat() {
|
||||||
chatStore.newChat()
|
chatStore.newChat()
|
||||||
}
|
}
|
||||||
@@ -58,7 +62,7 @@ function formatTime(ts: number) {
|
|||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showSessions" class="session-items">
|
<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>
|
<div v-else-if="sortedSessions.length === 0" class="session-empty">No sessions</div>
|
||||||
<button
|
<button
|
||||||
v-for="s in sortedSessions"
|
v-for="s in sortedSessions"
|
||||||
@@ -68,8 +72,11 @@ function formatTime(ts: number) {
|
|||||||
@click="chatStore.switchSession(s.id)"
|
@click="chatStore.switchSession(s.id)"
|
||||||
>
|
>
|
||||||
<div class="session-item-content">
|
<div class="session-item-content">
|
||||||
<span class="session-item-title">{{ s.id }}</span>
|
<span class="session-item-title">{{ s.title }}</span>
|
||||||
<span class="session-item-time">{{ formatTime(s.createdAt) }}</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>
|
</div>
|
||||||
<NPopconfirm
|
<NPopconfirm
|
||||||
v-if="s.id !== chatStore.activeSessionId || sortedSessions.length > 1"
|
v-if="s.id !== chatStore.activeSessionId || sortedSessions.length > 1"
|
||||||
@@ -96,7 +103,9 @@ function formatTime(ts: number) {
|
|||||||
</template>
|
</template>
|
||||||
</NButton>
|
</NButton>
|
||||||
<span class="header-session-title">{{ activeSessionLabel }}</span>
|
<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>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<NTooltip trigger="hover">
|
<NTooltip trigger="hover">
|
||||||
@@ -224,12 +233,31 @@ function formatTime(ts: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-item-time {
|
.session-item-time {
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
margin-top: 2px;
|
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 {
|
.session-item-delete {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -269,6 +297,14 @@ function formatTime(ts: number) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 240px;
|
||||||
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-session-title {
|
.header-session-title {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import ModelSelector from './ModelSelector.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -47,6 +48,32 @@ function handleNav(key: string) {
|
|||||||
<span>Jobs</span>
|
<span>Jobs</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'logs' }"
|
:class="{ active: selectedKey === 'logs' }"
|
||||||
@@ -63,6 +90,8 @@ function handleNav(key: string) {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<ModelSelector />
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="status-row">
|
<div class="status-row">
|
||||||
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
|
<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',
|
name: 'logs',
|
||||||
component: () => import('@/views/LogsView.vue'),
|
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',
|
path: '/settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
redirect: '/',
|
component: () => import('@/views/SettingsView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
+21
-11
@@ -1,19 +1,18 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { checkHealth, fetchModels } from '@/api/system'
|
import { checkHealth, fetchAvailableModels, updateDefaultModel, type AvailableModelGroup } from '@/api/system'
|
||||||
import type { Model } from '@/api/system'
|
|
||||||
|
|
||||||
export const useAppStore = defineStore('app', () => {
|
export const useAppStore = defineStore('app', () => {
|
||||||
const connected = ref(false)
|
const connected = ref(false)
|
||||||
const serverVersion = ref('')
|
const serverVersion = ref('')
|
||||||
const models = ref<Model[]>([])
|
const modelGroups = ref<AvailableModelGroup[]>([])
|
||||||
|
const selectedModel = ref('')
|
||||||
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
|
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
const streamEnabled = ref(true)
|
const streamEnabled = ref(true)
|
||||||
const sessionPersistence = ref(true)
|
const sessionPersistence = ref(true)
|
||||||
const maxTokens = ref(4096)
|
const maxTokens = ref(4096)
|
||||||
const selectedModel = ref('hermes-agent')
|
|
||||||
|
|
||||||
async function checkConnection() {
|
async function checkConnection() {
|
||||||
try {
|
try {
|
||||||
@@ -27,16 +26,26 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
async function loadModels() {
|
async function loadModels() {
|
||||||
try {
|
try {
|
||||||
const res = await fetchModels()
|
const res = await fetchAvailableModels()
|
||||||
models.value = res.data || []
|
modelGroups.value = res.groups
|
||||||
if (models.value.length > 0 && !models.value.find(m => m.id === selectedModel.value)) {
|
selectedModel.value = res.default
|
||||||
selectedModel.value = models.value[0].id
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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) {
|
function startHealthPolling(interval = 30000) {
|
||||||
stopHealthPolling()
|
stopHealthPolling()
|
||||||
checkConnection()
|
checkConnection()
|
||||||
@@ -53,13 +62,14 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
return {
|
return {
|
||||||
connected,
|
connected,
|
||||||
serverVersion,
|
serverVersion,
|
||||||
models,
|
modelGroups,
|
||||||
|
selectedModel,
|
||||||
streamEnabled,
|
streamEnabled,
|
||||||
sessionPersistence,
|
sessionPersistence,
|
||||||
maxTokens,
|
maxTokens,
|
||||||
selectedModel,
|
|
||||||
checkConnection,
|
checkConnection,
|
||||||
loadModels,
|
loadModels,
|
||||||
|
switchModel,
|
||||||
startHealthPolling,
|
startHealthPolling,
|
||||||
stopHealthPolling,
|
stopHealthPolling,
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-1
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
|
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
|
||||||
import { fetchSessions, fetchSession, deleteSession as deleteSessionApi, type SessionSummary, type HermesMessage } from '@/api/sessions'
|
import { fetchSessions, fetchSession, deleteSession as deleteSessionApi, type SessionSummary, type HermesMessage } from '@/api/sessions'
|
||||||
|
import { useAppStore } from './app'
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
id: string
|
id: string
|
||||||
@@ -31,6 +32,7 @@ export interface Session {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
model?: string
|
model?: string
|
||||||
|
provider?: string
|
||||||
messageCount?: number
|
messageCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +127,7 @@ function mapHermesSession(s: SessionSummary): Session {
|
|||||||
createdAt: Math.round(s.started_at * 1000),
|
createdAt: Math.round(s.started_at * 1000),
|
||||||
updatedAt: Math.round((s.ended_at || s.started_at) * 1000),
|
updatedAt: Math.round((s.ended_at || s.started_at) * 1000),
|
||||||
model: s.model,
|
model: s.model,
|
||||||
|
provider: (s as any).billing_provider || '',
|
||||||
messageCount: s.message_count,
|
messageCount: s.message_count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +148,22 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
try {
|
try {
|
||||||
const list = await fetchSessions('api_server')
|
const list = await fetchSessions('api_server')
|
||||||
sessions.value = list.map(mapHermesSession)
|
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
|
// Auto-select the most recent session
|
||||||
if (!activeSessionId.value && sessions.value.length > 0) {
|
if (!activeSessionId.value && sessions.value.length > 0) {
|
||||||
await switchSession(sessions.value[0].id)
|
await switchSession(sessions.value[0].id)
|
||||||
@@ -180,9 +199,15 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
if (detail && detail.messages) {
|
if (detail && detail.messages) {
|
||||||
const mapped = mapHermesMessages(detail.messages)
|
const mapped = mapHermesMessages(detail.messages)
|
||||||
activeSession.value.messages = mapped
|
activeSession.value.messages = mapped
|
||||||
// Update title from Hermes data
|
// Update title: use Hermes title, or fallback to first user message
|
||||||
if (detail.title) {
|
if (detail.title) {
|
||||||
activeSession.value.title = 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) {
|
} catch (err) {
|
||||||
@@ -198,9 +223,23 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
function newChat() {
|
function newChat() {
|
||||||
if (isStreaming.value) return
|
if (isStreaming.value) return
|
||||||
const session = createSession()
|
const session = createSession()
|
||||||
|
// Inherit current global model
|
||||||
|
const appStore = useAppStore()
|
||||||
|
session.model = appStore.selectedModel || undefined
|
||||||
switchSession(session.id)
|
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) {
|
async function deleteSession(sessionId: string) {
|
||||||
await deleteSessionApi(sessionId)
|
await deleteSessionApi(sessionId)
|
||||||
sessions.value = sessions.value.filter(s => s.id !== 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')
|
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({
|
const run = await startRun({
|
||||||
input: inputText,
|
input: inputText,
|
||||||
conversation_history: history,
|
conversation_history: history,
|
||||||
session_id: activeSession.value?.id,
|
session_id: activeSession.value?.id,
|
||||||
|
model: sessionModel || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const runId = (run as any).run_id || (run as any).id
|
const runId = (run as any).run_id || (run as any).id
|
||||||
@@ -446,6 +489,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
newChat,
|
newChat,
|
||||||
switchSession,
|
switchSession,
|
||||||
|
switchSessionModel,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopStreaming,
|
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">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
NButton, NInput, NSwitch, NSlider, NSelect, NDataTable, useMessage,
|
NButton, NSwitch, NSlider, NDataTable, useMessage,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { setServerUrl, setApiKey, getBaseUrlValue } from '@/api/client'
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const serverUrl = ref(getBaseUrlValue())
|
|
||||||
const apiKey = ref(localStorage.getItem('hermes_api_key') || '')
|
|
||||||
const testingConnection = ref(false)
|
const testingConnection = ref(false)
|
||||||
|
|
||||||
const modelOptions = computed(() =>
|
|
||||||
appStore.models.map(m => ({ label: m.id, value: m.id })),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function handleTestConnection() {
|
async function handleTestConnection() {
|
||||||
testingConnection.value = true
|
testingConnection.value = true
|
||||||
setServerUrl(serverUrl.value)
|
|
||||||
if (apiKey.value) setApiKey(apiKey.value)
|
|
||||||
try {
|
try {
|
||||||
await appStore.checkConnection()
|
await appStore.checkConnection()
|
||||||
if (appStore.connected) {
|
if (appStore.connected) {
|
||||||
@@ -35,34 +26,18 @@ async function handleTestConnection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSaveApiKey() {
|
const providerColumns = [
|
||||||
setApiKey(apiKey.value)
|
{ title: 'Provider', key: 'provider' },
|
||||||
message.success('API key saved')
|
{ title: 'Models', key: 'models' },
|
||||||
}
|
{ title: 'Base URL', key: 'base_url' },
|
||||||
|
|
||||||
const endpointColumns = [
|
|
||||||
{ title: 'Method', key: 'method', width: 80 },
|
|
||||||
{ title: 'Endpoint', key: 'endpoint' },
|
|
||||||
{ title: 'Description', key: 'description' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const endpoints = [
|
const endpoints = [
|
||||||
{ method: 'GET', endpoint: '/health', description: 'Health Check' },
|
{ 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: 'POST', endpoint: '/v1/runs', description: 'Start Async Run' },
|
||||||
{ method: 'GET', endpoint: '/v1/runs/{id}/events', description: 'SSE Event Stream' },
|
{ method: 'GET', endpoint: '/v1/runs/{id}/events', description: 'SSE Event Stream' },
|
||||||
{ method: 'GET', endpoint: '/api/jobs', description: 'List Jobs' },
|
{ method: 'GET', endpoint: '/api/jobs', description: 'List Jobs' },
|
||||||
{ method: 'POST', endpoint: '/api/jobs', description: 'Create Job' },
|
{ 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' },
|
{ method: 'POST', endpoint: '/api/jobs/{id}/run', description: 'Trigger Job Now' },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
@@ -77,17 +52,6 @@ const endpoints = [
|
|||||||
<!-- API Configuration -->
|
<!-- API Configuration -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3 class="section-title">API Configuration</h3>
|
<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="form-group">
|
||||||
<div class="connection-status">
|
<div class="connection-status">
|
||||||
<span class="status-dot" :class="{ on: appStore.connected, off: !appStore.connected }"></span>
|
<span class="status-dot" :class="{ on: appStore.connected, off: !appStore.connected }"></span>
|
||||||
@@ -100,17 +64,34 @@ const endpoints = [
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Chat Settings -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3 class="section-title">Chat Settings</h3>
|
<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">
|
<div class="form-group">
|
||||||
<label class="form-label">Stream Responses</label>
|
<label class="form-label">Stream Responses</label>
|
||||||
<NSwitch v-model:value="appStore.streamEnabled" />
|
<NSwitch v-model:value="appStore.streamEnabled" />
|
||||||
@@ -130,11 +111,11 @@ const endpoints = [
|
|||||||
<h3 class="section-title">About</h3>
|
<h3 class="section-title">About</h3>
|
||||||
<p class="about-text">
|
<p class="about-text">
|
||||||
Hermes Agent Web UI
|
Hermes Agent Web UI
|
||||||
<br />Version 0.1.0
|
<br />Version 0.1.3
|
||||||
</p>
|
</p>
|
||||||
<div class="endpoint-table">
|
<div class="endpoint-table">
|
||||||
<NDataTable
|
<NDataTable
|
||||||
:columns="endpointColumns"
|
:columns="[{ title: 'Method', key: 'method', width: 80 }, { title: 'Endpoint', key: 'endpoint' }, { title: 'Description', key: 'description' }]"
|
||||||
:data="endpoints"
|
:data="endpoints"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -202,14 +183,10 @@ const endpoints = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-with-action {
|
.form-hint {
|
||||||
display: flex;
|
font-size: 12px;
|
||||||
gap: 8px;
|
color: $text-muted;
|
||||||
align-items: center;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
.n-input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-status {
|
.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 {
|
.about-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: $text-secondary;
|
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