📄 web.ts • 43906 bytes
/**
* CmdCode V0.5 - Web API 后端
* Bun 原生 HTTP Server,提供 REST API + SSE 流式对话
*/
import { readFileSync, writeFileSync, statSync, readdirSync, existsSync, mkdirSync, renameSync, rmSync, unlinkSync, lstatSync } from 'node:fs'
import { join, resolve, basename, dirname } from 'node:path'
import { homedir } from 'node:os'
import { execSync } from 'node:child_process'
import { loadConfig, loadSafeConfig } from './config.js'
import { saveSessionPlaintext, loadChatKeyPool } from './apikeys.js'
import { ChatEngine, getSystemPrompt } from './chat.js'
import { SYSTEM_PROMPT_TEMPLATE } from './system-prompt.js'
import { BUILTIN_PROVIDERS } from './models.js'
import { executeTool, ALL_TOOLS, type ToolCall, type ToolResult, setUsername, setUserWorkspace, isSuperUser } from './tools.js'
import { listUserModels, createUserModel, updateUserModel, deleteUserModel } from './user-models.js'
import { getChatKeyPool } from './apikeys.js'
import { loadAppConfig, saveAppConfig, decryptField } from './crypto-util.js'
import { searchMemory } from './memory/memoryManager.js'
import { login, register, validateToken, verifyAuthToken, loadUserCache, getUserQuotaMb, type UserInfo } from './user.js'
// Shell 参数转义,防止命令注入
function shellQuote(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'"
}
// ═══════════════════════════════════════
// 并发限制(防止 LLM 调用耗尽资源)
// ═══════════════════════════════════════
class Semaphore {
private count = 0
constructor(private max: number) {}
tryAcquire(): boolean {
if (this.count < this.max) { this.count++; return true }
return false
}
release(): void {
if (this.count > 0) this.count--
}
}
const llmSemaphore = new Semaphore(3) // 最多3个并发 LLM 请求
const PORT = Number(process.env.PORT) || 3010
const WORKSPACE_BASE = join(homedir(), '.cmdcode', 'workspaces')
// ═══════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════
function json(data: any, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, api-key',
},
})
}
function cors(): Response {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, api-key',
},
})
}
/** 简单 Token 验证(从 Authorization header 提取) */
function getToken(req: Request): string | null {
const auth = req.headers.get('Authorization')
if (auth?.startsWith('Bearer ')) return auth.slice(7)
return null
}
/** 安全路径检查 */
function safePath(target: string, workspace: string): string | null {
const resolved = resolve(workspace, target)
// 允许所有用户访问 /tmp/ 临时目录
if (resolved.startsWith('/tmp/') || resolved === '/tmp') {
return resolved
}
// 检查是否在 workspace 内
const wsResolved = resolve(workspace)
if (!resolved.startsWith(wsResolved)) {
// 区分 symlink 和 bind mount:symlink 逃逸必须拦截,bind mount 可以放行
// (bind mount 是管理员设置的合法工作区扩展,如 SWE-bench 实例仓库)
try {
const stat = lstatSync(resolved)
if (stat.isSymbolicLink()) {
return null // symlink 逃逸:拒绝
}
// 非 symlink 的外部路径(如 bind mount):放行,但记录警告
console.warn(`[safePath] 放行非 symlink 外部路径: ${target} → ${resolved}`)
return resolved
} catch {
// lstat 失败(概率极低),默认拒绝
return null
}
}
return resolved
}
/** MIME 类型简易映射 */
function getMimeType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || ''
const mimeMap: Record<string, string> = {
html: 'text/html', htm: 'text/html', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript',
json: 'application/json', xml: 'application/xml', txt: 'text/plain', md: 'text/markdown',
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml',
pdf: 'application/pdf', zip: 'application/zip', tar: 'application/x-tar', gz: 'application/gzip',
py: 'text/x-python', ts: 'text/typescript', rs: 'text/x-rust', go: 'text/x-go',
c: 'text/x-c', cpp: 'text/x-c++', h: 'text/x-c', sh: 'text/x-sh', bash: 'text/x-sh',
yaml: 'text/yaml', yml: 'text/yaml', toml: 'text/toml', sql: 'text/x-sql',
}
return mimeMap[ext] || 'application/octet-stream'
}
// ═══════════════════════════════════════
// 会话存储(复用 apikeys.ts 的存储层)
// ═══════════════════════════════════════
const CMD_DIR = join(homedir(), '.cmdcode')
const SESSIONS_DIR = join(CMD_DIR, 'sessions')
function getSessionFile(sessionId: string): string {
return join(SESSIONS_DIR, `${sessionId}.json`)
}
function loadSessionData(sessionId: string): any | null {
const file = getSessionFile(sessionId)
if (!existsSync(file)) return null
try {
return JSON.parse(readFileSync(file, 'utf-8'))
} catch {
return null
}
}
function listAllSessions(): any[] {
if (!existsSync(SESSIONS_DIR)) return []
const files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json') && !f.includes('-'))
return files.map(f => {
const id = f.replace('.json', '')
const data = loadSessionData(id)
return {
id,
createdAt: data?.[0]?.created_at || '',
updatedAt: '',
messageCount: data?.length || 0,
preview: data?.[data.length - 1]?.content?.slice(0, 60) || '',
}
}).sort((a, b) => b.messageCount - a.messageCount)
}
// ═══════════════════════════════════════
// 用户认证(复用 CLI 的 user.ts → PHP/MySQL)
// ═══════════════════════════════════════
/** 从请求中提取 token 并验证,返回 UserInfo 或 null */
async function authenticate(req: Request): Promise<UserInfo | null> {
const token = req.headers.get('Authorization')?.replace('Bearer ', '')
if (!token) return null
try {
// 先用本地缓存快速验证
const cached = loadUserCache()
if (cached && cached.token === token) {
const valid = await validateToken(cached)
if (valid) return cached
}
// 缓存不命中 → 直接调用 PHP API 验证(多用户场景)
return await verifyAuthToken(token)
} catch {
return null
}
}
/** 需要登录才能访问的白名单路由(精确匹配) */
const PUBLIC_ROUTES = ['/api/health', '/api/auth/login', '/api/auth/register', '/api/hermes/status']
/** 需要登录才能访问的白名单路由前缀 */
const PUBLIC_PREFIXES: string[] = []
// ═══════════════════════════════════════
// 路由处理
// ═══════════════════════════════════════
async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url)
const path = url.pathname
const method = req.method
// CORS 预检
if (method === 'OPTIONS') return cors()
// 鉴权守卫:非公开路由需要登录
let currentUser: UserInfo | null = null
const isPublic = PUBLIC_ROUTES.includes(path) || PUBLIC_PREFIXES.some(p => path.startsWith(p))
if (!isPublic) {
currentUser = await authenticate(req)
if (!currentUser) {
return json({ error: '未登录或token已过期', code: 'UNAUTHORIZED' }, 401)
}
// 注入当前用户身份到工具沙箱,让 admin/root 类超级用户跳过路径和命令限制
setUsername(currentUser.username)
if (currentUser.workspaceDir) setUserWorkspace(currentUser.workspaceDir)
}
// 健康检查
if (path === '/api/health') return json({ status: 'ok', version: '0.5.0' })
// ── Hermes Agent 集成 ──
// 获取 Hermes 版本和状态
if (path === '/api/hermes/status' && method === 'GET') {
try {
const version = execSync('/home/administrator/.local/bin/hermes --version 2>&1', { encoding: 'utf-8', timeout: 8000 }).trim().split('\n')[0]
return json({ ok: true, version })
} catch (e: any) {
return json({ ok: false, error: String(e.message || 'Hermes not available') })
}
}
// Hermes 对话(非阻塞spawn + JSON返回)
if (path === '/api/hermes/chat' && method === 'POST') {
if (!llmSemaphore.tryAcquire()) {
return json({ ok: false, error: '服务繁忙(已达3个并发上限),请稍后重试' }, 503)
}
try {
const body = await req.json() as { message?: string }
const message = body?.message?.trim()
if (!message) return json({ ok: false, error: 'No message provided' })
// 非阻塞启动 hermes(不阻塞事件循环,浏览器可保持连接)
const proc = Bun.spawn(['/home/administrator/.local/bin/hermes', 'chat', '-q', message, '--max-turns', '5', '--yolo', '-Q'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, HOME: '/home/administrator', HERMES_HOME: '/home/administrator/.hermes' }
})
// 超时保护:5分钟后强制kill(SWE-bench等长任务需要充足时间)
const timeout = setTimeout(() => { try { proc.kill() } catch {} }, 300000)
// 收集输出
const output = await new Response(proc.stdout).text()
await proc.exited
clearTimeout(timeout)
const result = output.trim()
const lines = result.split('\n').filter((l: string) =>
!l.startsWith('Session:') &&
!l.startsWith('Duration:') &&
!l.includes('--resume') &&
!l.startsWith('Messages:') &&
l.trim()
)
return json({ ok: true, response: lines.join('\n').trim() || result })
} catch (e: any) {
const msg = String(e.message || '')
if (msg.includes('timed out') || msg.includes('killed')) {
return json({ ok: false, error: 'Hermes 执行超时(5分钟),请简化问题重试' })
}
return json({ ok: false, error: msg || 'Hermes chat failed' })
} finally {
llmSemaphore.release()
}
}
// 系统提示词 - 获取(含默认值)
if (path === '/api/systemprompt' && method === 'GET') {
const appConfig = loadAppConfig()
const content = appConfig?.systemPrompt || ''
return json({ content, isDefault: !content })
}
// 系统提示词 - 保存(空值=重置为默认)
if (path === '/api/systemprompt' && (method === 'PUT' || method === 'POST')) {
try {
const body = await req.json() as { content?: string }
saveAppConfig({ systemPrompt: body.content || '' } as any)
return json({ ok: true })
} catch (e: any) {
return json({ ok: false, error: e.message }, 500)
}
}
// 模型列表(内置 + 用户自定义)
if (path === '/api/models' && method === 'GET') {
const config = loadConfig()
const models = BUILTIN_PROVIDERS.map(p => ({
id: p.id,
name: p.name,
vendor: p.vendor,
baseUrl: p.url,
custom: false,
}))
// 合并用户自定义模型
const userModels = listUserModels('default')
for (const um of userModels) {
if (!models.some(m => m.id === um.model)) {
models.push({
id: um.model,
name: um.name,
vendor: um.baseUrl,
baseUrl: um.baseUrl,
custom: true,
customId: um.id,
})
}
}
return json({ models, current: { model: config.model, baseUrl: config.baseUrl } })
}
// 添加用户自定义模型
if (path === '/api/models/add' && method === 'POST') {
try {
const body = await req.json() as { name: string; model: string; baseUrl: string; apiKey: string; note1?: string; note2?: string }
if (!body.name || !body.model || !body.baseUrl || !body.apiKey) {
return json({ error: 'name, model, baseUrl, apiKey 为必填项' }, 400)
}
// 检查与内置模型ID冲突
if (BUILTIN_PROVIDERS.some(p => p.id === body.model)) {
return json({ error: `模型 ID "${body.model}" 与内置模型冲突,请换一个ID` }, 409)
}
const result = createUserModel('default', {
name: body.name,
model: body.model,
baseUrl: body.baseUrl,
apiKey: body.apiKey,
note1: body.note1 || '',
note2: body.note2 || '',
})
return json({ success: true, model: { id: result.id, name: result.name, model: result.model } })
} catch (e: any) {
return json({ error: e.message || '添加失败' }, 500)
}
}
// 删除用户自定义模型
if (path === '/api/models/delete' && method === 'POST') {
try {
const body = await req.json() as { customId: string }
if (!body.customId) return json({ error: 'customId 为必填项' }, 400)
const ok = deleteUserModel('default', body.customId)
if (!ok) return json({ error: '模型不存在' }, 404)
return json({ success: true })
} catch (e: any) {
return json({ error: e.message || '删除失败' }, 500)
}
}
// 更新用户自定义模型
if (path === '/api/models/update' && method === 'POST') {
try {
const body = await req.json() as { customId: string; updates: Record<string, string> }
if (!body.customId || !body.updates) return json({ error: 'customId 和 updates 为必填项' }, 400)
// 禁止修改内置模型
if (BUILTIN_PROVIDERS.some(p => p.id === body.customId)) {
return json({ error: '不允许修改内置模型' }, 403)
}
const updated = updateUserModel('default', body.customId, body.updates)
if (!updated) return json({ error: '模型不存在' }, 404)
return json({ success: true, model: { id: updated.id, name: updated.name, model: updated.model } })
} catch (e: any) {
return json({ error: e.message || '更新失败' }, 500)
}
}
// 用户注册(→ PHP/MySQL)
if (path === '/api/auth/register' && method === 'POST') {
try {
const body = await req.json() as { username: string; password: string }
if (!body.username || !body.password) return json({ error: '用户名和密码不能为空' }, 400)
if (!/^[a-zA-Z0-9_]{3,32}$/.test(body.username)) return json({ error: '用户名需3-32位字母数字下划线' }, 400)
if (body.password.length < 6) return json({ error: '密码至少6位' }, 400)
const userInfo = await register(body.username, body.password)
return json({ username: userInfo.username, token: userInfo.token, workspaceDir: userInfo.workspaceDir })
} catch (e: any) {
return json({ error: e.message || '注册失败' }, 400)
}
}
// 用户登录(→ PHP/MySQL)
if (path === '/api/auth/login' && method === 'POST') {
try {
const body = await req.json() as { username: string; password: string }
if (!body.username || !body.password) return json({ error: '用户名和密码不能为空' }, 400)
const userInfo = await login(body.username, body.password)
return json({ username: userInfo.username, token: userInfo.token, workspaceDir: userInfo.workspaceDir })
} catch (e: any) {
return json({ error: e.message || '用户名或密码错误' }, 401)
}
}
// 获取当前配置(脱敏)+ 合并 web-config.json
if (path === '/api/config' && method === 'GET') {
const config = loadSafeConfig() as any
try {
const webCfgPath = join(homedir(), '.cmdcode', 'web-config.json')
const webCfg = JSON.parse(readFileSync(webCfgPath, 'utf-8'))
Object.assign(config, webCfg)
} catch {}
return json(config)
}
// 保存配置(系统提示词、历史限制、PAVR开关等)
if (path === '/api/config' && method === 'POST') {
try {
const body = await req.json() as any
const configPath = join(homedir(), '.cmdcode', 'web-config.json')
let existing: any = {}
try { existing = JSON.parse(readFileSync(configPath, 'utf-8')) } catch {}
if (body.systemPrompt !== undefined) existing.systemPrompt = body.systemPrompt
if (body.maxHistory !== undefined) existing.maxHistory = Number(body.maxHistory)
if (body.pavr !== undefined) existing.pavr = !!body.pavr
writeFileSync(configPath, JSON.stringify(existing, null, 2))
return json({ ok: true })
} catch (e: any) {
return json({ ok: false, error: e.message }, 500)
}
}
// 获取系统提示词(统一返回 SYSTEM_PROMPT_TEMPLATE,不再查 secrets.enc 覆盖)
if (path === '/api/system-prompt' && method === 'GET') {
return json({ prompt: SYSTEM_PROMPT_TEMPLATE, isCustom: false })
}
// 会话列表
if (path === '/api/sessions' && method === 'GET') {
const sessions = listAllSessions()
return json({ sessions })
}
// 会话消息
const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)\/messages$/)
if (sessionMatch && method === 'GET') {
const sessionId = decodeURIComponent(sessionMatch[1])
const data = loadSessionData(sessionId)
if (!data) return json({ error: '会话不存在' }, 404)
return json({ messages: Array.isArray(data) ? data : [] })
}
// 文件浏览
if (path === '/api/files' && method === 'GET') {
const target = url.searchParams.get('path') || '.'
const workspace = url.searchParams.get('workspace') || WORKSPACE_BASE
const safe = safePath(target, workspace)
if (!safe || !safe.startsWith(resolve(workspace))) return json({ error: 'Access denied' }, 403)
try {
const stat = statSync(safe)
if (stat.isDirectory()) {
const entries = readdirSync(safe, { withFileTypes: true }).slice(0, 200).map(e => ({
name: e.name,
type: e.isDirectory() ? 'dir' as const : 'file' as const,
size: e.isFile() ? statSync(join(safe!, e.name)).size : undefined,
}))
return json({ path: target, type: 'dir', children: entries })
} else {
if (stat.size > 1024 * 1024) return json({ error: '文件过大(>1MB)' }, 400)
const content = readFileSync(safe, 'utf-8')
return json({ path: target, type: 'file', size: stat.size, content })
}
} catch (e: any) {
return json({ error: e.message }, 404)
}
}
// ═══════════════════════════════════════
// 用户文件管理 API(使用鉴权后的 currentUser workspace)
// ═══════════════════════════════════════
// 获取用户 workspace 路径
function userWorkspace(): string {
if (currentUser) return currentUser.workspaceDir
return join(WORKSPACE_BASE, 'default')
}
// 列出文件
if (path === '/api/files/list' && method === 'GET') {
const dir = url.searchParams.get('path') || '.'
const ws = userWorkspace()
const target = safePath(dir, ws)
if (!target) return json({ error: '路径越界' }, 403)
try {
if (!existsSync(target)) return json({ error: '目录不存在' }, 404)
const entries = readdirSync(target, { withFileTypes: true }).slice(0, 500).map(e => {
const full = join(target!, e.name)
let info: any = { name: e.name, type: e.isDirectory() ? 'dir' : 'file' }
if (e.isFile()) {
try { const s = statSync(full); info.size = s.size; info.modified = s.mtime.toISOString() } catch {}
}
return info
})
// 按类型排序:目录在前,文件在后
entries.sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
return json({ path: dir, entries })
} catch (e: any) {
return json({ error: e.message }, 500)
}
}
// 文件上传
if (path === '/api/files/upload' && method === 'POST') {
const ws = userWorkspace()
const dir = url.searchParams.get('path') || '.'
const targetDir = safePath(dir, ws)
if (!targetDir) return json({ error: '路径越界' }, 403)
try {
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true })
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) return json({ error: '未提供文件' }, 400)
// 单文件最大 10MB
if (file.size > 10 * 1024 * 1024) return json({ error: '文件过大(>10MB)' }, 400)
// 用户配额检查:非admin用户100MB上限
if (!isSuperUser()) {
const quotaLimit = currentUser ? getUserQuotaMb(currentUser.username) : 100
let usedMb = 0
if (existsSync(ws)) {
try {
const duOut = execSync(`du -sm ${JSON.stringify(ws)} 2>/dev/null || echo 0`, { encoding: 'utf-8', timeout: 10000 }).trim()
usedMb = Math.max(0, parseInt(duOut) || 0)
} catch { usedMb = 0 }
}
const uploadMb = file.size / (1024 * 1024)
if (usedMb + uploadMb > quotaLimit) {
return json({ error: `存储空间不足:已使用 ${usedMb.toFixed(1)}MB,上限 ${quotaLimit}MB` }, 400)
}
}
const dest = join(targetDir, file.name)
const buf = Buffer.from(await file.arrayBuffer())
writeFileSync(dest, buf)
return json({ ok: true, path: join(dir || '.', file.name), size: file.size })
} catch (e: any) {
return json({ error: e.message }, 500)
}
}
// 重命名
if (path === '/api/files/rename' && method === 'POST') {
const ws = userWorkspace()
const body = await req.json() as { oldPath: string; newName: string }
if (!body.oldPath || !body.newName) return json({ error: '参数不完整' }, 400)
const oldFull = safePath(body.oldPath, ws)
if (!oldFull) return json({ error: '路径越界' }, 403)
if (!existsSync(oldFull)) return json({ error: '文件不存在' }, 404)
const newFull = join(dirname(oldFull), body.newName)
try {
renameSync(oldFull, newFull)
return json({ ok: true, newPath: join(dirname(body.oldPath), body.newName) })
} catch (e: any) {
return json({ error: e.message }, 500)
}
}
// 删除
if (path === '/api/files/delete' && method === 'DELETE') {
const ws = userWorkspace()
const body = await req.json() as { paths: string[] }
if (!body.paths?.length) return json({ error: '未指定文件' }, 400)
const errors: string[] = []
for (const p of body.paths) {
const full = safePath(p, ws)
if (!full) { errors.push(`${p}: 路径越界`); continue }
try {
rmSync(full, { recursive: true, force: true })
} catch (e: any) { errors.push(`${p}: ${e.message}`) }
}
return json({ ok: errors.length === 0, errors: errors.length ? errors : undefined })
}
// 移动/复制
if (path === '/api/files/move' && method === 'POST') {
const ws = userWorkspace()
const body = await req.json() as { paths: string[]; dest: string; action: 'copy' | 'move' }
if (!body.paths?.length || !body.dest) return json({ error: '参数不完整' }, 400)
const destDir = safePath(body.dest, ws)
if (!destDir) return json({ error: '目标路径越界' }, 403)
try {
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true })
for (const p of body.paths) {
const src = safePath(p, ws)
if (!src || !existsSync(src)) continue
const dest = join(destDir, basename(src))
if (body.action === 'move') {
renameSync(src, dest)
} else {
// copy: 用 cp -r(Bun 无内置 copyFile 递归)
const { execSync: es } = await import('node:child_process')
es(`cp -r ${JSON.stringify(src)} ${JSON.stringify(dest)}`, { timeout: 30000 })
}
}
return json({ ok: true })
} catch (e: any) {
return json({ error: e.message }, 500)
}
}
// 新建目录
if (path === '/api/files/mkdir' && method === 'POST') {
const ws = userWorkspace()
const body = await req.json() as { path: string }
if (!body.path) return json({ error: '未指定目录' }, 400)
const full = safePath(body.path, ws)
if (!full) return json({ error: '路径越界' }, 403)
try {
mkdirSync(full, { recursive: true })
return json({ ok: true })
} catch (e: any) {
return json({ error: e.message }, 500)
}
}
// 文件下载
if (path === '/api/files/download' && method === 'GET') {
const ws = userWorkspace()
const filePath = url.searchParams.get('path') || ''
const full = safePath(filePath, ws)
if (!full || !existsSync(full)) return json({ error: '文件不存在' }, 404)
try {
const content = readFileSync(full)
const mime = getMimeType(basename(full))
return new Response(content, {
headers: {
'Content-Type': mime,
'Content-Disposition': `attachment; filename="${encodeURIComponent(basename(full))}"`,
'Access-Control-Allow-Origin': '*',
}
})
} catch (e: any) {
return json({ error: e.message }, 500)
}
}
// 从URL下载网页
if (path === '/api/files/download-url' && method === 'POST') {
const ws = userWorkspace()
const body = await req.json() as { url: string; targetDir?: string; overwrite?: boolean }
if (!body.url) return json({ error: 'URL不能为空' }, 400)
try {
const response = await fetch(body.url, { signal: AbortSignal.timeout(30_000) })
if (!response.ok) return json({ error: `HTTP ${response.status}: ${response.statusText}` }, 502)
const content = await response.text()
// 从URL或页面标题提取文件夹名
let folderName: string
try {
const urlObj = new URL(body.url)
folderName = urlObj.hostname.replace(/^www\./, '')
const titleMatch = content.match(/<title[^>]*>([^<]*)<\/title>/i)
if (titleMatch && titleMatch[1].trim()) {
folderName = titleMatch[1].trim().replace(/[/\\?*:|"<>]/g, '_').substring(0, 50)
}
} catch {
folderName = 'webpage_' + Date.now()
}
// 计算目标路径:支持targetDir子目录
const basePath = body.targetDir && body.targetDir !== '.'
? join(ws, body.targetDir)
: ws
const folderPath = join(basePath, folderName)
// overwrite 模式:必须已存在该文件夹
if (body.overwrite) {
if (!existsSync(folderPath)) {
return json({ error: `文件夹 "${folderName}" 不存在,请先使用「下载」按钮` }, 404)
}
}
mkdirSync(folderPath, { recursive: true } as any)
const filePath = join(folderPath, 'index.html')
writeFileSync(filePath, content, 'utf-8')
const relativePath = body.targetDir && body.targetDir !== '.'
? body.targetDir + '/' + folderName
: folderName
return json({ ok: true, folder: folderName, path: relativePath, size: content.length })
} catch (e: any) {
return json({ error: '下载失败: ' + (e.message || String(e)) }, 500)
}
}
// 配额查询
if (path === '/api/files/quota' && method === 'GET') {
const ws = userWorkspace()
const quotaLimit = isSuperUser() ? 1024 : 100 // MB: admin 1GB, 普通用户100MB
try {
let usedBytes = 0
if (existsSync(ws)) {
const { execSync: es } = await import('node:child_process')
const out = es(`du -sm ${JSON.stringify(ws)} 2>/dev/null || echo 0`, { encoding: 'utf-8', timeout: 10000 }).trim()
usedBytes = Math.max(0, parseInt(out) || 0)
}
return json({ usedMb: usedBytes, quotaMb: quotaLimit, remainingMb: Math.max(0, quotaLimit - usedBytes), usagePercent: Math.round((usedBytes / quotaLimit) * 100) })
} catch {
return json({ usedMb: 0, quotaMb: quotaLimit, remainingMb: quotaLimit, usagePercent: 0 })
}
}
// SSE 流式对话
const chatMatch = path.match(/^\/api\/chat\/([^/]+)$/)
if (chatMatch && method === 'POST') {
const sessionId = decodeURIComponent(chatMatch[1])
const body = await req.json() as { message: string; model?: string }
if (!body.message) return json({ error: '消息不能为空' }, 400)
// SSE 响应
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
const send = (data: any) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
}
// 心跳保活:防止反向代理/CDN因长时间无数据而超时断开
const heartbeatTimer = setInterval(() => {
try {
controller.enqueue(encoder.encode(': heartbeat\n\n'))
} catch {
clearInterval(heartbeatTimer)
}
}, 15000) // 15秒一次SSE注释心跳
try {
// 加载历史消息
const history = loadSessionData(sessionId) || []
const config = loadConfig()
// 模型覆盖:前端可指定模型,需从密钥池查找对应的 apiKey
// 逻辑:优先匹配密钥池中 model 字段相同的条目(精确匹配)
// 若无匹配,仅修改 model 名称,保留原 config 的 baseUrl/apiKey
// (ARK 等代理 API 可通过同一 baseUrl 路由多个模型)
if (body.model) {
const provider = BUILTIN_PROVIDERS.find(p => p.id === body.model)
const userModels = listUserModels('default')
const customModel = userModels.find((m: any) => m.model === body.model)
// 从密钥池查找匹配该模型的 apiKey
const pool = loadChatKeyPool()
const matched = pool.find((e: any) => e.model === body.model)
let matchedApiKey: string | null = null
if (matched) {
try {
matchedApiKey = decryptField(matched.apiKeyEncrypted)
} catch {}
}
if (matchedApiKey) {
// 密钥池精确匹配 → 使用该条目的 apiKey + baseUrl
config.apiKey = matchedApiKey
config.baseUrl = matched?.baseUrl || (provider?.url || config.baseUrl)
// 密钥池条目可指定 apiModel 覆盖模型名(如 minimax-m2.7-m1 → MiniMax-M2.7)
// 与 /api/proxy/chat 保持一致的解析逻辑
config.model = (matched as any).apiModel || body.model
} else if (customModel && customModel.apiKey) {
// 用户自定义模型匹配
config.model = customModel.model
config.baseUrl = customModel.baseUrl
config.apiKey = customModel.apiKey
} else {
// 无精确匹配 → 仅修改 model,保留原 config 的 baseUrl/apiKey
// 适用于 ARK 等代理 API(同一 key 可路由多个模型)
config.model = body.model
}
}
// 检查 API Key 是否已配置
if (!config.apiKey) {
send({ type: 'error', error: '⚠️ 未配置 API Key,请先在设置中添加 API Key(点击右上角齿轮图标)' })
controller.close()
return
}
// 创建 ChatEngine(带历史)
const engine = ChatEngine.fromHistory(
history.map((m: any) => ({ role: m.role, content: m.content })),
config,
)
// 流式对话
const response = await engine.chat(body.message, (delta) => {
send({ type: 'content', delta })
})
send({ type: 'done', content: response })
// 保存会话历史到磁盘(多轮对话持久化)
try {
saveSessionPlaintext(sessionId, engine.getHistory())
} catch (saveErr: any) {
console.error('Failed to save session:', saveErr.message)
}
} catch (e: any) {
send({ type: 'error', error: e.message || '对话失败' })
} finally {
clearInterval(heartbeatTimer)
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
},
})
}
// ═══════════════════════════════════════════
// 工具执行 API(前端直接调用大模型时使用)
// ═══════════════════════════════════════════
// 工具 Schema 列表(前端需要发送给大模型)
if (path === '/api/tools/schema' && method === 'GET') {
const tools = ALL_TOOLS.map(t => ({
type: 'function' as const,
function: { name: t.name, description: t.description, parameters: t.parameters },
}))
return json({ tools })
}
// 工具执行(前端检测到 tool_calls 后调用)
if (path === '/api/tools/execute' && method === 'POST') {
try {
const body = await req.json() as { id: string; name: string; arguments: string | Record<string, any> }
if (!body.name) return json({ error: '工具名称不能为空' }, 400)
const call: ToolCall = {
id: body.id || `call_${Date.now()}`,
name: body.name,
arguments: typeof body.arguments === 'string' ? body.arguments : JSON.stringify(body.arguments),
}
const result: ToolResult = await executeTool(call)
return json({ result })
} catch (e: any) {
return json({ error: e.message || '工具执行失败' }, 500)
}
}
// 批量工具执行(一次执行多个工具调用)
if (path === '/api/tools/execute-batch' && method === 'POST') {
try {
const body = await req.json() as { calls: Array<{ id: string; name: string; arguments: string | Record<string, any> }> }
if (!Array.isArray(body.calls) || body.calls.length === 0) {
return json({ error: 'calls 必须为非空数组' }, 400)
}
const results: ToolResult[] = []
for (const c of body.calls) {
const call: ToolCall = {
id: c.id || `call_${Date.now()}_${results.length}`,
name: c.name,
arguments: typeof c.arguments === 'string' ? c.arguments : JSON.stringify(c.arguments),
}
const result = await executeTool(call)
results.push(result)
}
return json({ results })
} catch (e: any) {
return json({ error: e.message || '批量工具执行失败' }, 500)
}
}
// CORS 代理:前端直调模式的核心 — 根据 modelId 查找凭证,转发到 LLM API
if (path === '/api/proxy/chat' && method === 'POST') {
if (!llmSemaphore.tryAcquire()) {
return json({ error: '服务繁忙(已达3个并发上限),请稍后重试' }, 503)
}
try {
const body = await req.json() as {
modelId?: string // 模型 ID(推荐,后端自动查找凭证)
url?: string // 或者直接提供 URL(兼容模式)
headers?: Record<string, string>
body: any // OpenAI 格式的请求体(messages, tools, stream 等)
}
let targetUrl = body.url || ''
let authHeader: Record<string, string> = body.headers || {}
let overrideModel: string | null = null // 密钥池指定的 API 模型名覆盖
// 如果提供了 modelId,从后端配置中查找凭证
if (body.modelId) {
const config = loadConfig()
// 先在用户自定义模型中查找(它们自带 apiKey)
const userModels = listUserModels('default')
const um = userModels.find(m => m.model === body.modelId)
if (um) {
targetUrl = um.baseUrl.replace(/\/+$/, '')
if (!targetUrl.includes('/chat/completions')) targetUrl += '/chat/completions'
authHeader['Authorization'] = `Bearer ${um.apiKey}`
} else {
// 在内置模型中查找
const builtin = BUILTIN_PROVIDERS.find(p => p.id === body.modelId)
if (builtin) {
// 从密钥池中查找该模型的 key
const pool = getChatKeyPool()
const matched = pool.find((e: any) => e.model === body.modelId)
let matchedApiKey: string | null = null
let matchedBaseUrl: string | null = null
if (matched) {
try {
matchedApiKey = decryptField(matched.apiKeyEncrypted)
matchedBaseUrl = matched.baseUrl
} catch {}
}
if (matchedApiKey) {
// 密钥池精确匹配 → 用该条目的 apiKey + baseUrl
targetUrl = (matchedBaseUrl || builtin.url).replace(/\/+$/, '')
if (!targetUrl.includes('/chat/completions')) targetUrl += '/chat/completions'
authHeader['Authorization'] = `Bearer ${matchedApiKey}`
// 密钥池条目可指定 apiModel 覆盖请求体的模型名(如 minimax→minimax-2.7)
if ((matched as any).apiModel) {
overrideModel = (matched as any).apiModel
}
} else {
// 无精确匹配 → 使用全局 config 的 apiKey(ARK 等代理 API 可通过同一 key 路由多个模型)
// 同时用内置模型的 url(如果 config 的 baseUrl 与之匹配的话)
targetUrl = builtin.url.replace(/\/+$/, '')
if (!targetUrl.includes('/chat/completions')) targetUrl += '/chat/completions'
// 降级到全局 config 的 key
if (config.apiKey) {
authHeader['Authorization'] = `Bearer ${config.apiKey}`
// 如果 config.baseUrl 与内置模型 url 不同,优先使用 config.baseUrl(ARK 代理场景)
if (config.baseUrl && config.baseUrl !== builtin.url) {
targetUrl = config.baseUrl.replace(/\/+$/, '')
if (!targetUrl.includes('/chat/completions')) targetUrl += '/chat/completions'
}
}
}
}
}
}
if (!targetUrl) return json({ error: '未找到模型配置,请检查 modelId 或提供 url' }, 400)
// MiMo 模型特殊处理:双认证头 + developer角色 + thinking参数
const isMiMo = (body.modelId || '').startsWith('mimo-') || targetUrl.includes('xiaomimimo.com')
let requestBody = body.body
// 密钥池指定了 apiModel → 覆盖请求体模型名(适配 MiniMax 等需要显示名的 API)
if (overrideModel && requestBody) {
requestBody.model = overrideModel
}
if (isMiMo) {
// 注入 api-key 认证头(MiMo 支持两种认证方式,双发最稳)
const bearerKey = authHeader['Authorization']?.replace('Bearer ', '') || ''
if (bearerKey) authHeader['api-key'] = bearerKey
// 请求体适配:system→developer + thinking + strict tools
if (requestBody.messages) {
requestBody.messages = requestBody.messages.map((m: any) => {
if (m.role === 'system') return { ...m, role: 'developer' }
return m
})
}
// thinking 模式(mimo-v2-flash 除外)
const modelId = body.modelId || requestBody.model || ''
if (!modelId.includes('flash')) {
requestBody.thinking = { type: 'enabled' }
}
// strict 模式
if (requestBody.tools) {
requestBody.tools = requestBody.tools.map((t: any) => ({
...t,
function: { ...t.function, strict: true }
}))
}
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...authHeader,
}
// 读取前端传入的超时时间,后端多给5秒缓冲
const upstreamTimeout = (body as any).timeout_ms || 60000;
const upstream = await fetch(targetUrl, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(upstreamTimeout + 5000)
})
// 流式透传
if (upstream.headers.get('content-type')?.includes('text/event-stream')) {
return new Response(upstream.body, {
status: upstream.status,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
},
})
}
// 非流式直接返回 JSON
const data = await upstream.json()
return json(data, upstream.status)
} catch (e: any) {
return json({ error: e.message || '代理请求失败' }, 500)
} finally {
llmSemaphore.release()
}
}
// 记忆搜索
if (path === '/api/memory/search' && method === 'POST') {
try {
const body = await req.json() as { query: string; limit?: number }
if (!body.query) return json({ error: '查询不能为空' }, 400)
const results = await searchMemory(body.query, undefined, body.limit || 10)
return json({ results })
} catch (e: any) {
return json({ error: e.message || '记忆搜索失败' }, 500)
}
}
// WebUI 静态页面
if (path === '/' || path === '/index.html') {
const uiPath = join(import.meta.dir, '..', 'webui', 'index.html')
if (existsSync(uiPath)) {
return new Response(readFileSync(uiPath), {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
}
return json({ error: 'Not found' }, 404)
}
// ═══════════════════════════════════════
// 启动服务器
// ═══════════════════════════════════════
const server = Bun.serve({
port: PORT,
hostname: "0.0.0.0",
fetch: handleRequest,
idleTimeout: 255, // max allowed; SSE streaming needs longer than default 10s
})
console.log(`
🌐 CmdCode Web API 已启动
📡 http://localhost:${server.port}
🔑 API 端点:
GET /api/health — 健康检查
POST /api/auth/register — 用户注册
POST /api/auth/login — 用户登录
GET /api/sessions — 会话列表
GET /api/sessions/:id/messages — 会话消息
POST /api/chat/:sessionId — SSE 流式对话
POST /api/proxy/chat — CORS 代理
POST /api/memory/search — 记忆搜索
📁 文件管理:
GET /api/files/list?path=. — 文件列表
POST /api/files/upload — 上传文件
POST /api/files/rename — 重命名
POST /api/files/move — 移动/复制
POST /api/files/mkdir — 新建目录
DELETE /api/files/delete — 删除
GET /api/files/download — 下载
GET /api/files/quota — 配额查询
`)