refactor(ai): migrate streaming to shared HTTP SSE

Squash Phase 2-2 streaming migration: frontend stream service, LLM SSE route, Agent SSE route, and IPC/preload cleanup.
This commit is contained in:
digua committed 2026-06-02 15:41:21 +08:00
1 parent 769115240e
commit 1af644e4ab
21 files changed
+888 -1211

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
/**
* CLI Agent stream runner — provides runAgentStream implementation
* for the shared HTTP route context.
*/
import type { DatabaseManager, AIConversationManager, AgentStreamChunk } from '@openchatlab/node-runtime'
import { SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime'
import { getChatOverview } from '@openchatlab/core'
import type { DataSnapshot } from '@openchatlab/node-runtime'
import type { AgentStreamRequest } from '@openchatlab/http-routes'
import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools'
import { adaptToolsForAgent } from './tool-adapter'
import { getDefaultAssistantConfig, buildPiModel } from './llm-config'
import { loadAssistantConfig } from './assistant-loader'
import { runServerAgent } from './agent'
function getAiDir(dbManager: DatabaseManager): string {
const pathProvider = (dbManager as any)['pathProvider']
if (!pathProvider) {
throw Object.assign(new Error('PathProvider not available'), { statusCode: 500 })
}
return pathProvider.getAiDataDir()
}
export function createCliRunAgentStream(
dbManager: DatabaseManager,
convManager: AIConversationManager
): (params: AgentStreamRequest, onEvent: (chunk: AgentStreamChunk) => void, abortSignal: AbortSignal) => Promise<void> {
return async (params, onEvent, abortSignal) => {
const {
userMessage,
conversationId,
historyLeafMessageId,
sessionId,
chatType,
locale,
assistantId,
compressionConfig,
ownerInfo,
mentionedMembers,
thinkingLevel,
} = params
const aiDataDir = getAiDir(dbManager)
let assistantSystemPrompt: string | undefined
if (assistantId) {
const assistantConfig = loadAssistantConfig(aiDataDir, assistantId)
if (assistantConfig?.systemPrompt) {
assistantSystemPrompt = assistantConfig.systemPrompt
}
}
const llmConfig = getDefaultAssistantConfig(aiDataDir)
const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50
const contextWindow = llmConfig ? (buildPiModel(llmConfig).contextWindow ?? 128000) : 128000
const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100))
const db = (dbManager as any).open?.(sessionId)
const agentTools = db
? adaptToolsForAgent(AGENT_TOOL_REGISTRY, () => ({ db, sessionId, locale }), { maxToolResultTokens })
: []
const skillManager = new SkillManager(aiDataDir)
skillManager.init()
const toolNames = agentTools.map((t: { name: string }) => t.name)
const skillMenu = skillManager.getSkillMenu(chatType ?? 'group', toolNames)
if (skillMenu) {
const activateSkillTool = createActivateSkillTool({
chatType: chatType ?? 'group',
allowedTools: toolNames,
locale,
getSkillConfig: (id) => skillManager.getSkillConfig(id),
})
agentTools.push(activateSkillTool as any)
}
const resolvedCompression = compressionConfig?.enabled
? {
enabled: true as const,
tokenThresholdPercent: compressionConfig.tokenThresholdPercent ?? 75,
bufferSizePercent: compressionConfig.bufferSizePercent ?? 20,
maxToolResultPercent: compressionConfig.maxToolResultPercent,
}
: undefined
let dataSnapshot: DataSnapshot | undefined
if (db) {
try {
const overview = getChatOverview(db, 5)
if (overview) {
dataSnapshot = {
name: overview.name,
platform: overview.platform,
type: overview.type,
totalMessages: overview.totalMessages,
totalMembers: overview.totalMembers,
firstMessageTs: overview.firstMessageTs,
lastMessageTs: overview.lastMessageTs,
capturedAt: Math.floor(Date.now() / 1000),
}
}
} catch {
// non-fatal
}
}
await runServerAgent({
userMessage,
conversationId,
historyLeafMessageId,
chatType,
locale,
assistantSystemPrompt,
skillMenu,
compressionConfig: resolvedCompression,
tools: agentTools,
aiDataDir,
convManager,
onEvent,
abortSignal,
ownerInfo,
mentionedMembers,
dataSnapshot,
thinkingLevel,
})
}
}
+2 -3
View File
@@ -24,10 +24,10 @@ import type { ConfigStorage } from '@openchatlab/node-runtime'
import { createServer } from './server'
import { setAuthToken, setRequireAuth } from '@openchatlab/http-routes'
import { registerWebRoutes } from './routes/web'
import { registerAiRoutes } from './routes/ai'
import { registerProxyRoutes } from './routes/proxy'
import { initServerAiLogger, closeServerAiLogger } from '../ai/logger'
import { getAssistantManager, getSkillManagerCore } from '../ai/manager-factory'
import { createCliRunAgentStream } from '../ai/agent-stream-runner'
import { initSync, cleanupSync } from '../sync'
import { resolveCliPath } from '../paths'
import { resolveApiKey, writeAuthProfile } from '@openchatlab/config'
@@ -164,7 +164,6 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
limits: { fileSize: 1024 * 1024 * 1024 }, // 1GB
})
registerAiRoutes(server, dbManager, convManager)
registerWebRoutes(server, dbManager, {
pathProvider,
nativeBinding,
@@ -176,6 +175,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
llmConfigStore,
customProviderStore: new CustomProviderStore(createFileConfigStorage(aiDataDir)),
customModelStore: new CustomModelStore(createFileConfigStorage(aiDataDir)),
runAgentStream: createCliRunAgentStream(dbManager, convManager),
},
})
@@ -229,4 +229,3 @@ export async function stopHttpServer(): Promise<void> {
export { createServer } from './server'
export { registerWebRoutes } from './routes/web'
export { registerAiRoutes } from './routes/ai'
-257
View File
@@ -1,257 +0,0 @@
/**
* AI Web API — CLI-specific routes
*
* Routes that remain CLI-specific (agent streaming, tool catalog, chat stream).
* CRUD routes for assistants, skills, LLM config, conversations, and summaries
* are now handled by @openchatlab/http-routes shared package.
*/
import type { FastifyInstance } from 'fastify'
import type { DatabaseManager, AIConversationManager } from '@openchatlab/node-runtime'
import { SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime'
import { getChatOverview } from '@openchatlab/core'
import type { DataSnapshot, OwnerInfo, MentionedMember } from '@openchatlab/node-runtime'
import { runSimpleLlmStream } from '@openchatlab/node-runtime'
import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools'
import { adaptToolsForAgent } from '../../ai/tool-adapter'
import { getDefaultAssistantConfig, buildPiModel } from '../../ai/llm-config'
import { loadAssistantConfig } from '../../ai/assistant-loader'
import { runServerAgent, type AgentStreamChunk } from '../../ai/agent'
function getAiDir(dbManager: DatabaseManager): string {
const pathProvider = (dbManager as any)['pathProvider']
if (!pathProvider) {
throw Object.assign(new Error('PathProvider not available'), { statusCode: 500 })
}
return pathProvider.getAiDataDir()
}
export function registerAiRoutes(
server: FastifyInstance,
dbManager: DatabaseManager,
convManager?: AIConversationManager
): void {
// ==================== LLM Simple Chat Stream ====================
server.post<{
Body: {
messages: Array<{ role: string; content: string }>
options?: { temperature?: number; maxTokens?: number }
}
}>('/_web/ai/llm/chat-stream', async (request, reply) => {
const { messages, options } = request.body
const aiDataDir = getAiDir(dbManager)
const llmConfig = getDefaultAssistantConfig(aiDataDir)
if (!llmConfig) {
return reply.code(400).send({ success: false, error: 'LLM service not configured' })
}
const piModel = buildPiModel(llmConfig)
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const sendChunk = (data: unknown) => {
reply.raw.write(`event: chunk\ndata: ${JSON.stringify(data)}\n\n`)
}
try {
await runSimpleLlmStream({
messages,
apiKey: llmConfig.apiKey,
piModel,
temperature: options?.temperature,
maxTokens: options?.maxTokens,
onChunk: sendChunk,
})
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
sendChunk({ content: '', isFinished: true, finishReason: 'error', error: msg })
}
reply.raw.end()
})
if (!convManager) return
// ==================== Agent SSE Stream ====================
const activeAgentAborts = new Map<string, AbortController>()
server.post<{
Body: {
userMessage: string
conversationId: string
historyLeafMessageId?: string | null
sessionId: string
chatType?: 'group' | 'private'
locale?: string
assistantId?: string
compressionConfig?: {
enabled: boolean
tokenThresholdPercent?: number
bufferSizePercent?: number
maxToolResultPercent?: number
}
ownerInfo?: OwnerInfo
mentionedMembers?: MentionedMember[]
thinkingLevel?: string
}
}>('/_web/ai/agent/stream', async (request, reply) => {
const {
userMessage,
conversationId,
historyLeafMessageId,
sessionId,
chatType,
locale,
assistantId,
compressionConfig,
ownerInfo,
mentionedMembers,
thinkingLevel,
} = request.body
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const abortController = new AbortController()
activeAgentAborts.set(requestId, abortController)
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Request-Id': requestId,
})
const sendSSE = (event: string, data: unknown) => {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
sendSSE('meta', { requestId })
const aiDataDir = getAiDir(dbManager)
let assistantSystemPrompt: string | undefined
if (assistantId) {
const assistantConfig = loadAssistantConfig(aiDataDir, assistantId)
if (assistantConfig?.systemPrompt) {
assistantSystemPrompt = assistantConfig.systemPrompt
}
}
const llmConfig = getDefaultAssistantConfig(aiDataDir)
const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50
const contextWindow = llmConfig ? (buildPiModel(llmConfig).contextWindow ?? 128000) : 128000
const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100))
const db = (dbManager as any).open?.(sessionId)
const agentTools = db
? adaptToolsForAgent(AGENT_TOOL_REGISTRY, () => ({ db, sessionId, locale }), { maxToolResultTokens })
: []
const skillManager = new SkillManager(aiDataDir)
skillManager.init()
const toolNames = agentTools.map((t: { name: string }) => t.name)
const skillMenu = skillManager.getSkillMenu(chatType ?? 'group', toolNames)
if (skillMenu) {
const activateSkillTool = createActivateSkillTool({
chatType: chatType ?? 'group',
allowedTools: toolNames,
locale,
getSkillConfig: (id) => skillManager.getSkillConfig(id),
})
agentTools.push(activateSkillTool as any)
}
const onEvent = (event: AgentStreamChunk) => {
sendSSE(event.type, event)
if (event.type === 'done') {
activeAgentAborts.delete(requestId)
reply.raw.end()
}
}
reply.raw.on('close', () => {
if (!abortController.signal.aborted) {
abortController.abort()
}
activeAgentAborts.delete(requestId)
})
try {
const resolvedCompression = compressionConfig?.enabled
? {
enabled: true as const,
tokenThresholdPercent: compressionConfig.tokenThresholdPercent ?? 75,
bufferSizePercent: compressionConfig.bufferSizePercent ?? 20,
maxToolResultPercent: compressionConfig.maxToolResultPercent,
}
: undefined
let dataSnapshot: DataSnapshot | undefined
if (db) {
try {
const overview = getChatOverview(db, 5)
if (overview) {
dataSnapshot = {
name: overview.name,
platform: overview.platform,
type: overview.type,
totalMessages: overview.totalMessages,
totalMembers: overview.totalMembers,
firstMessageTs: overview.firstMessageTs,
lastMessageTs: overview.lastMessageTs,
capturedAt: Math.floor(Date.now() / 1000),
}
}
} catch {
// non-fatal: proceed without snapshot
}
}
await runServerAgent({
userMessage,
conversationId,
historyLeafMessageId,
chatType,
locale,
assistantSystemPrompt,
skillMenu,
compressionConfig: resolvedCompression,
tools: agentTools,
aiDataDir,
convManager,
onEvent,
abortSignal: abortController.signal,
ownerInfo,
mentionedMembers,
dataSnapshot,
thinkingLevel,
})
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
sendSSE('error', { type: 'error', error: { name: 'ServerError', message: msg } })
sendSSE('done', { type: 'done', isFinished: true })
activeAgentAborts.delete(requestId)
reply.raw.end()
}
})
server.post<{
Body: { requestId: string }
}>('/_web/ai/agent/abort', async (request) => {
const { requestId } = request.body
const controller = activeAgentAborts.get(requestId)
if (controller) {
controller.abort()
activeAgentAborts.delete(requestId)
return { success: true }
}
return { success: false }
})
}
+3
View File
@@ -32,6 +32,7 @@ import type {
} from '@openchatlab/node-runtime'
import { createDatabaseManagerAdapter } from '@openchatlab/node-runtime'
import { registerSharedRoutes } from '@openchatlab/http-routes'
import type { HttpRouteContext } from '@openchatlab/http-routes'
import { MergeSessionCache } from '../../../merger/merge-cache'
import { registerImportRoutes } from './import'
import { openDirectoryPath, showPathInFolder } from './cache'
@@ -46,6 +47,7 @@ export interface AiContextOptions {
llmConfigStore: LLMConfigStore
customProviderStore: CustomProviderStore
customModelStore: CustomModelStore
runAgentStream?: HttpRouteContext['runAgentStream']
}
/**
@@ -130,6 +132,7 @@ export function registerWebRoutes(
llmConfigStore: ai.llmConfigStore,
customProviderStore: ai.customProviderStore,
customModelStore: ai.customModelStore,
runAgentStream: ai.runAgentStream,
}),
},
ai ? { requireAi: true } : undefined
+248
View File
@@ -0,0 +1,248 @@
/**
* Electron Agent stream runner — provides runAgentStream implementation
* for the shared HTTP route context.
*
* Mirrors the logic from ipc/ai.ts `agent:runStream` handler,
* but outputs events via callback instead of IPC.
*/
import type { AgentStreamChunk as SharedAgentStreamChunk } from '@openchatlab/node-runtime'
import type { AgentStreamRequest } from '@openchatlab/http-routes'
import { checkAndCompress, createCompressionLlmAdapter, formatAIError } from '@openchatlab/node-runtime'
import type { CompressionConfig, CompressionLlmAdapter, AgentRuntimeStatus } from '@openchatlab/node-runtime'
import { Agent, type AgentStreamChunk, type SkillContext } from './agent'
import type { ToolContext } from './tools/types'
import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from './llm'
import type { AIServiceConfig } from './llm/types'
import { getDefaultGeneralAssistantId } from './assistant/defaultGeneral'
import * as assistantManager from './assistant'
import type { AssistantConfig } from './assistant/types'
import * as skillManager from './skills'
import { aiLogger } from './logger'
import { serializeError } from './serialize-error'
import { getManager as getConversationManager } from './conversations'
import { t } from '../i18n'
import * as workerManager from '../worker/workerManager'
import { getProviderInfo, type LLMProvider } from './llm'
const DEFAULT_CONTEXT_WINDOW = 128000
function resolveProviderName(provider?: LLMProvider): string {
if (provider === 'openai-compatible') return t('llm.genericProviderName')
return provider ? getProviderInfo(provider)?.name || provider : t('llm.genericProviderName')
}
function buildCompressionAdapter(activeAIConfig: AIServiceConfig, onCompressing?: () => void): CompressionLlmAdapter {
const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '')
return createCompressionLlmAdapter({
piModel: buildPiModel(activeAIConfig),
apiKey: activeAIConfig.apiKey,
contextWindow: modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
onCompressing,
onError: (error) => aiLogger.warn('Compression', 'LLM compression attempt failed', { error: String(error) }),
})
}
const compressionLogger = {
info: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.info(cat, msg, extra),
warn: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.warn(cat, msg, extra),
error: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.error(cat, msg, extra),
}
export function createElectronRunAgentStream(): (
params: AgentStreamRequest,
onEvent: (chunk: SharedAgentStreamChunk) => void,
abortSignal: AbortSignal
) => Promise<void> {
return async (params, onEvent, abortSignal) => {
const {
userMessage,
conversationId,
historyLeafMessageId,
sessionId,
chatType,
locale,
assistantId,
skillId,
enableAutoSkill,
compressionConfig,
ownerInfo,
mentionedMembers,
thinkingLevel,
} = params
const requestId = `internal_${Date.now()}`
aiLogger.info('AgentStream', `Agent stream request: ${requestId}`, {
userMessage: userMessage.slice(0, 100),
sessionId,
conversationId,
chatType: chatType ?? 'group',
assistantId: assistantId ?? '(none)',
skillId: skillId ?? '(none)',
enableAutoSkill: enableAutoSkill ?? false,
})
const activeAIConfig = getDefaultAssistantConfig()
if (!activeAIConfig) {
onEvent({ type: 'error', error: { name: 'ConfigError', message: t('llm.notConfigured') } })
onEvent({ type: 'done', isFinished: true })
return
}
const piModel = buildPiModel(activeAIConfig)
if (compressionConfig?.enabled && conversationId && historyLeafMessageId === undefined) {
try {
const tempAssistantConfig = assistantId
? (assistantManager.getAssistantConfig(assistantId) ?? undefined)
: undefined
const systemPromptForCompression = tempAssistantConfig?.systemPrompt || ''
const compressionResult = await checkAndCompress(
conversationId,
compressionConfig as CompressionConfig,
systemPromptForCompression,
buildCompressionAdapter(activeAIConfig, () => {
onEvent({
type: 'status',
status: {
phase: 'compressing',
round: 0,
toolsUsed: 0,
contextTokens: 0,
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
updatedAt: Date.now(),
} satisfies AgentRuntimeStatus,
})
}),
getConversationManager(),
compressionLogger
)
if (compressionResult.compressed && compressionResult.summaryContent) {
onEvent({
type: 'compression_done',
compressionResult: {
summaryContent: compressionResult.summaryContent,
tokensBefore: compressionResult.tokensBefore ?? 0,
tokensAfter: compressionResult.tokensAfter ?? 0,
timestamp: Date.now(),
},
})
}
} catch (error) {
aiLogger.error('AgentStream', `Compression failed: ${requestId}`, { error: String(error) })
}
}
const defaultAssistantId = getDefaultGeneralAssistantId(locale)
let resolvedAssistantId = assistantId || defaultAssistantId
let assistantConfig: AssistantConfig | undefined =
assistantManager.getAssistantConfig(resolvedAssistantId) ?? undefined
if (!assistantConfig && resolvedAssistantId !== defaultAssistantId) {
resolvedAssistantId = defaultAssistantId
assistantConfig = assistantManager.getAssistantConfig(defaultAssistantId) ?? undefined
}
let skillCtx: SkillContext | undefined
if (skillId) {
const skillDef = skillManager.getSkillConfig(skillId) ?? undefined
if (skillDef) {
skillCtx = { skillDef }
}
} else if (enableAutoSkill) {
const effectiveChatType = chatType ?? 'group'
const allowedTools = assistantConfig?.allowedBuiltinTools
const menu = skillManager.getSkillMenu(effectiveChatType, allowedTools)
if (menu) {
skillCtx = { skillMenu: menu }
}
}
const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50
const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '')
const resolvedContextWindow = modelDef?.contextWindow || DEFAULT_CONTEXT_WINDOW
const maxToolResultTokens = Math.floor(resolvedContextWindow * (maxToolResultPercent / 100))
let dataSnapshot: ToolContext['dataSnapshot'] | undefined
try {
const overview = await workerManager.getChatOverview(sessionId, 5)
if (overview) {
dataSnapshot = {
name: overview.name,
platform: overview.platform,
type: overview.type,
totalMessages: overview.totalMessages,
totalMembers: overview.totalMembers,
firstMessageTs: overview.firstMessageTs,
lastMessageTs: overview.lastMessageTs,
capturedAt: Math.floor(Date.now() / 1000),
}
}
} catch (error) {
aiLogger.warn('AgentStream', `Failed to load data snapshot: ${requestId}`, { error: String(error) })
}
const context: ToolContext = {
sessionId,
conversationId,
historyLeafMessageId: historyLeafMessageId ?? undefined,
timeFilter: params.timeFilter,
maxMessagesLimit: params.maxMessagesLimit,
ownerInfo,
mentionedMembers: mentionedMembers as ToolContext['mentionedMembers'],
preprocessConfig: params.preprocessConfig as ToolContext['preprocessConfig'],
maxToolResultTokens,
dataSnapshot,
}
const agent = new Agent(
context,
piModel,
activeAIConfig.apiKey,
{
abortSignal,
thinkingLevel: thinkingLevel as import('@openchatlab/core').ThinkingLevel | undefined,
},
chatType ?? 'group',
locale ?? 'zh-CN',
assistantConfig,
skillCtx
)
try {
const result = await agent.executeStream(userMessage, (chunk: AgentStreamChunk) => {
if (abortSignal.aborted) return
onEvent(chunk as SharedAgentStreamChunk)
})
if (abortSignal.aborted) {
onEvent({
type: 'done',
isFinished: true,
usage: result.totalUsage,
})
return
}
onEvent({
type: 'done',
isFinished: true,
usage: result.totalUsage,
})
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
onEvent({ type: 'done', isFinished: true })
return
}
const serializedError = serializeError(error, activeAIConfig.provider)
serializedError.friendlyMessage = formatAIError(error, {
providerName: resolveProviderName(activeAIConfig.provider),
rawErrorLabel: t('llm.rawErrorLabel'),
})
aiLogger.error('AgentStream', `Agent execution error: ${requestId}`, serializedError)
onEvent({ type: 'error', error: serializedError, isFinished: true })
onEvent({ type: 'done', isFinished: true })
}
}
}
+2
View File
@@ -34,6 +34,7 @@ import { resolveApiKey, writeAuthProfile } from '@openchatlab/config'
import { getManager as getConversationManager } from './ai/conversations'
import { getManager as getAssistantManager } from './ai/assistant/manager'
import { getManager as getSkillManager } from './ai/skills/manager'
import { createElectronRunAgentStream } from './ai/agent-stream-runner'
export interface InternalEndpoint {
baseUrl: string
@@ -151,6 +152,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
downloadsDir: getDownloadsDir(),
defaultUserDataDir: getDefaultUserDataDir(),
isCustomDataDir: Boolean(getCustomDataDir()),
runAgentStream: createElectronRunAgentStream(),
}
newServer = Fastify({ logger: false, bodyLimit: JSON_BODY_LIMIT })
+14 -374
View File
@@ -1,32 +1,23 @@
// electron/main/ipc/ai.ts
/**
* AI IPC handlers — Electron-only subset
*
* Streaming (LLM chat-stream, Agent stream/abort) has been migrated to
* shared HTTP SSE routes. This file retains:
* - Debug mode, log file, desensitize rules
* - LLM non-streaming chat
* - Tool catalog / execute / cancel
* - Context token estimation
*/
import { ipcMain, shell } from 'electron'
import * as fs from 'fs'
import * as path from 'path'
import * as llm from '../ai/llm'
import { aiLogger, setDebugMode } from '../ai/logger'
import { serializeError } from '../ai/serialize-error'
import { getLogsDir } from '../paths'
import * as workerManager from '../worker/workerManager'
import { Agent, type AgentStreamChunk, type SkillContext } from '../ai/agent'
import { getDefaultGeneralAssistantId } from '../ai/assistant/defaultGeneral'
import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from '../ai/llm'
import type { AIServiceConfig } from '../ai/llm/types'
import { countMessagesTokens } from '@openchatlab/node-runtime'
import { getDefaultAssistantConfig, buildPiModel } from '../ai/llm'
import { countMessagesTokens, completeSimple, type PiMessage, type PiTextContent } from '@openchatlab/node-runtime'
import { stripAvatarFields } from '@openchatlab/core'
import * as assistantManager from '../ai/assistant'
import type { AssistantConfig } from '../ai/assistant/types'
import * as skillManager from '../ai/skills'
import {
checkAndCompress,
createCompressionLlmAdapter,
type CompressionConfig,
type CompressionLlmAdapter,
completeSimple,
runSimpleLlmStream,
formatAIError,
type PiMessage,
type PiTextContent,
} from '@openchatlab/node-runtime'
import { getManager as getConversationManager } from '../ai/conversations'
import { t } from '../i18n'
import type { ToolContext } from '../ai/tools/types'
@@ -34,25 +25,6 @@ import { TOOL_REGISTRY } from '../ai/tools/definitions'
import { getDefaultRulesForLocale, mergeRulesForLocale } from '@openchatlab/node-runtime'
import type { IpcContext } from './types'
const DEFAULT_CONTEXT_WINDOW = 128000
const compressionLogger = {
info: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.info(cat, msg, extra),
warn: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.warn(cat, msg, extra),
error: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.error(cat, msg, extra),
}
function buildCompressionAdapter(activeAIConfig: AIServiceConfig, onCompressing?: () => void): CompressionLlmAdapter {
const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '')
return createCompressionLlmAdapter({
piModel: buildPiModel(activeAIConfig),
apiKey: activeAIConfig.apiKey,
contextWindow: modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
onCompressing,
onError: (error) => aiLogger.warn('Compression', 'LLM compression attempt failed', { error: String(error) }),
})
}
function toPiSimpleMessages(messages: Array<{ role: string; content: string }>, timestamp: number): PiMessage[] {
return messages.map((message) => ({
role: message.role as 'user' | 'assistant',
@@ -61,15 +33,7 @@ function toPiSimpleMessages(messages: Array<{ role: string; content: string }>,
})) as unknown as PiMessage[]
}
// ==================== AI Agent 请求追踪 ====================
const activeAgentRequests = new Map<string, AbortController>()
function resolveProviderName(provider?: llm.LLMProvider): string {
if (provider === 'openai-compatible') return t('llm.genericProviderName')
return provider ? llm.getProviderInfo(provider)?.name || provider : t('llm.genericProviderName')
}
export function registerAIHandlers({ win }: IpcContext): void {
export function registerAIHandlers(_ctx: IpcContext): void {
console.log('[IPC] Registering AI handlers...')
try {
@@ -185,46 +149,6 @@ export function registerAIHandlers({ win }: IpcContext): void {
}
)
ipcMain.handle(
'llm:chatStream',
async (
_,
requestId: string,
messages: Array<{ role: string; content: string }>,
options?: { temperature?: number; maxTokens?: number }
) => {
try {
const activeConfig = getDefaultAssistantConfig()
if (!activeConfig) {
return { success: false, error: t('llm.notConfigured') }
}
const piModel = buildPiModel(activeConfig)
;(async () => {
await runSimpleLlmStream({
messages,
apiKey: activeConfig.apiKey,
piModel,
temperature: options?.temperature,
maxTokens: options?.maxTokens,
onChunk: (chunk) => {
if (chunk.error) {
win.webContents.send('llm:streamChunk', { requestId, error: chunk.error, chunk })
} else {
win.webContents.send('llm:streamChunk', { requestId, chunk })
}
},
})
})()
return { success: true }
} catch (error) {
aiLogger.error('IPC', 'llm:chatStream error', { error: String(error) })
return { success: false, error: String(error) }
}
}
)
// ==================== 工具测试 API ====================
const activeToolTests = new Map<string, AbortController>()
@@ -313,291 +237,7 @@ export function registerAIHandlers({ win }: IpcContext): void {
return { success: false }
})
// ==================== AI Agent API ====================
ipcMain.handle(
'agent:runStream',
async (
_,
requestId: string,
userMessage: string,
context: ToolContext,
chatType?: 'group' | 'private',
locale?: string,
assistantId?: string,
skillId?: string | null,
enableAutoSkill?: boolean,
compressionConfig?: CompressionConfig,
thinkingLevel?: string
) => {
aiLogger.info('IPC', `Agent stream request received: ${requestId}`, {
userMessage: userMessage.slice(0, 100),
sessionId: context.sessionId,
conversationId: context.conversationId,
chatType: chatType ?? 'group',
assistantId: assistantId ?? '(none)',
skillId: skillId ?? '(none)',
enableAutoSkill: enableAutoSkill ?? false,
})
try {
const abortController = new AbortController()
activeAgentRequests.set(requestId, abortController)
const activeAIConfig = getDefaultAssistantConfig()
if (!activeAIConfig) {
return { success: false, error: t('llm.notConfigured') }
}
const piModel = buildPiModel(activeAIConfig)
if (compressionConfig?.enabled && context.conversationId && context.historyLeafMessageId === undefined) {
try {
const tempAssistantConfig = assistantId
? (assistantManager.getAssistantConfig(assistantId) ?? undefined)
: undefined
const systemPromptForCompression = tempAssistantConfig?.systemPrompt || ''
const compressionResult = await checkAndCompress(
context.conversationId,
compressionConfig,
systemPromptForCompression,
buildCompressionAdapter(activeAIConfig, () => {
win.webContents.send('agent:streamChunk', {
requestId,
chunk: {
type: 'status',
status: {
phase: 'compressing',
round: 0,
toolsUsed: 0,
contextTokens: 0,
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
updatedAt: Date.now(),
} satisfies import('@electron/shared/types').AgentRuntimeStatus,
},
})
}),
getConversationManager(),
compressionLogger
)
aiLogger.info('IPC', `Compression result for ${requestId}`, compressionResult)
if (compressionResult.compressed && compressionResult.summaryContent) {
win.webContents.send('agent:streamChunk', {
requestId,
chunk: {
type: 'compression_done',
compressionResult: {
summaryContent: compressionResult.summaryContent,
tokensBefore: compressionResult.tokensBefore ?? 0,
tokensAfter: compressionResult.tokensAfter ?? 0,
timestamp: Date.now(),
},
},
})
}
} catch (error) {
aiLogger.error('IPC', `Compression failed for ${requestId}, continuing without compression`, {
error: String(error),
})
}
} else if (compressionConfig?.enabled && context.historyLeafMessageId !== undefined) {
aiLogger.info('IPC', `Skipping compression for edited branch request: ${requestId}`, {
conversationId: context.conversationId,
historyLeafMessageId: context.historyLeafMessageId,
})
}
const pp = context.preprocessConfig
aiLogger.info('IPC', `Agent context: ${requestId}`, {
model: activeAIConfig.model,
provider: activeAIConfig.provider,
baseUrl: activeAIConfig.baseUrl || '(default)',
maxMessagesLimit: context.maxMessagesLimit,
hasTimeFilter: !!context.timeFilter,
mentionedMembersCount: context.mentionedMembers?.length ?? 0,
preprocess: pp
? {
dataCleaning: pp.dataCleaning ?? true,
mergeConsecutive: pp.mergeConsecutive,
denoise: pp.denoise,
desensitize: pp.desensitize,
anonymizeNames: pp.anonymizeNames,
}
: '(disabled)',
})
const defaultAssistantId = getDefaultGeneralAssistantId(locale)
let resolvedAssistantId = assistantId || defaultAssistantId
let assistantConfig: AssistantConfig | undefined =
assistantManager.getAssistantConfig(resolvedAssistantId) ?? undefined
if (!assistantConfig && resolvedAssistantId !== defaultAssistantId) {
aiLogger.warn('IPC', `Assistant not found: ${resolvedAssistantId}, falling back to ${defaultAssistantId}`, {
requestedAssistantId: assistantId ?? null,
locale: locale ?? null,
})
resolvedAssistantId = defaultAssistantId
assistantConfig = assistantManager.getAssistantConfig(defaultAssistantId) ?? undefined
}
let skillCtx: SkillContext | undefined
if (skillId) {
const skillDef = skillManager.getSkillConfig(skillId) ?? undefined
if (skillDef) {
skillCtx = { skillDef }
} else {
aiLogger.warn('IPC', `Skill not found: ${skillId}`)
}
} else if (enableAutoSkill) {
const effectiveChatType = chatType ?? 'group'
const allowedTools = assistantConfig?.allowedBuiltinTools
const menu = skillManager.getSkillMenu(effectiveChatType, allowedTools)
if (menu) {
skillCtx = { skillMenu: menu }
}
}
const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50
const modelDef = llm.findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '')
const resolvedContextWindow = modelDef?.contextWindow || 128000
const maxToolResultTokens = Math.floor(resolvedContextWindow * (maxToolResultPercent / 100))
let dataSnapshot: ToolContext['dataSnapshot'] | undefined = context.dataSnapshot
try {
const overview = await workerManager.getChatOverview(context.sessionId, 5)
if (overview) {
dataSnapshot = {
name: overview.name,
platform: overview.platform,
type: overview.type,
totalMessages: overview.totalMessages,
totalMembers: overview.totalMembers,
firstMessageTs: overview.firstMessageTs,
lastMessageTs: overview.lastMessageTs,
capturedAt: Math.floor(Date.now() / 1000),
}
}
} catch (error) {
aiLogger.warn('IPC', `Failed to load agent data snapshot: ${requestId}`, { error: String(error) })
}
const enrichedContext: ToolContext = { ...context, maxToolResultTokens, dataSnapshot }
const agent = new Agent(
enrichedContext,
piModel,
activeAIConfig.apiKey,
{
abortSignal: abortController.signal,
thinkingLevel: thinkingLevel as import('@openchatlab/core').ThinkingLevel | undefined,
},
chatType ?? 'group',
locale ?? 'zh-CN',
assistantConfig,
skillCtx
)
;(async () => {
try {
const result = await agent.executeStream(userMessage, (chunk: AgentStreamChunk) => {
if (abortController.signal.aborted) {
return
}
if (chunk.type === 'tool_start') {
aiLogger.info('IPC', `Tool call: ${chunk.toolName}`, chunk.toolParams)
}
win.webContents.send('agent:streamChunk', { requestId, chunk })
})
if (abortController.signal.aborted) {
aiLogger.info('IPC', `Agent aborted: ${requestId}`)
win.webContents.send('agent:complete', {
requestId,
result: {
content: result.content,
toolsUsed: result.toolsUsed,
toolRounds: result.toolRounds,
totalUsage: result.totalUsage,
aborted: true,
},
})
return
}
win.webContents.send('agent:complete', {
requestId,
result: {
content: result.content,
toolsUsed: result.toolsUsed,
toolRounds: result.toolRounds,
totalUsage: result.totalUsage,
},
})
aiLogger.info('IPC', `Agent execution completed: ${requestId}`, {
toolsUsed: result.toolsUsed,
toolRounds: result.toolRounds,
contentLength: result.content.length,
totalUsage: result.totalUsage,
})
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
aiLogger.info('IPC', `Agent request aborted (error): ${requestId}`)
win.webContents.send('agent:complete', {
requestId,
result: { content: '', toolsUsed: [], toolRounds: 0, aborted: true },
})
return
}
const serializedError = serializeError(error, activeAIConfig.provider)
serializedError.friendlyMessage = formatAIError(error, {
providerName: resolveProviderName(activeAIConfig.provider),
rawErrorLabel: t('llm.rawErrorLabel'),
})
if (!serializedError.url && activeAIConfig.baseUrl) {
serializedError.url = activeAIConfig.baseUrl
}
aiLogger.error('IPC', `Agent execution error: ${requestId}`, serializedError)
win.webContents.send('agent:streamChunk', {
requestId,
chunk: { type: 'error', error: serializedError, isFinished: true },
})
win.webContents.send('agent:complete', {
requestId,
result: {
content: '',
toolsUsed: [],
toolRounds: 0,
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
error: serializedError,
},
})
} finally {
activeAgentRequests.delete(requestId)
}
})()
return { success: true }
} catch (error) {
aiLogger.error('IPC', `Failed to create Agent request: ${requestId}`, { error: String(error) })
return { success: false, error: String(error) }
}
}
)
ipcMain.handle('agent:abort', async (_, requestId: string) => {
aiLogger.info('IPC', `Abort request received: ${requestId}`)
const abortController = activeAgentRequests.get(requestId)
if (abortController) {
abortController.abort()
activeAgentRequests.delete(requestId)
aiLogger.info('IPC', `Agent request aborted: ${requestId}`)
return { success: true }
} else {
aiLogger.warn('IPC', `Agent request not found: ${requestId}`)
return { success: false, error: 'Request not found' }
}
})
// Agent streaming has been migrated to shared SSE route (ai-agent-stream.ts)
// ==================== 上下文 token 估算 ====================
+4 -256
View File
@@ -1,17 +1,14 @@
/**
* AI 相关 API — 仅保留 IPC 必须的能力
*
* Conversation/message CRUD 和 debug 已迁移到 HTTP 共享路由(FetchAIAdapter),
* Conversation/message CRUD 已迁移到 HTTP 共享路由(FetchAIAdapter)。
* LLM/Agent streaming 已迁移到 SSE 共享路由(useAgentStreamService/useLlmStreamService)。
* 此处只保留需要 worker、native shell、工具注册表等 IPC 才能提供的功能。
*/
import { ipcRenderer } from 'electron'
import type { ExportProgress } from '../../../../src/types/base'
// Agent API 类型 — 从 shared/types 统一导入
export type { TokenUsage, AgentRuntimeStatus } from '../../shared/types'
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../../shared/types'
export type { SerializedErrorInfo } from '../../shared/types'
export type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../../shared/types'
// ==================== 类型定义 ====================
@@ -25,42 +22,6 @@ export interface ChatOptions {
maxTokens?: number
}
export interface ChatStreamChunk {
content: string
isFinished: boolean
finishReason?: 'stop' | 'length' | 'error'
error?: string
thinking?: string
thinkingDone?: boolean
}
export interface AgentStreamChunk {
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'compression_done' | 'done' | 'error'
content?: string
thinkTag?: string
thinkDurationMs?: number
toolName?: string
toolParams?: Record<string, unknown>
toolResult?: unknown
status?: AgentRuntimeStatus
error?: SerializedErrorInfo
isFinished?: boolean
compressionResult?: {
summaryContent: string
tokensBefore: number
tokensAfter: number
timestamp: number
}
usage?: TokenUsage
}
export interface AgentResult {
content: string
toolsUsed: string[]
toolRounds: number
error?: SerializedErrorInfo
}
export interface DesensitizeRule {
id: string
label: string
@@ -98,24 +59,6 @@ export interface PreprocessConfig {
anonymizeNames: boolean
}
export interface ToolContext {
sessionId: string
conversationId?: string
historyLeafMessageId?: string | null
timeFilter?: { startTs: number; endTs: number }
maxMessagesLimit?: number
ownerInfo?: { platformId: string; displayName: string }
mentionedMembers?: Array<{
memberId: number
platformId: string
displayName: string
aliases: string[]
mentionText: string
}>
locale?: string
preprocessConfig?: PreprocessConfig
}
// ==================== AI API (IPC-only subset) ====================
export const aiApi = {
@@ -283,8 +226,7 @@ export const aiApi = {
},
}
// ==================== LLM API ====================
// LLM chat (non-streaming) still uses IPC for Electron-specific config resolution
export const llmApi = {
chat: (
messages: ChatMessage[],
@@ -292,198 +234,4 @@ export const llmApi = {
): Promise<{ success: boolean; content?: string; error?: string }> => {
return ipcRenderer.invoke('llm:chat', messages, options)
},
chatStream: (
messages: ChatMessage[],
options?: ChatOptions,
onChunk?: (chunk: ChatStreamChunk) => void
): Promise<{ success: boolean; error?: string }> => {
return new Promise((resolve) => {
const requestId = `llm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
console.log('[preload] chatStream 开始,requestId:', requestId)
const handler = (
_event: Electron.IpcRendererEvent,
data: { requestId: string; chunk: ChatStreamChunk; error?: string }
) => {
if (data.requestId === requestId) {
if (data.error) {
console.log('[preload] chatStream 收到错误:', data.error)
if (onChunk) {
onChunk({ content: '', isFinished: true, finishReason: 'error', error: data.error })
}
ipcRenderer.removeListener('llm:streamChunk', handler)
resolve({ success: false, error: data.error })
} else {
if (onChunk) {
onChunk(data.chunk)
}
if (data.chunk.isFinished) {
console.log('[preload] chatStream 完成,requestId:', requestId)
ipcRenderer.removeListener('llm:streamChunk', handler)
resolve({ success: true })
}
}
}
}
ipcRenderer.on('llm:streamChunk', handler)
ipcRenderer
.invoke('llm:chatStream', requestId, messages, options)
.then((result) => {
console.log('[preload] chatStream invoke 返回:', result)
if (!result.success) {
ipcRenderer.removeListener('llm:streamChunk', handler)
resolve(result)
}
})
.catch((error) => {
console.error('[preload] chatStream invoke 错误:', error)
ipcRenderer.removeListener('llm:streamChunk', handler)
resolve({ success: false, error: String(error) })
})
})
},
}
// ==================== Agent API ====================
export const agentApi = {
runStream: (
userMessage: string,
context: ToolContext,
onChunk?: (chunk: AgentStreamChunk) => void,
chatType?: 'group' | 'private',
locale?: string,
assistantId?: string,
skillId?: string | null,
enableAutoSkill?: boolean,
compressionConfig?: {
enabled: boolean
tokenThresholdPercent: number
bufferSizePercent: number
maxToolResultPercent?: number
},
thinkingLevel?: string
): {
requestId: string
promise: Promise<{ success: boolean; result?: AgentResult; error?: SerializedErrorInfo }>
} => {
const sanitizedContext: ToolContext = {
sessionId: context.sessionId,
conversationId: context.conversationId,
historyLeafMessageId: context.historyLeafMessageId,
timeFilter: context.timeFilter
? {
startTs: context.timeFilter.startTs,
endTs: context.timeFilter.endTs,
}
: undefined,
maxMessagesLimit: context.maxMessagesLimit,
ownerInfo: context.ownerInfo
? {
platformId: context.ownerInfo.platformId,
displayName: context.ownerInfo.displayName,
}
: undefined,
mentionedMembers: context.mentionedMembers
? context.mentionedMembers.map((member) => ({
memberId: member.memberId,
platformId: member.platformId,
displayName: member.displayName,
aliases: [...member.aliases],
mentionText: member.mentionText,
}))
: undefined,
locale: context.locale,
preprocessConfig: context.preprocessConfig,
}
const requestId = `agent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
console.log(
'[preload] Agent runStream 开始,requestId:',
requestId,
'conversationId:',
sanitizedContext.conversationId ?? 'none',
'chatType:',
chatType ?? 'group'
)
const promise = new Promise<{ success: boolean; result?: AgentResult; error?: SerializedErrorInfo }>((resolve) => {
const chunkHandler = (
_event: Electron.IpcRendererEvent,
data: { requestId: string; chunk: AgentStreamChunk }
) => {
if (data.requestId === requestId) {
if (onChunk) {
onChunk(data.chunk)
}
}
}
const completeHandler = (
_event: Electron.IpcRendererEvent,
data: { requestId: string; result: AgentResult & { error?: SerializedErrorInfo } }
) => {
if (data.requestId === requestId) {
console.log('[preload] Agent 完成,requestId:', requestId, 'hasError:', !!data.result?.error)
ipcRenderer.removeListener('agent:streamChunk', chunkHandler)
ipcRenderer.removeListener('agent:complete', completeHandler)
if (data.result?.error) {
resolve({ success: false, error: data.result.error })
} else {
resolve({ success: true, result: data.result })
}
}
}
ipcRenderer.on('agent:streamChunk', chunkHandler)
ipcRenderer.on('agent:complete', completeHandler)
ipcRenderer
.invoke(
'agent:runStream',
requestId,
userMessage,
sanitizedContext,
chatType,
locale,
assistantId,
skillId,
enableAutoSkill,
compressionConfig,
thinkingLevel
)
.then((result) => {
console.log('[preload] Agent invoke 返回:', result)
if (!result.success) {
ipcRenderer.removeListener('agent:streamChunk', chunkHandler)
ipcRenderer.removeListener('agent:complete', completeHandler)
resolve(result)
}
})
.catch((error) => {
console.error('[preload] Agent invoke 错误:', error)
ipcRenderer.removeListener('agent:streamChunk', chunkHandler)
ipcRenderer.removeListener('agent:complete', completeHandler)
resolve({
success: false,
error: {
name: error instanceof Error ? error.name : null,
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? (error.stack ?? null) : null,
},
})
})
})
return { requestId, promise }
},
abort: (requestId: string): Promise<{ success: boolean; error?: string }> => {
console.log('[preload] Agent abort 请求,requestId:', requestId)
return ipcRenderer.invoke('agent:abort', requestId)
},
}
+3 -94
View File
@@ -254,63 +254,16 @@ interface LLMChatOptions {
maxTokens?: number
}
interface LLMChatStreamChunk {
content: string
isFinished: boolean
finishReason?: 'stop' | 'length' | 'error'
error?: string
thinking?: string
thinkingDone?: boolean
}
/**
* LLM CRUD (config, provider, model, validate, etc.) has been migrated to
* HTTP service layer via FetchLLMAdapter. Only streaming / non-streaming
* chat APIs remain on IPC.
* HTTP service layer via FetchLLMAdapter. Streaming chat uses shared SSE route.
* Only non-streaming chat remains on IPC.
*/
interface LlmApi {
chat: (
messages: LLMChatMessage[],
options?: LLMChatOptions
) => Promise<{ success: boolean; content?: string; error?: string }>
chatStream: (
messages: LLMChatMessage[],
options?: LLMChatOptions,
onChunk?: (chunk: LLMChatStreamChunk) => void
) => Promise<{ success: boolean; error?: string }>
}
// TokenUsage & AgentRuntimeStatus — imported from electron/shared/types.ts
// Agent 相关类型
interface AgentStreamChunk {
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'compression_done' | 'done' | 'error'
content?: string
thinkTag?: string
thinkDurationMs?: number
toolName?: string
toolParams?: Record<string, unknown>
toolResult?: unknown
status?: AgentRuntimeStatus
error?: SerializedErrorInfo
isFinished?: boolean
compressionResult?: {
summaryContent: string
tokensBefore: number
tokensAfter: number
timestamp: number
}
/** Token 使用量(type=done 时返回累计值) */
usage?: TokenUsage
}
interface AgentResult {
content: string
toolsUsed: string[]
toolRounds: number
/** 总 Token 使用量(累计所有 LLM 调用) */
totalUsage?: TokenUsage
error?: SerializedErrorInfo
}
/** Owner 信息(当前用户在对话中的身份) */
@@ -362,50 +315,7 @@ interface PreprocessConfig {
anonymizeNames: boolean
}
interface ToolContext {
sessionId: string
conversationId?: string
historyLeafMessageId?: string | null
timeFilter?: { startTs: number; endTs: number }
/** 用户配置:每次发送给 AI 的最大消息条数 */
maxMessagesLimit?: number
/** Owner 信息(当前用户在对话中的身份) */
ownerInfo?: OwnerInfo
/** 本轮显式 @ 的成员 */
mentionedMembers?: Array<{
memberId: number
platformId: string
displayName: string
aliases: string[]
mentionText: string
}>
/** 语言环境 */
locale?: string
/** 聊天记录预处理配置 */
preprocessConfig?: PreprocessConfig
}
interface AgentApi {
runStream: (
userMessage: string,
context: ToolContext,
onChunk?: (chunk: AgentStreamChunk) => void,
chatType?: 'group' | 'private',
locale?: string,
assistantId?: string,
skillId?: string | null,
enableAutoSkill?: boolean,
compressionConfig?: {
enabled: boolean
tokenThresholdPercent: number
bufferSizePercent: number
maxToolResultPercent?: number
},
thinkingLevel?: string
) => { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: SerializedErrorInfo }> }
abort: (requestId: string) => Promise<{ success: boolean; error?: string }>
}
// Agent streaming migrated to shared SSE route (useAgentStreamService)
// Assistant CRUD migrated to HTTP service layer (FetchAssistantAdapter)
// Skill CRUD migrated to HTTP service layer (FetchSkillAdapter)
@@ -615,7 +525,6 @@ declare global {
chatApi: ChatApi
aiApi: AiApi
llmApi: LlmApi
agentApi: AgentApi
cacheApi: CacheApi
networkApi: NetworkApi
sessionApi: SessionApi
+1 -7
View File
@@ -8,7 +8,7 @@ import { electronAPI } from '@electron-toolkit/preload'
// 从拆分的模块导入 API
import { extendedApi } from './apis/core'
import { chatApi } from './apis/chat'
import { aiApi, llmApi, agentApi } from './apis/ai'
import { aiApi } from './apis/ai'
import { networkApi, cacheApi, sessionApi } from './apis/utils'
import { apiServerApi } from './apis/api-server'
import { internalApi } from './apis/internal-api'
@@ -37,8 +37,6 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('api', extendedApi)
contextBridge.exposeInMainWorld('chatApi', chatApi)
contextBridge.exposeInMainWorld('aiApi', aiApi)
contextBridge.exposeInMainWorld('llmApi', llmApi)
contextBridge.exposeInMainWorld('agentApi', agentApi)
contextBridge.exposeInMainWorld('cacheApi', cacheApi)
contextBridge.exposeInMainWorld('networkApi', networkApi)
contextBridge.exposeInMainWorld('sessionApi', sessionApi)
@@ -57,10 +55,6 @@ if (process.contextIsolated) {
// @ts-ignore (define in dts)
window.aiApi = aiApi
// @ts-ignore (define in dts)
window.llmApi = llmApi
// @ts-ignore (define in dts)
window.agentApi = agentApi
// @ts-ignore (define in dts)
window.cacheApi = cacheApi
// @ts-ignore (define in dts)
window.networkApi = networkApi