feat: 灵犀 Studio Web UI 定制版
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-06-05 11:29:11 +08:00
co-authored by Cursor
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
@@ -0,0 +1,11 @@
import type { ChatRunSocket } from '../../services/hermes/run-chat'
let chatRunServer: ChatRunSocket | null = null
export function setChatRunServer(server: ChatRunSocket): void {
chatRunServer = server
}
export function getChatRunServer(): ChatRunSocket | null {
return chatRunServer
}
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/codex-auth'
export const codexAuthRoutes = new Router()
codexAuthRoutes.post('/api/hermes/auth/codex/start', ctrl.start)
codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', ctrl.poll)
codexAuthRoutes.get('/api/hermes/auth/codex/status', ctrl.status)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/config'
export const configRoutes = new Router()
configRoutes.get('/api/hermes/config', ctrl.getConfig)
configRoutes.put('/api/hermes/config', ctrl.updateConfig)
configRoutes.put('/api/hermes/config/credentials', ctrl.updateCredentials)
@@ -0,0 +1,10 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/copilot-auth'
export const copilotAuthRoutes = new Router()
copilotAuthRoutes.post('/api/hermes/auth/copilot/start', ctrl.start)
copilotAuthRoutes.get('/api/hermes/auth/copilot/poll/:sessionId', ctrl.poll)
copilotAuthRoutes.get('/api/hermes/auth/copilot/check-token', ctrl.checkToken)
copilotAuthRoutes.post('/api/hermes/auth/copilot/enable', ctrl.enable)
copilotAuthRoutes.post('/api/hermes/auth/copilot/disable', ctrl.disable)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/cron-history'
export const cronHistoryRoutes = new Router()
cronHistoryRoutes.get('/api/cron-history', ctrl.listRuns)
cronHistoryRoutes.get('/api/cron-history/:jobId/:fileName', ctrl.readRun)
@@ -0,0 +1,120 @@
import Router from '@koa/router'
import { basename, extname, isAbsolute } from 'path'
import {
createFileProvider,
localProvider,
isInUploadDir,
validatePath,
resolveHermesPath,
} from '../../services/hermes/file-provider'
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
export const downloadRoutes = new Router()
// MIME type mapping for common extensions
const MIME_MAP: Record<string, string> = {
'.txt': 'text/plain',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.md': 'text/markdown',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.py': 'text/x-python',
'.ts': 'text/typescript',
'.tsx': 'text/typescript',
'.rs': 'text/x-rust',
'.go': 'text/x-go',
'.java': 'text/x-java',
'.c': 'text/x-c',
'.cpp': 'text/x-c++',
'.h': 'text/x-c',
'.sh': 'text/x-shellscript',
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.toml': 'text/toml',
'.log': 'text/plain',
}
function getMimeType(fileName: string): string {
const ext = extname(fileName).toLowerCase()
return MIME_MAP[ext] || 'application/octet-stream'
}
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
downloadRoutes.get('/api/hermes/download', async (ctx) => {
const filePath = ctx.query.path as string | undefined
const fileName = ctx.query.name as string | undefined
if (!filePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const profile = requestedProfile(ctx)
// Validate the path first
// Support both absolute and relative paths
const validPath = isAbsolute(filePath) ? validatePath(filePath) : resolveHermesPath(filePath, profile)
// Choose provider: always use local for upload directory files
let data: Buffer
if (isInUploadDir(validPath)) {
data = await localProvider.readFile(validPath)
} else {
const provider = await createFileProvider(profile)
data = await provider.readFile(validPath)
}
// Determine filename and MIME type
const name = fileName || basename(validPath)
const mime = getMimeType(name)
// Set response headers
ctx.set('Content-Type', mime)
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`)
ctx.set('Content-Length', String(data.length))
ctx.set('Cache-Control', 'no-cache')
ctx.body = data
} catch (err: any) {
const code = err.code || 'unknown'
const statusMap: Record<string, number> = {
missing_path: 400,
invalid_path: 400,
not_found: 404,
ENOENT: 404,
file_too_large: 413,
unsupported_backend: 501,
backend_error: 502,
backend_timeout: 504,
}
ctx.status = statusMap[code] || 500
ctx.body = { error: err.message, code }
}
})
+299
View File
@@ -0,0 +1,299 @@
import Router from '@koa/router'
import {
createFileProvider,
resolveHermesPath,
isSensitivePath,
MAX_EDIT_SIZE,
} from '../../services/hermes/file-provider'
function requestedProfile(ctx: any): string | undefined {
return ctx.state?.profile?.name
}
function resolveRequestPath(ctx: any, relativePath: string): string {
return resolveHermesPath(relativePath, requestedProfile(ctx))
}
async function createRequestFileProvider(ctx: any) {
return createFileProvider(requestedProfile(ctx))
}
function withAbsolutePath<T extends { path: string }>(ctx: any, entry: T): T & { absolutePath: string } {
return { ...entry, absolutePath: resolveRequestPath(ctx, entry.path) }
}
export const fileRoutes = new Router()
function handleError(ctx: any, err: any) {
const code = err.code || 'unknown'
const statusMap: Record<string, number> = {
missing_path: 400,
invalid_path: 400,
not_found: 404,
ENOENT: 404,
already_exists: 409,
permission_denied: 403,
file_too_large: 413,
not_a_directory: 400,
not_a_file: 400,
unsupported_backend: 501,
backend_error: 502,
backend_timeout: 504,
}
ctx.status = statusMap[code] || 500
ctx.body = { error: err.message, code }
}
// GET /api/hermes/files/list?path=
fileRoutes.get('/api/hermes/files/list', async (ctx) => {
const relativePath = (ctx.query.path as string) || ''
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
const entries = await provider.listDir(absPath)
entries.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
return a.name.localeCompare(b.name)
})
ctx.body = { entries: entries.map(entry => withAbsolutePath(ctx, entry)), path: relativePath, absolutePath: absPath }
} catch (err: any) {
handleError(ctx, err)
}
})
// GET /api/hermes/files/stat?path=
fileRoutes.get('/api/hermes/files/stat', async (ctx) => {
const relativePath = ctx.query.path as string
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
const info = await provider.stat(absPath)
ctx.body = withAbsolutePath(ctx, info)
} catch (err: any) {
handleError(ctx, err)
}
})
// GET /api/hermes/files/read?path=
fileRoutes.get('/api/hermes/files/read', async (ctx) => {
const relativePath = ctx.query.path as string
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
const data = await provider.readFile(absPath)
if (data.length > MAX_EDIT_SIZE) {
ctx.status = 413
ctx.body = { error: 'File too large to edit', code: 'file_too_large' }
return
}
ctx.body = { content: data.toString('utf-8'), path: relativePath, size: data.length }
} catch (err: any) {
handleError(ctx, err)
}
})
// PUT /api/hermes/files/write body: { path, content }
fileRoutes.put('/api/hermes/files/write', async (ctx) => {
const { path: relativePath, content } = ctx.request.body as { path?: string; content?: string }
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
if (isSensitivePath(relativePath)) {
ctx.status = 403
ctx.body = { error: 'Cannot modify sensitive file', code: 'permission_denied' }
return
}
try {
const buf = Buffer.from(content || '', 'utf-8')
if (buf.length > MAX_EDIT_SIZE) {
ctx.status = 413
ctx.body = { error: 'Content too large', code: 'file_too_large' }
return
}
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
await provider.writeFile(absPath, buf)
ctx.body = { ok: true, path: relativePath }
} catch (err: any) {
handleError(ctx, err)
}
})
// DELETE /api/hermes/files/delete body: { path, recursive? }
fileRoutes.delete('/api/hermes/files/delete', async (ctx) => {
const { path: relativePath, recursive } = ctx.request.body as { path?: string; recursive?: boolean }
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
if (isSensitivePath(relativePath)) {
ctx.status = 403
ctx.body = { error: 'Cannot delete sensitive file', code: 'permission_denied' }
return
}
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
if (recursive) {
await provider.deleteDir(absPath)
} else {
await provider.deleteFile(absPath)
}
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/rename body: { oldPath, newPath }
fileRoutes.post('/api/hermes/files/rename', async (ctx) => {
const { oldPath, newPath } = ctx.request.body as { oldPath?: string; newPath?: string }
if (!oldPath || !newPath) {
ctx.status = 400
ctx.body = { error: 'Missing oldPath or newPath', code: 'missing_path' }
return
}
if (isSensitivePath(oldPath)) {
ctx.status = 403
ctx.body = { error: 'Cannot rename sensitive file', code: 'permission_denied' }
return
}
try {
const absOld = resolveRequestPath(ctx, oldPath)
const absNew = resolveRequestPath(ctx, newPath)
const provider = await createRequestFileProvider(ctx)
await provider.renameFile(absOld, absNew)
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/mkdir body: { path }
fileRoutes.post('/api/hermes/files/mkdir', async (ctx) => {
const { path: relativePath } = ctx.request.body as { path?: string }
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
await provider.mkDir(absPath)
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/copy body: { srcPath, destPath }
fileRoutes.post('/api/hermes/files/copy', async (ctx) => {
const { srcPath, destPath } = ctx.request.body as { srcPath?: string; destPath?: string }
if (!srcPath || !destPath) {
ctx.status = 400
ctx.body = { error: 'Missing srcPath or destPath', code: 'missing_path' }
return
}
try {
const absSrc = resolveRequestPath(ctx, srcPath)
const absDest = resolveRequestPath(ctx, destPath)
const provider = await createRequestFileProvider(ctx)
await provider.copyFile(absSrc, absDest)
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/upload?path= (multipart/form-data)
fileRoutes.post('/api/hermes/files/upload', async (ctx) => {
const targetDir = (ctx.query.path as string) || ''
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400
ctx.body = { error: 'Expected multipart/form-data', code: 'invalid_request' }
return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400
ctx.body = { error: 'Missing boundary', code: 'invalid_request' }
return
}
const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk)
const raw = Buffer.concat(chunks)
const boundaryBuf = Buffer.from(boundary)
const parts = splitMultipart(raw, boundaryBuf)
const provider = await createRequestFileProvider(ctx)
const results: { name: string; path: string }[] = []
for (const part of parts) {
const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'))
if (headerEnd === -1) continue
const headerBuf = part.subarray(0, headerEnd)
const header = headerBuf.toString('utf-8')
const data = part.subarray(headerEnd + 4, part.length - 2)
let filename = ''
const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i)
if (filenameStarMatch) {
filename = decodeURIComponent(filenameStarMatch[1])
} else {
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
filename = filenameMatch[1]
}
if (data.length > MAX_EDIT_SIZE) {
ctx.status = 413
ctx.body = { error: `File ${filename} too large`, code: 'file_too_large' }
return
}
const filePath = targetDir ? `${targetDir}/${filename}` : filename
if (isSensitivePath(filePath)) {
ctx.status = 403
ctx.body = { error: `Cannot overwrite sensitive file: ${filename}`, code: 'permission_denied' }
return
}
const absPath = resolveRequestPath(ctx, filePath)
await provider.writeFile(absPath, data)
results.push({ name: filename, path: filePath })
}
ctx.body = { files: results }
})
function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] {
const parts: Buffer[] = []
let start = 0
while (true) {
const idx = raw.indexOf(boundary, start)
if (idx === -1) break
if (start > 0) {
const partStart = start + 2
parts.push(raw.subarray(partStart, idx))
}
start = idx + boundary.length
}
return parts
}
@@ -0,0 +1,415 @@
import Router from '@koa/router'
import type { GroupChatServer } from '../../services/hermes/group-chat'
import { isReservedMentionName } from '../../services/hermes/group-chat/mention-routing'
export const groupChatRoutes = new Router()
let chatServer: GroupChatServer | null = null
export function setGroupChatServer(server: GroupChatServer) {
chatServer = server
}
export function getGroupChatServer(): GroupChatServer | null {
return chatServer
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
function generateInviteCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)]
}
return code
}
type AgentInput = { profile: string; name?: string; description?: string; invited?: boolean | number }
function sanitizeAgentConnectReason(reason?: string): string {
return (reason || 'agent runtime connection failed')
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]')
.replace(/(api[_-]?key|token|secret|password)=([^\s]+)/gi, '$1=[REDACTED]')
.split('\n')[0]
.slice(0, 240)
}
function agentConnectFailureBody(profile: string, err: any) {
return {
code: 'PROFILE_AGENT_CONNECT_FAILED',
error: `Failed to connect agent "${profile}" to room`,
profile,
reason: sanitizeAgentConnectReason(err?.message),
}
}
async function connectAndPersistRoomAgent(server: GroupChatServer, roomId: string, input: AgentInput, agentId = generateId()) {
const profile = input.profile
const name = input.name || profile
const description = input.description || ''
const invited = input.invited ? 1 : 0
const client = await server.agentClients.createAgent({
agentId,
profile,
name,
description,
invited,
})
try {
await server.agentClients.addAgentToRoom(roomId, client)
return server.getStorage().addRoomAgent(roomId, agentId, profile, name, description, invited)
} catch (err) {
server.agentClients.removeAgentFromRoom(roomId, client.agentId)
throw err
}
}
// Create room
groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const { name, inviteCode, agents, compression } = ctx.request.body as {
name?: string
inviteCode?: string
agents?: { profile: string; name?: string; description?: string; invited?: boolean }[]
compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }
}
if (!name || !inviteCode) {
ctx.status = 400
ctx.body = { error: 'name and inviteCode are required' }
return
}
const reservedAgent = (agents || []).find(a => isReservedMentionName(a.name || a.profile))
if (reservedAgent) {
ctx.status = 400
ctx.body = { error: '`all` is reserved for @all mentions' }
return
}
const roomId = generateId()
const storage = chatServer.getStorage()
storage.saveRoom(roomId, name, inviteCode, compression)
const addedAgents = []
const agentResults = []
for (const a of agents || []) {
try {
const agent = await connectAndPersistRoomAgent(chatServer, roomId, {
profile: a.profile,
name: a.name || a.profile,
description: a.description || '',
invited: a.invited,
})
addedAgents.push(agent)
agentResults.push({ profile: a.profile, ok: true, agent })
} catch (err: any) {
console.error(`[GroupChat] Failed to connect agent ${a.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`)
agentResults.push({ ok: false, ...agentConnectFailureBody(a.profile, err) })
}
}
const room = storage.getRoom(roomId)
ctx.body = { room, agents: addedAgents, agentResults }
})
// Clone room roles/config without copying the conversation context.
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clone', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const sourceRoom = chatServer.getStorage().getRoom(ctx.params.roomId)
if (!sourceRoom) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
const { name, inviteCode } = ctx.request.body as { name?: string; inviteCode?: string }
const roomId = generateId()
const storage = chatServer.getStorage()
const code = inviteCode?.trim() || generateInviteCode()
storage.saveRoom(roomId, name?.trim() || `${sourceRoom.name} Copy`, code, {
triggerTokens: sourceRoom.triggerTokens,
maxHistoryTokens: sourceRoom.maxHistoryTokens,
tailMessageCount: sourceRoom.tailMessageCount,
})
const addedAgents = []
const agentResults = []
for (const sourceAgent of storage.getRoomAgents(sourceRoom.id)) {
try {
const agent = await connectAndPersistRoomAgent(chatServer, roomId, {
profile: sourceAgent.profile,
name: sourceAgent.name,
description: sourceAgent.description,
invited: sourceAgent.invited,
})
addedAgents.push(agent)
agentResults.push({ profile: sourceAgent.profile, ok: true, agent })
} catch (err: any) {
console.error(`[GroupChat] Failed to connect cloned agent ${sourceAgent.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`)
agentResults.push({ ok: false, ...agentConnectFailureBody(sourceAgent.profile, err) })
}
}
const room = storage.getRoom(roomId)
ctx.body = { room, agents: addedAgents, agentResults }
})
// Get room detail and messages
groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const room = chatServer.getStorage().getRoom(ctx.params.roomId)
if (!room) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
const offset = ctx.query.offset ? Math.max(0, parseInt(ctx.query.offset as string, 10) || 0) : 0
const limit = ctx.query.limit ? Math.max(1, parseInt(ctx.query.limit as string, 10) || 300) : 300
const messages = chatServer.getStorage().getMessages(ctx.params.roomId, limit, offset)
const total = chatServer.getStorage().getMessageCount(ctx.params.roomId)
const agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
const members = chatServer.getStorage().getRoomMembers(ctx.params.roomId)
ctx.body = { room, messages, agents, members, total, offset, limit, hasMore: offset + messages.length < total }
})
// List rooms
groupChatRoutes.get('/api/hermes/group-chat/rooms', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const user = ctx.state.user
const storage = chatServer.getStorage()
const rooms = !user || user.role === 'super_admin'
? storage.getAllRooms()
: storage.getRoomsForProfiles(user.profiles || [])
ctx.body = { rooms }
})
// Get room by invite code
groupChatRoutes.get('/api/hermes/group-chat/rooms/join/:code', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const room = chatServer.getStorage().getRoomByInviteCode(ctx.params.code)
if (!room) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
ctx.body = { room }
})
// Update room invite code
groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/invite-code', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const { inviteCode } = ctx.request.body as { inviteCode?: string }
if (!inviteCode) {
ctx.status = 400
ctx.body = { error: 'inviteCode is required' }
return
}
chatServer.getStorage().updateRoomInviteCode(ctx.params.roomId, inviteCode)
ctx.body = { success: true }
})
// Add agent to room
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const { profile, name, description, invited } = ctx.request.body as { profile?: string; name?: string; description?: string; invited?: boolean }
if (!profile) {
ctx.status = 400
ctx.body = { error: 'profile is required' }
return
}
if (isReservedMentionName(name || profile)) {
ctx.status = 400
ctx.body = { error: '`all` is reserved for @all mentions' }
return
}
// Prevent duplicate agent in same room
const existing = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
if (existing.find(a => a.profile === profile)) {
ctx.status = 409
ctx.body = { error: 'Agent already in room' }
return
}
try {
const agent = await connectAndPersistRoomAgent(chatServer, ctx.params.roomId, {
profile,
name: name || profile,
description: description || '',
invited,
})
ctx.body = { agent }
} catch (err: any) {
console.error(`[GroupChat] Failed to connect agent ${profile} to room ${ctx.params.roomId}: ${sanitizeAgentConnectReason(err.message)}`)
ctx.status = 502
ctx.body = agentConnectFailureBody(profile, err)
}
})
// List agents in room
groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
ctx.body = { agents }
})
// Remove agent from room
groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
const requestedAgentId = ctx.params.agentId
const storage = chatServer.getStorage()
const agent = storage.getRoomAgent(roomId, requestedAgentId)
if (!agent) {
ctx.status = 404
ctx.body = { error: 'Agent not found' }
return
}
storage.removeRoomMembersForAgent(roomId, agent)
storage.removeRoomAgent(roomId, requestedAgentId)
chatServer.agentClients.removeAgentFromRoom(roomId, agent.agentId)
ctx.body = {
success: true,
agents: storage.getRoomAgents(roomId),
members: storage.getRoomMembers(roomId),
}
})
// Delete room
groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
// Disconnect all agents in room
chatServer.agentClients.disconnectRoom(roomId)
// Delete all data
chatServer.getStorage().deleteRoom(roomId)
ctx.body = { success: true }
})
// Clear current room context while keeping members, agents, and room config.
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clear-context', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
if (!chatServer.getStorage().getRoom(roomId)) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
chatServer.getStorage().clearRoomContext(roomId)
chatServer.clearRoomRuntimeState(roomId)
ctx.body = { success: true, room: chatServer.getStorage().getRoom(roomId) }
})
// Update room compression config
groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/config', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
const { triggerTokens, maxHistoryTokens, tailMessageCount } = ctx.request.body as {
triggerTokens?: number
maxHistoryTokens?: number
tailMessageCount?: number
}
chatServer.getStorage().updateRoomConfig(roomId, { triggerTokens, maxHistoryTokens, tailMessageCount })
const room = chatServer.getStorage().getRoom(roomId)
ctx.body = { room }
})
// Force compress a room's context
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/compress', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
if (!chatServer.getStorage().getRoom(roomId)) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
const engine = chatServer.getContextEngine()
if (!engine) {
ctx.status = 503
ctx.body = { error: 'Context engine not available' }
return
}
try {
const result = await engine.forceCompress(roomId)
ctx.body = { success: true, summary: result }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
+13
View File
@@ -0,0 +1,13 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/jobs'
export const jobRoutes = new Router()
jobRoutes.get('/api/hermes/jobs', ctrl.list)
jobRoutes.get('/api/hermes/jobs/:id', ctrl.get)
jobRoutes.post('/api/hermes/jobs', ctrl.create)
jobRoutes.patch('/api/hermes/jobs/:id', ctrl.update)
jobRoutes.delete('/api/hermes/jobs/:id', ctrl.remove)
jobRoutes.post('/api/hermes/jobs/:id/pause', ctrl.pause)
jobRoutes.post('/api/hermes/jobs/:id/resume', ctrl.resume)
jobRoutes.post('/api/hermes/jobs/:id/run', ctrl.run)
@@ -0,0 +1,109 @@
import { WebSocketServer } from 'ws'
import type { WebSocket } from 'ws'
import type { Server as HttpServer, IncomingMessage } from 'http'
import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth'
import { userCanAccessProfile } from '../../db/hermes/users-store'
import { logger } from '../../services/logger'
import * as kanbanCli from '../../services/hermes/hermes-kanban'
interface KanbanEventsRequest extends IncomingMessage {
kanbanBoard?: string
kanbanProfile?: string
}
function sendJson(ws: WebSocket, payload: Record<string, unknown>) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(payload))
}
function streamLines(onLine: (line: string) => void) {
let buffer = ''
return (chunk: Buffer | string) => {
buffer += chunk.toString()
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed) onLine(trimmed)
}
}
}
export function setupKanbanEventsWebSocket(httpServers: HttpServer | HttpServer[]) {
const wss = new WebSocketServer({ noServer: true })
const servers = Array.isArray(httpServers) ? httpServers : [httpServers]
servers.forEach((httpServer) => {
httpServer.on('upgrade', async (req: KanbanEventsRequest, socket, head) => {
const url = new URL(req.url || '', `http://${req.headers.host}`)
if (url.pathname !== '/api/hermes/kanban/events') return
if (await isAuthEnabled()) {
const token = url.searchParams.get('token') || ''
const user = await authenticateUserToken(token)
if (!user) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
const profile = (url.searchParams.get('profile') || '').trim()
if (profile && user.role !== 'super_admin' && !userCanAccessProfile(user.id, profile)) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
socket.destroy()
return
}
req.kanbanProfile = profile || undefined
}
try {
req.kanbanBoard = kanbanCli.normalizeBoardSlug(url.searchParams.get('board'))
} catch {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
})
})
})
wss.on('connection', (ws, req: KanbanEventsRequest) => {
const board = req.kanbanBoard || 'default'
const child = kanbanCli.watchEvents({ board, interval: 0.5 })
let closed = false
sendJson(ws, { type: 'connected', board })
const closeChild = () => {
if (closed) return
closed = true
if (!child.killed) child.kill()
}
child.stdout?.on('data', streamLines((line) => {
if (line.toLowerCase().startsWith('watching kanban events')) return
sendJson(ws, { type: 'event', board })
}))
child.stderr?.on('data', streamLines((line) => {
sendJson(ws, { type: 'error', board, message: line })
}))
child.on('error', (err) => {
logger.error(err, 'Hermes CLI: kanban watch failed')
sendJson(ws, { type: 'error', board, message: err.message })
if (ws.readyState === ws.OPEN) ws.close()
})
child.on('exit', (code, signal) => {
sendJson(ws, { type: 'stopped', board, code, signal })
if (ws.readyState === ws.OPEN) ws.close()
})
ws.on('close', closeChild)
ws.on('error', closeChild)
})
logger.info('WebSocket ready at /api/hermes/kanban/events (kanban watch bridge)')
}
@@ -0,0 +1,30 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/kanban'
export const kanbanRoutes = new Router()
kanbanRoutes.get('/api/hermes/kanban/boards', ctrl.listBoards)
kanbanRoutes.post('/api/hermes/kanban/boards', ctrl.createBoard)
kanbanRoutes.delete('/api/hermes/kanban/boards/:slug', ctrl.archiveBoard)
kanbanRoutes.get('/api/hermes/kanban/capabilities', ctrl.capabilities)
kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats)
kanbanRoutes.get('/api/hermes/kanban/assignees', ctrl.assignees)
kanbanRoutes.get('/api/hermes/kanban/diagnostics', ctrl.diagnostics)
kanbanRoutes.post('/api/hermes/kanban/dispatch', ctrl.dispatch)
kanbanRoutes.get('/api/hermes/kanban/artifact', ctrl.readArtifact)
kanbanRoutes.get('/api/hermes/kanban/search-sessions', ctrl.searchSessions)
kanbanRoutes.post('/api/hermes/kanban/links', ctrl.linkTasks)
kanbanRoutes.delete('/api/hermes/kanban/links', ctrl.unlinkTasks)
kanbanRoutes.post('/api/hermes/kanban/tasks/bulk', ctrl.bulkUpdateTasks)
kanbanRoutes.get('/api/hermes/kanban', ctrl.list)
kanbanRoutes.get('/api/hermes/kanban/:id', ctrl.get)
kanbanRoutes.post('/api/hermes/kanban', ctrl.create)
kanbanRoutes.post('/api/hermes/kanban/complete', ctrl.complete)
kanbanRoutes.post('/api/hermes/kanban/unblock', ctrl.unblock)
kanbanRoutes.post('/api/hermes/kanban/:id/block', ctrl.block)
kanbanRoutes.post('/api/hermes/kanban/:id/assign', ctrl.assign)
kanbanRoutes.post('/api/hermes/kanban/:id/comments', ctrl.addComment)
kanbanRoutes.get('/api/hermes/kanban/:id/log', ctrl.taskLog)
kanbanRoutes.post('/api/hermes/kanban/:id/reclaim', ctrl.reclaim)
kanbanRoutes.post('/api/hermes/kanban/:id/reassign', ctrl.reassign)
kanbanRoutes.post('/api/hermes/kanban/:id/specify', ctrl.specify)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/logs'
export const logRoutes = new Router()
logRoutes.get('/api/hermes/logs', ctrl.list)
logRoutes.get('/api/hermes/logs/:name', ctrl.read)
+12
View File
@@ -0,0 +1,12 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/mcp'
export const mcpRoutes = new Router()
mcpRoutes.get('/api/hermes/mcp/servers', ctrl.listServers)
mcpRoutes.post('/api/hermes/mcp/servers', ctrl.addServer)
mcpRoutes.patch('/api/hermes/mcp/servers/:name', ctrl.updateServer)
mcpRoutes.delete('/api/hermes/mcp/servers/:name', ctrl.removeServer)
mcpRoutes.post('/api/hermes/mcp/servers/:name/test', ctrl.testServer)
mcpRoutes.get('/api/hermes/mcp/tools', ctrl.listTools)
mcpRoutes.post('/api/hermes/mcp/reload', ctrl.reloadMcp)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/media'
export const mediaRoutes = new Router()
mediaRoutes.post('/api/hermes/media/grok-image-to-video', ctrl.grokImageToVideo)
mediaRoutes.post('/api/hermes/media/apikey-image-generate', ctrl.apiKeyImageGenerate)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/memory'
export const memoryRoutes = new Router()
memoryRoutes.get('/api/hermes/memory', ctrl.get)
memoryRoutes.post('/api/hermes/memory', ctrl.save)
@@ -0,0 +1,19 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/models'
export const modelRoutes = new Router()
modelRoutes.get('/api/hermes/available-models', ctrl.getAvailable)
modelRoutes.post('/api/hermes/provider-models', ctrl.fetchProviderModelList)
modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels)
modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel)
modelRoutes.put('/api/hermes/model-alias', ctrl.setModelAlias)
modelRoutes.put('/api/hermes/model-visibility', ctrl.setModelVisibility)
modelRoutes.put('/api/hermes/custom-model', ctrl.addCustomModel)
modelRoutes.delete('/api/hermes/custom-model', ctrl.removeCustomModel)
// Model context routes
modelRoutes.get('/api/hermes/model-context', ctrl.getModelContext)
modelRoutes.get('/api/hermes/model-context/:provider/:model', ctrl.getModelContext)
modelRoutes.put('/api/hermes/model-context/:provider/:model', ctrl.updateModelContext)
modelRoutes.put('/api/hermes/model-context', ctrl.updateModelContext)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/nous-auth'
export const nousAuthRoutes = new Router()
nousAuthRoutes.post('/api/hermes/auth/nous/start', ctrl.start)
nousAuthRoutes.get('/api/hermes/auth/nous/poll/:sessionId', ctrl.poll)
nousAuthRoutes.get('/api/hermes/auth/nous/status', ctrl.status)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/performance-monitor'
import { requireSuperAdmin } from '../../middleware/user-auth'
export const performanceMonitorRoutes = new Router()
performanceMonitorRoutes.get('/api/hermes/performance/runtime', requireSuperAdmin, ctrl.runtime)
@@ -0,0 +1,6 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/plugins'
export const pluginRoutes = new Router()
pluginRoutes.get('/api/hermes/plugins', ctrl.list)
@@ -0,0 +1,20 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/profiles'
import { requireSuperAdmin } from '../../middleware/user-auth'
export const profileRoutes = new Router()
profileRoutes.get('/api/hermes/profiles', ctrl.list)
profileRoutes.post('/api/hermes/profiles', ctrl.create)
profileRoutes.get('/api/hermes/profiles/runtime-statuses', ctrl.runtimeStatuses)
profileRoutes.get('/api/hermes/profiles/:name/runtime-status', ctrl.runtimeStatus)
profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
profileRoutes.post('/api/hermes/profiles/:name/gateway/restart', ctrl.restartGatewayForProfile)
profileRoutes.put('/api/hermes/profiles/:name/avatar', ctrl.updateAvatar)
profileRoutes.delete('/api/hermes/profiles/:name/avatar', ctrl.deleteAvatar)
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove)
profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename)
profileRoutes.put('/api/hermes/profiles/active', requireSuperAdmin, ctrl.switchProfile)
profileRoutes.post('/api/hermes/profiles/:name/export', ctrl.exportProfile)
profileRoutes.post('/api/hermes/profiles/import', ctrl.importProfile)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/providers'
export const providerRoutes = new Router()
providerRoutes.post('/api/hermes/config/providers', ctrl.create)
providerRoutes.put('/api/hermes/config/providers/:poolKey', ctrl.update)
providerRoutes.delete('/api/hermes/config/providers/:poolKey', ctrl.remove)
@@ -0,0 +1,295 @@
import type { Context } from 'koa'
import { updateUsage } from '../../db/hermes/usage-store'
let gatewayManager: any = null
export function setGatewayManagerForTest(manager: any): void {
gatewayManager = manager
}
function getGatewayManager() { return gatewayManager }
// --- run_id → session_id mapping (in-memory, ephemeral) ---
const runSessionMap = new Map<string, string>()
export function setRunSession(runId: string, sessionId: string): void {
runSessionMap.set(runId, sessionId)
// Auto-cleanup after 30 minutes
setTimeout(() => runSessionMap.delete(runId), 30 * 60 * 1000)
}
export function getSessionForRun(runId: string): string | undefined {
return runSessionMap.get(runId)
}
// --- Helpers ---
function isTransientGatewayError(err: any): boolean {
const msg = String(err?.message || '')
const causeCode = String(err?.cause?.code || '')
return (
causeCode === 'ECONNREFUSED' ||
causeCode === 'ECONNRESET' ||
/ECONNREFUSED|ECONNRESET|fetch failed|socket hang up/i.test(msg)
)
}
async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
const healthUrl = `${upstream}/health`
while (Date.now() < deadline) {
try {
const res = await fetch(healthUrl, {
method: 'GET',
signal: AbortSignal.timeout(1200),
})
if (res.ok) return true
} catch { }
await new Promise(resolve => setTimeout(resolve, 250))
}
return false
}
/** Resolve profile name from request */
function resolveProfile(ctx: Context): string {
// Use header/query from request, but fall back to authoritative source if not provided
const requestedProfile = ctx.get('x-hermes-profile') || (ctx.query.profile as string)
if (requestedProfile) {
return requestedProfile
}
// Fallback: read from authoritative source (active_profile file)
try {
const { getActiveProfileName } = require('../../services/hermes/hermes-profile')
return getActiveProfileName()
} catch {
return 'default'
}
}
/** Resolve upstream URL for a request based on profile header/query */
function resolveUpstream(ctx: Context): string {
const mgr = getGatewayManager()
if (!mgr) {
throw new Error('GatewayManager not initialized')
}
const profile = resolveProfile(ctx)
if (profile && profile !== 'default') {
return mgr.getUpstream(profile)
}
return mgr.getUpstream()
}
function buildProxyHeaders(ctx: Context, upstream: string): Record<string, string> {
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' || lower === 'authorization') {
continue
} else {
const v = Array.isArray(value) ? value[0] : value
if (v) headers[key] = v
}
}
const mgr = getGatewayManager()
if (mgr) {
const apiKey = mgr.getApiKey(resolveProfile(ctx))
if (apiKey) {
headers['authorization'] = `Bearer ${apiKey}`
}
}
return headers
}
// --- SSE stream interception ---
const SSE_EVENTS_PATH = /^\/v1\/runs\/([^/]+)\/events$/
/**
* Parse SSE text chunks and extract run.completed events.
* Returns the run_id if a run.completed was found.
*/
function extractRunCompletedFromChunk(chunk: string, profile: string): string | null {
// SSE format: each line is "data: {...}\n\n"
const lines = chunk.split('\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.event === 'run.completed' && data.usage && data.run_id) {
const sessionId = getSessionForRun(data.run_id)
if (sessionId) {
updateUsage(sessionId, {
inputTokens: data.usage.input_tokens,
outputTokens: data.usage.output_tokens,
cacheReadTokens: data.usage.cache_read_tokens,
cacheWriteTokens: data.usage.cache_write_tokens,
reasoningTokens: data.usage.reasoning_tokens,
model: data.model || '',
profile,
})
return data.run_id
}
}
} catch { /* not JSON, skip */ }
}
return null
}
/**
* Stream an SSE response while intercepting run.completed events.
*/
async function streamSSE(ctx: Context, res: Response, profile: string): Promise<void> {
if (!res.body) {
ctx.res.end()
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// Forward raw bytes to client immediately
ctx.res.write(value)
// Also decode for interception
buffer += decoder.decode(value, { stream: true })
// Process complete SSE lines (delimited by double newline)
let newlineIdx: number
while ((newlineIdx = buffer.indexOf('\n\n')) !== -1) {
const eventBlock = buffer.slice(0, newlineIdx)
buffer = buffer.slice(newlineIdx + 2)
extractRunCompletedFromChunk(eventBlock, profile)
}
}
// Process remaining buffer
if (buffer.trim()) {
extractRunCompletedFromChunk(buffer, profile)
}
} finally {
ctx.res.end()
}
}
// --- Main proxy function ---
export async function proxy(ctx: Context) {
const profile = resolveProfile(ctx)
let upstream: string
try {
upstream = resolveUpstream(ctx)
} catch (e: any) {
ctx.status = 503
ctx.body = { error: { message: e?.message || 'GatewayManager not initialized' } }
return
}
const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api')
const params = new URLSearchParams(ctx.search || '')
params.delete('token')
const search = params.toString()
const url = `${upstream}${upstreamPath}${search ? `?${search}` : ''}`
const headers = buildProxyHeaders(ctx, upstream)
try {
let body: string | undefined
if (ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD') {
// @koa/bodyparser parses JSON into ctx.request.body but doesn't store rawBody
// by default. Re-serialize the parsed body to get the string form.
const parsed = (ctx as any).request.body
if (typeof parsed === 'string') {
body = parsed
} else if (parsed && typeof parsed === 'object') {
body = JSON.stringify(parsed)
}
}
const requestInit: RequestInit = { method: ctx.req.method, headers, body }
let res: Response
try {
res = await fetch(url, requestInit)
} catch (err: any) {
if (isTransientGatewayError(err) && await waitForGatewayReady(upstream)) {
res = await fetch(url, requestInit)
} else {
throw err
}
}
// Set response headers
res.headers.forEach((value, key) => {
const lower = key.toLowerCase()
if (lower !== 'transfer-encoding' && lower !== 'connection') {
ctx.set(key, value)
}
})
ctx.status = res.status
// Intercept POST /v1/runs to capture run_id → session_id mapping
if (ctx.req.method === 'POST' && /\/v1\/runs$/.test(upstreamPath) && body) {
try {
const parsed = JSON.parse(body)
if (parsed.session_id) {
const resBody = await res.text()
ctx.res.write(resBody)
ctx.res.end()
try {
const result = JSON.parse(resBody)
if (result.run_id) {
setRunSession(result.run_id, parsed.session_id)
}
} catch { /* response not JSON, ignore */ }
return
}
} catch { /* body not JSON, fall through to normal stream */ }
// No session_id in body — fall through to normal response handling below
}
// Intercept SSE streams for /v1/runs/{id}/events
const sseMatch = upstreamPath.match(SSE_EVENTS_PATH)
if (sseMatch) {
await streamSSE(ctx, res, profile)
return
}
// Default: pipe response body directly
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,26 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/sessions'
export const sessionRoutes = new Router()
sessionRoutes.get('/api/hermes/sessions/conversations', ctrl.listConversations)
sessionRoutes.get('/api/hermes/sessions/conversations/:id/messages', ctrl.getConversationMessages)
sessionRoutes.get('/api/hermes/sessions/conversations/:id/messages/paginated', ctrl.getConversationMessagesPaginated)
sessionRoutes.get('/api/hermes/sessions', ctrl.list)
sessionRoutes.get('/api/hermes/sessions/hermes', ctrl.listHermesSessions)
sessionRoutes.get('/api/hermes/sessions/hermes/:id', ctrl.getHermesSession)
sessionRoutes.post('/api/hermes/sessions/hermes/:id/import', ctrl.importHermesSession)
sessionRoutes.get('/api/hermes/search/sessions', ctrl.search)
sessionRoutes.get('/api/hermes/sessions/search', ctrl.search)
sessionRoutes.get('/api/hermes/sessions/usage', ctrl.usageBatch)
sessionRoutes.get('/api/hermes/usage/stats', ctrl.usageStats)
sessionRoutes.get('/api/hermes/sessions/context-length', ctrl.contextLength)
sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get)
sessionRoutes.get('/api/hermes/sessions/:id/export', ctrl.exportSession)
sessionRoutes.get('/api/hermes/sessions/:id/usage', ctrl.usageSingle)
sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove)
sessionRoutes.post('/api/hermes/sessions/batch-delete', ctrl.batchRemove)
sessionRoutes.post('/api/hermes/sessions/:id/rename', ctrl.rename)
sessionRoutes.post('/api/hermes/sessions/:id/workspace', ctrl.setWorkspace)
sessionRoutes.post('/api/hermes/sessions/:id/model', ctrl.setModel)
sessionRoutes.get('/api/hermes/workspace/folders', ctrl.listWorkspaceFolders)
@@ -0,0 +1,11 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/skills'
export const skillRoutes = new Router()
skillRoutes.get('/api/hermes/skills', ctrl.list)
skillRoutes.get('/api/hermes/skills/usage/stats', ctrl.usageStats)
skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle)
skillRoutes.put('/api/hermes/skills/pin', ctrl.pin_)
skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles)
skillRoutes.get('/api/hermes/skills/{*path}', ctrl.readFile_)
@@ -0,0 +1,352 @@
import { WebSocketServer } from 'ws'
import type { Server as HttpServer } from 'http'
import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs'
import { dirname, join, isAbsolute, resolve as resolvePath } from 'path'
import { homedir } from 'os'
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
import { getTerminalConfig, type TerminalConfig } from '../../services/hermes/file-provider'
import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth'
import { logger } from '../../services/logger'
let pty: any = null
function ensureNodePtySpawnHelperExecutable() {
if (process.platform !== 'darwin') return
try {
const nodePtyRoot = dirname(require.resolve('node-pty/package.json'))
const helperCandidates = [
join(nodePtyRoot, 'build', 'Release', 'spawn-helper'),
join(nodePtyRoot, 'build', 'Debug', 'spawn-helper'),
join(nodePtyRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
]
for (const helperPath of helperCandidates) {
if (!existsSync(helperPath)) continue
try {
accessSync(helperPath, fsConstants.X_OK)
} catch {
chmodSync(helperPath, 0o755)
logger.debug('Restored execute bit for node-pty helper: %s', helperPath)
}
}
} catch (err: any) {
logger.warn(err, 'Could not normalize node-pty helper permissions')
}
}
try {
ensureNodePtySpawnHelperExecutable()
// eslint-disable-next-line @typescript-eslint/no-require-imports
pty = require('node-pty')
} catch (err: any) {
logger.warn(err, 'node-pty failed to load, terminal feature disabled')
}
// ─── Shell detection ────────────────────────────────────────────
function findShell(): string {
// Windows 平台:使用 PowerShell
if (process.platform === 'win32') {
return 'powershell.exe'
}
// Unix 平台:使用 SHELL 环境变量,或回退到常用 shells
const candidates = [
process.env.SHELL,
'/bin/zsh',
'/bin/bash',
].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'
}
export function resolveTerminalCwd(
cfg: Pick<TerminalConfig, 'cwd'> = getTerminalConfig(),
profileDir = getActiveProfileDir(),
): string {
const configured = cfg.cwd?.trim()
const fallback = existsSync(profileDir) ? profileDir : homedir()
if (!configured) return fallback
const cwd = isAbsolute(configured) ? configured : resolvePath(profileDir, configured)
if (!existsSync(cwd)) {
logger.warn({ cwd }, 'Configured terminal cwd does not exist; falling back to Hermes profile directory')
return fallback
}
return cwd
}
// ─── Session types ──────────────────────────────────────────────
interface PtySession {
id: string
pty: { pid: number; onData: (cb: (data: string) => void) => void; onExit: (cb: (e: { exitCode: number }) => void) => void; write: (data: string) => void; kill: (signal?: string) => void; resize: (cols: number, rows: number) => void }
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: PtySession['pty']
try {
ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: resolveTerminalCwd(),
})
} catch (err: any) {
throw new Error(`Failed to spawn shell "${shell}": ${err.message}`)
}
const session: PtySession = {
id,
pty: ptyProcess,
shell,
pid: ptyProcess.pid,
createdAt: Date.now(),
}
return session
}
// ─── WebSocket server setup ─────────────────────────────────────
export function setupTerminalWebSocket(httpServers: HttpServer | HttpServer[]) {
if (!pty) {
logger.warn('node-pty not available, skipping terminal WebSocket setup')
return
}
const wss = new WebSocketServer({ noServer: true })
const defaultShell = findShell()
const servers = Array.isArray(httpServers) ? httpServers : [httpServers]
servers.forEach((httpServer) => {
httpServer.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url || '', `http://${req.headers.host}`)
if (url.pathname !== '/api/hermes/terminal') {
return
}
// Auth check
if (await isAuthEnabled()) {
const token = url.searchParams.get('token') || ''
if (!await authenticateUserToken(token)) {
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: string) => {
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 }: { exitCode: number }) => {
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)
logger.info('Session %s exited (pid %d, code %d)', session.id, session.pid, 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),
}))
logger.info('Session created: %s (%s, pid %d)', session.id, shellName(shell), 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)
}
logger.debug('Switched to session %s', 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
}
logger.info('Session closed: %s', 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()
logger.info('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 }))
logger.error(err, 'Failed to create session')
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),
}))
logger.info('First session created: %s (%s, pid %d)', firstSession.id, shellName(defaultShell), firstSession.pid)
})
logger.info('WebSocket ready at /terminal (shell: %s, transport: node-pty)', defaultShell)
}
+7
View File
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/tts'
export const ttsRoutes = new Router()
ttsRoutes.post('/api/hermes/tts', ctrl.generate)
ttsRoutes.post('/api/tts/proxy/audio/speech', ctrl.openaiProxy)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/weixin'
export const weixinRoutes = new Router()
weixinRoutes.get('/api/hermes/weixin/qrcode', ctrl.getQrcode)
weixinRoutes.get('/api/hermes/weixin/qrcode/status', ctrl.pollStatus)
weixinRoutes.post('/api/hermes/weixin/save', ctrl.save)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/xai-auth'
export const xaiAuthRoutes = new Router()
xaiAuthRoutes.post('/api/hermes/auth/xai/start', ctrl.start)
xaiAuthRoutes.get('/api/hermes/auth/xai/poll/:sessionId', ctrl.poll)
xaiAuthRoutes.get('/api/hermes/auth/xai/status', ctrl.status)