mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
/**
|
||
* 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
|
||
}
|
||
}
|
||
}
|