From 2e31a5bbcb0238648e1415c6d8a96909df5db68a Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Thu, 11 Sep 2025 09:51:29 +0800 Subject: [PATCH] Feat/api server (#9855) * feat: add api server This reverts commit c76aa035663998ad3d00ef38365ac4b642187c1d. * update yarn.lock * fix: correct import paths in ToolSettings component Update import paths for PreprocessSettings and WebSearchSettings to reference correct locations in DocProcessSettings and WebSearchSettings directories. * feat(settings): add API server settings link and route * fix(auth): improve authorization handling and error responses * feat(chat): enhance model validation and logging for chat completions feat(models): improve logging for model retrieval and filtering feat(utils): add model ID validation and support for OpenAI providers * feat(api-server): refactor config loading and remove unused ToolSettings component * refactor(ApiServerService): simplify config retrieval and improve error handling in ApiServerSettings * fix(mcp): remove unnecessary await in listTools return statement * refactor(ApiServerSettings): replace window.message with window.toast for notifications --------- Co-authored-by: kangfenmao --- package.json | 10 + packages/shared/IpcChannel.ts | 7 + src/main/apiServer/app.ts | 128 ++++++ src/main/apiServer/config.ts | 65 +++ src/main/apiServer/index.ts | 2 + src/main/apiServer/middleware/auth.ts | 62 +++ src/main/apiServer/middleware/error.ts | 21 + src/main/apiServer/middleware/openapi.ts | 206 +++++++++ src/main/apiServer/routes/chat.ts | 225 ++++++++++ src/main/apiServer/routes/mcp.ts | 153 +++++++ src/main/apiServer/routes/models.ts | 73 +++ src/main/apiServer/server.ts | 65 +++ .../apiServer/services/chat-completion.ts | 239 ++++++++++ src/main/apiServer/services/mcp.ts | 251 +++++++++++ src/main/apiServer/utils/index.ts | 231 ++++++++++ src/main/apiServer/utils/mcp.ts | 76 ++++ src/main/index.ts | 13 + src/main/ipc.ts | 4 + src/main/services/ApiServerService.ts | 108 +++++ .../src/pages/settings/SettingsPage.tsx | 9 + .../ApiServerSettings/ApiServerSettings.tsx | 425 ++++++++++++++++++ .../ToolSettings/ApiServerSettings/index.tsx | 1 + yarn.lock | 353 ++++++++++++++- 23 files changed, 2725 insertions(+), 2 deletions(-) create mode 100644 src/main/apiServer/app.ts create mode 100644 src/main/apiServer/config.ts create mode 100644 src/main/apiServer/index.ts create mode 100644 src/main/apiServer/middleware/auth.ts create mode 100644 src/main/apiServer/middleware/error.ts create mode 100644 src/main/apiServer/middleware/openapi.ts create mode 100644 src/main/apiServer/routes/chat.ts create mode 100644 src/main/apiServer/routes/mcp.ts create mode 100644 src/main/apiServer/routes/models.ts create mode 100644 src/main/apiServer/server.ts create mode 100644 src/main/apiServer/services/chat-completion.ts create mode 100644 src/main/apiServer/services/mcp.ts create mode 100644 src/main/apiServer/utils/index.ts create mode 100644 src/main/apiServer/utils/mcp.ts create mode 100644 src/main/services/ApiServerService.ts create mode 100644 src/renderer/src/pages/settings/ToolSettings/ApiServerSettings/ApiServerSettings.tsx create mode 100644 src/renderer/src/pages/settings/ToolSettings/ApiServerSettings/index.tsx diff --git a/package.json b/package.json index ca0cc7e176..9b82e5e693 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@libsql/win32-x64-msvc": "^0.4.7", "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "@strongtz/win32-arm64-msvc": "^0.4.7", + "express": "^5.1.0", "faiss-node": "^0.5.1", "graceful-fs": "^4.2.11", "jsdom": "26.1.0", @@ -84,6 +85,8 @@ "os-proxy-config": "^1.1.2", "selection-hook": "^1.0.12", "sharp": "^0.34.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", "turndown": "7.2.0" }, @@ -176,6 +179,10 @@ "@truto/turndown-plugin-gfm": "^1.0.2", "@tryfabric/martian": "^1.2.4", "@types/cli-progress": "^3", + "@types/content-type": "^1.1.9", + "@types/cors": "^2.8.19", + "@types/diff": "^7", + "@types/express": "^5", "@types/fs-extra": "^11", "@types/he": "^1", "@types/html-to-text": "^9", @@ -189,6 +196,9 @@ "@types/react-dom": "^19.0.4", "@types/react-infinite-scroll-component": "^5.0.0", "@types/react-transition-group": "^4.4.12", + "@types/react-window": "^1", + "@types/swagger-jsdoc": "^6", + "@types/swagger-ui-express": "^4.1.8", "@types/tinycolor2": "^1", "@types/turndown": "^5.0.5", "@types/word-extractor": "^1", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index a2a33e738e..87243df0c7 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -302,6 +302,13 @@ export enum IpcChannel { TRACE_CLEAN_LOCAL_DATA = 'trace:cleanLocalData', TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage', + // API Server + ApiServer_Start = 'api-server:start', + ApiServer_Stop = 'api-server:stop', + ApiServer_Restart = 'api-server:restart', + ApiServer_GetStatus = 'api-server:get-status', + ApiServer_GetConfig = 'api-server:get-config', + // Anthropic OAuth Anthropic_StartOAuthFlow = 'anthropic:start-oauth-flow', Anthropic_CompleteOAuthWithCode = 'anthropic:complete-oauth-with-code', diff --git a/src/main/apiServer/app.ts b/src/main/apiServer/app.ts new file mode 100644 index 0000000000..46da10f876 --- /dev/null +++ b/src/main/apiServer/app.ts @@ -0,0 +1,128 @@ +import { loggerService } from '@main/services/LoggerService' +import cors from 'cors' +import express from 'express' +import { v4 as uuidv4 } from 'uuid' + +import { authMiddleware } from './middleware/auth' +import { errorHandler } from './middleware/error' +import { setupOpenAPIDocumentation } from './middleware/openapi' +import { chatRoutes } from './routes/chat' +import { mcpRoutes } from './routes/mcp' +import { modelsRoutes } from './routes/models' + +const logger = loggerService.withContext('ApiServer') + +const app = express() + +// Global middleware +app.use((req, res, next) => { + const start = Date.now() + res.on('finish', () => { + const duration = Date.now() - start + logger.info(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`) + }) + next() +}) + +app.use((_req, res, next) => { + res.setHeader('X-Request-ID', uuidv4()) + next() +}) + +app.use( + cors({ + origin: '*', + allowedHeaders: ['Content-Type', 'Authorization'], + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] + }) +) + +/** + * @swagger + * /health: + * get: + * summary: Health check endpoint + * description: Check server status (no authentication required) + * tags: [Health] + * security: [] + * responses: + * 200: + * description: Server is healthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: ok + * timestamp: + * type: string + * format: date-time + * version: + * type: string + * example: 1.0.0 + */ +app.get('/health', (_req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.0.0' + }) +}) + +/** + * @swagger + * /: + * get: + * summary: API information + * description: Get basic API information and available endpoints + * tags: [General] + * security: [] + * responses: + * 200: + * description: API information + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * example: Cherry Studio API + * version: + * type: string + * example: 1.0.0 + * endpoints: + * type: object + */ +app.get('/', (_req, res) => { + res.json({ + name: 'Cherry Studio API', + version: '1.0.0', + endpoints: { + health: 'GET /health', + models: 'GET /v1/models', + chat: 'POST /v1/chat/completions', + mcp: 'GET /v1/mcps' + } + }) +}) + +// API v1 routes with auth +const apiRouter = express.Router() +apiRouter.use(authMiddleware) +apiRouter.use(express.json()) +// Mount routes +apiRouter.use('/chat', chatRoutes) +apiRouter.use('/mcps', mcpRoutes) +apiRouter.use('/models', modelsRoutes) +app.use('/v1', apiRouter) + +// Setup OpenAPI documentation +setupOpenAPIDocumentation(app) + +// Error handling (must be last) +app.use(errorHandler) + +export { app } diff --git a/src/main/apiServer/config.ts b/src/main/apiServer/config.ts new file mode 100644 index 0000000000..8bc4922968 --- /dev/null +++ b/src/main/apiServer/config.ts @@ -0,0 +1,65 @@ +import { ApiServerConfig } from '@types' +import { v4 as uuidv4 } from 'uuid' + +import { loggerService } from '../services/LoggerService' +import { reduxService } from '../services/ReduxService' + +const logger = loggerService.withContext('ApiServerConfig') + +const defaultHost = 'localhost' +const defaultPort = 23333 + +class ConfigManager { + private _config: ApiServerConfig | null = null + + private generateApiKey(): string { + return `cs-sk-${uuidv4()}` + } + + async load(): Promise { + try { + const settings = await reduxService.select('state.settings') + const serverSettings = settings?.apiServer + let apiKey = serverSettings?.apiKey + if (!apiKey || apiKey.trim() === '') { + apiKey = this.generateApiKey() + await reduxService.dispatch({ + type: 'settings/setApiServerApiKey', + payload: apiKey + }) + } + this._config = { + enabled: serverSettings?.enabled ?? false, + port: serverSettings?.port ?? defaultPort, + host: defaultHost, + apiKey: apiKey + } + return this._config + } catch (error: any) { + logger.warn('Failed to load config from Redux, using defaults:', error) + this._config = { + enabled: false, + port: defaultPort, + host: defaultHost, + apiKey: this.generateApiKey() + } + return this._config + } + } + + async get(): Promise { + if (!this._config) { + await this.load() + } + if (!this._config) { + throw new Error('Failed to load API server configuration') + } + return this._config + } + + async reload(): Promise { + return await this.load() + } +} + +export const config = new ConfigManager() diff --git a/src/main/apiServer/index.ts b/src/main/apiServer/index.ts new file mode 100644 index 0000000000..765ca05fba --- /dev/null +++ b/src/main/apiServer/index.ts @@ -0,0 +1,2 @@ +export { config } from './config' +export { apiServer } from './server' diff --git a/src/main/apiServer/middleware/auth.ts b/src/main/apiServer/middleware/auth.ts new file mode 100644 index 0000000000..02cf017247 --- /dev/null +++ b/src/main/apiServer/middleware/auth.ts @@ -0,0 +1,62 @@ +import crypto from 'crypto' +import { NextFunction, Request, Response } from 'express' + +import { config } from '../config' + +export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => { + const auth = req.header('Authorization') || '' + const xApiKey = req.header('x-api-key') || '' + + // Fast rejection if neither credential header provided + if (!auth && !xApiKey) { + return res.status(401).json({ error: 'Unauthorized: missing credentials' }) + } + + let token: string | undefined + + // Prefer Bearer if well‑formed + if (auth) { + const trimmed = auth.trim() + const bearerPrefix = /^Bearer\s+/i + if (bearerPrefix.test(trimmed)) { + const candidate = trimmed.replace(bearerPrefix, '').trim() + if (!candidate) { + return res.status(401).json({ error: 'Unauthorized: empty bearer token' }) + } + token = candidate + } + } + + // Fallback to x-api-key if token still not resolved + if (!token && xApiKey) { + if (!xApiKey.trim()) { + return res.status(401).json({ error: 'Unauthorized: empty x-api-key' }) + } + token = xApiKey.trim() + } + + if (!token) { + // At this point we had at least one header, but none yielded a usable token + return res.status(401).json({ error: 'Unauthorized: invalid credentials format' }) + } + + const { apiKey } = await config.get() + + if (!apiKey) { + // If server not configured, treat as forbidden (or could be 500). Choose 403 to avoid leaking config state. + return res.status(403).json({ error: 'Forbidden' }) + } + + // Timing-safe compare when lengths match, else immediate forbidden + if (token.length !== apiKey.length) { + return res.status(403).json({ error: 'Forbidden' }) + } + + const tokenBuf = Buffer.from(token) + const keyBuf = Buffer.from(apiKey) + if (!crypto.timingSafeEqual(tokenBuf, keyBuf)) { + return res.status(403).json({ error: 'Forbidden' }) + } + + return next() +} diff --git a/src/main/apiServer/middleware/error.ts b/src/main/apiServer/middleware/error.ts new file mode 100644 index 0000000000..65eef5e43d --- /dev/null +++ b/src/main/apiServer/middleware/error.ts @@ -0,0 +1,21 @@ +import { NextFunction, Request, Response } from 'express' + +import { loggerService } from '../../services/LoggerService' + +const logger = loggerService.withContext('ApiServerErrorHandler') + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => { + logger.error('API Server Error:', err) + + // Don't expose internal errors in production + const isDev = process.env.NODE_ENV === 'development' + + res.status(500).json({ + error: { + message: isDev ? err.message : 'Internal server error', + type: 'server_error', + ...(isDev && { stack: err.stack }) + } + }) +} diff --git a/src/main/apiServer/middleware/openapi.ts b/src/main/apiServer/middleware/openapi.ts new file mode 100644 index 0000000000..691bd8ec96 --- /dev/null +++ b/src/main/apiServer/middleware/openapi.ts @@ -0,0 +1,206 @@ +import { Express } from 'express' +import swaggerJSDoc from 'swagger-jsdoc' +import swaggerUi from 'swagger-ui-express' + +import { loggerService } from '../../services/LoggerService' + +const logger = loggerService.withContext('OpenAPIMiddleware') + +const swaggerOptions: swaggerJSDoc.Options = { + definition: { + openapi: '3.0.0', + info: { + title: 'Cherry Studio API', + version: '1.0.0', + description: 'OpenAI-compatible API for Cherry Studio with additional Cherry-specific endpoints', + contact: { + name: 'Cherry Studio', + url: 'https://github.com/CherryHQ/cherry-studio' + } + }, + servers: [ + { + url: 'http://localhost:23333', + description: 'Local development server' + } + ], + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Use the API key from Cherry Studio settings' + } + }, + schemas: { + Error: { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + message: { type: 'string' }, + type: { type: 'string' }, + code: { type: 'string' } + } + } + } + }, + ChatMessage: { + type: 'object', + properties: { + role: { + type: 'string', + enum: ['system', 'user', 'assistant', 'tool'] + }, + content: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + text: { type: 'string' }, + image_url: { + type: 'object', + properties: { + url: { type: 'string' } + } + } + } + } + } + ] + }, + name: { type: 'string' }, + tool_calls: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + type: { type: 'string' }, + function: { + type: 'object', + properties: { + name: { type: 'string' }, + arguments: { type: 'string' } + } + } + } + } + } + } + }, + ChatCompletionRequest: { + type: 'object', + required: ['model', 'messages'], + properties: { + model: { + type: 'string', + description: 'The model to use for completion, in format provider:model-id' + }, + messages: { + type: 'array', + items: { $ref: '#/components/schemas/ChatMessage' } + }, + temperature: { + type: 'number', + minimum: 0, + maximum: 2, + default: 1 + }, + max_tokens: { + type: 'integer', + minimum: 1 + }, + stream: { + type: 'boolean', + default: false + }, + tools: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + function: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + parameters: { type: 'object' } + } + } + } + } + } + } + }, + Model: { + type: 'object', + properties: { + id: { type: 'string' }, + object: { type: 'string', enum: ['model'] }, + created: { type: 'integer' }, + owned_by: { type: 'string' } + } + }, + MCPServer: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + command: { type: 'string' }, + args: { + type: 'array', + items: { type: 'string' } + }, + env: { type: 'object' }, + disabled: { type: 'boolean' } + } + } + } + }, + security: [ + { + BearerAuth: [] + } + ] + }, + apis: ['./src/main/apiServer/routes/*.ts', './src/main/apiServer/app.ts'] +} + +export function setupOpenAPIDocumentation(app: Express) { + try { + const specs = swaggerJSDoc(swaggerOptions) + + // Serve OpenAPI JSON + app.get('/api-docs.json', (_req, res) => { + res.setHeader('Content-Type', 'application/json') + res.send(specs) + }) + + // Serve Swagger UI + app.use( + '/api-docs', + swaggerUi.serve, + swaggerUi.setup(specs, { + customCss: ` + .swagger-ui .topbar { display: none; } + .swagger-ui .info .title { color: #1890ff; } + `, + customSiteTitle: 'Cherry Studio API Documentation' + }) + ) + + logger.info('OpenAPI documentation setup complete') + logger.info('Documentation available at /api-docs') + logger.info('OpenAPI spec available at /api-docs.json') + } catch (error) { + logger.error('Failed to setup OpenAPI documentation:', error as Error) + } +} diff --git a/src/main/apiServer/routes/chat.ts b/src/main/apiServer/routes/chat.ts new file mode 100644 index 0000000000..be43d866a4 --- /dev/null +++ b/src/main/apiServer/routes/chat.ts @@ -0,0 +1,225 @@ +import express, { Request, Response } from 'express' +import OpenAI from 'openai' +import { ChatCompletionCreateParams } from 'openai/resources' + +import { loggerService } from '../../services/LoggerService' +import { chatCompletionService } from '../services/chat-completion' +import { validateModelId } from '../utils' + +const logger = loggerService.withContext('ApiServerChatRoutes') + +const router = express.Router() + +/** + * @swagger + * /v1/chat/completions: + * post: + * summary: Create chat completion + * description: Create a chat completion response, compatible with OpenAI API + * tags: [Chat] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ChatCompletionRequest' + * responses: + * 200: + * description: Chat completion response + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * object: + * type: string + * example: chat.completion + * created: + * type: integer + * model: + * type: string + * choices: + * type: array + * items: + * type: object + * properties: + * index: + * type: integer + * message: + * $ref: '#/components/schemas/ChatMessage' + * finish_reason: + * type: string + * usage: + * type: object + * properties: + * prompt_tokens: + * type: integer + * completion_tokens: + * type: integer + * total_tokens: + * type: integer + * text/plain: + * schema: + * type: string + * description: Server-sent events stream (when stream=true) + * 400: + * description: Bad request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 429: + * description: Rate limit exceeded + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +router.post('/completions', async (req: Request, res: Response) => { + try { + const request: ChatCompletionCreateParams = req.body + + if (!request) { + return res.status(400).json({ + error: { + message: 'Request body is required', + type: 'invalid_request_error', + code: 'missing_body' + } + }) + } + + logger.info('Chat completion request:', { + model: request.model, + messageCount: request.messages?.length || 0, + stream: request.stream, + temperature: request.temperature + }) + + // Validate request + const validation = chatCompletionService.validateRequest(request) + if (!validation.isValid) { + return res.status(400).json({ + error: { + message: validation.errors.join('; '), + type: 'invalid_request_error', + code: 'validation_failed' + } + }) + } + + // Validate model ID and get provider + const modelValidation = await validateModelId(request.model) + if (!modelValidation.valid) { + const error = modelValidation.error! + logger.warn(`Model validation failed for '${request.model}':`, error) + return res.status(400).json({ + error: { + message: error.message, + type: 'invalid_request_error', + code: error.code + } + }) + } + + const provider = modelValidation.provider! + const modelId = modelValidation.modelId! + + logger.info('Model validation successful:', { + provider: provider.id, + providerType: provider.type, + modelId: modelId, + fullModelId: request.model + }) + + // Create OpenAI client + const client = new OpenAI({ + baseURL: provider.apiHost, + apiKey: provider.apiKey + }) + request.model = modelId + + // Handle streaming + if (request.stream) { + const streamResponse = await client.chat.completions.create(request) + + res.setHeader('Content-Type', 'text/plain; charset=utf-8') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + + try { + for await (const chunk of streamResponse as any) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`) + } + res.write('data: [DONE]\n\n') + res.end() + } catch (streamError: any) { + logger.error('Stream error:', streamError) + res.write( + `data: ${JSON.stringify({ + error: { + message: 'Stream processing error', + type: 'server_error', + code: 'stream_error' + } + })}\n\n` + ) + res.end() + } + return + } + + // Handle non-streaming + const response = await client.chat.completions.create(request) + return res.json(response) + } catch (error: any) { + logger.error('Chat completion error:', error) + + let statusCode = 500 + let errorType = 'server_error' + let errorCode = 'internal_error' + let errorMessage = 'Internal server error' + + if (error instanceof Error) { + errorMessage = error.message + + if (error.message.includes('API key') || error.message.includes('authentication')) { + statusCode = 401 + errorType = 'authentication_error' + errorCode = 'invalid_api_key' + } else if (error.message.includes('rate limit') || error.message.includes('quota')) { + statusCode = 429 + errorType = 'rate_limit_error' + errorCode = 'rate_limit_exceeded' + } else if (error.message.includes('timeout') || error.message.includes('connection')) { + statusCode = 502 + errorType = 'server_error' + errorCode = 'upstream_error' + } + } + + return res.status(statusCode).json({ + error: { + message: errorMessage, + type: errorType, + code: errorCode + } + }) + } +}) + +export { router as chatRoutes } diff --git a/src/main/apiServer/routes/mcp.ts b/src/main/apiServer/routes/mcp.ts new file mode 100644 index 0000000000..1e154ee583 --- /dev/null +++ b/src/main/apiServer/routes/mcp.ts @@ -0,0 +1,153 @@ +import express, { Request, Response } from 'express' + +import { loggerService } from '../../services/LoggerService' +import { mcpApiService } from '../services/mcp' + +const logger = loggerService.withContext('ApiServerMCPRoutes') + +const router = express.Router() + +/** + * @swagger + * /v1/mcps: + * get: + * summary: List MCP servers + * description: Get a list of all configured Model Context Protocol servers + * tags: [MCP] + * responses: + * 200: + * description: List of MCP servers + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: array + * items: + * $ref: '#/components/schemas/MCPServer' + * 503: + * description: Service unavailable + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * $ref: '#/components/schemas/Error' + */ +router.get('/', async (req: Request, res: Response) => { + try { + logger.info('Get all MCP servers request received') + const servers = await mcpApiService.getAllServers(req) + return res.json({ + success: true, + data: servers + }) + } catch (error: any) { + logger.error('Error fetching MCP servers:', error) + return res.status(503).json({ + success: false, + error: { + message: `Failed to retrieve MCP servers: ${error.message}`, + type: 'service_unavailable', + code: 'servers_unavailable' + } + }) + } +}) + +/** + * @swagger + * /v1/mcps/{server_id}: + * get: + * summary: Get MCP server info + * description: Get detailed information about a specific MCP server + * tags: [MCP] + * parameters: + * - in: path + * name: server_id + * required: true + * schema: + * type: string + * description: MCP server ID + * responses: + * 200: + * description: MCP server information + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * $ref: '#/components/schemas/MCPServer' + * 404: + * description: MCP server not found + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * error: + * $ref: '#/components/schemas/Error' + */ +router.get('/:server_id', async (req: Request, res: Response) => { + try { + logger.info('Get MCP server info request received') + const server = await mcpApiService.getServerInfo(req.params.server_id) + if (!server) { + logger.warn('MCP server not found') + return res.status(404).json({ + success: false, + error: { + message: 'MCP server not found', + type: 'not_found', + code: 'server_not_found' + } + }) + } + return res.json({ + success: true, + data: server + }) + } catch (error: any) { + logger.error('Error fetching MCP server info:', error) + return res.status(503).json({ + success: false, + error: { + message: `Failed to retrieve MCP server info: ${error.message}`, + type: 'service_unavailable', + code: 'server_info_unavailable' + } + }) + } +}) + +// Connect to MCP server +router.all('/:server_id/mcp', async (req: Request, res: Response) => { + const server = await mcpApiService.getServerById(req.params.server_id) + if (!server) { + logger.warn('MCP server not found') + return res.status(404).json({ + success: false, + error: { + message: 'MCP server not found', + type: 'not_found', + code: 'server_not_found' + } + }) + } + return await mcpApiService.handleRequest(req, res, server) +}) + +export { router as mcpRoutes } diff --git a/src/main/apiServer/routes/models.ts b/src/main/apiServer/routes/models.ts new file mode 100644 index 0000000000..9f4d2f13c9 --- /dev/null +++ b/src/main/apiServer/routes/models.ts @@ -0,0 +1,73 @@ +import express, { Request, Response } from 'express' + +import { loggerService } from '../../services/LoggerService' +import { chatCompletionService } from '../services/chat-completion' + +const logger = loggerService.withContext('ApiServerModelsRoutes') + +const router = express.Router() + +/** + * @swagger + * /v1/models: + * get: + * summary: List available models + * description: Returns a list of available AI models from all configured providers + * tags: [Models] + * responses: + * 200: + * description: List of available models + * content: + * application/json: + * schema: + * type: object + * properties: + * object: + * type: string + * example: list + * data: + * type: array + * items: + * $ref: '#/components/schemas/Model' + * 503: + * description: Service unavailable + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +router.get('/', async (_req: Request, res: Response) => { + try { + logger.info('Models list request received') + + const models = await chatCompletionService.getModels() + + if (models.length === 0) { + logger.warn( + 'No models available from providers. This may be because no OpenAI providers are configured or enabled.' + ) + } + + logger.info(`Returning ${models.length} models (OpenAI providers only)`) + logger.debug( + 'Model IDs:', + models.map((m) => m.id) + ) + + return res.json({ + object: 'list', + data: models + }) + } catch (error: any) { + logger.error('Error fetching models:', error) + return res.status(503).json({ + error: { + message: 'Failed to retrieve models from available providers', + type: 'service_unavailable', + code: 'models_unavailable' + } + }) + } +}) + +export { router as modelsRoutes } diff --git a/src/main/apiServer/server.ts b/src/main/apiServer/server.ts new file mode 100644 index 0000000000..2555fa8c2e --- /dev/null +++ b/src/main/apiServer/server.ts @@ -0,0 +1,65 @@ +import { createServer } from 'node:http' + +import { loggerService } from '../services/LoggerService' +import { app } from './app' +import { config } from './config' + +const logger = loggerService.withContext('ApiServer') + +export class ApiServer { + private server: ReturnType | null = null + + async start(): Promise { + if (this.server) { + logger.warn('Server already running') + return + } + + // Load config + const { port, host, apiKey } = await config.load() + + // Create server with Express app + this.server = createServer(app) + + // Start server + return new Promise((resolve, reject) => { + this.server!.listen(port, host, () => { + logger.info(`API Server started at http://${host}:${port}`) + logger.info(`API Key: ${apiKey}`) + resolve() + }) + + this.server!.on('error', reject) + }) + } + + async stop(): Promise { + if (!this.server) return + + return new Promise((resolve) => { + this.server!.close(() => { + logger.info('API Server stopped') + this.server = null + resolve() + }) + }) + } + + async restart(): Promise { + await this.stop() + await config.reload() + await this.start() + } + + isRunning(): boolean { + const hasServer = this.server !== null + const isListening = this.server?.listening || false + const result = hasServer && isListening + + logger.debug('isRunning check:', { hasServer, isListening, result }) + + return result + } +} + +export const apiServer = new ApiServer() diff --git a/src/main/apiServer/services/chat-completion.ts b/src/main/apiServer/services/chat-completion.ts new file mode 100644 index 0000000000..7df6226706 --- /dev/null +++ b/src/main/apiServer/services/chat-completion.ts @@ -0,0 +1,239 @@ +import OpenAI from 'openai' +import { ChatCompletionCreateParams } from 'openai/resources' + +import { loggerService } from '../../services/LoggerService' +import { + getProviderByModel, + getRealProviderModel, + listAllAvailableModels, + OpenAICompatibleModel, + transformModelToOpenAI, + validateProvider +} from '../utils' + +const logger = loggerService.withContext('ChatCompletionService') + +export interface ModelData extends OpenAICompatibleModel { + provider_id: string + model_id: string + name: string +} + +export interface ValidationResult { + isValid: boolean + errors: string[] +} + +export class ChatCompletionService { + async getModels(): Promise { + try { + logger.info('Getting available models from providers') + + const models = await listAllAvailableModels() + + // Use Map to deduplicate models by their full ID (provider:model_id) + const uniqueModels = new Map() + + for (const model of models) { + const openAIModel = transformModelToOpenAI(model) + const fullModelId = openAIModel.id // This is already in format "provider:model_id" + + // Only add if not already present (first occurrence wins) + if (!uniqueModels.has(fullModelId)) { + uniqueModels.set(fullModelId, { + ...openAIModel, + provider_id: model.provider, + model_id: model.id, + name: model.name + }) + } else { + logger.debug(`Skipping duplicate model: ${fullModelId}`) + } + } + + const modelData = Array.from(uniqueModels.values()) + + logger.info(`Successfully retrieved ${modelData.length} unique models from ${models.length} total models`) + + if (models.length > modelData.length) { + logger.debug(`Filtered out ${models.length - modelData.length} duplicate models`) + } + + return modelData + } catch (error: any) { + logger.error('Error getting models:', error) + return [] + } + } + + validateRequest(request: ChatCompletionCreateParams): ValidationResult { + const errors: string[] = [] + + // Validate model + if (!request.model) { + errors.push('Model is required') + } else if (typeof request.model !== 'string') { + errors.push('Model must be a string') + } else if (!request.model.includes(':')) { + errors.push('Model must be in format "provider:model_id"') + } + + // Validate messages + if (!request.messages) { + errors.push('Messages array is required') + } else if (!Array.isArray(request.messages)) { + errors.push('Messages must be an array') + } else if (request.messages.length === 0) { + errors.push('Messages array cannot be empty') + } else { + // Validate each message + request.messages.forEach((message, index) => { + if (!message.role) { + errors.push(`Message ${index}: role is required`) + } + if (!message.content) { + errors.push(`Message ${index}: content is required`) + } + }) + } + + // Validate optional parameters + if (request.temperature !== undefined) { + if (typeof request.temperature !== 'number' || request.temperature < 0 || request.temperature > 2) { + errors.push('Temperature must be a number between 0 and 2') + } + } + + if (request.max_tokens !== undefined) { + if (typeof request.max_tokens !== 'number' || request.max_tokens < 1) { + errors.push('max_tokens must be a positive number') + } + } + + return { + isValid: errors.length === 0, + errors + } + } + + async processCompletion(request: ChatCompletionCreateParams): Promise { + try { + logger.info('Processing chat completion request:', { + model: request.model, + messageCount: request.messages.length, + stream: request.stream + }) + + // Validate request + const validation = this.validateRequest(request) + if (!validation.isValid) { + throw new Error(`Request validation failed: ${validation.errors.join(', ')}`) + } + + // Get provider for the model + const provider = await getProviderByModel(request.model!) + if (!provider) { + throw new Error(`Provider not found for model: ${request.model}`) + } + + // Validate provider + if (!validateProvider(provider)) { + throw new Error(`Provider validation failed for: ${provider.id}`) + } + + // Extract model ID from the full model string + const modelId = getRealProviderModel(request.model) + + // Create OpenAI client for the provider + const client = new OpenAI({ + baseURL: provider.apiHost, + apiKey: provider.apiKey + }) + + // Prepare request with the actual model ID + const providerRequest = { + ...request, + model: modelId, + stream: false + } + + logger.debug('Sending request to provider:', { + provider: provider.id, + model: modelId, + apiHost: provider.apiHost + }) + + const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion + + logger.info('Successfully processed chat completion') + return response + } catch (error: any) { + logger.error('Error processing chat completion:', error) + throw error + } + } + + async *processStreamingCompletion( + request: ChatCompletionCreateParams + ): AsyncIterable { + try { + logger.info('Processing streaming chat completion request:', { + model: request.model, + messageCount: request.messages.length + }) + + // Validate request + const validation = this.validateRequest(request) + if (!validation.isValid) { + throw new Error(`Request validation failed: ${validation.errors.join(', ')}`) + } + + // Get provider for the model + const provider = await getProviderByModel(request.model!) + if (!provider) { + throw new Error(`Provider not found for model: ${request.model}`) + } + + // Validate provider + if (!validateProvider(provider)) { + throw new Error(`Provider validation failed for: ${provider.id}`) + } + + // Extract model ID from the full model string + const modelId = getRealProviderModel(request.model) + + // Create OpenAI client for the provider + const client = new OpenAI({ + baseURL: provider.apiHost, + apiKey: provider.apiKey + }) + + // Prepare streaming request + const streamingRequest = { + ...request, + model: modelId, + stream: true as const + } + + logger.debug('Sending streaming request to provider:', { + provider: provider.id, + model: modelId, + apiHost: provider.apiHost + }) + + const stream = await client.chat.completions.create(streamingRequest) + + for await (const chunk of stream) { + yield chunk + } + + logger.info('Successfully completed streaming chat completion') + } catch (error: any) { + logger.error('Error processing streaming chat completion:', error) + throw error + } + } +} + +// Export singleton instance +export const chatCompletionService = new ChatCompletionService() diff --git a/src/main/apiServer/services/mcp.ts b/src/main/apiServer/services/mcp.ts new file mode 100644 index 0000000000..99f1732114 --- /dev/null +++ b/src/main/apiServer/services/mcp.ts @@ -0,0 +1,251 @@ +import mcpService from '@main/services/MCPService' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp' +import { + isJSONRPCRequest, + JSONRPCMessage, + JSONRPCMessageSchema, + MessageExtraInfo +} from '@modelcontextprotocol/sdk/types' +import { MCPServer } from '@types' +import { randomUUID } from 'crypto' +import { EventEmitter } from 'events' +import { Request, Response } from 'express' +import { IncomingMessage, ServerResponse } from 'http' + +import { loggerService } from '../../services/LoggerService' +import { reduxService } from '../../services/ReduxService' +import { getMcpServerById } from '../utils/mcp' + +const logger = loggerService.withContext('MCPApiService') +const transports: Record = {} + +interface McpServerDTO { + id: MCPServer['id'] + name: MCPServer['name'] + type: MCPServer['type'] + description: MCPServer['description'] + url: string +} + +interface McpServersResp { + servers: Record +} + +/** + * MCPApiService - API layer for MCP server management + * + * This service provides a REST API interface for MCP servers while integrating + * with the existing application architecture: + * + * 1. Uses ReduxService to access the renderer's Redux store directly + * 2. Syncs changes back to the renderer via Redux actions + * 3. Leverages existing MCPService for actual server connections + * 4. Provides session management for API clients + */ +class MCPApiService extends EventEmitter { + private transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }) + + constructor() { + super() + this.initMcpServer() + logger.silly('MCPApiService initialized') + } + + private initMcpServer() { + this.transport.onmessage = this.onMessage + } + + /** + * Get servers directly from Redux store + */ + private async getServersFromRedux(): Promise { + try { + logger.silly('Getting servers from Redux store') + + // Try to get from cache first (faster) + const cachedServers = reduxService.selectSync('state.mcp.servers') + if (cachedServers && Array.isArray(cachedServers)) { + logger.silly(`Found ${cachedServers.length} servers in Redux cache`) + return cachedServers + } + + // If cache is not available, get fresh data + const servers = await reduxService.select('state.mcp.servers') + logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`) + return servers || [] + } catch (error: any) { + logger.error('Failed to get servers from Redux:', error) + return [] + } + } + + // get all activated servers + async getAllServers(req: Request): Promise { + try { + const servers = await this.getServersFromRedux() + logger.silly(`Returning ${servers.length} servers`) + const resp: McpServersResp = { + servers: {} + } + for (const server of servers) { + if (server.isActive) { + resp.servers[server.id] = { + id: server.id, + name: server.name, + type: 'streamableHttp', + description: server.description, + url: `${req.protocol}://${req.host}/v1/mcps/${server.id}/mcp` + } + } + } + return resp + } catch (error: any) { + logger.error('Failed to get all servers:', error) + throw new Error('Failed to retrieve servers') + } + } + + // get server by id + async getServerById(id: string): Promise { + try { + logger.silly(`getServerById called with id: ${id}`) + const servers = await this.getServersFromRedux() + const server = servers.find((s) => s.id === id) + if (!server) { + logger.warn(`Server with id ${id} not found`) + return null + } + logger.silly(`Returning server with id ${id}`) + return server + } catch (error: any) { + logger.error(`Failed to get server with id ${id}:`, error) + throw new Error('Failed to retrieve server') + } + } + + async getServerInfo(id: string): Promise { + try { + logger.silly(`getServerInfo called with id: ${id}`) + const server = await this.getServerById(id) + if (!server) { + logger.warn(`Server with id ${id} not found`) + return null + } + logger.silly(`Returning server info for id ${id}`) + + const client = await mcpService.initClient(server) + const tools = await client.listTools() + + logger.info(`Server with id ${id} info:`, { tools: JSON.stringify(tools) }) + + // const [version, tools, prompts, resources] = await Promise.all([ + // () => { + // try { + // return client.getServerVersion() + // } catch (error) { + // logger.error(`Failed to get server version for id ${id}:`, { error: error }) + // return '1.0.0' + // } + // }, + // (() => { + // try { + // return client.listTools() + // } catch (error) { + // logger.error(`Failed to list tools for id ${id}:`, { error: error }) + // return [] + // } + // })(), + // (() => { + // try { + // return client.listPrompts() + // } catch (error) { + // logger.error(`Failed to list prompts for id ${id}:`, { error: error }) + // return [] + // } + // })(), + // (() => { + // try { + // return client.listResources() + // } catch (error) { + // logger.error(`Failed to list resources for id ${id}:`, { error: error }) + // return [] + // } + // })() + // ]) + + return { + id: server.id, + name: server.name, + type: server.type, + description: server.description, + tools + } + } catch (error: any) { + logger.error(`Failed to get server info with id ${id}:`, error) + throw new Error('Failed to retrieve server info') + } + } + + async handleRequest(req: Request, res: Response, server: MCPServer) { + const sessionId = req.headers['mcp-session-id'] as string | undefined + logger.silly(`Handling request for server with sessionId ${sessionId}`) + let transport: StreamableHTTPServerTransport + if (sessionId && transports[sessionId]) { + transport = transports[sessionId] + } else { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + transports[sessionId] = transport + } + }) + + transport.onclose = () => { + logger.info(`Transport for sessionId ${sessionId} closed`) + if (transport.sessionId) { + delete transports[transport.sessionId] + } + } + const mcpServer = await getMcpServerById(server.id) + if (mcpServer) { + await mcpServer.connect(transport) + } + } + const jsonpayload = req.body + const messages: JSONRPCMessage[] = [] + + if (Array.isArray(jsonpayload)) { + for (const payload of jsonpayload) { + const message = JSONRPCMessageSchema.parse(payload) + messages.push(message) + } + } else { + const message = JSONRPCMessageSchema.parse(jsonpayload) + messages.push(message) + } + + for (const message of messages) { + if (isJSONRPCRequest(message)) { + if (!message.params) { + message.params = {} + } + if (!message.params._meta) { + message.params._meta = {} + } + message.params._meta.serverId = server.id + } + } + + logger.info(`Request body`, { rawBody: req.body, messages: JSON.stringify(messages) }) + await transport.handleRequest(req as IncomingMessage, res as ServerResponse, messages) + } + + private onMessage(message: JSONRPCMessage, extra?: MessageExtraInfo) { + logger.info(`Received message: ${JSON.stringify(message)}`, extra) + // Handle message here + } +} + +export const mcpApiService = new MCPApiService() diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts new file mode 100644 index 0000000000..9d3b81c328 --- /dev/null +++ b/src/main/apiServer/utils/index.ts @@ -0,0 +1,231 @@ +import { loggerService } from '@main/services/LoggerService' +import { reduxService } from '@main/services/ReduxService' +import { Model, Provider } from '@types' + +const logger = loggerService.withContext('ApiServerUtils') + +// OpenAI compatible model format +export interface OpenAICompatibleModel { + id: string + object: 'model' + created: number + owned_by: string + provider?: string + provider_model_id?: string +} + +export async function getAvailableProviders(): Promise { + try { + // Wait for store to be ready before accessing providers + const providers = await reduxService.select('state.llm.providers') + if (!providers || !Array.isArray(providers)) { + logger.warn('No providers found in Redux store, returning empty array') + return [] + } + + // Only support OpenAI type providers for API server + const openAIProviders = providers.filter((p: Provider) => p.enabled && p.type === 'openai') + + logger.info(`Filtered to ${openAIProviders.length} OpenAI providers from ${providers.length} total providers`) + + return openAIProviders + } catch (error: any) { + logger.error('Failed to get providers from Redux store:', error) + return [] + } +} + +export async function listAllAvailableModels(): Promise { + try { + const providers = await getAvailableProviders() + return providers.map((p: Provider) => p.models || []).flat() + } catch (error: any) { + logger.error('Failed to list available models:', error) + return [] + } +} + +export async function getProviderByModel(model: string): Promise { + try { + if (!model || typeof model !== 'string') { + logger.warn(`Invalid model parameter: ${model}`) + return undefined + } + + // Validate model format first + if (!model.includes(':')) { + logger.warn( + `Invalid model format, must contain ':' separator. Expected format "provider:model_id", got: ${model}` + ) + return undefined + } + + const providers = await getAvailableProviders() + const modelInfo = model.split(':') + + if (modelInfo.length < 2 || modelInfo[0].length === 0 || modelInfo[1].length === 0) { + logger.warn(`Invalid model format, expected "provider:model_id" with non-empty parts, got: ${model}`) + return undefined + } + + const providerId = modelInfo[0] + const provider = providers.find((p: Provider) => p.id === providerId) + + if (!provider) { + logger.warn( + `Provider '${providerId}' not found or not enabled. Available providers: ${providers.map((p) => p.id).join(', ')}` + ) + return undefined + } + + logger.debug(`Found provider '${providerId}' for model: ${model}`) + return provider + } catch (error: any) { + logger.error('Failed to get provider by model:', error) + return undefined + } +} + +export function getRealProviderModel(modelStr: string): string { + return modelStr.split(':').slice(1).join(':') +} + +export interface ModelValidationError { + type: 'invalid_format' | 'provider_not_found' | 'model_not_available' | 'unsupported_provider_type' + message: string + code: string +} + +export async function validateModelId( + model: string +): Promise<{ valid: boolean; error?: ModelValidationError; provider?: Provider; modelId?: string }> { + try { + if (!model || typeof model !== 'string') { + return { + valid: false, + error: { + type: 'invalid_format', + message: 'Model must be a non-empty string', + code: 'invalid_model_parameter' + } + } + } + + if (!model.includes(':')) { + return { + valid: false, + error: { + type: 'invalid_format', + message: "Invalid model format. Expected format: 'provider:model_id' (e.g., 'my-openai:gpt-4')", + code: 'invalid_model_format' + } + } + } + + const modelInfo = model.split(':') + if (modelInfo.length < 2 || modelInfo[0].length === 0 || modelInfo[1].length === 0) { + return { + valid: false, + error: { + type: 'invalid_format', + message: "Invalid model format. Both provider and model_id must be non-empty. Expected: 'provider:model_id'", + code: 'invalid_model_format' + } + } + } + + const providerId = modelInfo[0] + const modelId = getRealProviderModel(model) + const provider = await getProviderByModel(model) + + if (!provider) { + return { + valid: false, + error: { + type: 'provider_not_found', + message: `Provider '${providerId}' not found, not enabled, or not supported. Only OpenAI providers are currently supported.`, + code: 'provider_not_found' + } + } + } + + // Check if model exists in provider + const modelExists = provider.models?.some((m) => m.id === modelId) + if (!modelExists) { + const availableModels = provider.models?.map((m) => m.id).join(', ') || 'none' + return { + valid: false, + error: { + type: 'model_not_available', + message: `Model '${modelId}' not available in provider '${providerId}'. Available models: ${availableModels}`, + code: 'model_not_available' + } + } + } + + return { + valid: true, + provider, + modelId + } + } catch (error: any) { + logger.error('Error validating model ID:', error) + return { + valid: false, + error: { + type: 'invalid_format', + message: 'Failed to validate model ID', + code: 'validation_error' + } + } + } +} + +export function transformModelToOpenAI(model: Model): OpenAICompatibleModel { + return { + id: `${model.provider}:${model.id}`, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: model.owned_by || model.provider, + provider: model.provider, + provider_model_id: model.id + } +} + +export function validateProvider(provider: Provider): boolean { + try { + if (!provider) { + return false + } + + // Check required fields + if (!provider.id || !provider.type || !provider.apiKey || !provider.apiHost) { + logger.warn('Provider missing required fields:', { + id: !!provider.id, + type: !!provider.type, + apiKey: !!provider.apiKey, + apiHost: !!provider.apiHost + }) + return false + } + + // Check if provider is enabled + if (!provider.enabled) { + logger.debug(`Provider is disabled: ${provider.id}`) + return false + } + + // Only support OpenAI type providers + if (provider.type !== 'openai') { + logger.debug( + `Provider type '${provider.type}' not supported, only 'openai' type is currently supported: ${provider.id}` + ) + return false + } + + return true + } catch (error: any) { + logger.error('Error validating provider:', error) + return false + } +} diff --git a/src/main/apiServer/utils/mcp.ts b/src/main/apiServer/utils/mcp.ts new file mode 100644 index 0000000000..1ebe06ba68 --- /dev/null +++ b/src/main/apiServer/utils/mcp.ts @@ -0,0 +1,76 @@ +import mcpService from '@main/services/MCPService' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js' +import { MCPServer } from '@types' + +import { loggerService } from '../../services/LoggerService' +import { reduxService } from '../../services/ReduxService' + +const logger = loggerService.withContext('MCPApiService') + +const cachedServers: Record = {} + +async function handleListToolsRequest(request: any, extra: any): Promise { + logger.debug('Handling list tools request', { request: request, extra: extra }) + const serverId: string = request.params._meta.serverId + const serverConfig = await getMcpServerConfigById(serverId) + if (!serverConfig) { + throw new Error(`Server not found: ${serverId}`) + } + const client = await mcpService.initClient(serverConfig) + return client.listTools() +} + +async function handleCallToolRequest(request: any, extra: any): Promise { + logger.debug('Handling call tool request', { request: request, extra: extra }) + const serverId: string = request.params._meta.serverId + const serverConfig = await getMcpServerConfigById(serverId) + if (!serverConfig) { + throw new Error(`Server not found: ${serverId}`) + } + const client = await mcpService.initClient(serverConfig) + return client.callTool(request.params) +} + +async function getMcpServerConfigById(id: string): Promise { + const servers = await getServersFromRedux() + return servers.find((s) => s.id === id || s.name === id) +} + +/** + * Get servers directly from Redux store + */ +async function getServersFromRedux(): Promise { + try { + const servers = await reduxService.select('state.mcp.servers') + logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`) + return servers || [] + } catch (error: any) { + logger.error('Failed to get servers from Redux:', error) + return [] + } +} + +export async function getMcpServerById(id: string): Promise { + const server = cachedServers[id] + if (!server) { + const servers = await getServersFromRedux() + const mcpServer = servers.find((s) => s.id === id || s.name === id) + if (!mcpServer) { + throw new Error(`Server not found: ${id}`) + } + + const createMcpServer = (name: string, version: string): Server => { + const server = new Server({ name: name, version }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, handleListToolsRequest) + server.setRequestHandler(CallToolRequestSchema, handleCallToolRequest) + return server + } + + const newServer = createMcpServer(mcpServer.name, '0.1.0') + cachedServers[id] = newServer + return newServer + } + logger.silly('getMcpServer ', { server: server }) + return server +} diff --git a/src/main/index.ts b/src/main/index.ts index e35f7cebfe..b95cd70bf0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,6 +27,7 @@ import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' import process from 'node:process' +import { apiServerService } from './services/ApiServerService' const logger = loggerService.withContext('MainEntry') @@ -145,6 +146,17 @@ if (!app.requestSingleInstanceLock()) { //start selection assistant service initSelectionService() + + // Start API server if enabled + try { + const config = await apiServerService.getCurrentConfig() + logger.info('API server config:', config) + if (config.enabled) { + await apiServerService.start() + } + } catch (error: any) { + logger.error('Failed to check/start API server:', error) + } }) registerProtocolClient(app) @@ -190,6 +202,7 @@ if (!app.requestSingleInstanceLock()) { // 简单的资源清理,不阻塞退出流程 try { await mcpService.cleanup() + await apiServerService.stop() } catch (error) { logger.warn('Error cleaning up MCP service:', error as Error) } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8132353aab..875e70b656 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -15,6 +15,7 @@ import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types' import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' import { Notification } from 'src/renderer/src/types/notification' +import { apiServerService } from './services/ApiServerService' import appService from './services/AppService' import AppUpdater from './services/AppUpdater' import BackupManager from './services/BackupManager' @@ -782,6 +783,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { addStreamMessage(spanId, modelName, context, msg) ) + // API Server + apiServerService.registerIpcHandlers() + // Anthropic OAuth ipcMain.handle(IpcChannel.Anthropic_StartOAuthFlow, () => anthropicService.startOAuthFlow()) ipcMain.handle(IpcChannel.Anthropic_CompleteOAuthWithCode, (_, code: string) => diff --git a/src/main/services/ApiServerService.ts b/src/main/services/ApiServerService.ts new file mode 100644 index 0000000000..9a7bfad7e0 --- /dev/null +++ b/src/main/services/ApiServerService.ts @@ -0,0 +1,108 @@ +import { IpcChannel } from '@shared/IpcChannel' +import { ApiServerConfig } from '@types' +import { ipcMain } from 'electron' + +import { apiServer } from '../apiServer' +import { config } from '../apiServer/config' +import { loggerService } from './LoggerService' +const logger = loggerService.withContext('ApiServerService') + +export class ApiServerService { + constructor() { + // Use the new clean implementation + } + + async start(): Promise { + try { + await apiServer.start() + logger.info('API Server started successfully') + } catch (error: any) { + logger.error('Failed to start API Server:', error) + throw error + } + } + + async stop(): Promise { + try { + await apiServer.stop() + logger.info('API Server stopped successfully') + } catch (error: any) { + logger.error('Failed to stop API Server:', error) + throw error + } + } + + async restart(): Promise { + try { + await apiServer.restart() + logger.info('API Server restarted successfully') + } catch (error: any) { + logger.error('Failed to restart API Server:', error) + throw error + } + } + + isRunning(): boolean { + return apiServer.isRunning() + } + + async getCurrentConfig(): Promise { + return config.get() + } + + registerIpcHandlers(): void { + // API Server + ipcMain.handle(IpcChannel.ApiServer_Start, async () => { + try { + await this.start() + return { success: true } + } catch (error: any) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' } + } + }) + + ipcMain.handle(IpcChannel.ApiServer_Stop, async () => { + try { + await this.stop() + return { success: true } + } catch (error: any) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' } + } + }) + + ipcMain.handle(IpcChannel.ApiServer_Restart, async () => { + try { + await this.restart() + return { success: true } + } catch (error: any) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' } + } + }) + + ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => { + try { + const config = await this.getCurrentConfig() + return { + running: this.isRunning(), + config + } + } catch (error: any) { + return { + running: this.isRunning(), + config: null + } + } + }) + + ipcMain.handle(IpcChannel.ApiServer_GetConfig, async () => { + try { + return this.getCurrentConfig() + } catch (error: any) { + return null + } + }) + } +} + +// Export singleton instance +export const apiServerService = new ApiServerService() diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 6886d5f035..ca83e149f0 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -15,6 +15,7 @@ import { NotebookPen, Package, PictureInPicture2, + Server, Settings2, TextCursorInput, Zap @@ -37,6 +38,7 @@ import QuickAssistantSettings from './QuickAssistantSettings' import QuickPhraseSettings from './QuickPhraseSettings' import SelectionAssistantSettings from './SelectionAssistantSettings/SelectionAssistantSettings' import ShortcutSettings from './ShortcutSettings' +import { ApiServerSettings } from './ToolSettings/ApiServerSettings' import WebSearchSettings from './WebSearchSettings' const SettingsPage: FC = () => { @@ -108,6 +110,12 @@ const SettingsPage: FC = () => { {t('memory.title')} + + + + {t('apiServer.title')} + + @@ -152,6 +160,7 @@ const SettingsPage: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/pages/settings/ToolSettings/ApiServerSettings/ApiServerSettings.tsx b/src/renderer/src/pages/settings/ToolSettings/ApiServerSettings/ApiServerSettings.tsx new file mode 100644 index 0000000000..c949370ed4 --- /dev/null +++ b/src/renderer/src/pages/settings/ToolSettings/ApiServerSettings/ApiServerSettings.tsx @@ -0,0 +1,425 @@ +// TODO: Refactor this component to use HeroUI +import { useTheme } from '@renderer/context/ThemeProvider' +import { loggerService } from '@renderer/services/LoggerService' +import { RootState, useAppDispatch } from '@renderer/store' +import { setApiServerApiKey, setApiServerEnabled, setApiServerPort } from '@renderer/store/settings' +import { formatErrorMessage } from '@renderer/utils/error' +import { IpcChannel } from '@shared/IpcChannel' +import { Button, Input, InputNumber, Tooltip, Typography } from 'antd' +import { Copy, ExternalLink, Play, RotateCcw, Square } from 'lucide-react' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import styled from 'styled-components' +import { v4 as uuidv4 } from 'uuid' + +import { SettingContainer } from '../..' + +const logger = loggerService.withContext('ApiServerSettings') +const { Text, Title } = Typography + +const ApiServerSettings: FC = () => { + const { theme } = useTheme() + const dispatch = useAppDispatch() + const { t } = useTranslation() + + // API Server state with proper defaults + const apiServerConfig = useSelector((state: RootState) => state.settings.apiServer) + + const [apiServerRunning, setApiServerRunning] = useState(false) + const [apiServerLoading, setApiServerLoading] = useState(false) + + // API Server functions + const checkApiServerStatus = async () => { + try { + const status = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus) + setApiServerRunning(status.running) + } catch (error: any) { + logger.error('Failed to check API server status:', error) + } + } + + useEffect(() => { + checkApiServerStatus() + }, []) + + const handleApiServerToggle = async (enabled: boolean) => { + setApiServerLoading(true) + try { + if (enabled) { + const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Start) + if (result.success) { + setApiServerRunning(true) + window.toast.success(t('apiServer.messages.startSuccess')) + } else { + window.toast.error(t('apiServer.messages.startError') + result.error) + } + } else { + const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Stop) + if (result.success) { + setApiServerRunning(false) + window.toast.success(t('apiServer.messages.stopSuccess')) + } else { + window.toast.error(t('apiServer.messages.stopError') + result.error) + } + } + } catch (error) { + window.toast.error(t('apiServer.messages.operationFailed') + formatErrorMessage(error)) + } finally { + dispatch(setApiServerEnabled(enabled)) + setApiServerLoading(false) + } + } + + const handleApiServerRestart = async () => { + setApiServerLoading(true) + try { + const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Restart) + if (result.success) { + await checkApiServerStatus() + window.toast.success(t('apiServer.messages.restartSuccess')) + } else { + window.toast.error(t('apiServer.messages.restartError') + result.error) + } + } catch (error) { + window.toast.error(t('apiServer.messages.restartFailed') + (error as Error).message) + } finally { + setApiServerLoading(false) + } + } + + const copyApiKey = () => { + navigator.clipboard.writeText(apiServerConfig.apiKey) + window.toast.success(t('apiServer.messages.apiKeyCopied')) + } + + const regenerateApiKey = () => { + const newApiKey = `cs-sk-${uuidv4()}` + dispatch(setApiServerApiKey(newApiKey)) + window.toast.success(t('apiServer.messages.apiKeyRegenerated')) + } + + const handlePortChange = (value: string) => { + const port = parseInt(value) || 23333 + if (port >= 1000 && port <= 65535) { + dispatch(setApiServerPort(port)) + } + } + + const openApiDocs = () => { + if (apiServerRunning) { + window.open(`http://localhost:${apiServerConfig.port}/api-docs`, '_blank') + } + } + + return ( + + {/* Header Section */} + + + + {t('apiServer.title')} + + {t('apiServer.description')} + + {apiServerRunning && ( + + )} + + + {/* Server Control Panel with integrated configuration */} + + + + + + {apiServerRunning ? t('apiServer.status.running') : t('apiServer.status.stopped')} + + + {apiServerRunning ? `http://localhost:${apiServerConfig.port}` : t('apiServer.fields.port.description')} + + + + + + {apiServerRunning && ( + + + + {t('apiServer.actions.restart.button')} + + + )} + + {/* Port input when server is stopped */} + {!apiServerRunning && ( + handlePortChange(String(value || 23333))} + min={1000} + max={65535} + disabled={apiServerRunning} + placeholder="23333" + size="middle" + /> + )} + + + {apiServerRunning ? ( + handleApiServerToggle(false)}> + + + ) : ( + handleApiServerToggle(true)}> + + + )} + + + + + {/* API Key Configuration */} + + {t('apiServer.fields.apiKey.label')} + {t('apiServer.fields.apiKey.description')} + + + {!apiServerRunning && ( + + {t('apiServer.actions.regenerate')} + + )} + + } onClick={copyApiKey} disabled={!apiServerConfig.apiKey} /> + + + } + /> + + {/* Authorization header info */} + + {t('apiServer.authHeader.title')} + + + + + ) +} + +// Styled Components +const Container = styled(SettingContainer)` + display: flex; + flex-direction: column; + height: calc(100vh - var(--navbar-height)); +` + +const HeaderSection = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +` + +const HeaderContent = styled.div` + flex: 1; +` + +const ServerControlPanel = styled.div<{ $status: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-radius: 8px; + background: var(--color-background); + border: 1px solid ${(props) => (props.$status ? 'var(--color-status-success)' : 'var(--color-border)')}; + transition: all 0.3s ease; + margin-bottom: 16px; +` + +const StatusSection = styled.div` + display: flex; + align-items: center; + gap: 10px; +` + +const StatusIndicator = styled.div<{ $status: boolean }>` + position: relative; + width: 10px; + height: 10px; + border-radius: 50%; + background: ${(props) => (props.$status ? 'var(--color-status-success)' : 'var(--color-status-error)')}; + + &::before { + content: ''; + position: absolute; + inset: -3px; + border-radius: 50%; + background: ${(props) => (props.$status ? 'var(--color-status-success)' : 'var(--color-status-error)')}; + opacity: 0.2; + animation: ${(props) => (props.$status ? 'pulse 2s infinite' : 'none')}; + } + + @keyframes pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.2; + } + 50% { + transform: scale(1.5); + opacity: 0.1; + } + } +` + +const StatusContent = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +` + +const StatusText = styled.div<{ $status: boolean }>` + font-weight: 600; + font-size: 14px; + color: ${(props) => (props.$status ? 'var(--color-status-success)' : 'var(--color-text-1)')}; + margin: 0; +` + +const StatusSubtext = styled.div` + font-size: 12px; + color: var(--color-text-3); + margin: 0; +` + +const ControlSection = styled.div` + display: flex; + align-items: center; + gap: 12px; +` + +const RestartButton = styled.div<{ $loading: boolean }>` + display: flex; + align-items: center; + gap: 4px; + color: var(--color-text-2); + cursor: ${(props) => (props.$loading ? 'not-allowed' : 'pointer')}; + opacity: ${(props) => (props.$loading ? 0.5 : 1)}; + font-size: 12px; + transition: all 0.2s ease; + + &:hover { + color: ${(props) => (props.$loading ? 'var(--color-text-2)' : 'var(--color-primary)')}; + } +` + +const StyledInputNumber = styled(InputNumber)` + width: 80px; + border-radius: 6px; + border: 1.5px solid var(--color-border); + margin-right: 5px; +` + +const StartButton = styled.div<{ $loading: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + cursor: ${(props) => (props.$loading ? 'not-allowed' : 'pointer')}; + opacity: ${(props) => (props.$loading ? 0.5 : 1)}; + transition: all 0.2s ease; + + &:hover { + transform: ${(props) => (props.$loading ? 'scale(1)' : 'scale(1.1)')}; + } +` + +const StopButton = styled.div<{ $loading: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + cursor: ${(props) => (props.$loading ? 'not-allowed' : 'pointer')}; + opacity: ${(props) => (props.$loading ? 0.5 : 1)}; + transition: all 0.2s ease; + + &:hover { + transform: ${(props) => (props.$loading ? 'scale(1)' : 'scale(1.1)')}; + } +` + +const ConfigurationField = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + background: var(--color-background); + border-radius: 8px; + border: 1px solid var(--color-border); +` + +const FieldLabel = styled.div` + font-size: 14px; + font-weight: 500; + color: var(--color-text-1); + margin: 0; +` + +const FieldDescription = styled.div` + font-size: 12px; + color: var(--color-text-3); + margin: 0; +` + +const StyledInput = styled(Input)` + width: 100%; + border-radius: 6px; + border: 1.5px solid var(--color-border); +` + +const InputButtonContainer = styled.div` + display: flex; + align-items: center; + gap: 4px; +` + +const InputButton = styled(Button)` + border: none; + padding: 0 4px; + background: transparent; +` + +const RegenerateButton = styled(Button)` + padding: 0 4px; + font-size: 12px; + height: auto; + line-height: 1; + border: none; + background: transparent; +` + +const AuthHeaderSection = styled.div` + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +` + +export default ApiServerSettings diff --git a/src/renderer/src/pages/settings/ToolSettings/ApiServerSettings/index.tsx b/src/renderer/src/pages/settings/ToolSettings/ApiServerSettings/index.tsx new file mode 100644 index 0000000000..fbe9ebe611 --- /dev/null +++ b/src/renderer/src/pages/settings/ToolSettings/ApiServerSettings/index.tsx @@ -0,0 +1 @@ +export { default as ApiServerSettings } from './ApiServerSettings' diff --git a/yarn.lock b/yarn.lock index 0efff85f08..a1e4ad40ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -481,6 +481,48 @@ __metadata: languageName: node linkType: hard +"@apidevtools/json-schema-ref-parser@npm:^9.0.6": + version: 9.1.2 + resolution: "@apidevtools/json-schema-ref-parser@npm:9.1.2" + dependencies: + "@jsdevtools/ono": "npm:^7.1.3" + "@types/json-schema": "npm:^7.0.6" + call-me-maybe: "npm:^1.0.1" + js-yaml: "npm:^4.1.0" + checksum: 10c0/ebf952eb2e00bf0919f024e72897e047fd5012f0a9e47ac361873f6de0a733b9334513cdbc73205a6b43ac4a652b8c87f55e489c39b2d60bd0bc1cb2b411e218 + languageName: node + linkType: hard + +"@apidevtools/openapi-schemas@npm:^2.0.4": + version: 2.1.0 + resolution: "@apidevtools/openapi-schemas@npm:2.1.0" + checksum: 10c0/f4aa0f9df32e474d166c84ef91bceb18fa1c4f44b5593879529154ef340846811ea57dc2921560f157f692262827d28d988dd6e19fb21f00320e9961964176b4 + languageName: node + linkType: hard + +"@apidevtools/swagger-methods@npm:^3.0.2": + version: 3.0.2 + resolution: "@apidevtools/swagger-methods@npm:3.0.2" + checksum: 10c0/8c390e8e50c0be7787ba0ba4c3758488bde7c66c2d995209b4b48c1f8bc988faf393cbb24a4bd1cd2d42ce5167c26538e8adea5c85eb922761b927e4dab9fa1c + languageName: node + linkType: hard + +"@apidevtools/swagger-parser@npm:10.0.3": + version: 10.0.3 + resolution: "@apidevtools/swagger-parser@npm:10.0.3" + dependencies: + "@apidevtools/json-schema-ref-parser": "npm:^9.0.6" + "@apidevtools/openapi-schemas": "npm:^2.0.4" + "@apidevtools/swagger-methods": "npm:^3.0.2" + "@jsdevtools/ono": "npm:^7.1.3" + call-me-maybe: "npm:^1.0.1" + z-schema: "npm:^5.0.1" + peerDependencies: + openapi-types: ">=7" + checksum: 10c0/3b43f719c2d647ac8dcf30f132834d413ce21cbf7a8d9c3b35ec91149dd25d608c8fd892358fcd61a8edd8c5140a7fb13676f948e2d87067d081a47b8c7107e9 + languageName: node + linkType: hard + "@asamuzakjp/css-color@npm:^3.1.1": version: 3.1.2 resolution: "@asamuzakjp/css-color@npm:3.1.2" @@ -5702,6 +5744,13 @@ __metadata: languageName: node linkType: hard +"@jsdevtools/ono@npm:^7.1.3": + version: 7.1.3 + resolution: "@jsdevtools/ono@npm:7.1.3" + checksum: 10c0/a9f7e3e8e3bc315a34959934a5e2f874c423cf4eae64377d3fc9de0400ed9f36cb5fd5ebce3300d2e8f4085f557c4a8b591427a583729a87841fda46e6c216b9 + languageName: node + linkType: hard + "@kangfenmao/keyv-storage@npm:^0.1.0": version: 0.1.0 resolution: "@kangfenmao/keyv-storage@npm:0.1.0" @@ -9025,6 +9074,13 @@ __metadata: languageName: node linkType: hard +"@scarf/scarf@npm:=1.4.0": + version: 1.4.0 + resolution: "@scarf/scarf@npm:1.4.0" + checksum: 10c0/332118bb488e7a70eaad068fb1a33f016d30442fb0498b37a80cb425c1e741853a5de1a04dce03526ed6265481ecf744aa6e13f072178d19e6b94b19f623ae1c + languageName: node + linkType: hard + "@selderee/plugin-htmlparser2@npm:^0.11.0": version: 0.11.0 resolution: "@selderee/plugin-htmlparser2@npm:0.11.0" @@ -11241,6 +11297,16 @@ __metadata: languageName: node linkType: hard +"@types/body-parser@npm:*": + version: 1.19.6 + resolution: "@types/body-parser@npm:1.19.6" + dependencies: + "@types/connect": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/542da05c924dce58ee23f50a8b981fee36921850c82222e384931fda3e106f750f7880c47be665217d72dbe445129049db6eb1f44e7a06b09d62af8f3cca8ea7 + languageName: node + linkType: hard + "@types/cacheable-request@npm:^6.0.1": version: 6.0.3 resolution: "@types/cacheable-request@npm:6.0.3" @@ -11271,6 +11337,31 @@ __metadata: languageName: node linkType: hard +"@types/connect@npm:*": + version: 3.4.38 + resolution: "@types/connect@npm:3.4.38" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/2e1cdba2c410f25649e77856505cd60223250fa12dff7a503e492208dbfdd25f62859918f28aba95315251fd1f5e1ffbfca1e25e73037189ab85dd3f8d0a148c + languageName: node + linkType: hard + +"@types/content-type@npm:^1.1.9": + version: 1.1.9 + resolution: "@types/content-type@npm:1.1.9" + checksum: 10c0/d8b198257862991880d38985ad9871241db18b21ec728bddc78e4c61e0f987cc037dae6c5f9bd2bcc08f41de74ad371180af2fcdefeafe25d0ccae0c3fceb7fd + languageName: node + linkType: hard + +"@types/cors@npm:^2.8.19": + version: 2.8.19 + resolution: "@types/cors@npm:2.8.19" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/b5dd407040db7d8aa1bd36e79e5f3f32292f6b075abc287529e9f48df1a25fda3e3799ba30b4656667ffb931d3b75690c1d6ca71e39f7337ea6dfda8581916d0 + languageName: node + linkType: hard + "@types/d3-array@npm:*": version: 3.2.1 resolution: "@types/d3-array@npm:3.2.1" @@ -11559,6 +11650,13 @@ __metadata: languageName: node linkType: hard +"@types/diff@npm:^7": + version: 7.0.2 + resolution: "@types/diff@npm:7.0.2" + checksum: 10c0/ac4de3f982242292e006ace98a9d41363ebc244145939466139828ffa6c476acc15eea2bad39bd7e0868003c497614f6d7e734d4999c4f09d95dfd173d24d723 + languageName: node + linkType: hard + "@types/estree-jsx@npm:^1.0.0": version: 1.0.5 resolution: "@types/estree-jsx@npm:1.0.5" @@ -11575,6 +11673,29 @@ __metadata: languageName: node linkType: hard +"@types/express-serve-static-core@npm:^5.0.0": + version: 5.0.7 + resolution: "@types/express-serve-static-core@npm:5.0.7" + dependencies: + "@types/node": "npm:*" + "@types/qs": "npm:*" + "@types/range-parser": "npm:*" + "@types/send": "npm:*" + checksum: 10c0/28666f6a0743b8678be920a6eed075bc8afc96fc7d8ef59c3c049bd6b51533da3b24daf3b437d061e053fba1475e4f3175cb4972f5e8db41608e817997526430 + languageName: node + linkType: hard + +"@types/express@npm:*, @types/express@npm:^5": + version: 5.0.3 + resolution: "@types/express@npm:5.0.3" + dependencies: + "@types/body-parser": "npm:*" + "@types/express-serve-static-core": "npm:^5.0.0" + "@types/serve-static": "npm:*" + checksum: 10c0/f0fbc8daa7f40070b103cf4d020ff1dd08503477d866d1134b87c0390bba71d5d7949cb8b4e719a81ccba89294d8e1573414e6dcbb5bb1d097a7b820928ebdef + languageName: node + linkType: hard + "@types/fs-extra@npm:9.0.13, @types/fs-extra@npm:^9.0.11": version: 9.0.13 resolution: "@types/fs-extra@npm:9.0.13" @@ -11631,7 +11752,14 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:^7.0.15": +"@types/http-errors@npm:*": + version: 2.0.5 + resolution: "@types/http-errors@npm:2.0.5" + checksum: 10c0/00f8140fbc504f47356512bd88e1910c2f07e04233d99c88c854b3600ce0523c8cd0ba7d1897667243282eb44c59abb9245959e2428b9de004f93937f52f7c15 + languageName: node + linkType: hard + +"@types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.6": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db @@ -11733,6 +11861,13 @@ __metadata: languageName: node linkType: hard +"@types/mime@npm:^1": + version: 1.3.5 + resolution: "@types/mime@npm:1.3.5" + checksum: 10c0/c2ee31cd9b993804df33a694d5aa3fa536511a49f2e06eeab0b484fef59b4483777dbb9e42a4198a0809ffbf698081fdbca1e5c2218b82b91603dfab10a10fbc + languageName: node + linkType: hard + "@types/ms@npm:*": version: 2.1.0 resolution: "@types/ms@npm:2.1.0" @@ -11794,6 +11929,20 @@ __metadata: languageName: node linkType: hard +"@types/qs@npm:*": + version: 6.14.0 + resolution: "@types/qs@npm:6.14.0" + checksum: 10c0/5b3036df6e507483869cdb3858201b2e0b64b4793dc4974f188caa5b5732f2333ab9db45c08157975054d3b070788b35088b4bc60257ae263885016ee2131310 + languageName: node + linkType: hard + +"@types/range-parser@npm:*": + version: 1.2.7 + resolution: "@types/range-parser@npm:1.2.7" + checksum: 10c0/361bb3e964ec5133fa40644a0b942279ed5df1949f21321d77de79f48b728d39253e5ce0408c9c17e4e0fd95ca7899da36841686393b9f7a1e209916e9381a3c + languageName: node + linkType: hard + "@types/react-dom@npm:^19.0.4": version: 19.1.2 resolution: "@types/react-dom@npm:19.1.2" @@ -11821,6 +11970,24 @@ __metadata: languageName: node linkType: hard +"@types/react-window@npm:^1": + version: 1.8.8 + resolution: "@types/react-window@npm:1.8.8" + dependencies: + "@types/react": "npm:*" + checksum: 10c0/2170a3957752603e8b994840c5d31b72ddf94c427c0f42b0175b343cc54f50fe66161d8871e11786ec7a59906bd33861945579a3a8f745455a3744268ec1069f + languageName: node + linkType: hard + +"@types/react@npm:*": + version: 19.1.12 + resolution: "@types/react@npm:19.1.12" + dependencies: + csstype: "npm:^3.0.2" + checksum: 10c0/e35912b43da0caaab5252444bab87a31ca22950cde2822b3b3dc32e39c2d42dad1a4cf7b5dde9783aa2d007f0b2cba6ab9563fc6d2dbcaaa833b35178118767c + languageName: node + linkType: hard + "@types/react@npm:^19.0.12": version: 19.1.2 resolution: "@types/react@npm:19.1.2" @@ -11846,6 +12013,27 @@ __metadata: languageName: node linkType: hard +"@types/send@npm:*": + version: 0.17.5 + resolution: "@types/send@npm:0.17.5" + dependencies: + "@types/mime": "npm:^1" + "@types/node": "npm:*" + checksum: 10c0/a86c9b89bb0976ff58c1cdd56360ea98528f4dbb18a5c2287bb8af04815513a576a42b4e0e1e7c4d14f7d6ea54733f6ef935ebff8c65e86d9c222881a71e1f15 + languageName: node + linkType: hard + +"@types/serve-static@npm:*": + version: 1.15.8 + resolution: "@types/serve-static@npm:1.15.8" + dependencies: + "@types/http-errors": "npm:*" + "@types/node": "npm:*" + "@types/send": "npm:*" + checksum: 10c0/8ad86a25b87da5276cb1008c43c74667ff7583904d46d5fcaf0355887869d859d453d7dc4f890788ae04705c23720e9b6b6f3215e2d1d2a4278bbd090a9268dd + languageName: node + linkType: hard + "@types/stylis@npm:4.2.5": version: 4.2.5 resolution: "@types/stylis@npm:4.2.5" @@ -11853,6 +12041,23 @@ __metadata: languageName: node linkType: hard +"@types/swagger-jsdoc@npm:^6": + version: 6.0.4 + resolution: "@types/swagger-jsdoc@npm:6.0.4" + checksum: 10c0/fbe17d91a12e1e60a255b02e6def6877c81b356c75ffcd0e5167fbaf1476e2d6600cd7eea79e6b3e0ff7929dec33ade345147509ed3b98026f63c782b74514f6 + languageName: node + linkType: hard + +"@types/swagger-ui-express@npm:^4.1.8": + version: 4.1.8 + resolution: "@types/swagger-ui-express@npm:4.1.8" + dependencies: + "@types/express": "npm:*" + "@types/serve-static": "npm:*" + checksum: 10c0/9c9e8327c40376b98b6fbd5dd2d722b7b5473e5c168af809431f16b34c948d2d3d44ce2157d2066355e9926ba86416481a30cd1cbcdbb064dd2fedb28442a85a + languageName: node + linkType: hard + "@types/tinycolor2@npm:^1": version: 1.4.6 resolution: "@types/tinycolor2@npm:1.4.6" @@ -12904,6 +13109,10 @@ __metadata: "@truto/turndown-plugin-gfm": "npm:^1.0.2" "@tryfabric/martian": "npm:^1.2.4" "@types/cli-progress": "npm:^3" + "@types/content-type": "npm:^1.1.9" + "@types/cors": "npm:^2.8.19" + "@types/diff": "npm:^7" + "@types/express": "npm:^5" "@types/fs-extra": "npm:^11" "@types/he": "npm:^1" "@types/html-to-text": "npm:^9" @@ -12917,6 +13126,9 @@ __metadata: "@types/react-dom": "npm:^19.0.4" "@types/react-infinite-scroll-component": "npm:^5.0.0" "@types/react-transition-group": "npm:^4.4.12" + "@types/react-window": "npm:^1" + "@types/swagger-jsdoc": "npm:^6" + "@types/swagger-ui-express": "npm:^4.1.8" "@types/tinycolor2": "npm:^1" "@types/turndown": "npm:^5.0.5" "@types/word-extractor": "npm:^1" @@ -12967,6 +13179,7 @@ __metadata: eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-unused-imports: "npm:^4.1.4" + express: "npm:^5.1.0" faiss-node: "npm:^0.5.1" fast-diff: "npm:^1.3.0" fast-xml-parser: "npm:^5.2.0" @@ -13048,6 +13261,8 @@ __metadata: string-width: "npm:^7.2.0" striptags: "npm:^3.2.0" styled-components: "npm:^6.1.11" + swagger-jsdoc: "npm:^6.2.8" + swagger-ui-express: "npm:^5.0.1" tailwindcss: "npm:^4.1.13" tar: "npm:^7.4.3" tesseract.js: "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch" @@ -14217,6 +14432,13 @@ __metadata: languageName: node linkType: hard +"call-me-maybe@npm:^1.0.1": + version: 1.0.2 + resolution: "call-me-maybe@npm:1.0.2" + checksum: 10c0/8eff5dbb61141ebb236ed71b4e9549e488bcb5451c48c11e5667d5c75b0532303788a1101e6978cafa2d0c8c1a727805599c2741e3e0982855c9f1d78cd06c9f + languageName: node + linkType: hard + "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -14840,6 +15062,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:6.2.0": + version: 6.2.0 + resolution: "commander@npm:6.2.0" + checksum: 10c0/1b701c6726fc2b6c6a7d9ab017be9465153546a05767cdd0e15e9f9a11c07f88f64d47684b90b07e5fb103d173efb6afdf4a21f6d6c4c25f7376bd027d21062c + languageName: node + linkType: hard + "commander@npm:7": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -14868,6 +15097,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^9.4.1": + version: 9.5.0 + resolution: "commander@npm:9.5.0" + checksum: 10c0/5f7784fbda2aaec39e89eb46f06a999e00224b3763dc65976e05929ec486e174fe9aac2655f03ba6a5e83875bd173be5283dc19309b7c65954701c02025b3c1d + languageName: node + linkType: hard + "compare-version@npm:^0.1.2": version: 0.1.2 resolution: "compare-version@npm:0.1.2" @@ -16089,6 +16325,15 @@ __metadata: languageName: node linkType: hard +"doctrine@npm:3.0.0": + version: 3.0.0 + resolution: "doctrine@npm:3.0.0" + dependencies: + esutils: "npm:^2.0.2" + checksum: 10c0/c96bdccabe9d62ab6fea9399fdff04a66e6563c1d6fb3a3a063e8d53c3bb136ba63e84250bbf63d00086a769ad53aef92d2bd483f03f837fc97b71cbee6b2520 + languageName: node + linkType: hard + "docx@npm:^9.0.2": version: 9.3.0 resolution: "docx@npm:9.3.0" @@ -17285,7 +17530,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^5.0.1": +"express@npm:^5.0.1, express@npm:^5.1.0": version: 5.1.0 resolution: "express@npm:5.1.0" dependencies: @@ -18189,6 +18434,20 @@ __metadata: languageName: node linkType: hard +"glob@npm:7.1.6": + version: 7.1.6 + resolution: "glob@npm:7.1.6" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.0.4" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/2575cce9306ac534388db751f0aa3e78afedb6af8f3b529ac6b2354f66765545145dba8530abf7bff49fb399a047d3f9b6901c38ee4c9503f592960d9af67763 + languageName: node + linkType: hard + "glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.12, glob@npm:^10.3.7, glob@npm:^10.4.1": version: 10.4.5 resolution: "glob@npm:10.4.5" @@ -20341,6 +20600,13 @@ __metadata: languageName: node linkType: hard +"lodash.get@npm:^4.4.2": + version: 4.4.2 + resolution: "lodash.get@npm:4.4.2" + checksum: 10c0/48f40d471a1654397ed41685495acb31498d5ed696185ac8973daef424a749ca0c7871bf7b665d5c14f5cc479394479e0307e781f61d5573831769593411be6e + languageName: node + linkType: hard + "lodash.isequal@npm:^4.5.0": version: 4.5.0 resolution: "lodash.isequal@npm:4.5.0" @@ -20355,6 +20621,13 @@ __metadata: languageName: node linkType: hard +"lodash.mergewith@npm:^4.6.2": + version: 4.6.2 + resolution: "lodash.mergewith@npm:4.6.2" + checksum: 10c0/4adbed65ff96fd65b0b3861f6899f98304f90fd71e7f1eb36c1270e05d500ee7f5ec44c02ef979b5ddbf75c0a0b9b99c35f0ad58f4011934c4d4e99e5200b3b5 + languageName: node + linkType: hard + "lodash@npm:^4.17.15, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" @@ -26618,6 +26891,51 @@ __metadata: languageName: node linkType: hard +"swagger-jsdoc@npm:^6.2.8": + version: 6.2.8 + resolution: "swagger-jsdoc@npm:6.2.8" + dependencies: + commander: "npm:6.2.0" + doctrine: "npm:3.0.0" + glob: "npm:7.1.6" + lodash.mergewith: "npm:^4.6.2" + swagger-parser: "npm:^10.0.3" + yaml: "npm:2.0.0-1" + bin: + swagger-jsdoc: bin/swagger-jsdoc.js + checksum: 10c0/7e20f08e8d90cc1e787cd82c096291cf12533359f89c70fbe4295a01f7c4734f2e82a03ba94027127bcd3da04b817abfe979f00d00ef0cd8283e449250a66215 + languageName: node + linkType: hard + +"swagger-parser@npm:^10.0.3": + version: 10.0.3 + resolution: "swagger-parser@npm:10.0.3" + dependencies: + "@apidevtools/swagger-parser": "npm:10.0.3" + checksum: 10c0/d1a5c05f651f21a23508a36416071630b83e91dfffd52a6d44b06ca2cd1b86304c0dd2f4c04526c999b70062fa89bde3f5d54a1436626f4350590b6c6265a098 + languageName: node + linkType: hard + +"swagger-ui-dist@npm:>=5.0.0": + version: 5.28.1 + resolution: "swagger-ui-dist@npm:5.28.1" + dependencies: + "@scarf/scarf": "npm:=1.4.0" + checksum: 10c0/1c8e2a70644b9a9b45e4938722b0bbd374fb2ca0776c9f0ebef8394bdf7223066fa53303912a3972de2b0893152f53d1c3fd44d149a46ee985a96de3ddab2a89 + languageName: node + linkType: hard + +"swagger-ui-express@npm:^5.0.1": + version: 5.0.1 + resolution: "swagger-ui-express@npm:5.0.1" + dependencies: + swagger-ui-dist: "npm:>=5.0.0" + peerDependencies: + express: ">=4.0.0 || >=5.0.0-beta" + checksum: 10c0/dbe9830caef7fe455241e44e74958bac62642997e4341c1b0f38a3d684d19a4a81b431217c656792d99f046a1b5f261abf7783ede0afe41098cd4450401f6fd1 + languageName: node + linkType: hard + "symbol-tree@npm:^3.2.4": version: 3.2.4 resolution: "symbol-tree@npm:3.2.4" @@ -27851,6 +28169,13 @@ __metadata: languageName: node linkType: hard +"validator@npm:^13.7.0": + version: 13.15.15 + resolution: "validator@npm:13.15.15" + checksum: 10c0/f5349d1fbb9cc36f9f6c5dab1880764ddad1d0d2b084e2a71e5964f7de1635d20e406611559df9a3db24828ce775cbee5e3b6dd52f0d555a61939ed7ea5990bd + languageName: node + linkType: hard + "vary@npm:^1, vary@npm:^1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" @@ -28626,6 +28951,13 @@ __metadata: languageName: node linkType: hard +"yaml@npm:2.0.0-1": + version: 2.0.0-1 + resolution: "yaml@npm:2.0.0-1" + checksum: 10c0/e76eba2fbae37cd3e5bff057184be7cdca849895149d2f5660386871a501d76d2e1ec5906c48269a9fe798f214df31d342675b37bcd9d09af7c12eb6fb46a740 + languageName: node + linkType: hard + "yaml@npm:^2.2.1, yaml@npm:^2.7.0": version: 2.7.1 resolution: "yaml@npm:2.7.1" @@ -28710,6 +29042,23 @@ __metadata: languageName: node linkType: hard +"z-schema@npm:^5.0.1": + version: 5.0.5 + resolution: "z-schema@npm:5.0.5" + dependencies: + commander: "npm:^9.4.1" + lodash.get: "npm:^4.4.2" + lodash.isequal: "npm:^4.5.0" + validator: "npm:^13.7.0" + dependenciesMeta: + commander: + optional: true + bin: + z-schema: bin/z-schema + checksum: 10c0/e4c812cfe6468c19b2a21d07d4ff8fb70359062d33400b45f89017eaa3efe9d51e85963f2b115eaaa99a16b451782249bf9b1fa8b31d35cc473e7becb3e44264 + languageName: node + linkType: hard + "zip-stream@npm:^6.0.1": version: 6.0.1 resolution: "zip-stream@npm:6.0.1"