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:
ekko
2026-04-12 23:23:50 +08:00
parent ee9f56dfbd
commit 5887462f7d
21 changed files with 1941 additions and 106 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ dist
dist-ssr
server/dist
*.local
ROADMAP.md
# Server data
server/data/
server/node_modules/
+19 -2
View File
@@ -72,6 +72,7 @@ hermes-web-ui/
│ │ ├── proxy.ts # API proxy to Hermes (/api/*, /v1/*)
│ │ ├── upload.ts # File upload (POST /upload)
│ │ ├── sessions.ts # Session management via Hermes CLI
│ │ ├── filesystem.ts # Skills, memory, config model management
│ │ ├── webhook.ts # Webhook receiver
│ │ └── logs.ts # Log file listing and reading
│ └── services/
@@ -80,13 +81,16 @@ hermes-web-ui/
│ ├── api/ # Frontend API layer
│ ├── stores/ # Pinia state management
│ ├── components/
│ │ ├── layout/AppSidebar.vue # Sidebar navigation
│ │ ├── layout/
│ │ │ ├── AppSidebar.vue # Sidebar navigation
│ │ │ └── ModelSelector.vue # Global model selector
│ │ ├── chat/ # Chat components
│ │ └── jobs/ # Job components
│ ├── views/
│ │ ├── ChatView.vue # Chat page
│ │ ├── JobsView.vue # Jobs page
│ │ ── LogsView.vue # Logs page
│ │ ── LogsView.vue # Logs page
│ │ └── SettingsView.vue # Settings (model management)
│ └── router/index.ts # Router configuration
└── dist/ # Build output (published to npm)
├── server/index.js # Compiled BFF
@@ -102,6 +106,16 @@ hermes-web-ui/
- Multi-session switching with message history
- Markdown rendering with syntax highlighting and code copy
- File upload support (saved to temp, path passed to API)
- Model selector — automatically discovers available models from `~/.hermes/auth.json` credential pool
- Global model switching (updates `~/.hermes/config.yaml`)
- Per-session model display (badge in chat header and session list)
### Model Management
- Automatically reads credential pool from `~/.hermes/auth.json`
- Fetches available models from each provider endpoint (`/v1/models`)
- Groups models by provider (e.g. zai, subrouter.ai)
- Switching model updates `model.provider` in config.yaml to bypass env auto-detection
- Error handling: parallel fetching, per-provider timeout, fallback to config.yaml parsing
### Scheduled Jobs
- Job list view (including paused/disabled jobs)
@@ -133,6 +147,9 @@ The BFF layer handles:
- SSE streaming passthrough
- File upload to temp directory
- Session CRUD via Hermes CLI
- Model discovery from `~/.hermes/auth.json` credential pool
- Config.yaml model switching (reads/writes `~/.hermes/config.yaml`)
- Skills, memory, and custom provider management
- Log file reading and parsing
- Static file serving (SPA fallback)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-web-ui",
"version": "0.1.3",
"version": "0.1.4",
"description": "Hermes Agent Web UI - Chat and Job Management Dashboard",
"repository": {
"type": "git",
+2
View File
@@ -11,6 +11,7 @@ import { uploadRoutes } from './routes/upload'
import { sessionRoutes } from './routes/sessions'
import { webhookRoutes } from './routes/webhook'
import { logRoutes } from './routes/logs'
import { fsRoutes } from './routes/filesystem'
import * as hermesCli from './services/hermes-cli'
const { restartGateway } = hermesCli
@@ -28,6 +29,7 @@ export async function bootstrap() {
app.use(logRoutes.routes())
app.use(uploadRoutes.routes())
app.use(sessionRoutes.routes())
app.use(fsRoutes.routes())
// Health endpoint with version
app.use(async (ctx, next) => {
+511
View File
@@ -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 }
}
})
+1
View File
@@ -9,6 +9,7 @@ import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
onMounted(() => {
appStore.loadModels()
appStore.startHealthPolling()
})
+1
View File
@@ -10,6 +10,7 @@ export interface StartRunRequest {
instructions?: string
conversation_history?: ChatMessage[]
session_id?: string
model?: string
}
export interface StartRunResponse {
+55
View File
@@ -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 }),
})
}
+68
View File
@@ -16,6 +16,41 @@ export interface ModelsResponse {
data: Model[]
}
// Config-based model types
export interface ModelInfo {
id: string
label: string
}
export interface ModelGroup {
provider: string
models: ModelInfo[]
}
export interface ConfigModelsResponse {
default: string
groups: ModelGroup[]
}
export interface AvailableModelGroup {
provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai")
label: string // display name (e.g. "zai", "subrouter.ai")
base_url: string
models: string[]
}
export interface AvailableModelsResponse {
default: string
groups: AvailableModelGroup[]
}
export interface CustomProvider {
name: string
base_url: string
api_key: string
model: string
}
export async function checkHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/health')
}
@@ -23,3 +58,36 @@ export async function checkHealth(): Promise<HealthResponse> {
export async function fetchModels(): Promise<ModelsResponse> {
return request<ModelsResponse>('/v1/models')
}
export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
return request<ConfigModelsResponse>('/api/config/models')
}
export async function fetchAvailableModels(): Promise<AvailableModelsResponse> {
return request<AvailableModelsResponse>('/api/available-models')
}
export async function updateDefaultModel(data: {
default: string
provider?: string
base_url?: string
api_key?: string
}): Promise<void> {
await request('/api/config/model', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function addCustomProvider(data: CustomProvider): Promise<void> {
await request('/api/config/providers', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function removeCustomProvider(name: string): Promise<void> {
await request(`/api/config/providers/${encodeURIComponent(name)}`, {
method: 'DELETE',
})
}
+111 -24
View File
@@ -9,13 +9,108 @@ const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const fileInputRef = ref<HTMLInputElement>()
const attachments = ref<Attachment[]>([])
const isDragging = ref(false)
const dragCounter = ref(0)
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
// --- Voice input (Web Speech API) ---
// TODO: re-enable when needed — browser-native speech-to-text
// const hasSpeechRecognition = ref(false)
// let recognition: SpeechRecognition | null = null
// let finalTranscript = ''
// let prefixText = ''
// onMounted(() => {
// const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
// if (!SR) return
// recognition = new SR()
// recognition.continuous = false
// recognition.interimResults = true
// recognition.lang = 'en-US'
// hasSpeechRecognition.value = true
// recognition.onresult = (event: SpeechRecognitionEvent) => { ... }
// recognition.onend = () => { ... }
// recognition.onerror = (event: SpeechRecognitionErrorEvent) => { ... }
// })
// onUnmounted(() => { if (recognition && isRecording.value) recognition.stop() })
// --- File attachment helpers ---
function addFile(file: File) {
if (attachments.value.find(a => a.name === file.name)) return
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
const url = URL.createObjectURL(file)
attachments.value.push({
id,
name: file.name,
type: file.type,
size: file.size,
url,
file,
})
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
for (const file of input.files) addFile(file)
input.value = ''
}
// --- Paste image ---
function handlePaste(e: ClipboardEvent) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(i => i.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const blob = item.getAsFile()
if (!blob) continue
const ext = item.type.split('/')[1] || 'png'
const file = new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type })
addFile(file)
}
}
// --- Drag and drop ---
function handleDragOver(e: DragEvent) {
e.preventDefault()
}
function handleDragEnter(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.types.includes('Files')) {
dragCounter.value++
isDragging.value = true
}
}
function handleDragLeave() {
dragCounter.value--
if (dragCounter.value <= 0) {
dragCounter.value = 0
isDragging.value = false
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
dragCounter.value = 0
isDragging.value = false
const files = Array.from(e.dataTransfer?.files || [])
if (!files.length) return
for (const file of files) addFile(file)
textareaRef.value?.focus()
}
// --- Send ---
function handleSend() {
const text = inputText.value.trim()
if (!text && attachments.value.length === 0) return
@@ -24,7 +119,6 @@ function handleSend() {
inputText.value = ''
attachments.value = []
// Reset textarea height
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
@@ -43,28 +137,6 @@ function handleInput(e: Event) {
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files
if (!files) return
for (const file of files) {
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
const url = URL.createObjectURL(file)
attachments.value.push({
id,
name: file.name,
type: file.type,
size: file.size,
url,
file,
})
}
// Reset input so the same file can be re-selected
input.value = ''
}
function removeAttachment(id: string) {
const idx = attachments.value.findIndex(a => a.id === id)
if (idx !== -1) {
@@ -110,7 +182,14 @@ function isImage(type: string): boolean {
</div>
</div>
<div class="input-wrapper">
<div
class="input-wrapper"
:class="{ 'drag-over': isDragging }"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<input
ref="fileInputRef"
type="file"
@@ -126,6 +205,7 @@ function isImage(type: string): boolean {
rows="1"
@keydown="handleKeydown"
@input="handleInput"
@paste="handlePaste"
></textarea>
<div class="input-actions">
<NTooltip trigger="hover">
@@ -288,4 +368,11 @@ function isImage(type: string): boolean {
flex-shrink: 0;
align-items: center;
}
// Drag-over state
.input-wrapper.drag-over {
border-color: #4a90d9;
border-style: dashed;
background-color: rgba(74, 144, 217, 0.04);
}
</style>
+41 -5
View File
@@ -20,6 +20,10 @@ const activeSessionLabel = computed(() =>
chatStore.activeSession?.id || 'New Chat',
)
const sessionModelLabel = computed(() =>
chatStore.activeSession?.model || appStore.selectedModel || '',
)
function handleNewChat() {
chatStore.newChat()
}
@@ -58,7 +62,7 @@ function formatTime(ts: number) {
</NButton>
</div>
<div v-if="showSessions" class="session-items">
<div v-if="chatStore.isLoadingSessions" class="session-loading">Loading...</div>
<div v-if="chatStore.isLoadingSessions && sortedSessions.length === 0" class="session-loading">Loading...</div>
<div v-else-if="sortedSessions.length === 0" class="session-empty">No sessions</div>
<button
v-for="s in sortedSessions"
@@ -68,8 +72,11 @@ function formatTime(ts: number) {
@click="chatStore.switchSession(s.id)"
>
<div class="session-item-content">
<span class="session-item-title">{{ s.id }}</span>
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
<span class="session-item-title">{{ s.title }}</span>
<span class="session-item-meta">
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
</span>
</div>
<NPopconfirm
v-if="s.id !== chatStore.activeSessionId || sortedSessions.length > 1"
@@ -96,7 +103,9 @@ function formatTime(ts: number) {
</template>
</NButton>
<span class="header-session-title">{{ activeSessionLabel }}</span>
<span class="model-badge">{{ appStore.selectedModel }}</span>
</div>
<div class="header-center">
<span v-if="sessionModelLabel" class="model-badge">{{ sessionModelLabel }}</span>
</div>
<div class="header-actions">
<NTooltip trigger="hover">
@@ -224,12 +233,31 @@ function formatTime(ts: number) {
}
.session-item-time {
display: block;
font-size: 11px;
color: $text-muted;
}
.session-item-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.session-item-model {
font-size: 10px;
color: $accent-primary;
background: rgba($accent-primary, 0.08);
padding: 0 5px;
border-radius: 3px;
line-height: 16px;
flex-shrink: 0;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-item-delete {
flex-shrink: 0;
opacity: 0;
@@ -269,6 +297,14 @@ function formatTime(ts: number) {
align-items: center;
gap: 8px;
overflow: hidden;
flex: 1;
min-width: 0;
}
.header-center {
flex-shrink: 0;
max-width: 240px;
min-width: 140px;
}
.header-session-title {
+29
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import ModelSelector from './ModelSelector.vue'
const route = useRoute()
const router = useRouter()
@@ -47,6 +48,32 @@ function handleNav(key: string) {
<span>Jobs</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'skills' }"
@click="handleNav('skills')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>Skills</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'memory' }"
@click="handleNav('memory')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
<span>Memory</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'logs' }"
@@ -63,6 +90,8 @@ function handleNav(key: string) {
</button>
</nav>
<ModelSelector />
<div class="sidebar-footer">
<div class="status-row">
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
+55
View File
@@ -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>
+247
View File
@@ -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>
+193
View File
@@ -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
View File
@@ -18,10 +18,20 @@ const router = createRouter({
name: 'logs',
component: () => import('@/views/LogsView.vue'),
},
{
path: '/skills',
name: 'skills',
component: () => import('@/views/SkillsView.vue'),
},
{
path: '/memory',
name: 'memory',
component: () => import('@/views/MemoryView.vue'),
},
{
path: '/settings',
name: 'settings',
redirect: '/',
component: () => import('@/views/SettingsView.vue'),
},
],
})
+21 -11
View File
@@ -1,19 +1,18 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { checkHealth, fetchModels } from '@/api/system'
import type { Model } from '@/api/system'
import { checkHealth, fetchAvailableModels, updateDefaultModel, type AvailableModelGroup } from '@/api/system'
export const useAppStore = defineStore('app', () => {
const connected = ref(false)
const serverVersion = ref('')
const models = ref<Model[]>([])
const modelGroups = ref<AvailableModelGroup[]>([])
const selectedModel = ref('')
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
// Settings
const streamEnabled = ref(true)
const sessionPersistence = ref(true)
const maxTokens = ref(4096)
const selectedModel = ref('hermes-agent')
async function checkConnection() {
try {
@@ -27,16 +26,26 @@ export const useAppStore = defineStore('app', () => {
async function loadModels() {
try {
const res = await fetchModels()
models.value = res.data || []
if (models.value.length > 0 && !models.value.find(m => m.id === selectedModel.value)) {
selectedModel.value = models.value[0].id
}
const res = await fetchAvailableModels()
modelGroups.value = res.groups
selectedModel.value = res.default
} catch {
// ignore
}
}
async function switchModel(modelId: string, providerOverride?: string) {
try {
// Find the group containing this model to get provider info
const group = modelGroups.value.find(g => g.models.includes(modelId))
const provider = providerOverride || group?.provider || ''
await updateDefaultModel({ default: modelId, provider })
selectedModel.value = modelId
} catch (err: any) {
console.error('Failed to switch model:', err)
}
}
function startHealthPolling(interval = 30000) {
stopHealthPolling()
checkConnection()
@@ -53,13 +62,14 @@ export const useAppStore = defineStore('app', () => {
return {
connected,
serverVersion,
models,
modelGroups,
selectedModel,
streamEnabled,
sessionPersistence,
maxTokens,
selectedModel,
checkConnection,
loadModels,
switchModel,
startHealthPolling,
stopHealthPolling,
}
+45 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
import { fetchSessions, fetchSession, deleteSession as deleteSessionApi, type SessionSummary, type HermesMessage } from '@/api/sessions'
import { useAppStore } from './app'
export interface Attachment {
id: string
@@ -31,6 +32,7 @@ export interface Session {
createdAt: number
updatedAt: number
model?: string
provider?: string
messageCount?: number
}
@@ -125,6 +127,7 @@ function mapHermesSession(s: SessionSummary): Session {
createdAt: Math.round(s.started_at * 1000),
updatedAt: Math.round((s.ended_at || s.started_at) * 1000),
model: s.model,
provider: (s as any).billing_provider || '',
messageCount: s.message_count,
}
}
@@ -145,6 +148,22 @@ export const useChatStore = defineStore('chat', () => {
try {
const list = await fetchSessions('api_server')
sessions.value = list.map(mapHermesSession)
// Backfill titles from first user message for sessions with null title
const nullTitleSessions = sessions.value.filter(s => s.title === 'New Chat')
if (nullTitleSessions.length > 0) {
await Promise.allSettled(
nullTitleSessions.map(async (s) => {
const detail = await fetchSession(s.id)
if (detail?.messages) {
const firstUser = detail.messages.find(m => m.role === 'user')
if (firstUser) {
const t = firstUser.content.slice(0, 40)
s.title = t + (firstUser.content.length > 40 ? '...' : '')
}
}
})
)
}
// Auto-select the most recent session
if (!activeSessionId.value && sessions.value.length > 0) {
await switchSession(sessions.value[0].id)
@@ -180,9 +199,15 @@ export const useChatStore = defineStore('chat', () => {
if (detail && detail.messages) {
const mapped = mapHermesMessages(detail.messages)
activeSession.value.messages = mapped
// Update title from Hermes data
// Update title: use Hermes title, or fallback to first user message
if (detail.title) {
activeSession.value.title = detail.title
} else {
const firstUser = mapped.find(m => m.role === 'user')
if (firstUser) {
const t = firstUser.content.slice(0, 40)
activeSession.value.title = t + (firstUser.content.length > 40 ? '...' : '')
}
}
}
} catch (err) {
@@ -198,9 +223,23 @@ export const useChatStore = defineStore('chat', () => {
function newChat() {
if (isStreaming.value) return
const session = createSession()
// Inherit current global model
const appStore = useAppStore()
session.model = appStore.selectedModel || undefined
switchSession(session.id)
}
async function switchSessionModel(modelId: string, provider?: string) {
if (!activeSession.value) return
activeSession.value.model = modelId
activeSession.value.provider = provider || ''
// If provider changed, update global config too (Hermes requires it)
if (provider) {
const { useAppStore } = await import('./app')
await useAppStore().switchModel(modelId, provider)
}
}
async function deleteSession(sessionId: string) {
await deleteSessionApi(sessionId)
sessions.value = sessions.value.filter(s => s.id !== sessionId)
@@ -273,10 +312,14 @@ export const useChatStore = defineStore('chat', () => {
inputText = inputText ? inputText + '\n\n' + pathParts.join('\n') : pathParts.join('\n')
}
const appStore = useAppStore()
// Use session-level model if set, otherwise fall back to global
const sessionModel = activeSession.value?.model || appStore.selectedModel
const run = await startRun({
input: inputText,
conversation_history: history,
session_id: activeSession.value?.id,
model: sessionModel || undefined,
})
const runId = (run as any).run_id || (run as any).id
@@ -446,6 +489,7 @@ export const useChatStore = defineStore('chat', () => {
isLoadingMessages,
newChat,
switchSession,
switchSessionModel,
deleteSession,
sendMessage,
stopStreaming,
+322
View File
@@ -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
View File
@@ -1,26 +1,17 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref } from 'vue'
import {
NButton, NInput, NSwitch, NSlider, NSelect, NDataTable, useMessage,
NButton, NSwitch, NSlider, NDataTable, useMessage,
} from 'naive-ui'
import { useAppStore } from '@/stores/app'
import { setServerUrl, setApiKey, getBaseUrlValue } from '@/api/client'
const appStore = useAppStore()
const message = useMessage()
const serverUrl = ref(getBaseUrlValue())
const apiKey = ref(localStorage.getItem('hermes_api_key') || '')
const testingConnection = ref(false)
const modelOptions = computed(() =>
appStore.models.map(m => ({ label: m.id, value: m.id })),
)
async function handleTestConnection() {
testingConnection.value = true
setServerUrl(serverUrl.value)
if (apiKey.value) setApiKey(apiKey.value)
try {
await appStore.checkConnection()
if (appStore.connected) {
@@ -35,34 +26,18 @@ async function handleTestConnection() {
}
}
function handleSaveApiKey() {
setApiKey(apiKey.value)
message.success('API key saved')
}
const endpointColumns = [
{ title: 'Method', key: 'method', width: 80 },
{ title: 'Endpoint', key: 'endpoint' },
{ title: 'Description', key: 'description' },
const providerColumns = [
{ title: 'Provider', key: 'provider' },
{ title: 'Models', key: 'models' },
{ title: 'Base URL', key: 'base_url' },
]
const endpoints = [
{ method: 'GET', endpoint: '/health', description: 'Health Check' },
{ method: 'GET', endpoint: '/v1/health', description: 'Health Check (v1)' },
{ method: 'GET', endpoint: '/v1/models', description: 'Model List' },
{ method: 'POST', endpoint: '/v1/chat/completions', description: 'Chat Completions (OpenAI compatible)' },
{ method: 'POST', endpoint: '/v1/responses', description: 'Create Response (stateful)' },
{ method: 'GET', endpoint: '/v1/responses/{id}', description: 'Get Stored Response' },
{ method: 'DELETE', endpoint: '/v1/responses/{id}', description: 'Delete Response' },
{ method: 'POST', endpoint: '/v1/runs', description: 'Start Async Run' },
{ method: 'GET', endpoint: '/v1/runs/{id}/events', description: 'SSE Event Stream' },
{ method: 'GET', endpoint: '/api/jobs', description: 'List Jobs' },
{ method: 'POST', endpoint: '/api/jobs', description: 'Create Job' },
{ method: 'GET', endpoint: '/api/jobs/{id}', description: 'Get Job Detail' },
{ method: 'PATCH', endpoint: '/api/jobs/{id}', description: 'Update Job' },
{ method: 'DELETE', endpoint: '/api/jobs/{id}', description: 'Delete Job' },
{ method: 'POST', endpoint: '/api/jobs/{id}/pause', description: 'Pause Job' },
{ method: 'POST', endpoint: '/api/jobs/{id}/resume', description: 'Resume Job' },
{ method: 'POST', endpoint: '/api/jobs/{id}/run', description: 'Trigger Job Now' },
]
</script>
@@ -77,17 +52,6 @@ const endpoints = [
<!-- API Configuration -->
<section class="settings-section">
<h3 class="section-title">API Configuration</h3>
<div class="form-group">
<label class="form-label">Server URL</label>
<NInput v-model:value="serverUrl" placeholder="http://127.0.0.1:8642" />
</div>
<div class="form-group">
<label class="form-label">API Key (optional)</label>
<div class="input-with-action">
<NInput v-model:value="apiKey" type="password" show-password-on="click" placeholder="Enter API key" />
<NButton size="small" @click="handleSaveApiKey">Save</NButton>
</div>
</div>
<div class="form-group">
<div class="connection-status">
<span class="status-dot" :class="{ on: appStore.connected, off: !appStore.connected }"></span>
@@ -100,17 +64,34 @@ const endpoints = [
</div>
</section>
<!-- Model Management -->
<section class="settings-section">
<h3 class="section-title">Model Management</h3>
<div class="form-group">
<label class="form-label">Current Model</label>
<div class="current-model">{{ appStore.selectedModel || 'Not set' }}</div>
</div>
<div v-if="appStore.modelGroups.length > 0" class="form-group">
<label class="form-label">Available Models</label>
<p class="form-hint">Models are discovered from ~/.hermes/auth.json credential pool. Use the sidebar selector to switch.</p>
<NDataTable
:columns="providerColumns"
:data="appStore.modelGroups.map(g => ({
provider: g.label,
models: g.models.join(', '),
base_url: g.base_url,
}))"
:bordered="false"
size="small"
:row-props="() => ({ style: 'cursor: default;' })"
/>
</div>
</section>
<!-- Chat Settings -->
<section class="settings-section">
<h3 class="section-title">Chat Settings</h3>
<div class="form-group">
<label class="form-label">Default Model</label>
<NSelect
v-model:value="appStore.selectedModel"
:options="modelOptions"
placeholder="Select model"
/>
</div>
<div class="form-group">
<label class="form-label">Stream Responses</label>
<NSwitch v-model:value="appStore.streamEnabled" />
@@ -130,11 +111,11 @@ const endpoints = [
<h3 class="section-title">About</h3>
<p class="about-text">
Hermes Agent Web UI
<br />Version 0.1.0
<br />Version 0.1.3
</p>
<div class="endpoint-table">
<NDataTable
:columns="endpointColumns"
:columns="[{ title: 'Method', key: 'method', width: 80 }, { title: 'Endpoint', key: 'endpoint' }, { title: 'Description', key: 'description' }]"
:data="endpoints"
:bordered="false"
size="small"
@@ -202,14 +183,10 @@ const endpoints = [
}
}
.input-with-action {
display: flex;
gap: 8px;
align-items: center;
.n-input {
flex: 1;
}
.form-hint {
font-size: 12px;
color: $text-muted;
margin-bottom: 10px;
}
.connection-status {
@@ -241,6 +218,22 @@ const endpoints = [
}
}
.current-model {
font-size: 14px;
font-weight: 500;
color: $text-primary;
padding: 6px 10px;
background: $bg-secondary;
border-radius: $radius-sm;
display: inline-block;
}
.empty-text {
font-size: 13px;
color: $text-muted;
font-style: italic;
}
.about-text {
font-size: 13px;
color: $text-secondary;
+154
View File
@@ -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>