Files
Hermes-ui/server/src/routes/filesystem.ts
T

631 lines
19 KiB
TypeScript
Raw Normal View History

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 []
}
}
// --- Hardcoded model catalogs (single source: src/shared/providers.ts) ---
import { buildProviderModelMap } from '../shared/providers'
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
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 escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
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,
})
}
// Resolve models: hardcoded catalog first, live probe as fallback
const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = []
const liveEndpoints: typeof endpoints = []
for (const ep of endpoints) {
const catalogModels = PROVIDER_MODEL_CATALOG[ep.key]
if (catalogModels && catalogModels.length > 0) {
groups.push({ provider: ep.key, label: ep.label, base_url: ep.base_url, models: catalogModels })
} else {
liveEndpoints.push(ep)
}
}
// Only probe endpoints not in the catalog
if (liveEndpoints.length > 0) {
const results = await Promise.allSettled(
liveEndpoints.map(async ep => {
const models = await fetchProviderModels(ep.base_url, ep.token)
return { ...ep, models }
}),
)
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, providerKey } = ctx.request.body as {
name: string
base_url: string
api_key: string
model: string
providerKey?: string | null
}
if (!name || !base_url || !model) {
ctx.status = 400
ctx.body = { error: 'Missing name, base_url, or model' }
return
}
if (!api_key) {
ctx.status = 400
ctx.body = { error: 'Missing API key' }
return
}
try {
// 1. Write to config.yaml custom_providers
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')
// 2. Write to auth.json credential_pool so GET /api/available-models sees it immediately
const poolKey = providerKey
|| `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
const auth = await loadAuthJson() || { credential_pool: {} }
if (!auth.credential_pool) auth.credential_pool = {}
// Don't overwrite existing entries for built-in providers
if (!auth.credential_pool[poolKey]) {
auth.credential_pool[poolKey] = []
}
auth.credential_pool[poolKey].push({
id: `${poolKey}-${Date.now()}`,
label: name,
base_url,
access_token: api_key,
last_status: null,
})
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
// 3. Auto-switch model to the newly added provider
let yaml2 = await safeReadFile(configPath) || ''
const modelBlockMatch = yaml2.match(/^(model:\s*\n(?: .+\n)*)/m)
if (modelBlockMatch) {
const lines = [`model:`, ` default: ${model}`, ` provider: ${poolKey}`]
yaml2 = yaml2.replace(modelBlockMatch[1], lines.join('\n') + '\n')
await writeFile(configPath, yaml2, 'utf-8')
}
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// DELETE /api/config/providers/:poolKey
fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => {
const poolKey = decodeURIComponent(ctx.params.poolKey)
try {
const auth = await loadAuthJson()
if (!auth?.credential_pool) {
ctx.status = 404
ctx.body = { error: 'No credential pool found' }
return
}
const keys = Object.keys(auth.credential_pool)
// Guard: cannot delete the last provider
if (keys.length <= 1) {
ctx.status = 400
ctx.body = { error: 'Cannot delete the last provider' }
return
}
if (!(poolKey in auth.credential_pool)) {
ctx.status = 404
ctx.body = { error: `Provider "${poolKey}" not found` }
return
}
// Check if this is the current active provider
const yaml = await safeReadFile(configPath) || ''
const providerMatch = yaml.match(/^ provider:\s*(.+)$/m)
const isCurrent = providerMatch && providerMatch[1].trim() === poolKey
// Save base_url before deleting (needed for config.yaml cleanup)
const deletedBaseUrl = auth.credential_pool[poolKey]?.[0]?.base_url
// 1. Delete from auth.json
delete auth.credential_pool[poolKey]
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
// 2. Remove matching entry from config.yaml custom_providers
// Use base_url to match — more reliable than name (preset key ≠ display name)
if (deletedBaseUrl) {
await copyFile(configPath, configPath + '.bak')
let newYaml = await safeReadFile(configPath) || ''
const entryRegex = new RegExp(
`^- name:.*\\n(?:[ \\t]+.*\\n)*? base_url:\\s*${escapeRegExp(deletedBaseUrl)}\\s*\\n(?:[ \\t]+.*\\n)*`,
'gm',
)
newYaml = newYaml.replace(entryRegex, '').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
await writeFile(configPath, newYaml, 'utf-8')
}
// 3. If was the current provider, switch to first remaining
if (isCurrent) {
const remainingKeys = Object.keys(auth.credential_pool)
if (remainingKeys.length > 0) {
const fallback = remainingKeys[0]
const fallbackEntry = auth.credential_pool[fallback]?.[0]
const catalogModels = PROVIDER_MODEL_CATALOG[fallback] || []
const fallbackModel = catalogModels[0] || fallbackEntry?.label || fallback
await copyFile(configPath, configPath + '.bak')
let newYaml = await safeReadFile(configPath) || ''
const modelBlockMatch = newYaml.match(/^(model:\s*\n(?: .+\n)*)/m)
if (modelBlockMatch) {
const lines = [`model:`, ` default: ${fallbackModel}`, ` provider: ${fallback}`]
newYaml = newYaml.replace(modelBlockMatch[1], lines.join('\n') + '\n')
await writeFile(configPath, newYaml, 'utf-8')
}
}
}
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})