From d0b2f18d9a42a08547b4624b3ead57be1e5bae3f Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:38:07 +0800 Subject: [PATCH] feat: Support Cherry Studio as a Service (CSaaS) (#8098) --- AGENT.md | 1 + CLAUDE.md | 105 +++++ package.json | 10 +- packages/shared/IpcChannel.ts | 8 +- src/main/apiServer/app.ts | 128 +++++ src/main/apiServer/config.ts | 64 +++ src/main/apiServer/index.ts | 2 + src/main/apiServer/middleware/auth.ts | 25 + 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 | 66 +++ src/main/apiServer/server.ts | 65 +++ .../apiServer/services/chat-completion.ts | 222 +++++++++ src/main/apiServer/services/mcp.ts | 251 ++++++++++ src/main/apiServer/utils/index.ts | 111 +++++ src/main/apiServer/utils/mcp.ts | 76 +++ src/main/index.ts | 9 + src/main/ipc.ts | 4 + src/main/services/ApiServerService.ts | 108 +++++ src/main/services/MCPService.ts | 23 +- src/main/services/ReduxService.ts | 3 +- src/renderer/src/i18n/locales/en-us.json | 53 +++ src/renderer/src/i18n/locales/ja-jp.json | 53 +++ src/renderer/src/i18n/locales/ru-ru.json | 53 +++ src/renderer/src/i18n/locales/zh-cn.json | 53 +++ src/renderer/src/i18n/locales/zh-tw.json | 53 +++ src/renderer/src/i18n/translate/el-gr.json | 53 +++ src/renderer/src/i18n/translate/es-es.json | 53 +++ src/renderer/src/i18n/translate/fr-fr.json | 53 +++ src/renderer/src/i18n/translate/pt-pt.json | 53 +++ .../ApiServerSettings/ApiServerSettings.tsx | 444 ++++++++++++++++++ .../settings/ApiServerSettings/index.tsx | 1 + .../src/pages/settings/SettingsPage.tsx | 15 + src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 16 + src/renderer/src/store/settings.ts | 29 +- src/renderer/src/types/index.ts | 6 + yarn.lock | 344 +++++++++++++- 40 files changed, 3191 insertions(+), 29 deletions(-) create mode 120000 AGENT.md create mode 100644 CLAUDE.md 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/ApiServerSettings/ApiServerSettings.tsx create mode 100644 src/renderer/src/pages/settings/ApiServerSettings/index.tsx diff --git a/AGENT.md b/AGENT.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/AGENT.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..28d63a3d9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Environment Setup +- **Prerequisites**: Node.js v20.x.x, Yarn 4.6.0 +- **Setup Yarn**: `corepack enable && corepack prepare yarn@4.6.0 --activate` +- **Install Dependencies**: `yarn install` + +### Development +- **Start Development**: `yarn dev` - Runs Electron app in development mode +- **Debug Mode**: `yarn debug` - Starts with debugging enabled, use chrome://inspect + +### Testing & Quality +- **Run Tests**: `yarn test` - Runs all tests (Vitest) +- **Run E2E Tests**: `yarn test:e2e` - Playwright end-to-end tests +- **Type Check**: `yarn typecheck` - Checks TypeScript for both node and web +- **Lint**: `yarn lint` - ESLint with auto-fix +- **Format**: `yarn format` - Prettier formatting + +### Build & Release +- **Build**: `yarn build` - Builds for production (includes typecheck) +- **Platform-specific builds**: + - Windows: `yarn build:win` + - macOS: `yarn build:mac` + - Linux: `yarn build:linux` + +## Architecture Overview + +### Electron Multi-Process Architecture +- **Main Process** (`src/main/`): Node.js backend handling system integration, file operations, and services +- **Renderer Process** (`src/renderer/`): React-based UI running in Chromium +- **Preload Scripts** (`src/preload/`): Secure bridge between main and renderer processes + +### Key Architectural Components + +#### Main Process Services (`src/main/services/`) +- **MCPService**: Model Context Protocol server management +- **KnowledgeService**: Document processing and knowledge base management +- **FileStorage/S3Storage/WebDav**: Multiple storage backends +- **WindowService**: Multi-window management (main, mini, selection windows) +- **ProxyManager**: Network proxy handling +- **SearchService**: Full-text search capabilities + +#### AI Core (`src/renderer/src/aiCore/`) +- **Middleware System**: Composable pipeline for AI request processing +- **Client Factory**: Supports multiple AI providers (OpenAI, Anthropic, Gemini, etc.) +- **Stream Processing**: Real-time response handling + +#### State Management (`src/renderer/src/store/`) +- **Redux Toolkit**: Centralized state management +- **Persistent Storage**: Redux-persist for data persistence +- **Thunks**: Async actions for complex operations + +#### Knowledge Management +- **Embeddings**: Vector search with multiple providers (OpenAI, Voyage, etc.) +- **OCR**: Document text extraction (system OCR, Doc2x, Mineru) +- **Preprocessing**: Document preparation pipeline +- **Loaders**: Support for various file formats (PDF, DOCX, EPUB, etc.) + +### Build System +- **Electron-Vite**: Development and build tooling +- **Workspaces**: Monorepo structure with `packages/` directory +- **Multiple Entry Points**: Main app, mini window, selection toolbar +- **Styled Components**: CSS-in-JS styling with SWC optimization + +### Testing Strategy +- **Vitest**: Unit and integration testing +- **Playwright**: End-to-end testing +- **Component Testing**: React Testing Library +- **Coverage**: Available via `yarn test:coverage` + +### Key Patterns +- **IPC Communication**: Secure main-renderer communication via preload scripts +- **Service Layer**: Clear separation between UI and business logic +- **Plugin Architecture**: Extensible via MCP servers and middleware +- **Multi-language Support**: i18n with dynamic loading +- **Theme System**: Light/dark themes with custom CSS variables + +## Logging Standards + +### Usage +```typescript +// Main process +import { loggerService } from '@logger' +const logger = loggerService.withContext('moduleName') + +// Renderer process (set window source first) +loggerService.initWindowSource('windowName') +const logger = loggerService.withContext('moduleName') + +// Logging +logger.info('message', CONTEXT) +logger.error('message', new Error('error'), CONTEXT) +``` + +### Log Levels (highest to lowest) +- `error` - Critical errors causing crash/unusable functionality +- `warn` - Potential issues that don't affect core functionality +- `info` - Application lifecycle and key user actions +- `verbose` - Detailed flow information for feature tracing +- `debug` - Development diagnostic info (not for production) +- `silly` - Extreme debugging, low-level information diff --git a/package.json b/package.json index a9805e0c0..93b21a23f 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,8 @@ "os-proxy-config": "^1.1.2", "pdfjs-dist": "4.10.38", "selection-hook": "^1.0.8", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "turndown": "7.2.0" }, "devDependencies": { @@ -120,7 +122,7 @@ "@langchain/community": "^0.3.36", "@langchain/ollama": "^0.2.1", "@mistralai/mistralai": "^1.7.5", - "@modelcontextprotocol/sdk": "^1.12.3", + "@modelcontextprotocol/sdk": "^1.17.0", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", "@opentelemetry/api": "^1.9.0", @@ -141,7 +143,10 @@ "@testing-library/user-event": "^14.6.1", "@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/lodash": "^4.17.5", "@types/markdown-it": "^14", @@ -152,6 +157,8 @@ "@types/react-dom": "^19.0.4", "@types/react-infinite-scroll-component": "^5.0.0", "@types/react-window": "^1", + "@types/swagger-jsdoc": "^6", + "@types/swagger-ui-express": "^4.1.8", "@types/tinycolor2": "^1", "@types/word-extractor": "^1", "@uiw/codemirror-extensions-langs": "^4.23.14", @@ -195,6 +202,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", + "express": "^5.1.0", "fast-diff": "^1.3.0", "fast-xml-parser": "^5.2.0", "fetch-socks": "1.3.2", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 42ff62db2..daf6643d7 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -273,5 +273,11 @@ export enum IpcChannel { TRACE_SET_TITLE = 'trace:setTitle', TRACE_ADD_END_MESSAGE = 'trace:addEndMessage', TRACE_CLEAN_LOCAL_DATA = 'trace:cleanLocalData', - TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage' + 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' } diff --git a/src/main/apiServer/app.ts b/src/main/apiServer/app.ts new file mode 100644 index 000000000..46da10f87 --- /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 000000000..d9b73d521 --- /dev/null +++ b/src/main/apiServer/config.ts @@ -0,0 +1,64 @@ +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') + +class ConfigManager { + private _config: ApiServerConfig | null = null + + async load(): Promise { + try { + const settings = await reduxService.select('state.settings') + + // Auto-generate API key if not set + if (!settings?.apiServer?.apiKey) { + const generatedKey = `cs-sk-${uuidv4()}` + await reduxService.dispatch({ + type: 'settings/setApiServerApiKey', + payload: generatedKey + }) + + this._config = { + port: settings?.apiServer?.port ?? 23333, + host: 'localhost', + apiKey: generatedKey + } + } else { + this._config = { + port: settings?.apiServer?.port ?? 23333, + host: 'localhost', + apiKey: settings.apiServer.apiKey + } + } + + return this._config + } catch (error: any) { + logger.warn('Failed to load config from Redux, using defaults:', error) + this._config = { + port: 23333, + host: 'localhost', + apiKey: `cs-sk-${uuidv4()}` + } + 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 000000000..765ca05fb --- /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 000000000..416bd297a --- /dev/null +++ b/src/main/apiServer/middleware/auth.ts @@ -0,0 +1,25 @@ +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') + + if (!auth || !auth.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + const token = auth.slice(7) // Remove 'Bearer ' prefix + + if (!token) { + return res.status(401).json({ error: 'Unauthorized, Bearer token is empty' }) + } + + const { apiKey } = await config.get() + + if (token !== apiKey) { + 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 000000000..65eef5e43 --- /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 000000000..691bd8ec9 --- /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 000000000..70529f5f3 --- /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 { getProviderByModel, getRealProviderModel } 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 + }) + + // 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' + } + }) + } + + // Get provider + const provider = await getProviderByModel(request.model) + if (!provider) { + return res.status(400).json({ + error: { + message: `Model "${request.model}" not found`, + type: 'invalid_request_error', + code: 'model_not_found' + } + }) + } + + // Validate model availability + const modelId = getRealProviderModel(request.model) + const model = provider.models?.find((m) => m.id === modelId) + if (!model) { + return res.status(400).json({ + error: { + message: `Model "${modelId}" not available in provider "${provider.id}"`, + type: 'invalid_request_error', + code: 'model_not_available' + } + }) + } + + // 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 000000000..1e154ee58 --- /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 000000000..74d37cc1b --- /dev/null +++ b/src/main/apiServer/routes/models.ts @@ -0,0 +1,66 @@ +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') + } + + logger.info(`Returning ${models.length} models`) + 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', + 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 000000000..2555fa8c2 --- /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 000000000..fe503a2d3 --- /dev/null +++ b/src/main/apiServer/services/chat-completion.ts @@ -0,0 +1,222 @@ +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() + + const modelData: ModelData[] = models.map((model) => { + const openAIModel = transformModelToOpenAI(model) + return { + ...openAIModel, + provider_id: model.provider, + model_id: model.id, + name: model.name + } + }) + + logger.info(`Successfully retrieved ${modelData.length} 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 000000000..99f173211 --- /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 000000000..e53dc95e7 --- /dev/null +++ b/src/main/apiServer/utils/index.ts @@ -0,0 +1,111 @@ +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 +} + +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 [] + } + return providers.filter((p: Provider) => p.enabled) + } 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() as Model[] + } 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 + } + + const providers = await getAvailableProviders() + const modelInfo = model.split(':') + + if (modelInfo.length < 2) { + logger.warn(`Invalid model format, expected "provider:model": ${model}`) + return undefined + } + + const providerId = modelInfo[0] + const provider = providers.find((p: Provider) => p.id === providerId) + + if (!provider) { + logger.warn(`Provider not found for model: ${model}`) + return undefined + } + + 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 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 + } +} + +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 + } + + 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 000000000..77d7009f4 --- /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 await 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 5f933bee3..5b61299e4 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') @@ -139,6 +140,13 @@ if (!app.requestSingleInstanceLock()) { //start selection assistant service initSelectionService() + + // Start API server if enabled + try { + await apiServerService.start() + } catch (error: any) { + logger.error('Failed to start API server:', error) + } }) registerProtocolClient(app) @@ -184,6 +192,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 39f677a6b..78bcb5cb8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -13,6 +13,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' @@ -695,4 +696,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { (_, spanId: string, modelName: string, context: string, msg: any) => addStreamMessage(spanId, modelName, context, msg) ) + + // API Server + apiServerService.registerIpcHandlers() } diff --git a/src/main/services/ApiServerService.ts b/src/main/services/ApiServerService.ts new file mode 100644 index 000000000..5c0ec63b8 --- /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 await 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 await this.getCurrentConfig() + } catch (error: any) { + return null + } + }) + } +} + +// Export singleton instance +export const apiServerService = new ApiServerService() diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 0be226ccd..0921a5b82 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -19,6 +19,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory' // Import notification schemas from MCP SDK import { CancelledNotificationSchema, + type GetPromptResult, LoggingMessageNotificationSchema, ProgressNotificationSchema, PromptListChangedNotificationSchema, @@ -27,15 +28,7 @@ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' import { nanoid } from '@reduxjs/toolkit' -import type { - GetMCPPromptResponse, - GetResourceResponse, - MCPCallToolResponse, - MCPPrompt, - MCPResource, - MCPServer, - MCPTool -} from '@types' +import type { GetResourceResponse, MCPCallToolResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types' import { app } from 'electron' import { EventEmitter } from 'events' import { memoize } from 'lodash' @@ -192,6 +185,7 @@ class McpService { }, authProvider } + logger.debug(`StreamableHTTPClientTransport options:`, options) return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options) } else if (server.type === 'sse') { const options: SSEClientTransportOptions = { @@ -568,6 +562,7 @@ class McpService { private async listToolsImpl(server: MCPServer): Promise { logger.debug(`Listing tools for server: ${server.name}`) const client = await this.initClient(server) + logger.debug(`Client for server: ${server.name}`, client) try { const { tools } = await client.listTools() const serverTools: MCPTool[] = [] @@ -705,11 +700,7 @@ class McpService { /** * Get a specific prompt from an MCP server (implementation) */ - private async getPromptImpl( - server: MCPServer, - name: string, - args?: Record - ): Promise { + private async getPromptImpl(server: MCPServer, name: string, args?: Record): Promise { logger.debug(`Getting prompt ${name} from server: ${server.name}`) const client = await this.initClient(server) return await client.getPrompt({ name, arguments: args }) @@ -722,8 +713,8 @@ class McpService { public async getPrompt( _: Electron.IpcMainInvokeEvent, { server, name, args }: { server: MCPServer; name: string; args?: Record } - ): Promise { - const cachedGetPrompt = withCache<[MCPServer, string, Record | undefined], GetMCPPromptResponse>( + ): Promise { + const cachedGetPrompt = withCache<[MCPServer, string, Record | undefined], GetPromptResult>( this.getPromptImpl.bind(this), (server, name, args) => { const serverKey = this.getServerKey(server) diff --git a/src/main/services/ReduxService.ts b/src/main/services/ReduxService.ts index 04b579284..cdbaff42b 100644 --- a/src/main/services/ReduxService.ts +++ b/src/main/services/ReduxService.ts @@ -68,7 +68,8 @@ export class ReduxService extends EventEmitter { const selectorFn = new Function('state', `return ${selector}`) return selectorFn(this.stateCache) } catch (error) { - logger.error('Failed to select from cache:', error as Error) + // change it to debug level as it not block other operations + logger.debug('Failed to select from cache:', error as Error) return undefined } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index dcb2ee1fb..cfa956d20 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -76,6 +76,59 @@ }, "title": "Agents" }, + "apiServer": { + "actions": { + "copy": "Copy", + "regenerate": "Regenerate", + "restart": { + "button": "Restart", + "tooltip": "Restart Server" + } + }, + "authHeaderText": "Use in Authorization header:", + "configuration": "Configuration", + "description": "Expose Cherry Studio's AI capabilities through OpenAI-compatible HTTP APIs", + "documentation": { + "title": "API Documentation", + "unavailable": { + "description": "Start the API server to view the interactive documentation", + "title": "API Documentation Unavailable" + } + }, + "fields": { + "apiKey": { + "copyTooltip": "Copy API Key", + "label": "API Key", + "placeholder": "API key will be auto-generated" + }, + "port": { + "helpText": "Stop server to change port", + "label": "Port" + }, + "url": { + "copyTooltip": "Copy URL", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "API Key copied to clipboard", + "apiKeyRegenerated": "API Key regenerated", + "operationFailed": "API Server operation failed: ", + "restartError": "Failed to restart API Server: ", + "restartFailed": "API Server restart failed: ", + "restartSuccess": "API Server restarted successfully", + "startError": "Failed to start API Server: ", + "startSuccess": "API Server started successfully", + "stopError": "Failed to stop API Server: ", + "stopSuccess": "API Server stopped successfully", + "urlCopied": "Server URL copied to clipboard" + }, + "status": { + "running": "Running", + "stopped": "Stopped" + }, + "title": "API Server" + }, "assistants": { "abbr": "Assistants", "clear": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 617f71925..bf5295826 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -76,6 +76,59 @@ }, "title": "エージェント" }, + "apiServer": { + "actions": { + "copy": "コピー", + "regenerate": "再生成", + "restart": { + "button": "再起動", + "tooltip": "サーバーを再起動" + } + }, + "authHeaderText": "認証ヘッダーで使用:", + "configuration": "設定", + "description": "OpenAI 互換の HTTP API を通じて Cherry Studio の AI 機能を公開します", + "documentation": { + "title": "API ドキュメント", + "unavailable": { + "description": "インタラクティブドキュメントを表示するには API サーバーを開始してください", + "title": "API ドキュメントが利用できません" + } + }, + "fields": { + "apiKey": { + "copyTooltip": "API キーをコピー", + "label": "API キー", + "placeholder": "API キーは自動生成されます" + }, + "port": { + "helpText": "ポートを変更するにはサーバーを停止してください", + "label": "ポート" + }, + "url": { + "copyTooltip": "URL をコピー", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "API キーがクリップボードにコピーされました", + "apiKeyRegenerated": "API キーが再生成されました", + "operationFailed": "API サーバーの操作に失敗しました:", + "restartError": "API サーバーの再起動に失敗しました:", + "restartFailed": "API サーバーの再起動に失敗しました:", + "restartSuccess": "API サーバーが正常に再起動されました", + "startError": "API サーバーの開始に失敗しました:", + "startSuccess": "API サーバーが正常に開始されました", + "stopError": "API サーバーの停止に失敗しました:", + "stopSuccess": "API サーバーが正常に停止されました", + "urlCopied": "サーバー URL がクリップボードにコピーされました" + }, + "status": { + "running": "実行中", + "stopped": "停止中" + }, + "title": "API サーバー" + }, "assistants": { "abbr": "アシスタント", "clear": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 102d60502..9de68649b 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -76,6 +76,59 @@ }, "title": "Агенты" }, + "apiServer": { + "actions": { + "copy": "Копировать", + "regenerate": "Перегенерировать", + "restart": { + "button": "Перезапустить", + "tooltip": "Перезапустить сервер" + } + }, + "authHeaderText": "Использовать в заголовке авторизации:", + "configuration": "Конфигурация", + "description": "Предоставляет возможности ИИ Cherry Studio через HTTP API, совместимые с OpenAI", + "documentation": { + "title": "Документация API", + "unavailable": { + "description": "Запустите API сервер для просмотра интерактивной документации", + "title": "Документация API недоступна" + } + }, + "fields": { + "apiKey": { + "copyTooltip": "Копировать API ключ", + "label": "API Ключ", + "placeholder": "API ключ будет сгенерирован автоматически" + }, + "port": { + "helpText": "Остановите сервер для изменения порта", + "label": "Порт" + }, + "url": { + "copyTooltip": "Копировать URL", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "API ключ скопирован в буфер обмена", + "apiKeyRegenerated": "API ключ перегенерирован", + "operationFailed": "Операция API сервера не удалась: ", + "restartError": "Не удалось перезапустить API сервер: ", + "restartFailed": "Перезапуск API сервера не удался: ", + "restartSuccess": "API сервер успешно перезапущен", + "startError": "Не удалось запустить API сервер: ", + "startSuccess": "API сервер успешно запущен", + "stopError": "Не удалось остановить API сервер: ", + "stopSuccess": "API сервер успешно остановлен", + "urlCopied": "URL сервера скопирован в буфер обмена" + }, + "status": { + "running": "Работает", + "stopped": "Остановлен" + }, + "title": "API Сервер" + }, "assistants": { "abbr": "Ассистент", "clear": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 7fe3afba6..e13ce69ee 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -76,6 +76,59 @@ }, "title": "智能体" }, + "apiServer": { + "actions": { + "copy": "复制", + "regenerate": "重新生成", + "restart": { + "button": "重启", + "tooltip": "重启服务器" + } + }, + "authHeaderText": "在授权标头中使用:", + "configuration": "配置", + "description": "通过 OpenAI 兼容的 HTTP API 暴露 Cherry Studio 的 AI 功能", + "documentation": { + "title": "API 文档", + "unavailable": { + "description": "启动 API 服务器以查看交互式文档", + "title": "API 文档不可用" + } + }, + "fields": { + "apiKey": { + "copyTooltip": "复制 API 密钥", + "label": "API 密钥", + "placeholder": "API 密钥将自动生成" + }, + "port": { + "helpText": "停止服务器以更改端口", + "label": "端口" + }, + "url": { + "copyTooltip": "复制 URL", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "API 密钥已复制到剪贴板", + "apiKeyRegenerated": "API 密钥已重新生成", + "operationFailed": "API 服务器操作失败:", + "restartError": "重启 API 服务器失败:", + "restartFailed": "API 服务器重启失败:", + "restartSuccess": "API 服务器重启成功", + "startError": "启动 API 服务器失败:", + "startSuccess": "API 服务器启动成功", + "stopError": "停止 API 服务器失败:", + "stopSuccess": "API 服务器停止成功", + "urlCopied": "服务器 URL 已复制到剪贴板" + }, + "status": { + "running": "运行中", + "stopped": "已停止" + }, + "title": "API 服务器" + }, "assistants": { "abbr": "助手", "clear": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8b006fd5c..d9f461c89 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -76,6 +76,59 @@ }, "title": "智慧代理人" }, + "apiServer": { + "actions": { + "copy": "複製", + "regenerate": "重新生成", + "restart": { + "button": "重新啟動", + "tooltip": "重新啟動伺服器" + } + }, + "authHeaderText": "在授權標頭中使用:", + "configuration": "配置", + "description": "透過 OpenAI 相容的 HTTP API 公開 Cherry Studio 的 AI 功能", + "documentation": { + "title": "API 文件", + "unavailable": { + "description": "啟動 API 伺服器以檢視互動式文件", + "title": "API 文件無法使用" + } + }, + "fields": { + "apiKey": { + "copyTooltip": "複製 API 金鑰", + "label": "API 金鑰", + "placeholder": "API 金鑰將自動生成" + }, + "port": { + "helpText": "停止伺服器以變更連接埠", + "label": "連接埠" + }, + "url": { + "copyTooltip": "複製 URL", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "API 金鑰已複製到剪貼簿", + "apiKeyRegenerated": "API 金鑰已重新生成", + "operationFailed": "API 伺服器操作失敗:", + "restartError": "重新啟動 API 伺服器失敗:", + "restartFailed": "API 伺服器重新啟動失敗:", + "restartSuccess": "API 伺服器重新啟動成功", + "startError": "啟動 API 伺服器失敗:", + "startSuccess": "API 伺服器啟動成功", + "stopError": "停止 API 伺服器失敗:", + "stopSuccess": "API 伺服器停止成功", + "urlCopied": "伺服器 URL 已複製到剪貼簿" + }, + "status": { + "running": "執行中", + "stopped": "已停止" + }, + "title": "API 伺服器" + }, "assistants": { "abbr": "助手", "clear": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index da29b4ac5..c7b9e5a8c 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -76,6 +76,59 @@ }, "title": "Ειδικοί" }, + "apiServer": { + "actions": { + "copy": "Αντιγραφή", + "regenerate": "Αναδημιουργία", + "restart": { + "button": "Επανεκκίνηση", + "tooltip": "Επανεκκίνηση Διακομιστή" + } + }, + "authHeaderText": "Χρήση στην κεφαλίδα εξουσιοδότησης:", + "configuration": "Διαμόρφωση", + "description": "Εκθέτει τις δυνατότητες AI του Cherry Studio μέσω API HTTP συμβατών με OpenAI", + "documentation": { + "title": "Τεκμηρίωση API", + "unavailable": { + "description": "Ξεκινήστε τον διακομιστή API για να δείτε την διαδραστική τεκμηρίωση", + "title": "Τεκμηρίωση API Μη Διαθέσιμη" + } + }, + "fields": { + "apiKey": { + "copyTooltip": "Αντιγραφή Κλειδιού API", + "label": "Κλειδί API", + "placeholder": "Το κλειδί API θα δημιουργηθεί αυτόματα" + }, + "port": { + "helpText": "Σταματήστε τον διακομιστή για να αλλάξετε τη θύρα", + "label": "Θύρα" + }, + "url": { + "copyTooltip": "Αντιγραφή URL", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "Το κλειδί API αντιγράφηκε στο πρόχειρο", + "apiKeyRegenerated": "Το κλειδί API αναδημιουργήθηκε", + "operationFailed": "Η λειτουργία του Διακομιστή API απέτυχε: ", + "restartError": "Αποτυχία επανεκκίνησης του Διακομιστή API: ", + "restartFailed": "Η επανεκκίνηση του Διακομιστή API απέτυχε: ", + "restartSuccess": "Ο διακομιστής API επανεκκινήθηκε επιτυχώς", + "startError": "Αποτυχία εκκίνησης του Διακομιστή API: ", + "startSuccess": "Ο διακομιστής API ξεκίνησε επιτυχώς", + "stopError": "Αποτυχία διακοπής του Διακομιστή API: ", + "stopSuccess": "Ο διακομιστής API σταμάτησε επιτυχώς", + "urlCopied": "Το URL του διακομιστή αντιγράφηκε στο πρόχειρο" + }, + "status": { + "running": "Εκτελείται", + "stopped": "Σταματημένος" + }, + "title": "Διακομιστής API" + }, "assistants": { "abbr": "Βοηθός", "clear": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index a8d4e2fff..0b6ef5fbb 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -76,6 +76,59 @@ }, "title": "Agente" }, + "apiServer": { + "actions": { + "copy": "Copiar", + "regenerate": "Regenerar", + "restart": { + "button": "Reiniciar", + "tooltip": "Reiniciar Servidor" + } + }, + "authHeaderText": "Usar en el encabezado de autorización:", + "configuration": "Configuración", + "description": "Expone las capacidades de IA de Cherry Studio a través de APIs HTTP compatibles con OpenAI", + "documentation": { + "title": "Documentación API", + "unavailable": { + "description": "Inicia el servidor API para ver la documentación interactiva", + "title": "Documentación API No Disponible" + } + }, + "fields": { + "apiKey": { + "copyTooltip": "Copiar Clave API", + "label": "Clave API", + "placeholder": "La clave API se generará automáticamente" + }, + "port": { + "helpText": "Detén el servidor para cambiar el puerto", + "label": "Puerto" + }, + "url": { + "copyTooltip": "Copiar URL", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "Clave API copiada al portapapeles", + "apiKeyRegenerated": "Clave API regenerada", + "operationFailed": "Falló la operación del Servidor API: ", + "restartError": "Error al reiniciar el Servidor API: ", + "restartFailed": "Falló el reinicio del Servidor API: ", + "restartSuccess": "Servidor API reiniciado exitosamente", + "startError": "Error al iniciar el Servidor API: ", + "startSuccess": "Servidor API iniciado exitosamente", + "stopError": "Error al detener el Servidor API: ", + "stopSuccess": "Servidor API detenido exitosamente", + "urlCopied": "URL del servidor copiada al portapapeles" + }, + "status": { + "running": "Ejecutándose", + "stopped": "Detenido" + }, + "title": "Servidor API" + }, "assistants": { "abbr": "Asistente", "clear": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index ae4b31e6c..d6d2a5210 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -76,6 +76,59 @@ }, "title": "Agent intelligent" }, + "apiServer": { + "actions": { + "copy": "Copier", + "regenerate": "Régénérer", + "restart": { + "button": "Redémarrer", + "tooltip": "Redémarrer le Serveur" + } + }, + "authHeaderText": "Utiliser dans l'en-tête d'autorisation :", + "configuration": "Configuration", + "description": "Expose les capacités IA de Cherry Studio via des APIs HTTP compatibles OpenAI", + "documentation": { + "title": "Documentation API", + "unavailable": { + "description": "Démarrez le serveur API pour voir la documentation interactive", + "title": "Documentation API Indisponible" + } + }, + "fields": { + "apiKey": { + "copyTooltip": "Copier la Clé API", + "label": "Clé API", + "placeholder": "La clé API sera générée automatiquement" + }, + "port": { + "helpText": "Arrêtez le serveur pour changer le port", + "label": "Port" + }, + "url": { + "copyTooltip": "Copier l'URL", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "Clé API copiée dans le presse-papiers", + "apiKeyRegenerated": "Clé API régénérée", + "operationFailed": "Opération du Serveur API échouée : ", + "restartError": "Échec du redémarrage du Serveur API : ", + "restartFailed": "Redémarrage du Serveur API échoué : ", + "restartSuccess": "Serveur API redémarré avec succès", + "startError": "Échec du démarrage du Serveur API : ", + "startSuccess": "Serveur API démarré avec succès", + "stopError": "Échec de l'arrêt du Serveur API : ", + "stopSuccess": "Serveur API arrêté avec succès", + "urlCopied": "URL du serveur copiée dans le presse-papiers" + }, + "status": { + "running": "En cours d'exécution", + "stopped": "Arrêté" + }, + "title": "Serveur API" + }, "assistants": { "abbr": "Aide", "clear": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index dab84639e..2f9adea0b 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -76,6 +76,59 @@ }, "title": "Agente" }, + "apiServer": { + "actions": { + "copy": "Copiar", + "regenerate": "Regenerar", + "restart": { + "button": "Reiniciar", + "tooltip": "Reiniciar Servidor" + } + }, + "authHeaderText": "Usar no cabeçalho de autorização:", + "configuration": "Configuração", + "description": "Expõe as capacidades de IA do Cherry Studio através de APIs HTTP compatíveis com OpenAI", + "documentation": { + "title": "Documentação API", + "unavailable": { + "description": "Inicie o servidor API para ver a documentação interativa", + "title": "Documentação API Indisponível" + } + }, + "fields": { + "apiKey": { + "copyTooltip": "Copiar Chave API", + "label": "Chave API", + "placeholder": "A chave API será gerada automaticamente" + }, + "port": { + "helpText": "Pare o servidor para alterar a porta", + "label": "Porta" + }, + "url": { + "copyTooltip": "Copiar URL", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "Chave API copiada para a área de transferência", + "apiKeyRegenerated": "Chave API regenerada", + "operationFailed": "Operação do Servidor API falhou: ", + "restartError": "Falha ao reiniciar o Servidor API: ", + "restartFailed": "Reinício do Servidor API falhou: ", + "restartSuccess": "Servidor API reiniciado com sucesso", + "startError": "Falha ao iniciar o Servidor API: ", + "startSuccess": "Servidor API iniciado com sucesso", + "stopError": "Falha ao parar o Servidor API: ", + "stopSuccess": "Servidor API parado com sucesso", + "urlCopied": "URL do servidor copiada para a área de transferência" + }, + "status": { + "running": "A executar", + "stopped": "Parado" + }, + "title": "Servidor API" + }, "assistants": { "abbr": "Assistente", "clear": { diff --git a/src/renderer/src/pages/settings/ApiServerSettings/ApiServerSettings.tsx b/src/renderer/src/pages/settings/ApiServerSettings/ApiServerSettings.tsx new file mode 100644 index 000000000..73ac76397 --- /dev/null +++ b/src/renderer/src/pages/settings/ApiServerSettings/ApiServerSettings.tsx @@ -0,0 +1,444 @@ +import { CopyOutlined, GlobalOutlined, ReloadOutlined } from '@ant-design/icons' +import { useTheme } from '@renderer/context/ThemeProvider' +import { loggerService } from '@renderer/services/LoggerService' +import { RootState, useAppDispatch } from '@renderer/store' +import { setApiServerApiKey, setApiServerPort } from '@renderer/store/settings' +import { IpcChannel } from '@shared/IpcChannel' +import { Button, Card, Input, Space, Switch, Tooltip, Typography } from 'antd' +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 ConfigCard = styled(Card)` + margin-bottom: 24px; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border: 1px solid var(--color-border); + + .ant-card-head { + border-bottom: 1px solid var(--color-border); + padding: 16px 24px; + } + + .ant-card-body { + padding: 16px 20px; + } +` + +const SectionHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + + h4 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--color-text-1); + } +` + +const FieldLabel = styled.div` + font-size: 14px; + font-weight: 500; + color: var(--color-text-1); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; +` + +const ActionButtonGroup = styled(Space)` + .ant-btn { + border-radius: 6px; + font-weight: 500; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + + .ant-btn-primary { + background: #1677ff; + border-color: #1677ff; + } + + .ant-btn:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } +` + +const StyledInput = styled(Input)` + border-radius: 6px; + border: 1.5px solid var(--color-border); + + &:focus, + &:focus-within { + border-color: #1677ff; + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1); + } +` + +const ServerControlPanel = styled.div<{ status: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-radius: 8px; + background: ${(props) => + props.status + ? 'linear-gradient(135deg, #f6ffed 0%, #f0f9ff 100%)' + : 'linear-gradient(135deg, #fff2f0 0%, #fafafa 100%)'}; + border: 1px solid ${(props) => (props.status ? '#d9f7be' : '#ffd6d6')}; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + } +` + +const StatusSection = styled.div<{ status: boolean }>` + display: flex; + align-items: center; + gap: 10px; + + .status-indicator { + position: relative; + width: 10px; + height: 10px; + border-radius: 50%; + background: ${(props) => (props.status ? '#52c41a' : '#ff4d4f')}; + + &::before { + content: ''; + position: absolute; + inset: -3px; + border-radius: 50%; + background: ${(props) => (props.status ? '#52c41a' : '#ff4d4f')}; + opacity: 0.2; + animation: ${(props) => (props.status ? 'pulse 2s infinite' : 'none')}; + } + } + + .status-content { + display: flex; + flex-direction: column; + gap: 2px; + } + + .status-text { + font-weight: 600; + font-size: 14px; + color: ${(props) => (props.status ? '#52c41a' : '#ff4d4f')}; + margin: 0; + } + + .status-subtext { + font-size: 12px; + color: var(--color-text-3); + margin: 0; + } + + @keyframes pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.2; + } + 50% { + transform: scale(1.5); + opacity: 0.1; + } + } +` + +const ControlSection = styled.div` + display: flex; + align-items: center; + gap: 12px; + + + .restart-btn { + opacity: 0; + transform: translateX(10px); + transition: all 0.3s ease; + + &.visible { + opacity: 1; + transform: translateX(0); + } + } +` + +const ApiServerSettings: FC = () => { + const { theme } = useTheme() + const dispatch = useAppDispatch() + const { t } = useTranslation() + + // API Server state with proper defaults + const apiServerConfig = useSelector((state: RootState) => { + return 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.message.success(t('apiServer.messages.startSuccess')) + } else { + window.message.error(t('apiServer.messages.startError') + result.error) + } + } else { + const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Stop) + if (result.success) { + setApiServerRunning(false) + window.message.success(t('apiServer.messages.stopSuccess')) + } else { + window.message.error(t('apiServer.messages.stopError') + result.error) + } + } + } catch (error) { + window.message.error(t('apiServer.messages.operationFailed') + (error as Error).message) + } finally { + setApiServerLoading(false) + } + } + + const handleApiServerRestart = async () => { + setApiServerLoading(true) + try { + const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Restart) + if (result.success) { + await checkApiServerStatus() + window.message.success(t('apiServer.messages.restartSuccess')) + } else { + window.message.error(t('apiServer.messages.restartError') + result.error) + } + } catch (error) { + window.message.error(t('apiServer.messages.restartFailed') + (error as Error).message) + } finally { + setApiServerLoading(false) + } + } + + const copyApiKey = () => { + navigator.clipboard.writeText(apiServerConfig.apiKey) + window.message.success(t('apiServer.messages.apiKeyCopied')) + } + + const regenerateApiKey = () => { + const newApiKey = `cs-sk-${uuidv4()}` + dispatch(setApiServerApiKey(newApiKey)) + window.message.success(t('apiServer.messages.apiKeyRegenerated')) + } + + const handlePortChange = (value: string) => { + const port = parseInt(value) || 23333 + if (port >= 1000 && port <= 65535) { + dispatch(setApiServerPort(port)) + } + } + + return ( + + {/* Header Section */} +
+ + {t('apiServer.title')} + + {t('apiServer.description')} +
+ + {/* Server Status & Configuration Card */} + + +

{t('apiServer.configuration')}

+ + }> + + {/* Server Control Panel */} + + +
+
+
+ {apiServerRunning ? t('apiServer.status.running') : t('apiServer.status.stopped')} +
+
+ {apiServerRunning ? `http://localhost:${apiServerConfig.port}` : t('apiServer.fields.port.helpText')} +
+
+ + + + + + + + + + + {/* Configuration Fields */} +
+ {/* Port Configuration */} + {!apiServerRunning && ( +
+ {t('apiServer.fields.port.label')} + handlePortChange(e.target.value)} + style={{ width: 100 }} + min={1000} + max={65535} + disabled={apiServerRunning} + placeholder="23333" + size="small" + /> + {apiServerRunning && ( + + {t('apiServer.fields.port.helpText')} + + )} +
+ )} + + {/* API Key Configuration */} +
+ {t('apiServer.fields.apiKey.label')} + + + + + + {!apiServerRunning && ( + + )} + +
+ + {/* Authorization header info */} + + {t('apiServer.authHeaderText')}{' '} + + Bearer {apiServerConfig.apiKey || 'your-api-key'} + + +
+ + + + {/* API Documentation Card */} + +

{t('apiServer.documentation.title')}

+ + }> + {apiServerRunning ? ( +