feat: Support Cherry Studio as a Service (CSaaS) (#8098)

This commit is contained in:
LiuVaayne 2025-07-30 12:38:07 +08:00 committed by GitHub
parent 47c909dda4
commit d0b2f18d9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 3191 additions and 29 deletions

1
AGENT.md Symbolic link
View File

@ -0,0 +1 @@
CLAUDE.md

105
CLAUDE.md Normal file
View File

@ -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

View File

@ -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",

View File

@ -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'
}

128
src/main/apiServer/app.ts Normal file
View File

@ -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 }

View File

@ -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<ApiServerConfig> {
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<ApiServerConfig> {
if (!this._config) {
await this.load()
}
if (!this._config) {
throw new Error('Failed to load API server configuration')
}
return this._config
}
async reload(): Promise<ApiServerConfig> {
return await this.load()
}
}
export const config = new ConfigManager()

View File

@ -0,0 +1,2 @@
export { config } from './config'
export { apiServer } from './server'

View File

@ -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()
}

View File

@ -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 })
}
})
}

View File

@ -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)
}
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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<typeof createServer> | null = null
async start(): Promise<void> {
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<void> {
if (!this.server) return
return new Promise((resolve) => {
this.server!.close(() => {
logger.info('API Server stopped')
this.server = null
resolve()
})
})
}
async restart(): Promise<void> {
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()

View File

@ -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<ModelData[]> {
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<OpenAI.Chat.Completions.ChatCompletion> {
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<OpenAI.Chat.Completions.ChatCompletionChunk> {
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()

View File

@ -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<string, StreamableHTTPServerTransport> = {}
interface McpServerDTO {
id: MCPServer['id']
name: MCPServer['name']
type: MCPServer['type']
description: MCPServer['description']
url: string
}
interface McpServersResp {
servers: Record<string, McpServerDTO>
}
/**
* 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<MCPServer[]> {
try {
logger.silly('Getting servers from Redux store')
// Try to get from cache first (faster)
const cachedServers = reduxService.selectSync<MCPServer[]>('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<MCPServer[]>('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<McpServersResp> {
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<MCPServer | null> {
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<any> {
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()

View File

@ -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<Provider[]> {
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<Model[]> {
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<Provider | undefined> {
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
}
}

View File

@ -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<string, Server> = {}
async function handleListToolsRequest(request: any, extra: any): Promise<ListToolsResult> {
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<any> {
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<MCPServer | undefined> {
const servers = await getServersFromRedux()
return servers.find((s) => s.id === id || s.name === id)
}
/**
* Get servers directly from Redux store
*/
async function getServersFromRedux(): Promise<MCPServer[]> {
try {
const servers = await reduxService.select<MCPServer[]>('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<Server> {
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
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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<void> {
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<void> {
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<void> {
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<ApiServerConfig> {
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()

View File

@ -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<MCPTool[]> {
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<string, any>
): Promise<GetMCPPromptResponse> {
private async getPromptImpl(server: MCPServer, name: string, args?: Record<string, any>): Promise<GetPromptResult> {
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<string, any> }
): Promise<GetMCPPromptResponse> {
const cachedGetPrompt = withCache<[MCPServer, string, Record<string, any> | undefined], GetMCPPromptResponse>(
): Promise<GetPromptResult> {
const cachedGetPrompt = withCache<[MCPServer, string, Record<string, any> | undefined], GetPromptResult>(
this.getPromptImpl.bind(this),
(server, name, args) => {
const serverKey = this.getServerKey(server)

View File

@ -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
}
}

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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 (
<SettingContainer theme={theme} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header Section */}
<div style={{ marginBottom: 32 }}>
<Title level={3} style={{ margin: 0, marginBottom: 8 }}>
{t('apiServer.title')}
</Title>
<Text type="secondary">{t('apiServer.description')}</Text>
</div>
{/* Server Status & Configuration Card */}
<ConfigCard
title={
<SectionHeader>
<GlobalOutlined />
<h4>{t('apiServer.configuration')}</h4>
</SectionHeader>
}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{/* Server Control Panel */}
<ServerControlPanel status={apiServerRunning}>
<StatusSection status={apiServerRunning}>
<div className="status-indicator" />
<div className="status-content">
<div className="status-text">
{apiServerRunning ? t('apiServer.status.running') : t('apiServer.status.stopped')}
</div>
<div className="status-subtext">
{apiServerRunning ? `http://localhost:${apiServerConfig.port}` : t('apiServer.fields.port.helpText')}
</div>
</div>
</StatusSection>
<ControlSection>
<Switch
checked={apiServerRunning}
loading={apiServerLoading}
onChange={handleApiServerToggle}
size="default"
/>
<Tooltip title={t('apiServer.actions.restart.tooltip')}>
<Button
icon={<ReloadOutlined />}
onClick={handleApiServerRestart}
loading={apiServerLoading}
size="small"
type="text"
className={`restart-btn ${apiServerRunning ? 'visible' : ''}`}>
{t('apiServer.actions.restart.button')}
</Button>
</Tooltip>
</ControlSection>
</ServerControlPanel>
{/* Configuration Fields */}
<div style={{ display: 'grid', gap: '12px' }}>
{/* Port Configuration */}
{!apiServerRunning && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<FieldLabel style={{ minWidth: 50, margin: 0 }}>{t('apiServer.fields.port.label')}</FieldLabel>
<StyledInput
type="number"
value={apiServerConfig.port}
onChange={(e) => handlePortChange(e.target.value)}
style={{ width: 100 }}
min={1000}
max={65535}
disabled={apiServerRunning}
placeholder="23333"
size="small"
/>
{apiServerRunning && (
<Text type="secondary" style={{ fontSize: 12 }}>
{t('apiServer.fields.port.helpText')}
</Text>
)}
</div>
)}
{/* API Key Configuration */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<FieldLabel style={{ minWidth: 50, margin: 0 }}>{t('apiServer.fields.apiKey.label')}</FieldLabel>
<StyledInput
value={apiServerConfig.apiKey}
readOnly
style={{ flex: 1, minWidth: 200, maxWidth: 300 }}
placeholder={t('apiServer.fields.apiKey.placeholder')}
disabled={apiServerRunning}
size="small"
/>
<ActionButtonGroup>
<Tooltip title={t('apiServer.fields.apiKey.copyTooltip')}>
<Button icon={<CopyOutlined />} onClick={copyApiKey} disabled={!apiServerConfig.apiKey} size="small">
{t('apiServer.actions.copy')}
</Button>
</Tooltip>
{!apiServerRunning && (
<Button onClick={regenerateApiKey} disabled={apiServerRunning} size="small">
{t('apiServer.actions.regenerate')}
</Button>
)}
</ActionButtonGroup>
</div>
{/* Authorization header info */}
<Text type="secondary" style={{ fontSize: 11, lineHeight: 1.3 }}>
{t('apiServer.authHeaderText')}{' '}
<Text code style={{ fontSize: 11 }}>
Bearer {apiServerConfig.apiKey || 'your-api-key'}
</Text>
</Text>
</div>
</Space>
</ConfigCard>
{/* API Documentation Card */}
<ConfigCard
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
marginBottom: 0
}}
styles={{
body: {
flex: 1,
display: 'flex',
flexDirection: 'column',
padding: 0
}
}}
title={
<SectionHeader>
<h4>{t('apiServer.documentation.title')}</h4>
</SectionHeader>
}>
{apiServerRunning ? (
<iframe
src={`http://localhost:${apiServerConfig.port}/api-docs`}
style={{
width: '100%',
border: 'none',
height: 'calc(100vh - 500px)'
}}
title="API Documentation"
sandbox="allow-scripts allow-forms"
/>
) : (
<div
style={{
textAlign: 'center',
padding: '60px 20px',
color: 'var(--color-text-2)',
background: 'var(--color-bg-2)',
borderRadius: 8,
border: '1px dashed var(--color-border)',
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
margin: 16,
height: '300px'
}}>
<GlobalOutlined style={{ fontSize: 48, marginBottom: 16, opacity: 0.3 }} />
<div style={{ fontSize: 16, fontWeight: 500, marginBottom: 8 }}>
{t('apiServer.documentation.unavailable.title')}
</div>
<div style={{ fontSize: 14 }}>{t('apiServer.documentation.unavailable.description')}</div>
</div>
)}
</ConfigCard>
</SettingContainer>
)
}
export default ApiServerSettings

View File

@ -0,0 +1 @@
export { default as ApiServerSettings } from './ApiServerSettings'

View File

@ -10,6 +10,7 @@ import {
Package,
PencilRuler,
Rocket,
Server,
Settings2,
SquareTerminal,
TextCursorInput,
@ -22,6 +23,7 @@ import { Link, Route, Routes, useLocation } from 'react-router-dom'
import styled from 'styled-components'
import AboutSettings from './AboutSettings'
import { ApiServerSettings } from './ApiServerSettings'
import DataSettings from './DataSettings/DataSettings'
import DisplaySettings from './DisplaySettings/DisplaySettings'
import GeneralSettings from './GeneralSettings'
@ -77,6 +79,18 @@ const SettingsPage: FC = () => {
{t('settings.mcp.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/api-server">
<MenuItem className={isRoute('/settings/api-server')}>
<Server size={18} />
{t('apiServer.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/tool">
<MenuItem className={isRoute('/settings/tool')}>
<PencilRuler size={18} />
{t('settings.tool.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/memory">
<MenuItem className={isRoute('/settings/memory')}>
<Brain size={18} />
@ -133,6 +147,7 @@ const SettingsPage: FC = () => {
<Route path="tool/*" element={<ToolSettings />} />
<Route path="mcp/*" element={<MCPSettings />} />
<Route path="memory" element={<MemorySettings />} />
<Route path="api-server" element={<ApiServerSettings />} />
<Route path="general/*" element={<GeneralSettings />} />
<Route path="display" element={<DisplaySettings />} />
<Route path="shortcut" element={<ShortcutSettings />} />

View File

@ -60,7 +60,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 124,
version: 125,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@ -1919,6 +1919,22 @@ const migrateConfig = {
logger.error('migrate 124 error', error as Error)
return state
}
},
'125': (state: RootState) => {
try {
// Initialize API server configuration if not present
if (!state.settings.apiServer) {
state.settings.apiServer = {
host: 'localhost',
port: 23333,
apiKey: `cs-sk-${uuid()}`
}
}
return state
} catch (error) {
logger.error('migrate 125 error', error as Error)
return state
}
}
}

View File

@ -1,6 +1,7 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
import {
ApiServerConfig,
AssistantsSortType,
CodeStyleVarious,
LanguageVarious,
@ -204,6 +205,8 @@ export interface SettingsState {
enableDeveloperMode: boolean
// UI
navbarPosition: 'left' | 'top'
// API Server
apiServer: ApiServerConfig
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@ -376,7 +379,13 @@ export const initialState: SettingsState = {
// Developer mode
enableDeveloperMode: false,
// UI
navbarPosition: 'left'
navbarPosition: 'left',
// API Server
apiServer: {
host: 'localhost',
port: 23333,
apiKey: `cs-sk-${uuid()}`
}
}
const settingsSlice = createSlice({
@ -780,6 +789,19 @@ const settingsSlice = createSlice({
},
setNavbarPosition: (state, action: PayloadAction<'left' | 'top'>) => {
state.navbarPosition = action.payload
},
// API Server actions
setApiServerPort: (state, action: PayloadAction<number>) => {
state.apiServer = {
...state.apiServer,
port: action.payload
}
},
setApiServerApiKey: (state, action: PayloadAction<string>) => {
state.apiServer = {
...state.apiServer,
apiKey: action.payload
}
}
}
})
@ -902,7 +924,10 @@ export const {
setS3,
setS3Partial,
setEnableDeveloperMode,
setNavbarPosition
setNavbarPosition,
// API Server actions
setApiServerPort,
setApiServerApiKey
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@ -841,6 +841,12 @@ export type S3Config = {
export type { Message } from './newMessage'
export interface ApiServerConfig {
host: string
port: number
apiKey: string
}
// Memory Service Types
// ========================================================================
export interface MemoryConfig {

344
yarn.lock
View File

@ -244,6 +244,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"
@ -3121,6 +3163,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"
@ -3989,22 +4038,23 @@ __metadata:
languageName: node
linkType: hard
"@modelcontextprotocol/sdk@npm:^1.12.3":
version: 1.12.3
resolution: "@modelcontextprotocol/sdk@npm:1.12.3"
"@modelcontextprotocol/sdk@npm:^1.17.0":
version: 1.17.0
resolution: "@modelcontextprotocol/sdk@npm:1.17.0"
dependencies:
ajv: "npm:^6.12.6"
content-type: "npm:^1.0.5"
cors: "npm:^2.8.5"
cross-spawn: "npm:^7.0.5"
eventsource: "npm:^3.0.2"
eventsource-parser: "npm:^3.0.0"
express: "npm:^5.0.1"
express-rate-limit: "npm:^7.5.0"
pkce-challenge: "npm:^5.0.0"
raw-body: "npm:^3.0.0"
zod: "npm:^3.23.8"
zod-to-json-schema: "npm:^3.24.1"
checksum: 10c0/8bc0b91e596ec886efc64d68ae8474247647405f1a5ae407e02439c74c2a03528b3fbdce8f9352d9c2df54aa4548411e1aa1816ab3b09e045c2ff4202e2fd374
checksum: 10c0/ac497edeb05a434bf8092475e4354ec602644b0197735d3bcd809ee1922f2078ab71e7d8d9dbe1c42765978fa3f2f807df01a2a3ad421c986f0b2207c3a40a68
languageName: node
linkType: hard
@ -5026,6 +5076,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"
@ -6180,6 +6237,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"
@ -6210,6 +6277,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"
@ -6521,6 +6613,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"
@ -6573,7 +6688,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
@ -6668,6 +6790,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"
@ -6720,6 +6849,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"
@ -6772,6 +6915,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"
@ -6779,6 +6943,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"
@ -7781,7 +7962,7 @@ __metadata:
"@libsql/client": "npm:0.14.0"
"@libsql/win32-x64-msvc": "npm:^0.4.7"
"@mistralai/mistralai": "npm:^1.7.5"
"@modelcontextprotocol/sdk": "npm:^1.12.3"
"@modelcontextprotocol/sdk": "npm:^1.17.0"
"@mozilla/readability": "npm:^0.6.0"
"@notionhq/client": "npm:^2.2.15"
"@opentelemetry/api": "npm:^1.9.0"
@ -7803,7 +7984,10 @@ __metadata:
"@testing-library/user-event": "npm:^14.6.1"
"@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/lodash": "npm:^4.17.5"
"@types/markdown-it": "npm:^14"
@ -7814,6 +7998,8 @@ __metadata:
"@types/react-dom": "npm:^19.0.4"
"@types/react-infinite-scroll-component": "npm:^5.0.0"
"@types/react-window": "npm:^1"
"@types/swagger-jsdoc": "npm:^6"
"@types/swagger-ui-express": "npm:^4.1.8"
"@types/tinycolor2": "npm:^1"
"@types/word-extractor": "npm:^1"
"@uiw/codemirror-extensions-langs": "npm:^4.23.14"
@ -7857,6 +8043,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"
fast-diff: "npm:^1.3.0"
fast-xml-parser: "npm:^5.2.0"
fetch-socks: "npm:1.3.2"
@ -7923,6 +8110,8 @@ __metadata:
strict-url-sanitise: "npm:^0.0.1"
string-width: "npm:^7.2.0"
styled-components: "npm:^6.1.11"
swagger-jsdoc: "npm:^6.2.8"
swagger-ui-express: "npm:^5.0.1"
tar: "npm:^7.4.3"
tiny-pinyin: "npm:^1.3.2"
tokenx: "npm:^1.1.0"
@ -8991,6 +9180,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"
@ -9518,6 +9714,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"
@ -9546,6 +9749,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"
@ -10681,6 +10891,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"
@ -11737,6 +11956,13 @@ __metadata:
languageName: node
linkType: hard
"eventsource-parser@npm:^3.0.0":
version: 3.0.3
resolution: "eventsource-parser@npm:3.0.3"
checksum: 10c0/2594011630efba56cafafc8ed6bd9a50db8f6d5dd62089b0950346e7961828c16efe07a588bdea3ba79e568fd9246c8163824a2ffaade767e1fdb2270c1fae0b
languageName: node
linkType: hard
"eventsource-parser@npm:^3.0.1":
version: 3.0.1
resolution: "eventsource-parser@npm:3.0.1"
@ -11814,7 +12040,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:
@ -12684,6 +12910,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"
@ -14622,6 +14862,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"
@ -14636,6 +14883,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"
@ -20110,6 +20364,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.27.0
resolution: "swagger-ui-dist@npm:5.27.0"
dependencies:
"@scarf/scarf": "npm:=1.4.0"
checksum: 10c0/8f50e67b9b92a6953f28954cda137af82550ab236813c984a6f71ec314e14121a960187e62fd5db42fc1a5763d7e687ad990f4f921c7e96d3e02b06bf631d8ae
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"
@ -21104,6 +21403,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"
@ -21820,6 +22126,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"
@ -21868,6 +22181,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"