Files
ChatLab/apps/desktop/main/internal-api.ts
T

377 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Electron Internal API Server
*
* Provides HTTP-based business communication for the Renderer process,
* reusing @openchatlab/http-routes shared routes with ephemeral auth.
*
* Completely isolated from the user-facing External API Server
* (apps/desktop/main/api/). Different port, different token, different lifecycle.
*/
import * as fs from 'fs'
import * as path from 'path'
import { randomBytes, createHmac, timingSafeEqual } from 'crypto'
import { ipcMain } from 'electron'
import Fastify, { type FastifyInstance, type FastifyError, type FastifyRequest, type FastifyReply } from 'fastify'
import type { PathProvider } from '@openchatlab/core'
import {
DatabaseManager,
createDatabaseManagerAdapter,
LLMConfigStore,
CustomProviderStore,
CustomModelStore,
MergeSessionCache,
raiseChatDbCompatibilityGate,
streamingImport,
createSemanticIndexWorkerRuntimeClient,
appLogger,
} from '@openchatlab/node-runtime'
import type { StreamImportDeps, SemanticIndexRuntime } from '@openchatlab/node-runtime'
import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec'
import multipart from '@fastify/multipart'
import type { ConfigStorage } from '@openchatlab/node-runtime'
import {
registerSharedRoutes,
ApiError,
ApiErrorCode,
apiErrorFromUnknown,
errorResponse,
serverError,
} from '@openchatlab/http-routes'
import type { HttpRouteContext } from '@openchatlab/http-routes'
import { resolveApiKey, writeAuthProfile, deleteAuthProfile } from '@openchatlab/config'
import { getManager as getAIChatManager } from './ai/chats'
import { getManager as getAssistantManager } from './ai/assistant/manager'
import { getManager as getSkillManager } from './ai/skills/manager'
import { createElectronRunAgentStream } from './ai/agent-stream-runner'
import { createExecuteElectronAiTool } from './ai/tools/debug-executor'
import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat'
import { resolveModelDownloadProxyUrl } from './network/proxy'
export interface InternalEndpoint {
baseUrl: string
token: string
}
let server: FastifyInstance | null = null
let endpoint: InternalEndpoint | null = null
let dbManager: DatabaseManager | null = null
let mergeCache: MergeSessionCache | null = null
let semanticIndexService: SemanticIndexRuntime | null = null
const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50 MB
function createFileConfigStorage(aiDataDir: string): ConfigStorage {
return {
readJson<T>(key: string): T | null {
try {
return JSON.parse(fs.readFileSync(path.join(aiDataDir, `${key}.json`), 'utf-8')) as T
} catch {
return null
}
},
writeJson<T>(key: string, data: T): void {
if (!fs.existsSync(aiDataDir)) fs.mkdirSync(aiDataDir, { recursive: true })
fs.writeFileSync(path.join(aiDataDir, `${key}.json`), JSON.stringify(data, null, 2), 'utf-8')
},
}
}
/**
* Start the Internal API Server.
* Must be called before createWindow() so the Renderer can retrieve the endpoint.
*/
export async function startInternalServer(pathProvider: PathProvider): Promise<InternalEndpoint> {
if (server) return endpoint!
let newServer: FastifyInstance | null = null
let newDbManager: DatabaseManager | null = null
let newSemanticIndexService: SemanticIndexRuntime | null = null
try {
const token = `int_${randomBytes(32).toString('hex')}`
const { app } = await import('electron')
const runtime = assertDesktopDataDirCompatible(pathProvider, getDesktopAppVersion(app.getVersion()))
newDbManager = new DatabaseManager(pathProvider, { runtime })
const sessionAdapter = createDatabaseManagerAdapter(newDbManager)
const aiDataDir = pathProvider.getAiDataDir()
const llmConfigStore = new LLMConfigStore(createFileConfigStorage(aiDataDir), {
resolveApiKey: (provider, authProfile) => resolveApiKey(provider, authProfile) || undefined,
onApiKeyCreated: (config, apiKey) => {
const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider
writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey })
return profileName
},
onApiKeyDeleted: (config) => {
const profileName = (config as unknown as Record<string, unknown>).authProfile as string | undefined
if (profileName) deleteAuthProfile(profileName)
},
})
const configStorage = createFileConfigStorage(aiDataDir)
const newMergeCache = new MergeSessionCache(pathProvider)
newMergeCache.cleanupOrphans()
// 语义索引 worker client:启动 internal server 时不拉起 worker;状态/构建/检索按需 lazy start。
try {
newSemanticIndexService = createSemanticIndexWorkerRuntimeClient({
pathProvider,
runtime,
sqliteVecLoadablePath: getSqliteVecLoadablePath().replace('app.asar', 'app.asar.unpacked'),
getModelDownloadProxyUrl: resolveModelDownloadProxyUrl,
workerEntryUrl: import.meta.url.endsWith('.ts')
? undefined
: new URL('./semantic-index-worker.js', import.meta.url),
})
} catch (err) {
console.warn('[semantic-index] worker client unavailable:', err instanceof Error ? err.message : String(err))
newSemanticIndexService = null
}
const electronStreamImport = async (dm: DatabaseManager, filePath: string) => {
const deps: StreamImportDeps = {
openDatabase(sessionId: string) {
return dm.openRawSessionDatabase(sessionId, { create: true, initializeChatTables: true })
},
deleteDatabase(sessionId: string) {
dm.deleteSessionDatabaseFiles(sessionId)
},
onProgress() {
/* no progress for merge-triggered import */
},
}
const result = await streamingImport(filePath, deps)
if (!result.sessionId) throw new Error('Import succeeded but no sessionId returned')
try {
raiseChatDbCompatibilityGate(pathProvider, runtime)
} catch (error) {
dm.deleteSessionDatabaseFiles(result.sessionId)
throw error
}
return { sessionId: result.sessionId }
}
const { shell } = await import('electron')
const { getDefaultUserDataDir, getUserDataDir, getDownloadsDir } = await import('./paths')
const ctx: HttpRouteContext = {
dbManager: newDbManager,
sessionAdapter,
pathProvider,
runtimeIdentity: runtime,
getVersion: () => getDesktopAppVersion(app.getVersion()),
mergeSessionCache: newMergeCache,
streamImport: electronStreamImport,
aiDataDir,
aiChatManager: getAIChatManager(),
assistantManager: getAssistantManager(),
skillManagerCore: getSkillManager(),
llmConfigStore,
customProviderStore: new CustomProviderStore(configStorage),
customModelStore: new CustomModelStore(configStorage),
semanticIndexService: newSemanticIndexService ?? undefined,
openDirectory: (dirPath) => shell.openPath(dirPath).then(() => {}),
showInFolder: (filePath) => {
shell.showItemInFolder(filePath)
return Promise.resolve()
},
downloadsDir: getDownloadsDir(),
defaultUserDataDir: getDefaultUserDataDir(),
isCustomDataDir: path.resolve(getUserDataDir()) !== path.resolve(getDefaultUserDataDir()),
runAgentStream: createElectronRunAgentStream(newSemanticIndexService ?? undefined),
executeAiTool: createExecuteElectronAiTool(newSemanticIndexService ?? undefined),
}
newServer = Fastify({ logger: false, bodyLimit: JSON_BODY_LIMIT })
await newServer.register(multipart, { limits: { fileSize: 1024 * 1024 * 1024 } })
// CORS: dev allows the Vite dev server origin; prod allows Electron app origins only
const isDev = !app.isPackaged
const devOrigin = process.env.ELECTRON_RENDERER_URL || 'http://localhost:13100'
const setCorsHeader = (reply: FastifyReply, name: string, value: string) => {
reply.header(name, value)
reply.raw.setHeader(name, value)
}
newServer.addHook('onRequest', (request, reply, done) => {
const origin = request.headers.origin
if (!origin) {
done()
return
}
if (isDev) {
const isLoopbackOrigin =
origin.startsWith('http://localhost:') ||
origin.startsWith('http://127.0.0.1:') ||
origin.startsWith('http://[::1]:')
if (origin === devOrigin || isLoopbackOrigin) {
setCorsHeader(reply, 'Access-Control-Allow-Origin', origin)
}
} else if (origin === 'file://' || origin === 'app://' || origin === 'null') {
setCorsHeader(reply, 'Access-Control-Allow-Origin', origin)
}
// SSE 路由会直接调用 reply.raw.writeHead(),因此 CORS 必须同时写到 raw response;
// 只写 Fastify reply header 时,流式响应会丢失跨域头并在浏览器侧表现为 Failed to fetch。
setCorsHeader(reply, 'Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
setCorsHeader(reply, 'Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (request.method === 'OPTIONS') {
reply.code(204).send()
return
}
done()
})
newServer.addHook('onRequest', createInternalAuthHook(token))
newServer.setErrorHandler((error: FastifyError, request, reply) => {
const apiError = apiErrorFromUnknown(error)
if (apiError) {
reply.code(apiError.statusCode).send(errorResponse(apiError))
return
}
if (error.statusCode === 413) {
const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit')
reply.code(413).send(errorResponse(bodyErr))
return
}
const statusCode = (error as any).statusCode
if (statusCode && statusCode >= 400 && statusCode < 600) {
reply.code(statusCode).send({ success: false, error: { code: 'CLIENT_ERROR', message: error.message } })
return
}
appLogger.error('http', `${request.method} ${request.url} -> 500`, error)
const err = serverError(error.message)
reply.code(err.statusCode).send(errorResponse(err))
})
registerSharedRoutes(newServer, ctx, { requireAi: true })
await newServer.listen({ port: 0, host: '127.0.0.1' })
const address = newServer.server.address()
const port = typeof address === 'object' && address ? address.port : 0
server = newServer
dbManager = newDbManager
mergeCache = newMergeCache
semanticIndexService = newSemanticIndexService
endpoint = { baseUrl: `http://127.0.0.1:${port}`, token }
console.log(`[InternalAPI] Server started on port ${port}`)
return endpoint
} catch (err) {
try {
await newServer?.close()
} catch {
/* best-effort */
}
try {
newDbManager?.closeAll()
} catch {
/* best-effort */
}
try {
await newSemanticIndexService?.close()
} catch {
/* best-effort */
}
server = null
dbManager = null
mergeCache = null
semanticIndexService = null
endpoint = null
throw err
}
}
export function getInternalEndpoint(): InternalEndpoint | null {
return endpoint
}
/** Main-process DatabaseManager backing the internal server (null before startup). */
export function getInternalDbManager(): DatabaseManager | null {
return dbManager
}
/** Lazy semantic-index runtime backing the internal server (null before startup / unavailable). */
export function getInternalSemanticIndexService(): SemanticIndexRuntime | null {
return semanticIndexService
}
export async function stopInternalServer(): Promise<void> {
if (!server) return
try {
await server.close()
} catch (err) {
console.error('[InternalAPI] Error closing server:', err)
} finally {
try {
mergeCache?.clear()
} catch {
/* best-effort */
}
try {
dbManager?.closeAll()
} catch {
/* best-effort */
}
try {
await semanticIndexService?.close()
} catch {
/* best-effort */
}
server = null
endpoint = null
dbManager = null
mergeCache = null
semanticIndexService = null
console.log('[InternalAPI] Server stopped')
}
}
/**
* Register IPC handler so the Renderer can retrieve the endpoint via preload.
* Must be called before createWindow().
*/
export function registerInternalApiIpc(): void {
ipcMain.handle('internal-api:getEndpoint', () => getInternalEndpoint())
}
// ==================== Auth Hook (Internal Only) ====================
const hmacKey = randomBytes(32)
function safeTokenCompare(a: string, b: string): boolean {
const hashA = createHmac('sha256', hmacKey).update(a).digest()
const hashB = createHmac('sha256', hmacKey).update(b).digest()
return timingSafeEqual(hashA, hashB)
}
/**
* Auth hook for the Internal Server. ALL routes require Bearer token, no exceptions.
* Independent from @openchatlab/http-routes auth (which uses global state for External Server).
*/
function createInternalAuthHook(token: string) {
return async function internalAuthHook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
if (request.method === 'OPTIONS') return
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } })
return
}
const provided = authHeader.slice(7)
if (!safeTokenCompare(provided, token)) {
reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } })
return
}
}
}