refactor: restructure project for multi-agent extensibility

- Migrate source to packages/client and packages/server directories
- Namespace all Hermes-specific code under hermes/ subdirectories
  (api/hermes/, components/hermes/, views/hermes/, stores/hermes/)
- Add hermes.* route names and /hermes/* path prefixes
- Upgrade @koa/router to v15, adapt path-to-regexp v8 syntax
- Fix proxy path rewriting: /api/hermes/v1/* → /v1/*, /api/hermes/* → /api/*
- Fix frontend API paths to match backend /api/hermes/* routes
- Fix WebSocket terminal path to /api/hermes/terminal
- Add proxyMiddleware for reliable unmatched route proxying
- Add profiles route module and hermes-cli profile commands
- Update CLAUDE.md development guide with new architecture
- Add Chinese README (README_zh.md)
- Add Web Terminal feature to README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-16 08:38:18 +08:00
parent 4917242dca
commit 351c861777
106 changed files with 1409 additions and 317 deletions
+315
View File
@@ -0,0 +1,315 @@
import Router from '@koa/router'
import { readFile, writeFile, copyFile } from 'fs/promises'
import { chmod } from 'fs/promises'
import { resolve } from 'path'
import { homedir } from 'os'
import YAML from 'js-yaml'
import { restartGateway } from '../../services/hermes-cli'
// Platform sections that require gateway restart after config change
const PLATFORM_SECTIONS = new Set([
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
'weixin', 'wecom', 'feishu', 'dingtalk',
])
const configPath = resolve(homedir(), '.hermes/config.yaml')
const envPath = resolve(homedir(), '.hermes/.env')
// Env var → (platform, configPath in PlatformConfig) mapping
// Matches hermes _apply_env_overrides() in gateway/config.py
const envPlatformMap: Record<string, [string, string]> = {
TELEGRAM_BOT_TOKEN: ['telegram', 'token'],
DISCORD_BOT_TOKEN: ['discord', 'token'],
SLACK_BOT_TOKEN: ['slack', 'token'],
MATRIX_ACCESS_TOKEN: ['matrix', 'token'],
MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'],
FEISHU_APP_ID: ['feishu', 'extra.app_id'],
FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'],
DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'],
DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'],
// DingTalk has no _apply_env_overrides entry in hermes;
// the adapter reads these env vars directly at runtime.
DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'],
WECOM_BOT_ID: ['wecom', 'extra.bot_id'],
WECOM_SECRET: ['wecom', 'extra.secret'],
WEIXIN_TOKEN: ['weixin', 'token'],
WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'],
WEIXIN_BASE_URL: ['weixin', 'extra.base_url'],
WHATSAPP_ENABLED: ['whatsapp', 'enabled'],
}
// Reverse map: (platform, configPath) → env var
const platformEnvMap: Record<string, Record<string, string>> = {}
for (const [envVar, [platform, configPath]] of Object.entries(envPlatformMap)) {
if (!platformEnvMap[platform]) platformEnvMap[platform] = {}
platformEnvMap[platform][configPath] = envVar
}
function parseEnv(raw: string): Record<string, string> {
const env: Record<string, string> = {}
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx === -1) continue
const key = trimmed.slice(0, eqIdx).trim()
const val = trimmed.slice(eqIdx + 1).trim()
if (val) env[key] = val
}
return env
}
function setNested(obj: Record<string, any>, path: string, value: any) {
const parts = path.split('.')
let cur = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]]) cur[parts[i]] = {}
cur = cur[parts[i]]
}
cur[parts[parts.length - 1]] = value
}
function getNested(obj: Record<string, any>, path: string): any {
const parts = path.split('.')
let cur = obj
for (const p of parts) {
if (!cur || typeof cur !== 'object') return undefined
cur = cur[p]
}
return cur
}
async function readEnvPlatforms(): Promise<Record<string, any>> {
try {
const raw = await readFile(envPath, 'utf-8')
const env = parseEnv(raw)
const platforms: Record<string, any> = {}
for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) {
const val = env[envKey]
if (val === undefined || val === '') continue
if (!platforms[platform]) platforms[platform] = {}
let finalVal: any = val
if (cfgPath === 'enabled') finalVal = val === 'true'
setNested(platforms[platform], cfgPath, finalVal)
}
return platforms
} catch {
return {}
}
}
// Write a KEY=value to .env (matching hermes save_env_value behavior)
// If value is empty, remove the line instead
async function saveEnvValue(key: string, value: string): Promise<void> {
let raw: string
try {
raw = await readFile(envPath, 'utf-8')
} catch {
raw = ''
}
const remove = !value
const lines = raw.split('\n')
let found = false
const result: string[] = []
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('#')) {
// Check if there's a commented-out version of this key
if (trimmed.startsWith(`# ${key}=`)) {
if (!remove) {
result.push(`${key}=${value}`)
}
found = true
} else {
result.push(line)
}
} else {
const eqIdx = trimmed.indexOf('=')
if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) {
if (!remove) {
result.push(`${key}=${value}`)
}
found = true
} else {
result.push(line)
}
}
}
if (!found && !remove) {
result.push(`${key}=${value}`)
}
// Remove trailing empty lines, keep exactly one trailing newline
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
await writeFile(envPath, output, 'utf-8')
// Set permissions to 0600 (owner only), matching hermes behavior
try { await chmod(envPath, 0o600) } catch { /* ignore */ }
}
async function readConfig(): Promise<Record<string, any>> {
const raw = await readFile(configPath, 'utf-8')
return (YAML.load(raw) as Record<string, any>) || {}
}
async function writeConfig(data: Record<string, any>): Promise<void> {
await copyFile(configPath, configPath + '.bak')
const yamlStr = YAML.dump(data, {
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false,
})
await writeFile(configPath, yamlStr, 'utf-8')
}
export const configRoutes = new Router()
// GET /api/config — read config sections
configRoutes.get('/api/hermes/config', async (ctx) => {
try {
const config = await readConfig()
// Merge .env platform credentials into platforms section
const envPlatforms = await readEnvPlatforms()
if (Object.keys(envPlatforms).length > 0) {
// Deep-merge: env values fill in missing, don't overwrite config.yaml
const existing = config.platforms || {}
for (const [platform, vals] of Object.entries(envPlatforms)) {
existing[platform] = { ...(existing[platform] || {}), ...(vals as Record<string, any>) }
}
config.platforms = existing
}
const { section, sections } = ctx.query
if (section) {
ctx.body = { [section as string]: config[section as string] || {} }
} else if (sections) {
const keys = (sections as string).split(',')
const result: Record<string, any> = {}
for (const key of keys) {
result[key.trim()] = config[key.trim()] || {}
}
ctx.body = result
} else {
ctx.body = config
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// PUT /api/config — update a config section (writes to config.yaml)
configRoutes.put('/api/hermes/config', async (ctx) => {
const { section, values } = ctx.request.body as {
section: string
values: Record<string, any>
}
if (!section || !values) {
ctx.status = 400
ctx.body = { error: 'Missing section or values' }
return
}
try {
const config = await readConfig()
config[section] = { ...(config[section] || {}), ...values }
await writeConfig(config)
// Restart gateway for platform/channel config changes
if (PLATFORM_SECTIONS.has(section)) {
await restartGateway()
}
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// PUT /api/config/credentials — save platform credentials to .env
// Body: { platform: string, values: Record<string, any> }
// values keys match PlatformConfig paths: 'token', 'extra.app_id', 'extra.app_secret', etc.
configRoutes.put('/api/hermes/config/credentials', async (ctx) => {
const { platform, values } = ctx.request.body as {
platform: string
values: Record<string, any>
}
if (!platform || !values) {
ctx.status = 400
ctx.body = { error: 'Missing platform or values' }
return
}
try {
const envMap = platformEnvMap[platform]
if (!envMap) {
ctx.status = 400
ctx.body = { error: `Unknown platform: ${platform}` }
return
}
// Also clean up config.yaml platforms.<platform> to keep in sync
const config = await readConfig()
let configChanged = false
// Flatten nested values: { extra: { app_id: '' } } → { 'extra.app_id': '' }
const flatValues: Record<string, any> = {}
for (const [key, val] of Object.entries(values)) {
if (key === 'extra' && val && typeof val === 'object') {
for (const [subKey, subVal] of Object.entries(val as Record<string, any>)) {
flatValues[`extra.${subKey}`] = subVal
}
} else {
flatValues[key] = val
}
}
for (const [cfgPath, val] of Object.entries(flatValues)) {
const envVar = envMap[cfgPath]
if (!envVar) continue
if (val === undefined || val === null || val === '') {
await saveEnvValue(envVar, '')
// Remove from config.yaml too
const parts = cfgPath.split('.')
let obj: any = config.platforms?.[platform]
if (obj) {
if (parts.length === 1) {
delete obj[parts[0]]
} else {
let cur = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]]) break
cur = cur[parts[i]]
}
delete cur[parts[parts.length - 1]]
// Clean up empty extra
if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra
}
if (Object.keys(obj).length === 0) {
if (!config.platforms) config.platforms = {}
delete config.platforms[platform]
}
configChanged = true
}
} else {
await saveEnvValue(envVar, String(val))
}
}
if (configChanged) {
await writeConfig(config)
}
// Restart gateway for platform credential changes
await restartGateway()
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
@@ -0,0 +1,651 @@
import Router from '@koa/router'
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
import { join, resolve } from 'path'
import { homedir } from 'os'
import YAML from 'js-yaml'
// --- 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 saveAuthJson(auth: AuthJson): Promise<void> {
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
}
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
enabled: boolean
}
interface SkillCategory {
name: string
description: string
skills: SkillInfo[]
}
// --- Helpers ---
function extractDescription(content: string): string {
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 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
}
}
// --- Config YAML helpers ---
const configPath = resolve(homedir(), '.hermes/config.yaml')
async function readConfigYaml(): Promise<Record<string, any>> {
const raw = await safeReadFile(configPath)
if (!raw) return {}
return (YAML.load(raw) as Record<string, any>) || {}
}
async function writeConfigYaml(config: Record<string, any>): Promise<void> {
await copyFile(configPath, configPath + '.bak')
const yamlStr = YAML.dump(config, {
lineWidth: -1,
noRefs: true,
quotingType: '"',
})
await writeFile(configPath, yamlStr, 'utf-8')
}
// --- Skills Routes ---
// List all skills grouped by category
fsRoutes.get('/api/hermes/skills', async (ctx) => {
const skillsDir = join(hermesDir, 'skills')
try {
// Read disabled skills list from config.yaml
const config = await readConfigYaml()
const disabledList: string[] = config.skills?.disabled || []
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),
enabled: !disabledList.includes(se.name),
})
}
}
if (skills.length > 0) {
categories.push({ name: entry.name, description: catDescription, skills })
}
}
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}` }
}
})
// Toggle skill enabled/disabled via config.yaml skills.disabled
fsRoutes.put('/api/hermes/skills/toggle', async (ctx) => {
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
if (!name || typeof enabled !== 'boolean') {
ctx.status = 400
ctx.body = { error: 'Missing name or enabled flag' }
return
}
try {
const config = await readConfigYaml()
if (!config.skills) config.skills = {}
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
const disabled = config.skills.disabled as string[]
const idx = disabled.indexOf(name)
if (enabled) {
// Enable: remove from disabled list
if (idx !== -1) disabled.splice(idx, 1)
} else {
// Disable: add to disabled list
if (idx === -1) disabled.push(name)
}
await writeConfigYaml(config)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// List files in a skill directory
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/hermes/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/ (must be registered after the /files route)
fsRoutes.get('/api/hermes/skills/{*path}', async (ctx) => {
const filePath = (ctx.params as any).path
const fullPath = resolve(join(hermesDir, 'skills', filePath))
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 ---
fsRoutes.get('/api/hermes/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,
}
})
fsRoutes.post('/api/hermes/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 ---
interface ModelInfo {
id: string
label: string
}
interface ModelGroup {
provider: string
models: ModelInfo[]
}
// Build model list from user's actual config.yaml using js-yaml
function buildModelGroups(config: Record<string, any>): { default: string; groups: ModelGroup[] } {
let defaultModel = ''
let defaultProvider = ''
const groups: ModelGroup[] = []
const allModelIds = new Set<string>()
// 1. Extract current model
const modelSection = config.model
if (typeof modelSection === 'object' && modelSection !== null) {
defaultModel = String(modelSection.default || '').trim()
defaultProvider = String(modelSection.provider || '').trim()
} else if (typeof modelSection === 'string') {
defaultModel = modelSection.trim()
}
// 2. Extract custom_providers section
const customProviders = config.custom_providers
if (Array.isArray(customProviders)) {
const customModels: ModelInfo[] = []
for (const entry of customProviders) {
if (entry && typeof entry === 'object') {
const cName = String(entry.name || '').trim()
const cModel = String(entry.model || '').trim()
if (cName && cModel) {
customModels.push({ id: cModel, label: `${cName}: ${cModel}` })
allModelIds.add(cModel)
}
}
}
if (customModels.length > 0) {
groups.push({ provider: 'Custom', models: customModels })
}
}
// 3. 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/hermes/available-models', async (ctx) => {
try {
const auth = await loadAuthJson()
const pool = auth?.credential_pool || {}
const config = await readConfigYaml()
const modelSection = config.model
let currentDefault = ''
if (typeof modelSection === 'object' && modelSection !== null) {
currentDefault = String(modelSection.default || '').trim()
} else if (typeof modelSection === 'string') {
currentDefault = modelSection.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)
}
}
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(config)
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/hermes/config/models', async (ctx) => {
try {
const config = await readConfigYaml()
ctx.body = buildModelGroups(config)
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// PUT /api/config/model
fsRoutes.put('/api/hermes/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 {
const config = await readConfigYaml()
if (typeof config.model !== 'object' || config.model === null) {
config.model = {}
}
config.model.default = defaultModel
if (reqProvider) {
config.model.provider = reqProvider
}
await writeConfigYaml(config)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// POST /api/config/providers
fsRoutes.post('/api/hermes/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
const config = await readConfigYaml()
if (!Array.isArray(config.custom_providers)) {
config.custom_providers = []
}
config.custom_providers.push({ name, base_url, api_key, model })
await writeConfigYaml(config)
// 2. Write to auth.json credential_pool
const poolKey = providerKey
|| `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
const auth = await loadAuthJson() || { credential_pool: {} }
if (!auth.credential_pool) auth.credential_pool = {}
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 saveAuthJson(auth)
// 3. Auto-switch model to the newly added provider
const config2 = await readConfigYaml()
if (typeof config2.model !== 'object' || config2.model === null) {
config2.model = {}
}
config2.model.default = model
config2.model.provider = poolKey
await writeConfigYaml(config2)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// DELETE /api/config/providers/:poolKey
fsRoutes.delete('/api/hermes/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)
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 config = await readConfigYaml()
const currentProvider = config.model?.provider
const isCurrent = currentProvider === poolKey
// Save base_url before deleting
const deletedBaseUrl = auth.credential_pool[poolKey]?.[0]?.base_url
// 1. Delete from auth.json
delete auth.credential_pool[poolKey]
await saveAuthJson(auth)
// 2. Remove matching entry from config.yaml custom_providers
if (deletedBaseUrl && Array.isArray(config.custom_providers)) {
config.custom_providers = (config.custom_providers as any[]).filter(
(entry: any) => entry.base_url !== deletedBaseUrl,
)
await writeConfigYaml(config)
}
// 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
const config2 = await readConfigYaml()
if (typeof config2.model !== 'object' || config2.model === null) {
config2.model = {}
}
config2.model.default = fallbackModel
config2.model.provider = fallback
await writeConfigYaml(config2)
}
}
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
@@ -0,0 +1,21 @@
import Router from '@koa/router'
import { sessionRoutes } from './sessions'
import { profileRoutes } from './profiles'
import { configRoutes } from './config'
import { fsRoutes } from './filesystem'
import { logRoutes } from './logs'
import { weixinRoutes } from './weixin'
import { proxyRoutes, proxyMiddleware } from './proxy'
import { setupTerminalWebSocket } from './terminal'
export const hermesRoutes = new Router()
hermesRoutes.use(sessionRoutes.routes())
hermesRoutes.use(profileRoutes.routes())
hermesRoutes.use(configRoutes.routes())
hermesRoutes.use(fsRoutes.routes())
hermesRoutes.use(logRoutes.routes())
hermesRoutes.use(weixinRoutes.routes())
hermesRoutes.use(proxyRoutes.routes())
export { setupTerminalWebSocket, proxyMiddleware }
+61
View File
@@ -0,0 +1,61 @@
import Router from '@koa/router'
import * as hermesCli from '../../services/hermes-cli'
export const logRoutes = new Router()
// List available log files
logRoutes.get('/api/hermes/logs', async (ctx) => {
const files = await hermesCli.listLogFiles()
ctx.body = { files }
})
interface LogEntry {
timestamp: string
level: string
logger: string
message: string
raw: string
}
// Parse a single log line into structured entry
function parseLine(line: string): LogEntry | null {
// Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message
const match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/)
if (match) {
return {
timestamp: match[1],
level: match[2],
logger: match[3],
message: match[4],
raw: line,
}
}
// Unparseable line (e.g. traceback continuation)
return null
}
// Read log lines (parsed)
logRoutes.get('/api/hermes/logs/:name', async (ctx) => {
const logName = ctx.params.name
const lines = ctx.query.lines ? parseInt(ctx.query.lines as string, 10) : 100
const level = (ctx.query.level as string) || undefined
const session = (ctx.query.session as string) || undefined
const since = (ctx.query.since as string) || undefined
try {
const content = await hermesCli.readLogs(logName, lines, level, session, since)
const rawLines = content.split('\n')
const entries: (LogEntry | null)[] = []
for (const line of rawLines) {
// Skip header lines like "--- ~/.hermes/logs/agent.log (last 100) ---"
if (line.startsWith('---') || line.trim() === '') continue
entries.push(parseLine(line))
}
ctx.body = { entries }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
@@ -0,0 +1,190 @@
import Router from '@koa/router'
import * as hermesCli from '../../services/hermes-cli'
export const profileRoutes = new Router()
// GET /api/profiles - List all profiles
profileRoutes.get('/api/hermes/profiles', async (ctx) => {
try {
const profiles = await hermesCli.listProfiles()
ctx.body = { profiles }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// POST /api/profiles - Create a new profile
profileRoutes.post('/api/hermes/profiles', async (ctx) => {
const { name, clone } = ctx.request.body as { name?: string; clone?: boolean }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
try {
const output = await hermesCli.createProfile(name, clone)
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// GET /api/profiles/:name - Get profile details
profileRoutes.get('/api/hermes/profiles/:name', async (ctx) => {
const { name } = ctx.params
try {
const profile = await hermesCli.getProfile(name)
ctx.body = { profile }
} catch (err: any) {
ctx.status = err.message.includes('not found') ? 404 : 500
ctx.body = { error: err.message }
}
})
// DELETE /api/profiles/:name - Delete a profile
profileRoutes.delete('/api/hermes/profiles/:name', async (ctx) => {
const { name } = ctx.params
if (name === 'default') {
ctx.status = 400
ctx.body = { error: 'Cannot delete the default profile' }
return
}
try {
const ok = await hermesCli.deleteProfile(name)
if (ok) {
ctx.body = { success: true }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to delete profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// POST /api/profiles/:name/rename - Rename a profile
profileRoutes.post('/api/hermes/profiles/:name/rename', async (ctx) => {
const { name } = ctx.params
const { new_name } = ctx.request.body as { new_name?: string }
if (!new_name) {
ctx.status = 400
ctx.body = { error: 'Missing new_name' }
return
}
try {
const ok = await hermesCli.renameProfile(name, new_name)
if (ok) {
ctx.body = { success: true }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to rename profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// PUT /api/profiles/active - Switch active profile
profileRoutes.put('/api/hermes/profiles/active', async (ctx) => {
const { name } = ctx.request.body as { name?: string }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
try {
// 1. Stop gateway (try launchd/systemd first, ignore if unavailable e.g. WSL)
try { await hermesCli.stopGateway() } catch { }
// 2. Kill gateway by port if still running (for WSL / background mode)
try {
const { execSync } = await import('child_process')
const isWin = process.platform === 'win32'
let pids = ''
if (isWin) {
const out = execSync('netstat -aon | findstr :8642', { encoding: 'utf-8', timeout: 5000 }).trim()
const lines = out.split('\n').filter(l => l.includes('LISTENING'))
pids = Array.from(new Set(lines.map(l => l.trim().split(/\s+/).pop()).filter(Boolean))).join(' ')
} else {
pids = execSync('lsof -ti:8642', { encoding: 'utf-8', timeout: 5000 }).trim()
}
if (pids) {
if (isWin) {
execSync(`taskkill /F /PID ${pids.split(' ').join(' /PID ')}`, { timeout: 5000 })
} else {
execSync(`kill -9 ${pids}`, { timeout: 5000 })
}
await new Promise(r => setTimeout(r, 2000))
}
} catch { }
// 3. Switch profile
const output = await hermesCli.useProfile(name)
await new Promise(r => setTimeout(r, 1000))
// 4. Start gateway — try launchd/systemd first, fall back to background mode
try {
await hermesCli.restartGateway()
} catch {
// Fallback for WSL / environments without launchd/systemd
try {
const pid = await hermesCli.startGatewayBackground()
await new Promise(r => setTimeout(r, 3000))
console.log(`[Profile] Gateway started in background mode (PID: ${pid})`)
} catch (err: any) {
console.error('[Profile] Gateway start failed:', err.message)
}
}
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// POST /api/profiles/:name/export - Export profile to archive
profileRoutes.post('/api/hermes/profiles/:name/export', async (ctx) => {
const { name } = ctx.params
const { output } = ctx.request.body as { output?: string }
try {
const result = await hermesCli.exportProfile(name, output)
ctx.body = { success: true, message: result.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// POST /api/profiles/import - Import profile from archive
profileRoutes.post('/api/hermes/profiles/import', async (ctx) => {
const { archive, name } = ctx.request.body as { archive?: string; name?: string }
if (!archive) {
ctx.status = 400
ctx.body = { error: 'Missing archive path' }
return
}
try {
const result = await hermesCli.importProfile(archive, name)
ctx.body = { success: true, message: result.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
@@ -0,0 +1,85 @@
import type { Context } from 'koa'
import { config } from '../../config'
export async function proxy(ctx: Context) {
const upstream = config.upstream.replace(/\/$/, '')
// Rewrite path for upstream gateway:
// /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix)
// /api/hermes/* -> /api/* (upstream uses /api/ prefix)
const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api')
const url = `${upstream}${upstreamPath}${ctx.search || ''}`
console.log(`[PROXY] ${ctx.method} ${ctx.path} -> ${url}`)
// Build headers — forward most, strip browser-specific ones
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries(ctx.headers)) {
if (value == null) continue
const lower = key.toLowerCase()
if (lower === 'host') {
headers['host'] = new URL(upstream).host
} else if (lower !== 'origin' && lower !== 'referer' && lower !== 'connection') {
const v = Array.isArray(value) ? value[0] : value
if (v) headers[key] = v
}
}
// Add SSE-friendly headers
if (ctx.path.match(/\/events$/)) {
headers['x-accel-buffering'] = 'no'
headers['cache-control'] = 'no-cache'
}
try {
// Build request body from raw body
let body: string | undefined
if (ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD') {
body = (ctx as any).request.rawBody as string | undefined
}
const res = await fetch(url, {
method: ctx.req.method,
headers,
body,
})
// Set response headers
const resHeaders: Record<string, string> = {}
res.headers.forEach((value, key) => {
const lower = key.toLowerCase()
if (lower !== 'transfer-encoding' && lower !== 'connection') {
resHeaders[key] = value
}
})
if (ctx.path.match(/\/events$/)) {
resHeaders['x-accel-buffering'] = 'no'
resHeaders['cache-control'] = 'no-cache'
}
ctx.status = res.status
ctx.set(resHeaders)
// Stream response body
if (res.body) {
const reader = res.body.getReader()
const pump = async () => {
while (true) {
const { done, value } = await reader.read()
if (done) break
ctx.res.write(value)
}
ctx.res.end()
}
await pump()
} else {
ctx.res.end()
}
} catch (err: any) {
if (!ctx.res.headersSent) {
ctx.status = 502
ctx.set('Content-Type', 'application/json')
ctx.body = { error: { message: `Proxy error: ${err.message}` } }
} else {
ctx.res.end()
}
}
}
@@ -0,0 +1,17 @@
import Router from '@koa/router'
import type { Context, Next } from 'koa'
import { proxy } from './proxy-handler'
export const proxyRoutes = new Router()
// Proxy unmatched /api/hermes/* and /v1/* to upstream Hermes API
proxyRoutes.all('/api/hermes/{*any}', proxy)
proxyRoutes.all('/v1/{*any}', proxy)
// Also register as middleware so it works reliably with nested .use()
export async function proxyMiddleware(ctx: Context, next: Next) {
if (ctx.path.startsWith('/api/hermes/') || ctx.path.startsWith('/v1/')) {
return proxy(ctx)
}
await next()
}
@@ -0,0 +1,51 @@
import Router from '@koa/router'
import * as hermesCli from '../../services/hermes-cli'
export const sessionRoutes = new Router()
// List sessions from Hermes
sessionRoutes.get('/api/hermes/sessions', async (ctx) => {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const sessions = await hermesCli.listSessions(source, limit)
ctx.body = { sessions }
})
// Get single session with messages
sessionRoutes.get('/api/hermes/sessions/:id', async (ctx) => {
const session = await hermesCli.getSession(ctx.params.id)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
ctx.body = { session }
})
// Delete session from Hermes
sessionRoutes.delete('/api/hermes/sessions/:id', async (ctx) => {
const ok = await hermesCli.deleteSession(ctx.params.id)
if (!ok) {
ctx.status = 500
ctx.body = { error: 'Failed to delete session' }
return
}
ctx.body = { ok: true }
})
// Rename session
sessionRoutes.post('/api/hermes/sessions/:id/rename', async (ctx) => {
const { title } = ctx.request.body as { title?: string }
if (!title || typeof title !== 'string') {
ctx.status = 400
ctx.body = { error: 'title is required' }
return
}
const ok = await hermesCli.renameSession(ctx.params.id, title.trim())
if (!ok) {
ctx.status = 500
ctx.body = { error: 'Failed to rename session' }
return
}
ctx.body = { ok: true }
})
@@ -0,0 +1,287 @@
import { WebSocketServer } from 'ws'
import type { Server as HttpServer } from 'http'
import { existsSync } from 'fs'
import * as pty from 'node-pty'
import { getToken } from '../../services/auth'
// ─── Shell detection ────────────────────────────────────────────
function findShell(): string {
const candidates = [
process.env.SHELL,
'/bin/zsh',
'/bin/bash',
process.platform === 'win32' ? 'powershell.exe' : null,
process.platform === 'win32' ? 'cmd.exe' : null,
].filter(Boolean) as string[]
for (const shell of candidates) {
if (existsSync(shell)) return shell
}
return '/bin/bash'
}
function shellName(shell: string): string {
return shell.split('/').pop() || 'shell'
}
// ─── Session types ──────────────────────────────────────────────
interface PtySession {
id: string
pty: pty.IPty
shell: string
pid: number
createdAt: number
}
interface Connection {
sessions: Map<string, PtySession>
activeSessionId: string | null
outputBuffers: Map<string, string[]>
}
// ─── Helpers ────────────────────────────────────────────────────
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
function createSession(shell: string): PtySession {
const id = generateId()
let ptyProcess: pty.IPty
try {
ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: process.env.HOME || undefined,
})
} catch (err: any) {
throw new Error(`Failed to spawn shell "${shell}": ${err.message}. Run "npm rebuild node-pty" to fix.`)
}
const session: PtySession = {
id,
pty: ptyProcess,
shell,
pid: ptyProcess.pid,
createdAt: Date.now(),
}
return session
}
// ─── WebSocket server setup ─────────────────────────────────────
export function setupTerminalWebSocket(httpServer: HttpServer) {
const wss = new WebSocketServer({ noServer: true })
const defaultShell = findShell()
httpServer.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url || '', `http://${req.headers.host}`)
if (url.pathname !== '/api/hermes/terminal') {
socket.destroy()
return
}
// Auth check
const authToken = await getToken()
if (authToken) {
const token = url.searchParams.get('token') || ''
if (token !== authToken) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
})
})
wss.on('connection', (ws) => {
const conn: Connection = {
sessions: new Map(),
activeSessionId: null,
outputBuffers: new Map(),
}
// ─── PTY output → WebSocket ──────────────────────────────────
function attachPtyOutput(session: PtySession) {
session.pty.onData((data) => {
if (ws.readyState !== ws.OPEN) return
if (conn.activeSessionId === session.id) {
ws.send(data)
} else {
// Buffer output for inactive sessions
let buf = conn.outputBuffers.get(session.id)
if (!buf) {
buf = []
conn.outputBuffers.set(session.id, buf)
}
buf.push(data)
// Cap buffer at 1MB to prevent memory issues
if (buf.length > 5000) {
buf.splice(0, buf.length - 5000)
}
}
})
session.pty.onExit(({ exitCode }) => {
conn.outputBuffers.delete(session.id)
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
}
conn.sessions.delete(session.id)
console.log(`[Terminal] Session ${session.id} exited (pid ${session.pid}, code ${exitCode})`)
})
}
// ─── Message handler ────────────────────────────────────────
ws.on('message', (raw) => {
const msg = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw)
// JSON control message
if (msg.charCodeAt(0) === 0x7B) {
try {
const parsed = JSON.parse(msg)
handleControl(parsed)
} catch {
// Not valid JSON, fall through to raw input
writeRaw(msg)
}
return
}
writeRaw(msg)
})
function writeRaw(data: string) {
const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null
if (session) {
session.pty.write(data)
}
}
function handleControl(parsed: any) {
switch (parsed.type) {
case 'create': {
const shell = parsed.shell || defaultShell
let session: PtySession
try {
session = createSession(shell)
} catch (err: any) {
ws.send(JSON.stringify({ type: 'error', message: err.message }))
return
}
conn.sessions.set(session.id, session)
conn.activeSessionId = session.id
attachPtyOutput(session)
ws.send(JSON.stringify({
type: 'created',
id: session.id,
pid: session.pid,
shell: shellName(shell),
}))
console.log(`[Terminal] Session created: ${session.id} (${shellName(shell)}, pid ${session.pid})`)
break
}
case 'switch': {
const { sessionId } = parsed
const session = conn.sessions.get(sessionId)
if (!session) {
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }))
return
}
conn.activeSessionId = sessionId
// Send switched first so frontend mounts the correct terminal
ws.send(JSON.stringify({ type: 'switched', id: sessionId }))
// Then flush buffered output for this session
const buf = conn.outputBuffers.get(sessionId)
if (buf && buf.length > 0) {
for (const chunk of buf) {
ws.send(chunk)
}
conn.outputBuffers.delete(sessionId)
}
console.log(`[Terminal] Switched to session ${sessionId}`)
break
}
case 'close': {
const { sessionId } = parsed
const session = conn.sessions.get(sessionId)
if (!session) return
session.pty.kill()
conn.sessions.delete(sessionId)
conn.outputBuffers.delete(sessionId)
if (conn.activeSessionId === sessionId) {
// Auto-switch to the first remaining session
const remaining = Array.from(conn.sessions.keys())
conn.activeSessionId = remaining.length > 0 ? remaining[0] : null
}
console.log(`[Terminal] Session closed: ${sessionId}`)
break
}
case 'resize': {
const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null
if (!session) return
const cols = Math.max(1, parsed.cols || 0)
const rows = Math.max(1, parsed.rows || 0)
try { session.pty.resize(cols, rows) } catch { }
break
}
}
}
// ─── Cleanup ────────────────────────────────────────────────
ws.on('close', () => {
for (const session of Array.from(conn.sessions.values())) {
try { session.pty.kill() } catch { }
}
conn.sessions.clear()
console.log(`[Terminal] Connection closed, all sessions killed`)
})
ws.on('error', () => {
for (const session of Array.from(conn.sessions.values())) {
try { session.pty.kill() } catch { }
}
conn.sessions.clear()
})
// ─── Auto-create first session ──────────────────────────────
let firstSession: PtySession
try {
firstSession = createSession(defaultShell)
} catch (err: any) {
ws.send(JSON.stringify({ type: 'error', message: err.message }))
console.error(`[Terminal] Failed to create session: ${err.message}`)
ws.close()
return
}
conn.sessions.set(firstSession.id, firstSession)
conn.activeSessionId = firstSession.id
attachPtyOutput(firstSession)
ws.send(JSON.stringify({
type: 'created',
id: firstSession.id,
pid: firstSession.pid,
shell: shellName(defaultShell),
}))
console.log(`[Terminal] First session created: ${firstSession.id} (${shellName(defaultShell)}, pid ${firstSession.pid})`)
})
console.log(`[Terminal] WebSocket ready at /terminal (shell: ${defaultShell})`)
}
+136
View File
@@ -0,0 +1,136 @@
import Router from '@koa/router'
import axios from 'axios'
import { readFile, writeFile } from 'fs/promises'
import { chmod } from 'fs/promises'
import { resolve } from 'path'
import { homedir } from 'os'
import { restartGateway } from '../../services/hermes-cli'
const envPath = resolve(homedir(), '.hermes/.env')
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
export const weixinRoutes = new Router()
// GET /api/weixin/qrcode — fetch QR code from Tencent iLink API
weixinRoutes.get('/api/hermes/weixin/qrcode', async (ctx) => {
try {
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, {
params: { bot_type: 3 },
timeout: 15000,
})
const data = res.data
if (!data || !data.qrcode) {
ctx.status = 500
ctx.body = { error: 'Failed to get QR code' }
return
}
ctx.body = {
qrcode: data.qrcode,
qrcode_url: data.qrcode_img_content,
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message || 'Failed to connect to iLink API' }
}
})
// GET /api/weixin/qrcode/status — poll QR scan status
weixinRoutes.get('/api/hermes/weixin/qrcode/status', async (ctx) => {
const qrcode = ctx.query.qrcode as string
if (!qrcode) {
ctx.status = 400
ctx.body = { error: 'Missing qrcode parameter' }
return
}
try {
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_qrcode_status`, {
params: { qrcode },
timeout: 35000,
})
const data = res.data
const status = data?.status || 'wait'
ctx.body = { status }
// If confirmed, return credentials so frontend can save them
if (status === 'confirmed') {
ctx.body = {
status: 'confirmed',
account_id: data.ilink_bot_id,
token: data.bot_token,
base_url: data.baseurl,
}
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message || 'Failed to poll QR status' }
}
})
// POST /api/weixin/save — save weixin credentials to .env
weixinRoutes.post('/api/hermes/weixin/save', async (ctx) => {
const { account_id, token, base_url } = ctx.request.body as {
account_id: string
token: string
base_url?: string
}
if (!account_id || !token) {
ctx.status = 400
ctx.body = { error: 'Missing account_id or token' }
return
}
try {
let raw: string
try {
raw = await readFile(envPath, 'utf-8')
} catch {
raw = ''
}
const entries: Record<string, string> = {
WEIXIN_ACCOUNT_ID: account_id,
WEIXIN_TOKEN: token,
}
if (base_url) entries.WEIXIN_BASE_URL = base_url
const lines = raw.split('\n')
const existingKeys = new Set<string>()
const result: string[] = []
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('#')) {
result.push(line)
continue
}
const eqIdx = trimmed.indexOf('=')
if (eqIdx !== -1) {
const key = trimmed.slice(0, eqIdx).trim()
if (key in entries) {
result.push(`${key}=${entries[key]}`)
existingKeys.add(key)
continue
}
}
result.push(line)
}
for (const [key, val] of Object.entries(entries)) {
if (!existingKeys.has(key)) {
result.push(`${key}=${val}`)
}
}
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
await writeFile(envPath, output, 'utf-8')
try { await chmod(envPath, 0o600) } catch { /* ignore */ }
await restartGateway()
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
+52
View File
@@ -0,0 +1,52 @@
import Router from '@koa/router'
import { randomBytes } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { config } from '../config'
export const uploadRoutes = new Router()
uploadRoutes.post('/upload', async (ctx) => {
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400
ctx.body = { error: 'Expected multipart/form-data' }
return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400
ctx.body = { error: 'Missing boundary' }
return
}
await mkdir(config.uploadDir, { recursive: true })
// Read raw body
const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString('latin1')
const parts = body.split(boundary).slice(1, -1)
const results: { name: string; path: string }[] = []
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n')
if (headerEnd === -1) continue
const header = part.substring(0, headerEnd)
const data = part.substring(headerEnd + 4, part.length - 2)
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
const filename = filenameMatch[1]
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext
const savedPath = `${config.uploadDir}/${savedName}`
await writeFile(savedPath, Buffer.from(data, 'binary'))
results.push({ name: filename, path: savedPath })
}
ctx.body = { files: results }
})
+33
View File
@@ -0,0 +1,33 @@
import Router from '@koa/router'
import { emitWebhook } from '../services/hermes'
export const webhookRoutes = new Router()
/**
* POST /webhook — receive callbacks from Hermes Agent
*
* Expected body:
* {
* "event": "run.completed" | "job.completed" | ...,
* "run_id": "...",
* "data": { ... }
* }
*
* TODO: Add signature verification when Hermes supports webhook signing
*/
webhookRoutes.post('/webhook', async (ctx) => {
const payload = ctx.request.body
if (!payload || !payload.event) {
ctx.status = 400
ctx.body = { error: 'Missing event field' }
return
}
console.log(`[Webhook] Received event: ${payload.event}`)
// Emit to registered callbacks
emitWebhook(payload)
ctx.body = { ok: true }
})