- 修复登录时VSCode弹出"账户不一致"确认框的问题 - 添加SSE业务错误码401检测,正确触发重新登录流程 - 修复侧边栏登录状态不刷新的问题,添加onDidChangeSessions监听
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
/**
|
||
* SSE 事件处理器
|
||
* 处理与后端的流式通信
|
||
* 使用 eventsource-parser + Node.js 原生 http 模块
|
||
*/
|
||
import * as http from 'http';
|
||
import * as https from 'https';
|
||
import { URL } from 'url';
|
||
import { createParser, type EventSourceParser } from 'eventsource-parser';
|
||
import { getApiUrl, getConfig } from '../config/settings';
|
||
import type {
|
||
DialogRequest,
|
||
SSEEventType,
|
||
TextDeltaEvent,
|
||
ToolCallRequest,
|
||
ToolConfirmEvent,
|
||
PlanConfirmEvent,
|
||
AskUserEvent,
|
||
CompleteEvent,
|
||
ErrorEvent,
|
||
ToolStartEvent,
|
||
ToolCompleteEvent,
|
||
ToolErrorEvent,
|
||
WarningEvent,
|
||
NotificationEvent,
|
||
DepthUpdateEvent,
|
||
AgentStartEvent,
|
||
AgentProgressEvent,
|
||
AgentCompleteEvent,
|
||
AgentErrorEvent,
|
||
ContextUsageEvent,
|
||
CreditUpdateEvent
|
||
} from '../types/api';
|
||
import type { MemoryCompactedEvent } from '../types/memory';
|
||
|
||
/**
|
||
* SSE 事件回调接口
|
||
*/
|
||
export interface SSECallbacks {
|
||
/** 收到文本增量 */
|
||
onTextDelta?: (data: TextDeltaEvent) => void;
|
||
/** 收到工具调用请求 */
|
||
onToolCall?: (data: ToolCallRequest) => void;
|
||
/** 收到工具确认请求(Ask 模式) */
|
||
onToolConfirm?: (data: ToolConfirmEvent) => void;
|
||
/** 收到计划确认请求(Plan 模式) */
|
||
onPlanConfirm?: (data: PlanConfirmEvent) => void;
|
||
/** 阶段进度更新 */
|
||
onPhaseProgress?: (data: import('../types/api').PhaseProgressEvent) => void;
|
||
/** 添加计划步骤 */
|
||
onPlanStepAdd?: (data: import('../types/api').PlanStepAddEvent) => void;
|
||
/** 删除计划步骤 */
|
||
onPlanStepRemove?: (data: import('../types/api').PlanStepRemoveEvent) => void;
|
||
/** 更新计划步骤 */
|
||
onPlanStepUpdate?: (data: import('../types/api').PlanStepUpdateEvent) => void;
|
||
/** 更新计划摘要 */
|
||
onPlanSummaryUpdate?: (data: import('../types/api').PlanSummaryUpdateEvent) => void;
|
||
/** 工具开始执行 */
|
||
onToolStart?: (data: ToolStartEvent) => void;
|
||
/** 工具执行完成 */
|
||
onToolComplete?: (data: ToolCompleteEvent) => void;
|
||
/** 工具执行错误 */
|
||
onToolError?: (data: ToolErrorEvent) => void;
|
||
/** 收到用户提问 */
|
||
onAskUser?: (data: AskUserEvent) => void;
|
||
/** 对话完成 */
|
||
onComplete?: (data: CompleteEvent) => void;
|
||
/** 错误 */
|
||
onError?: (data: ErrorEvent) => void;
|
||
/** 警告 */
|
||
onWarning?: (data: WarningEvent) => void;
|
||
/** 通知 */
|
||
onNotification?: (data: NotificationEvent) => void;
|
||
/** 深度更新 */
|
||
onDepthUpdate?: (data: DepthUpdateEvent) => void;
|
||
/** 子智能体启动 */
|
||
onAgentStart?: (data: AgentStartEvent) => void;
|
||
/** 子智能体进度 */
|
||
onAgentProgress?: (data: AgentProgressEvent) => void;
|
||
/** 子智能体完成 */
|
||
onAgentComplete?: (data: AgentCompleteEvent) => void;
|
||
/** 子智能体错误 */
|
||
onAgentError?: (data: AgentErrorEvent) => void;
|
||
/** 记忆压缩完成 */
|
||
onMemoryCompacted?: (data: MemoryCompactedEvent) => void;
|
||
/** 上下文使用量更新 */
|
||
onContextUsage?: (data: ContextUsageEvent) => void;
|
||
/** 资源点余额更新 */
|
||
onCreditUpdate?: (data: CreditUpdateEvent) => void;
|
||
/** 连接打开 */
|
||
onOpen?: () => void;
|
||
/** 连接关闭 */
|
||
onClose?: () => void;
|
||
}
|
||
|
||
/**
|
||
* SSE 会话控制器
|
||
*/
|
||
export class SSEController {
|
||
private request: http.ClientRequest | null = null;
|
||
private isConnected = false;
|
||
private isAborted = false;
|
||
|
||
/**
|
||
* 是否已连接
|
||
*/
|
||
get connected(): boolean {
|
||
return this.isConnected;
|
||
}
|
||
|
||
/**
|
||
* 设置请求对象
|
||
*/
|
||
setRequest(req: http.ClientRequest): void {
|
||
this.request = req;
|
||
}
|
||
|
||
/**
|
||
* 设置连接状态
|
||
*/
|
||
setConnected(connected: boolean): void {
|
||
this.isConnected = connected;
|
||
}
|
||
|
||
/**
|
||
* 是否已中止
|
||
*/
|
||
get aborted(): boolean {
|
||
return this.isAborted;
|
||
}
|
||
|
||
/**
|
||
* 中止当前连接
|
||
*/
|
||
abort(): void {
|
||
if (this.request && !this.isAborted) {
|
||
this.isAborted = true;
|
||
this.request.destroy();
|
||
this.request = null;
|
||
this.isConnected = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发起流式对话
|
||
* @param request 对话请求
|
||
* @param callbacks 事件回调
|
||
* @returns SSE 控制器(用于中止连接)
|
||
*/
|
||
export async function startStreamDialog(
|
||
request: DialogRequest,
|
||
callbacks: SSECallbacks
|
||
): Promise<SSEController> {
|
||
const controller = new SSEController();
|
||
|
||
const urlString = getApiUrl('/api/dialog/stream');
|
||
const url = new URL(urlString);
|
||
const isHttps = url.protocol === 'https:';
|
||
const httpModule = isHttps ? https : http;
|
||
|
||
const body = JSON.stringify(request);
|
||
|
||
console.log(`[SSE] 开始流式对话: taskId=${request.taskId}, mode=${request.mode}, url=${urlString}`);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const options: http.RequestOptions = {
|
||
hostname: url.hostname,
|
||
port: url.port || (isHttps ? 443 : 80),
|
||
path: url.pathname + url.search,
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
'Content-Length': Buffer.byteLength(body),
|
||
...(request.token ? { 'Authorization': `Bearer ${request.token}` } : {})
|
||
}
|
||
};
|
||
|
||
const req = httpModule.request(options, (res) => {
|
||
// 检查响应状态
|
||
if (res.statusCode !== 200) {
|
||
let errorBody = '';
|
||
res.on('data', chunk => errorBody += chunk);
|
||
res.on('end', () => {
|
||
// 检测是否是登录状态过期
|
||
const isLoginExpired = errorBody.includes('登录状态已过期') ||
|
||
errorBody.includes('token') && errorBody.includes('过期') ||
|
||
res.statusCode === 401;
|
||
|
||
if (isLoginExpired) {
|
||
const error = new Error('LOGIN_EXPIRED:登录状态已过期,请重新登录');
|
||
callbacks.onError?.({ message: error.message });
|
||
reject(error);
|
||
} else {
|
||
const error = new Error(`SSE 连接失败: ${res.statusCode} ${errorBody}`);
|
||
callbacks.onError?.({ message: error.message });
|
||
reject(error);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 连接成功
|
||
console.log('[SSE] 连接已建立');
|
||
controller.setConnected(true);
|
||
callbacks.onOpen?.();
|
||
resolve(controller);
|
||
|
||
// 创建 SSE 解析器
|
||
const parser = createParser({
|
||
onEvent: (event) => {
|
||
const eventType = event.event as SSEEventType;
|
||
const eventData = event.data;
|
||
|
||
if (!eventData) {
|
||
console.log(`[SSE] 收到空事件: ${eventType}`);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = JSON.parse(eventData);
|
||
console.log(`[SSE] 收到事件: ${eventType}`, data);
|
||
|
||
// 分发事件到对应回调
|
||
dispatchEvent(eventType, data, callbacks);
|
||
} catch (e) {
|
||
console.error(`[SSE] 解析事件数据失败: ${eventData}`, e);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 设置编码
|
||
res.setEncoding('utf8');
|
||
|
||
// 处理数据流
|
||
res.on('data', (chunk: string) => {
|
||
if (!controller.aborted) {
|
||
console.log('[SSE] 收到原始数据块:', chunk.substring(0, 200));
|
||
|
||
// 检查是否是业务错误码(Gateway 返回 HTTP 200 但响应体是错误 JSON)
|
||
try {
|
||
const trimmed = chunk.trim();
|
||
if (trimmed.startsWith('{') && trimmed.includes('"code"')) {
|
||
const json = JSON.parse(trimmed);
|
||
if (json.code === 401 || json.msg?.includes('登录状态已过期')) {
|
||
console.log('[SSE] 检测到登录过期业务错误');
|
||
const error = new Error('LOGIN_EXPIRED:登录状态已过期,请重新登录');
|
||
callbacks.onError?.({ message: error.message });
|
||
controller.abort();
|
||
reject(error);
|
||
return;
|
||
}
|
||
}
|
||
} catch {
|
||
// 不是 JSON 格式,继续正常处理
|
||
}
|
||
|
||
parser.feed(chunk);
|
||
}
|
||
});
|
||
|
||
// 处理连接关闭
|
||
res.on('end', () => {
|
||
console.log('[SSE] 连接已关闭');
|
||
controller.setConnected(false);
|
||
callbacks.onClose?.();
|
||
});
|
||
|
||
// 处理错误
|
||
res.on('error', (err) => {
|
||
if (!controller.aborted) {
|
||
console.error('[SSE] 响应错误:', err);
|
||
controller.setConnected(false);
|
||
callbacks.onError?.({ message: err.message });
|
||
}
|
||
});
|
||
});
|
||
|
||
// 保存请求引用用于中止
|
||
controller.setRequest(req);
|
||
|
||
// 处理请求错误
|
||
req.on('error', (err) => {
|
||
if (!controller.aborted) {
|
||
console.error('[SSE] 请求错误:', err);
|
||
controller.setConnected(false);
|
||
callbacks.onError?.({ message: err.message });
|
||
reject(err);
|
||
}
|
||
});
|
||
|
||
// 处理超时
|
||
const { timeout } = getConfig();
|
||
req.setTimeout(timeout, () => {
|
||
if (!controller.aborted) {
|
||
console.error('[SSE] 请求超时');
|
||
controller.abort();
|
||
const error = new Error('请求超时');
|
||
callbacks.onError?.({ message: error.message });
|
||
reject(error);
|
||
}
|
||
});
|
||
|
||
// 发送请求体
|
||
req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 分发 SSE 事件到对应回调
|
||
*/
|
||
function dispatchEvent(
|
||
eventType: SSEEventType,
|
||
data: unknown,
|
||
callbacks: SSECallbacks
|
||
): void {
|
||
switch (eventType) {
|
||
case 'text_delta':
|
||
callbacks.onTextDelta?.(data as TextDeltaEvent);
|
||
break;
|
||
case 'tool_call':
|
||
callbacks.onToolCall?.(data as ToolCallRequest);
|
||
break;
|
||
case 'tool_confirm':
|
||
callbacks.onToolConfirm?.(data as ToolConfirmEvent);
|
||
break;
|
||
case 'plan_confirm':
|
||
callbacks.onPlanConfirm?.(data as PlanConfirmEvent);
|
||
break;
|
||
case 'phase_progress':
|
||
callbacks.onPhaseProgress?.(data as import('../types/api').PhaseProgressEvent);
|
||
break;
|
||
case 'plan_step_add':
|
||
callbacks.onPlanStepAdd?.(data as import('../types/api').PlanStepAddEvent);
|
||
break;
|
||
case 'plan_step_remove':
|
||
callbacks.onPlanStepRemove?.(data as import('../types/api').PlanStepRemoveEvent);
|
||
break;
|
||
case 'plan_step_update':
|
||
callbacks.onPlanStepUpdate?.(data as import('../types/api').PlanStepUpdateEvent);
|
||
break;
|
||
case 'plan_summary_update':
|
||
callbacks.onPlanSummaryUpdate?.(data as import('../types/api').PlanSummaryUpdateEvent);
|
||
break;
|
||
case 'tool_start':
|
||
callbacks.onToolStart?.(data as ToolStartEvent);
|
||
break;
|
||
case 'tool_complete':
|
||
callbacks.onToolComplete?.(data as ToolCompleteEvent);
|
||
break;
|
||
case 'tool_error':
|
||
callbacks.onToolError?.(data as ToolErrorEvent);
|
||
break;
|
||
case 'ask_user':
|
||
callbacks.onAskUser?.(data as AskUserEvent);
|
||
break;
|
||
case 'complete':
|
||
callbacks.onComplete?.(data as CompleteEvent);
|
||
break;
|
||
case 'error':
|
||
callbacks.onError?.(data as ErrorEvent);
|
||
break;
|
||
case 'warning':
|
||
callbacks.onWarning?.(data as WarningEvent);
|
||
break;
|
||
case 'notification':
|
||
callbacks.onNotification?.(data as NotificationEvent);
|
||
break;
|
||
case 'depth_update':
|
||
callbacks.onDepthUpdate?.(data as DepthUpdateEvent);
|
||
break;
|
||
case 'agent_start':
|
||
callbacks.onAgentStart?.(data as AgentStartEvent);
|
||
break;
|
||
case 'agent_progress':
|
||
callbacks.onAgentProgress?.(data as AgentProgressEvent);
|
||
break;
|
||
case 'agent_complete':
|
||
callbacks.onAgentComplete?.(data as AgentCompleteEvent);
|
||
break;
|
||
case 'agent_error':
|
||
callbacks.onAgentError?.(data as AgentErrorEvent);
|
||
break;
|
||
case 'memory_compacted':
|
||
callbacks.onMemoryCompacted?.(data as MemoryCompactedEvent);
|
||
break;
|
||
case 'context_usage':
|
||
callbacks.onContextUsage?.(data as ContextUsageEvent);
|
||
break;
|
||
case 'credit_update':
|
||
callbacks.onCreditUpdate?.(data as CreditUpdateEvent);
|
||
break;
|
||
case 'heartbeat':
|
||
// 心跳事件:仅用于保持连接,不需要特殊处理
|
||
// Node.js req.setTimeout 会在收到数据时自动重置计时器
|
||
console.log('[SSE] 收到心跳');
|
||
break;
|
||
default:
|
||
console.log(`[SSE] 未知事件类型: ${eventType}`, data);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成任务ID
|
||
*/
|
||
export function generateTaskId(): string {
|
||
return `task-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
||
}
|