feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user