mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
769115240e
commit
1af644e4ab
21 files changed
+888
-1211
No files matched your search
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 估算 ====================
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
Vendored
+3
-94
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user