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:
fullex 2025-12-02 12:47:30 +08:00
parent 673ef660e0
commit 819f6de64d
13 changed files with 179 additions and 188 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2392,7 +2392,7 @@
},
{
"category": "preferences",
"defaultValue": "`cs-sk-${uuid()}`",
"defaultValue": "VALUE: null",
"originalKey": "apiKey",
"status": "classified",
"targetKey": "feature.csaas.api_key",