mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 11:20:07 +08:00
refactor(api): migrate API server configuration to preference service
- Removed the config module and replaced its usage with preferenceService across the API server implementation. - Updated the auth middleware to retrieve the API key from preferenceService instead of the config. - Adjusted the ApiServerService to ensure a valid API key is generated and stored in preferences. - Refactored the useApiServer hook to utilize the new preference system for API server configuration. - Updated related tests to mock preferenceService instead of config. - Cleaned up unused imports and adjusted related components to align with the new configuration approach.
This commit is contained in:
parent
673ef660e0
commit
819f6de64d
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Auto-generated preferences configuration
|
||||
* Generated at: 2025-11-29T03:45:07.207Z
|
||||
* Generated at: 2025-12-02T03:48:58.274Z
|
||||
*
|
||||
* This file is automatically generated from classification.json
|
||||
* To update this file, modify classification.json and run:
|
||||
@ -303,7 +303,7 @@ export interface PreferenceSchemas {
|
||||
// redux/settings/yuqueUrl
|
||||
'data.integration.yuque.url': string
|
||||
// redux/settings/apiServer.apiKey
|
||||
'feature.csaas.api_key': string
|
||||
'feature.csaas.api_key': string | null
|
||||
// redux/settings/apiServer.enabled
|
||||
'feature.csaas.enabled': boolean
|
||||
// redux/settings/apiServer.host
|
||||
@ -581,7 +581,7 @@ export const DefaultPreferences: PreferenceSchemas = {
|
||||
'data.integration.yuque.repo_id': '',
|
||||
'data.integration.yuque.token': '',
|
||||
'data.integration.yuque.url': '',
|
||||
'feature.csaas.api_key': '`cs-sk-${uuid()}`',
|
||||
'feature.csaas.api_key': null,
|
||||
'feature.csaas.enabled': false,
|
||||
'feature.csaas.host': 'localhost',
|
||||
'feature.csaas.port': 23333,
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
import type { ApiServerConfig } from '@types'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { loggerService } from '../services/LoggerService'
|
||||
import { reduxService } from '../services/ReduxService'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerConfig')
|
||||
|
||||
const defaultHost = 'localhost'
|
||||
const defaultPort = 23333
|
||||
|
||||
class ConfigManager {
|
||||
private _config: ApiServerConfig | null = null
|
||||
|
||||
private generateApiKey(): string {
|
||||
return `cs-sk-${uuidv4()}`
|
||||
}
|
||||
|
||||
async load(): Promise<ApiServerConfig> {
|
||||
try {
|
||||
const settings = await reduxService.select('state.settings')
|
||||
const serverSettings = settings?.apiServer
|
||||
let apiKey = serverSettings?.apiKey
|
||||
if (!apiKey || apiKey.trim() === '') {
|
||||
apiKey = this.generateApiKey()
|
||||
await reduxService.dispatch({
|
||||
type: 'settings/setApiServerApiKey',
|
||||
payload: apiKey
|
||||
})
|
||||
}
|
||||
this._config = {
|
||||
enabled: serverSettings?.enabled ?? false,
|
||||
port: serverSettings?.port ?? defaultPort,
|
||||
host: defaultHost,
|
||||
apiKey: apiKey
|
||||
}
|
||||
return this._config
|
||||
} catch (error: any) {
|
||||
logger.warn('Failed to load config from Redux, using defaults', { error })
|
||||
this._config = {
|
||||
enabled: false,
|
||||
port: defaultPort,
|
||||
host: defaultHost,
|
||||
apiKey: this.generateApiKey()
|
||||
}
|
||||
return this._config
|
||||
}
|
||||
}
|
||||
|
||||
async get(): Promise<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()
|
||||
@ -1,2 +1 @@
|
||||
export { config } from './config'
|
||||
export { apiServer } from './server'
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { config } from '../../config'
|
||||
import { authMiddleware } from '../auth'
|
||||
|
||||
// Mock the config module
|
||||
vi.mock('../../config', () => ({
|
||||
config: {
|
||||
// Mock the preferenceService module
|
||||
vi.mock('@data/PreferenceService', () => ({
|
||||
preferenceService: {
|
||||
get: vi.fn()
|
||||
}
|
||||
}))
|
||||
@ -20,7 +20,7 @@ vi.mock('@logger', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockConfig = config as any
|
||||
const mockPreferenceService = preferenceService as any
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
let req: Partial<Request>
|
||||
@ -45,24 +45,24 @@ describe('authMiddleware', () => {
|
||||
})
|
||||
|
||||
describe('Missing credentials', () => {
|
||||
it('should return 401 when both auth headers are missing', async () => {
|
||||
it('should return 401 when both auth headers are missing', () => {
|
||||
;(req.header as any).mockReturnValue('')
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: missing credentials' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 when both auth headers are empty strings', async () => {
|
||||
it('should return 401 when both auth headers are empty strings', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return ''
|
||||
if (header === 'x-api-key') return ''
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: missing credentials' })
|
||||
@ -71,30 +71,30 @@ describe('authMiddleware', () => {
|
||||
})
|
||||
|
||||
describe('Server configuration', () => {
|
||||
it('should return 403 when API key is not configured', async () => {
|
||||
it('should return 403 when API key is not configured', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'some-key'
|
||||
return ''
|
||||
})
|
||||
|
||||
mockConfig.get.mockResolvedValue({ apiKey: '' })
|
||||
mockPreferenceService.get.mockReturnValue('')
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 when API key is null', async () => {
|
||||
it('should return 403 when API key is null', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'some-key'
|
||||
return ''
|
||||
})
|
||||
|
||||
mockConfig.get.mockResolvedValue({ apiKey: null })
|
||||
mockPreferenceService.get.mockReturnValue(null)
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
@ -106,80 +106,80 @@ describe('authMiddleware', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
||||
mockPreferenceService.get.mockReturnValue(validApiKey)
|
||||
})
|
||||
|
||||
it('should authenticate successfully with valid API key', async () => {
|
||||
it('should authenticate successfully with valid API key', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return validApiKey
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 with invalid API key', async () => {
|
||||
it('should return 403 with invalid API key', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'invalid-key'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 with empty API key', async () => {
|
||||
it('should return 401 with empty API key', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return ' '
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: empty x-api-key' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle API key with whitespace', async () => {
|
||||
it('should handle API key with whitespace', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return ` ${validApiKey} `
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prioritize API key over Bearer token when both are present', async () => {
|
||||
it('should prioritize API key over Bearer token when both are present', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return validApiKey
|
||||
if (header === 'authorization') return 'Bearer invalid-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 when API key is invalid even if Bearer token is valid', async () => {
|
||||
it('should return 403 when API key is invalid even if Bearer token is valid', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'invalid-key'
|
||||
if (header === 'authorization') return `Bearer ${validApiKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
@ -191,92 +191,92 @@ describe('authMiddleware', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
||||
mockPreferenceService.get.mockReturnValue(validApiKey)
|
||||
})
|
||||
|
||||
it('should authenticate successfully with valid Bearer token when no API key', async () => {
|
||||
it('should authenticate successfully with valid Bearer token when no API key', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return `Bearer ${validApiKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 with invalid Bearer token', async () => {
|
||||
it('should return 403 with invalid Bearer token', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Bearer invalid-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 with malformed authorization header', async () => {
|
||||
it('should return 401 with malformed authorization header', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Basic sometoken'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 with Bearer without space', async () => {
|
||||
it('should return 401 with Bearer without space', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Bearer'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Bearer token with only trailing spaces (edge case)', async () => {
|
||||
it('should handle Bearer token with only trailing spaces (edge case)', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Bearer ' // This will be trimmed to "Bearer" and fail format check
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Bearer token with case insensitive prefix', async () => {
|
||||
it('should handle Bearer token with case insensitive prefix', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return `bearer ${validApiKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Bearer token with whitespace', async () => {
|
||||
it('should handle Bearer token with whitespace', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return ` Bearer ${validApiKey} `
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
@ -287,40 +287,29 @@ describe('authMiddleware', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
||||
mockPreferenceService.get.mockReturnValue(validApiKey)
|
||||
})
|
||||
|
||||
it('should handle config.get() rejection', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return validApiKey
|
||||
return ''
|
||||
})
|
||||
|
||||
mockConfig.get.mockRejectedValue(new Error('Config error'))
|
||||
|
||||
await expect(authMiddleware(req as Request, res as Response, next)).rejects.toThrow('Config error')
|
||||
})
|
||||
|
||||
it('should use timing-safe comparison for different length tokens', async () => {
|
||||
it('should use timing-safe comparison for different length tokens', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'short'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 when neither credential format is valid', async () => {
|
||||
it('should return 401 when neither credential format is valid', () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Invalid format'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
||||
@ -332,10 +321,10 @@ describe('authMiddleware', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
||||
mockPreferenceService.get.mockReturnValue(validApiKey)
|
||||
})
|
||||
|
||||
it('should handle similar length but different API keys securely', async () => {
|
||||
it('should handle similar length but different API keys securely', () => {
|
||||
const similarKey = 'valid-api-key-124' // Same length, different last char
|
||||
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
@ -343,14 +332,14 @@ describe('authMiddleware', () => {
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle similar length but different Bearer tokens securely', async () => {
|
||||
it('should handle similar length but different Bearer tokens securely', () => {
|
||||
const similarKey = 'valid-api-key-124' // Same length, different last char
|
||||
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
@ -358,7 +347,7 @@ describe('authMiddleware', () => {
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import crypto from 'crypto'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { config } from '../config'
|
||||
|
||||
const isValidToken = (token: string, apiKey: string): boolean => {
|
||||
if (token.length !== apiKey.length) {
|
||||
return false
|
||||
@ -12,7 +11,7 @@ const isValidToken = (token: string, apiKey: string): boolean => {
|
||||
return crypto.timingSafeEqual(tokenBuf, keyBuf)
|
||||
}
|
||||
|
||||
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const auth = req.header('authorization') || ''
|
||||
const xApiKey = req.header('x-api-key') || ''
|
||||
|
||||
@ -21,7 +20,7 @@ export const authMiddleware = async (req: Request, res: Response, next: NextFunc
|
||||
return res.status(401).json({ error: 'Unauthorized: missing credentials' })
|
||||
}
|
||||
|
||||
const { apiKey } = await config.get()
|
||||
const apiKey = preferenceService.get('feature.csaas.api_key')
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(403).json({ error: 'Forbidden' })
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { loggerService } from '@logger'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
|
||||
import { windowService } from '../services/WindowService'
|
||||
import { app } from './app'
|
||||
import { config } from './config'
|
||||
|
||||
const logger = loggerService.withContext('ApiServer')
|
||||
|
||||
@ -28,8 +28,9 @@ export class ApiServer {
|
||||
this.server = null
|
||||
}
|
||||
|
||||
// Load config
|
||||
const { port, host } = await config.load()
|
||||
// Load config from preference service
|
||||
const port = preferenceService.get('feature.csaas.port')
|
||||
const host = preferenceService.get('feature.csaas.host')
|
||||
|
||||
// Create server with Express app
|
||||
this.server = createServer(app)
|
||||
@ -78,7 +79,6 @@ export class ApiServer {
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await this.stop()
|
||||
await config.reload()
|
||||
await this.start()
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import type {
|
||||
ApiServerConfig,
|
||||
@ -7,12 +8,14 @@ import type {
|
||||
StopApiServerStatusResult
|
||||
} from '@types'
|
||||
import { ipcMain } from 'electron'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { apiServer } from '../apiServer'
|
||||
import { config } from '../apiServer/config'
|
||||
import { loggerService } from './LoggerService'
|
||||
const logger = loggerService.withContext('ApiServerService')
|
||||
|
||||
//FIXME v2 refactor: ApiServer的启动/停止,是否运行的逻辑,现在比较乱。特别是在v2的新数据架构下,需要进一步优化,现在仅仅是做了简单的替换
|
||||
// 例如: 这样的warning:[mainWindow::PreferenceService] Attempted to confirm mismatched request for feature.csaas.enabled: expected req_1764650398717_0zb6wc350_feature.csaas.enabled, got req_1764650398704_41o5b5l1b_feature.csaas.enabled
|
||||
export class ApiServerService {
|
||||
constructor() {
|
||||
// Use the new clean implementation
|
||||
@ -20,6 +23,8 @@ export class ApiServerService {
|
||||
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
// Ensure valid API key before starting
|
||||
await this.ensureValidApiKey()
|
||||
await apiServer.start()
|
||||
logger.info('API Server started successfully')
|
||||
} catch (error: any) {
|
||||
@ -52,8 +57,36 @@ export class ApiServerService {
|
||||
return apiServer.isRunning()
|
||||
}
|
||||
|
||||
async getCurrentConfig(): Promise<ApiServerConfig> {
|
||||
return config.get()
|
||||
/**
|
||||
* Get current API server configuration from preference service
|
||||
*/
|
||||
getCurrentConfig(): ApiServerConfig {
|
||||
const config = preferenceService.getMultiple([
|
||||
'feature.csaas.enabled',
|
||||
'feature.csaas.host',
|
||||
'feature.csaas.port',
|
||||
'feature.csaas.api_key'
|
||||
])
|
||||
|
||||
return {
|
||||
enabled: config['feature.csaas.enabled'] ?? false,
|
||||
host: config['feature.csaas.host'] ?? 'localhost',
|
||||
port: config['feature.csaas.port'] ?? 23333,
|
||||
apiKey: config['feature.csaas.api_key'] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a valid API key exists, generate one if null
|
||||
*/
|
||||
async ensureValidApiKey(): Promise<string> {
|
||||
let apiKey = preferenceService.get('feature.csaas.api_key')
|
||||
if (apiKey === null) {
|
||||
apiKey = `cs-sk-${uuidv4()}`
|
||||
await preferenceService.set('feature.csaas.api_key', apiKey)
|
||||
logger.info('Generated new API key')
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
|
||||
registerIpcHandlers(): void {
|
||||
@ -85,14 +118,15 @@ export class ApiServerService {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async (): Promise<GetApiServerStatusResult> => {
|
||||
ipcMain.handle(IpcChannel.ApiServer_GetStatus, (): GetApiServerStatusResult => {
|
||||
try {
|
||||
const config = await this.getCurrentConfig()
|
||||
const config = this.getCurrentConfig()
|
||||
return {
|
||||
running: this.isRunning(),
|
||||
config
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('IpcChannel.ApiServer_GetStatus', error as Error)
|
||||
return {
|
||||
running: this.isRunning(),
|
||||
config: null
|
||||
@ -100,7 +134,7 @@ export class ApiServerService {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ApiServer_GetConfig, async () => {
|
||||
ipcMain.handle(IpcChannel.ApiServer_GetConfig, () => {
|
||||
try {
|
||||
return this.getCurrentConfig()
|
||||
} catch (error: any) {
|
||||
|
||||
@ -12,8 +12,8 @@ import type {
|
||||
SDKMessage
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { loggerService } from '@logger'
|
||||
import { config as apiConfigService } from '@main/apiServer/config'
|
||||
import { validateModelId } from '@main/apiServer/utils'
|
||||
import getLoginShellEnvironment from '@main/utils/shell-env'
|
||||
import { app } from 'electron'
|
||||
@ -101,7 +101,11 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
return aiStream
|
||||
}
|
||||
|
||||
const apiConfig = await apiConfigService.get()
|
||||
const apiConfig = preferenceService.getMultiple([
|
||||
'feature.csaas.host',
|
||||
'feature.csaas.port',
|
||||
'feature.csaas.api_key'
|
||||
])
|
||||
const loginShellEnv = await getLoginShellEnvironment()
|
||||
const loginShellEnvWithoutProxies = Object.fromEntries(
|
||||
Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy'))
|
||||
@ -110,9 +114,9 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
const env = {
|
||||
...loginShellEnvWithoutProxies,
|
||||
// TODO: fix the proxy api server
|
||||
// ANTHROPIC_API_KEY: apiConfig.apiKey,
|
||||
// ANTHROPIC_AUTH_TOKEN: apiConfig.apiKey,
|
||||
// ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
|
||||
// ANTHROPIC_API_KEY: apiConfig['feature.csaas.api_key'],
|
||||
// ANTHROPIC_AUTH_TOKEN: apiConfig['feature.csaas.api_key'],
|
||||
// ANTHROPIC_BASE_URL: `http://${apiConfig['feature.csaas.host']}:${apiConfig['feature.csaas.port']}/${modelInfo.provider.id}`,
|
||||
ANTHROPIC_API_KEY: modelInfo.provider.apiKey,
|
||||
ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey,
|
||||
ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost,
|
||||
@ -269,9 +273,9 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
for (const mcpId of session.mcps) {
|
||||
mcpList[mcpId] = {
|
||||
type: 'http',
|
||||
url: `http://${apiConfig.host}:${apiConfig.port}/v1/mcps/${mcpId}/mcp`,
|
||||
url: `http://${apiConfig['feature.csaas.host']}:${apiConfig['feature.csaas.port']}/v1/mcps/${mcpId}/mcp`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiConfig.apiKey}`
|
||||
Authorization: `Bearer ${apiConfig['feature.csaas.api_key']}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useMultiplePreferences } from '@data/hooks/usePreference'
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setApiServerEnabled as setApiServerEnabledAction } from '@renderer/store/settings'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -28,11 +27,14 @@ const cleanupIpcIfEmpty = () => {
|
||||
|
||||
export const useApiServer = () => {
|
||||
const { t } = useTranslation()
|
||||
// FIXME: We currently store two copies of the config data in both the renderer and the main processes,
|
||||
// which carries the risk of data inconsistency. This should be modified so that the main process stores
|
||||
// the data, and the renderer retrieves it.
|
||||
const apiServerConfig = useAppSelector((state) => state.settings.apiServer)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
// Use new preference system for API server configuration
|
||||
const [apiServerConfig, setApiServerConfig] = useMultiplePreferences({
|
||||
enabled: 'feature.csaas.enabled',
|
||||
host: 'feature.csaas.host',
|
||||
port: 'feature.csaas.port',
|
||||
apiKey: 'feature.csaas.api_key'
|
||||
})
|
||||
|
||||
// Initial state - no longer optimistic, wait for actual status
|
||||
const [apiServerRunning, setApiServerRunning] = useState(false)
|
||||
@ -40,9 +42,9 @@ export const useApiServer = () => {
|
||||
|
||||
const setApiServerEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
dispatch(setApiServerEnabledAction(enabled))
|
||||
setApiServerConfig({ enabled })
|
||||
},
|
||||
[dispatch]
|
||||
[setApiServerConfig]
|
||||
)
|
||||
|
||||
// API Server functions
|
||||
@ -51,15 +53,15 @@ export const useApiServer = () => {
|
||||
try {
|
||||
const status = await window.api.apiServer.getStatus()
|
||||
setApiServerRunning(status.running)
|
||||
if (status.running && !apiServerConfig.enabled) {
|
||||
setApiServerEnabled(true)
|
||||
}
|
||||
// if (status.running && !apiServerConfig.enabled) {
|
||||
// setApiServerEnabled(true)
|
||||
// }
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to check API server status:', error)
|
||||
} finally {
|
||||
setApiServerLoading(false)
|
||||
}
|
||||
}, [apiServerConfig.enabled, setApiServerEnabled])
|
||||
}, [])
|
||||
|
||||
const startApiServer = useCallback(async () => {
|
||||
if (apiServerLoading) return
|
||||
@ -153,6 +155,7 @@ export const useApiServer = () => {
|
||||
stopApiServer,
|
||||
restartApiServer,
|
||||
checkApiServerStatus,
|
||||
setApiServerEnabled
|
||||
setApiServerEnabled,
|
||||
setApiServerConfig
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCache } from '@data/hooks/useCache'
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
|
||||
import { DraggableVirtualList } from '@renderer/components/DraggableList'
|
||||
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
|
||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||
@ -196,7 +196,19 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
||||
[setActiveTopic]
|
||||
)
|
||||
|
||||
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||
const [exportMenuOptions] = useMultiplePreferences({
|
||||
docx: 'data.export.menus.docx',
|
||||
image: 'data.export.menus.image',
|
||||
joplin: 'data.export.menus.joplin',
|
||||
markdown: 'data.export.menus.markdown',
|
||||
markdown_reason: 'data.export.menus.markdown_reason',
|
||||
notes: 'data.export.menus.notes',
|
||||
notion: 'data.export.menus.notion',
|
||||
obsidian: 'data.export.menus.obsidian',
|
||||
plain_text: 'data.export.menus.plain_text',
|
||||
siyuan: 'data.export.menus.siyuan',
|
||||
yuque: 'data.export.menus.yuque'
|
||||
})
|
||||
|
||||
const [_targetTopic, setTargetTopic] = useState<Topic | null>(null)
|
||||
const targetTopic = useDeferredValue(_targetTopic)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useMultiplePreferences } from '@data/hooks/usePreference'
|
||||
import { loggerService } from '@logger'
|
||||
import HighlightText from '@renderer/components/HighlightText'
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
@ -10,7 +11,6 @@ import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||
import { fetchNoteSummary } from '@renderer/services/ApiService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import type { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService'
|
||||
import type { RootState } from '@renderer/store'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectSortType } from '@renderer/store/note'
|
||||
import type { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
@ -38,7 +38,6 @@ import {
|
||||
import type { FC, Ref } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useFullTextSearch } from './hooks/useFullTextSearch'
|
||||
@ -348,7 +347,16 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const { bases } = useKnowledgeBases()
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const sortType = useAppSelector(selectSortType)
|
||||
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||
const [exportMenuOptions] = useMultiplePreferences({
|
||||
docx: 'data.export.menus.docx',
|
||||
image: 'data.export.menus.image',
|
||||
joplin: 'data.export.menus.joplin',
|
||||
markdown: 'data.export.menus.markdown',
|
||||
notion: 'data.export.menus.notion',
|
||||
obsidian: 'data.export.menus.obsidian',
|
||||
siyuan: 'data.export.menus.siyuan',
|
||||
yuque: 'data.export.menus.yuque'
|
||||
})
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||
const [renamingNodeIds, setRenamingNodeIds] = useState<Set<string>>(new Set())
|
||||
const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState<Set<string>>(new Set())
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useApiServer } from '@renderer/hooks/useApiServer'
|
||||
import type { RootState } from '@renderer/store'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setApiServerApiKey, setApiServerPort } from '@renderer/store/settings'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { Alert, Button, Input, InputNumber, Tooltip, Typography } from 'antd'
|
||||
import { Copy, ExternalLink, Play, RotateCcw, Square } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@ -18,13 +14,19 @@ const { Text, Title } = Typography
|
||||
|
||||
const ApiServerSettings: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { t } = useTranslation()
|
||||
|
||||
// API Server state with proper defaults
|
||||
const apiServerConfig = useSelector((state: RootState) => state.settings.apiServer)
|
||||
const { apiServerRunning, apiServerLoading, startApiServer, stopApiServer, restartApiServer, setApiServerEnabled } =
|
||||
useApiServer()
|
||||
// API Server state from useApiServer hook
|
||||
const {
|
||||
apiServerConfig,
|
||||
apiServerRunning,
|
||||
apiServerLoading,
|
||||
startApiServer,
|
||||
stopApiServer,
|
||||
restartApiServer,
|
||||
setApiServerEnabled,
|
||||
setApiServerConfig
|
||||
} = useApiServer()
|
||||
|
||||
const handleApiServerToggle = async (enabled: boolean) => {
|
||||
try {
|
||||
@ -45,20 +47,26 @@ const ApiServerSettings: FC = () => {
|
||||
}
|
||||
|
||||
const copyApiKey = () => {
|
||||
navigator.clipboard.writeText(apiServerConfig.apiKey)
|
||||
if (apiServerConfig.apiKey) {
|
||||
navigator.clipboard.writeText(apiServerConfig.apiKey)
|
||||
}
|
||||
window.toast.success(t('apiServer.messages.apiKeyCopied'))
|
||||
}
|
||||
|
||||
const generateApiKey = () => {
|
||||
return `cs-sk-${uuidv4()}`
|
||||
}
|
||||
|
||||
|
||||
const regenerateApiKey = () => {
|
||||
const newApiKey = `cs-sk-${uuidv4()}`
|
||||
dispatch(setApiServerApiKey(newApiKey))
|
||||
setApiServerConfig({ apiKey: generateApiKey() })
|
||||
window.toast.success(t('apiServer.messages.apiKeyRegenerated'))
|
||||
}
|
||||
|
||||
const handlePortChange = (value: string) => {
|
||||
const port = parseInt(value) || 23333
|
||||
if (port >= 1000 && port <= 65535) {
|
||||
dispatch(setApiServerPort(port))
|
||||
setApiServerConfig({ port })
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,7 +160,7 @@ const ApiServerSettings: FC = () => {
|
||||
<FieldDescription>{t('apiServer.fields.apiKey.description')}</FieldDescription>
|
||||
|
||||
<StyledInput
|
||||
value={apiServerConfig.apiKey}
|
||||
value={apiServerConfig.apiKey || ''}
|
||||
readOnly
|
||||
placeholder={t('apiServer.fields.apiKey.placeholder')}
|
||||
size="middle"
|
||||
|
||||
@ -2392,7 +2392,7 @@
|
||||
},
|
||||
{
|
||||
"category": "preferences",
|
||||
"defaultValue": "`cs-sk-${uuid()}`",
|
||||
"defaultValue": "VALUE: null",
|
||||
"originalKey": "apiKey",
|
||||
"status": "classified",
|
||||
"targetKey": "feature.csaas.api_key",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user