📄 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   — 配额查询
`)