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

+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