Spaces:
Running
Running
github-actions[bot]
sync: upstream b70f787 Merge pull request #84 from huangzt/feature/vue-logs-ui
c6dedd5 | /** | |
| * Cursor2API v2 - 入口 | |
| * | |
| * 将 Cursor 文档页免费 AI 接口代理为 Anthropic Messages API | |
| * 通过提示词注入让 Claude Code 拥有完整工具调用能力 | |
| */ | |
| import 'dotenv/config'; | |
| import { createRequire } from 'module'; | |
| import express from 'express'; | |
| import { getConfig, initConfigWatcher, stopConfigWatcher } from './config.js'; | |
| import { handleMessages, listModels, countTokens } from './handler.js'; | |
| import { handleOpenAIChatCompletions, handleOpenAIResponses } from './openai-handler.js'; | |
| import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream, serveLogViewerLogin, apiClearLogs, serveVueApp } from './log-viewer.js'; | |
| import { apiGetConfig, apiSaveConfig } from './config-api.js'; | |
| import { loadLogsFromFiles } from './logger.js'; | |
| // 从 package.json 读取版本号,统一来源,避免多处硬编码 | |
| const require = createRequire(import.meta.url); | |
| const { version: VERSION } = require('../package.json') as { version: string }; | |
| const app = express(); | |
| const config = getConfig(); | |
| // 解析 JSON body(增大限制以支持 base64 图片,单张图片可达 10MB+) | |
| app.use(express.json({ limit: '50mb' })); | |
| // CORS | |
| app.use((_req, res, next) => { | |
| res.header('Access-Control-Allow-Origin', '*'); | |
| res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); | |
| res.header('Access-Control-Allow-Headers', '*'); | |
| if (_req.method === 'OPTIONS') { | |
| res.sendStatus(200); | |
| return; | |
| } | |
| next(); | |
| }); | |
| // ★ 静态文件路由(无需鉴权,CSS/JS 等) | |
| app.use('/public', express.static('public')); | |
| // ★ 日志查看器鉴权中间件:配置了 authTokens 时需要验证 | |
| const logViewerAuth = (req: express.Request, res: express.Response, next: express.NextFunction) => { | |
| const tokens = getConfig().authTokens; | |
| if (!tokens || tokens.length === 0) return next(); // 未配置 token 则放行 | |
| // 支持多种传入方式: query ?token=xxx, Authorization header, x-api-key header | |
| const tokenFromQuery = req.query.token as string | undefined; | |
| const authHeader = req.headers['authorization'] || req.headers['x-api-key']; | |
| const tokenFromHeader = authHeader ? String(authHeader).replace(/^Bearer\s+/i, '').trim() : undefined; | |
| const token = tokenFromQuery || tokenFromHeader; | |
| if (!token || !tokens.includes(token)) { | |
| // HTML 页面请求 → 返回登录页; API 请求 → 返回 JSON 错误 | |
| if (req.path === '/logs') { | |
| return serveLogViewerLogin(req, res); | |
| } | |
| res.status(401).json({ error: { message: 'Unauthorized. Provide token via ?token=xxx or Authorization header.', type: 'auth_error' } }); | |
| return; | |
| } | |
| next(); | |
| }; | |
| // ★ 日志查看器路由(带鉴权) | |
| app.get('/logs', logViewerAuth, serveLogViewer); | |
| // Vue3 日志 UI(无服务端鉴权,由 Vue 应用内部处理) | |
| app.get('/vuelogs', serveVueApp); | |
| app.get('/api/logs', logViewerAuth, apiGetLogs); | |
| app.get('/api/requests', logViewerAuth, apiGetRequests); | |
| app.get('/api/stats', logViewerAuth, apiGetStats); | |
| app.get('/api/payload/:requestId', logViewerAuth, apiGetPayload); | |
| app.get('/api/logs/stream', logViewerAuth, apiLogsStream); | |
| app.post('/api/logs/clear', logViewerAuth, apiClearLogs); | |
| app.get('/api/config', logViewerAuth, apiGetConfig); | |
| app.post('/api/config', logViewerAuth, apiSaveConfig); | |
| // ★ API 鉴权中间件:配置了 authTokens 则需要 Bearer token | |
| app.use((req, res, next) => { | |
| // 跳过无需鉴权的路径 | |
| if (req.method === 'GET' || req.path === '/health') { | |
| return next(); | |
| } | |
| const tokens = getConfig().authTokens; | |
| if (!tokens || tokens.length === 0) { | |
| return next(); // 未配置 token 则全部放行 | |
| } | |
| const authHeader = req.headers['authorization'] || req.headers['x-api-key']; | |
| if (!authHeader) { | |
| res.status(401).json({ error: { message: 'Missing authentication token. Use Authorization: Bearer <token>', type: 'auth_error' } }); | |
| return; | |
| } | |
| const token = String(authHeader).replace(/^Bearer\s+/i, '').trim(); | |
| if (!tokens.includes(token)) { | |
| console.log(`[Auth] 拒绝无效 token: ${token.substring(0, 8)}...`); | |
| res.status(403).json({ error: { message: 'Invalid authentication token', type: 'auth_error' } }); | |
| return; | |
| } | |
| next(); | |
| }); | |
| // ==================== 路由 ==================== | |
| // Anthropic Messages API | |
| app.post('/v1/messages', handleMessages); | |
| app.post('/messages', handleMessages); | |
| // OpenAI Chat Completions API(兼容) | |
| app.post('/v1/chat/completions', handleOpenAIChatCompletions); | |
| app.post('/chat/completions', handleOpenAIChatCompletions); | |
| // OpenAI Responses API(Cursor IDE Agent 模式) | |
| app.post('/v1/responses', handleOpenAIResponses); | |
| app.post('/responses', handleOpenAIResponses); | |
| // Token 计数 | |
| app.post('/v1/messages/count_tokens', countTokens); | |
| app.post('/messages/count_tokens', countTokens); | |
| // OpenAI 兼容模型列表 | |
| app.get('/v1/models', listModels); | |
| // 健康检查 | |
| app.get('/health', (_req, res) => { | |
| res.json({ status: 'ok', version: VERSION }); | |
| }); | |
| // 根路径 | |
| app.get('/', (_req, res) => { | |
| res.json({ | |
| name: 'cursor2api', | |
| version: VERSION, | |
| description: 'Cursor Docs AI → Anthropic & OpenAI & Cursor IDE API Proxy', | |
| endpoints: { | |
| anthropic_messages: 'POST /v1/messages', | |
| openai_chat: 'POST /v1/chat/completions', | |
| openai_responses: 'POST /v1/responses', | |
| models: 'GET /v1/models', | |
| health: 'GET /health', | |
| log_viewer: 'GET /logs', | |
| log_viewer_vue: 'GET /vuelogs', | |
| }, | |
| usage: { | |
| claude_code: 'export ANTHROPIC_BASE_URL=http://localhost:' + config.port, | |
| openai_compatible: 'OPENAI_BASE_URL=http://localhost:' + config.port + '/v1', | |
| cursor_ide: 'OPENAI_BASE_URL=http://localhost:' + config.port + '/v1 (选用 Claude 模型)', | |
| }, | |
| }); | |
| }); | |
| // ==================== 启动 ==================== | |
| // ★ 从日志文件加载历史(必须在 listen 之前) | |
| loadLogsFromFiles(); | |
| app.listen(config.port, () => { | |
| const auth = config.authTokens?.length ? `${config.authTokens.length} token(s)` : 'open'; | |
| const logPersist = config.logging?.file_enabled | |
| ? `file(${config.logging.persist_mode || 'summary'}) → ${config.logging.dir}` | |
| : 'memory only'; | |
| // Tools 配置摘要 | |
| const toolsCfg = config.tools; | |
| let toolsInfo = 'default (full, desc=full)'; | |
| if (toolsCfg) { | |
| if (toolsCfg.disabled) { | |
| toolsInfo = '\x1b[33mdisabled\x1b[0m (不注入工具定义,节省上下文)'; | |
| } else if (toolsCfg.passthrough) { | |
| toolsInfo = '\x1b[36mpassthrough\x1b[0m (原始 JSON 嵌入)'; | |
| } else { | |
| const parts: string[] = []; | |
| parts.push(`schema=${toolsCfg.schemaMode}`); | |
| parts.push(toolsCfg.descriptionMaxLength === 0 ? 'desc=full' : `desc≤${toolsCfg.descriptionMaxLength}`); | |
| if (toolsCfg.includeOnly?.length) parts.push(`whitelist=${toolsCfg.includeOnly.length}`); | |
| if (toolsCfg.exclude?.length) parts.push(`blacklist=${toolsCfg.exclude.length}`); | |
| toolsInfo = parts.join(', '); | |
| } | |
| } | |
| console.log(''); | |
| console.log(` \x1b[36m⚡ Cursor2API v${VERSION}\x1b[0m`); | |
| console.log(` ├─ Server: \x1b[32mhttp://localhost:${config.port}\x1b[0m`); | |
| console.log(` ├─ Model: ${config.cursorModel}`); | |
| console.log(` ├─ Auth: ${auth}`); | |
| console.log(` ├─ Tools: ${toolsInfo}`); | |
| console.log(` ├─ Logging: ${logPersist}`); | |
| console.log(` └─ Logs: \x1b[35mhttp://localhost:${config.port}/logs\x1b[0m`); | |
| console.log(` └─ Logs Vue3: \x1b[35mhttp://localhost:${config.port}/vuelogs\x1b[0m`); | |
| console.log(''); | |
| // ★ 启动 config.yaml 热重载监听 | |
| initConfigWatcher(); | |
| }); | |
| // ★ 优雅关闭:停止文件监听 | |
| process.on('SIGTERM', () => { | |
| stopConfigWatcher(); | |
| process.exit(0); | |
| }); | |
| process.on('SIGINT', () => { | |
| stopConfigWatcher(); | |
| process.exit(0); | |
| }); | |