6342a46e59
问题:Server 端只存储最终文本响应,工具调用的中间消息丢失。 解决方案: - Agent.chat() 返回 ChatResult,包含完整消息链 - Server SessionManager 简化为只管理会话元数据 - 消息 API 改为从 Core Storage 读取 - 移除 Server 端的消息存储和 addMessage 方法 影响范围: - core: Agent.chat() 返回类型变更 - server: SessionManager 接口变更,移除消息存储 - server: GET /sessions/:id/messages 从 Core 读取 - server: 移除 POST /sessions/:id/messages 端点
523 lines
14 KiB
TypeScript
523 lines
14 KiB
TypeScript
/**
|
||
* Agent Adapter
|
||
*
|
||
* 将 core 模块的 Agent 适配到 Server 环境
|
||
* 处理流式输出、事件推送等
|
||
*
|
||
* 使用接口定义避免直接依赖 @ai-assistant/core 类型
|
||
*/
|
||
|
||
import type { SessionStatus } from '../types.js';
|
||
import { getSessionManager } from '../session/manager.js';
|
||
import { broadcastToSession } from '../ws.js';
|
||
import { emitStatusEvent, emitLogEvent } from '../sse.js';
|
||
import { createServerPermissionCallback } from '../permission/handler.js';
|
||
|
||
// ============================================================================
|
||
// Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型)
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Token 使用情况接口
|
||
*/
|
||
export interface TokenUsage {
|
||
input: number;
|
||
contextLimit: number;
|
||
available: number;
|
||
usagePercent: number;
|
||
}
|
||
|
||
/**
|
||
* 压缩结果接口
|
||
*/
|
||
export interface CompressionResult {
|
||
type: 'prune' | 'compaction' | 'both' | 'none';
|
||
status: 'success' | 'noop' | 'failed_empty_summary' | 'failed_token_inflated' | 'failed_error';
|
||
freedTokens: number;
|
||
error?: string;
|
||
originalTokens?: number;
|
||
summaryTokens?: number;
|
||
}
|
||
|
||
/**
|
||
* Chat 返回结果
|
||
*/
|
||
interface ChatResult {
|
||
text: string;
|
||
messages: unknown[];
|
||
}
|
||
|
||
/**
|
||
* Agent 实例接口
|
||
*/
|
||
interface AgentInstance {
|
||
setRegistry(registry: unknown): void;
|
||
chat(message: string, onStream?: (chunk: string) => void): Promise<ChatResult>;
|
||
getToolCount(): { core: number; discovered: number; total: number };
|
||
getContextUsageFormatted(): string;
|
||
getContextUsage(): TokenUsage;
|
||
compactHistory(): Promise<{ freedTokens: number; type: string }>;
|
||
getCompressionManager(): {
|
||
shouldCompress(messages: unknown[]): boolean;
|
||
};
|
||
getHistory(): unknown[];
|
||
}
|
||
|
||
/**
|
||
* Agent 构造函数接口
|
||
*/
|
||
interface AgentConstructor {
|
||
new (config: unknown): AgentInstance;
|
||
}
|
||
|
||
/**
|
||
* Tool Registry 接口
|
||
*/
|
||
interface ToolRegistry {
|
||
getCoreTools(): unknown[];
|
||
getAllTools(): unknown[];
|
||
}
|
||
|
||
/**
|
||
* Permission Manager 接口
|
||
*/
|
||
interface PermissionManager {
|
||
setAskCallback(callback: (ctx: unknown) => Promise<{ allow: boolean; remember?: boolean }>): void;
|
||
}
|
||
|
||
/**
|
||
* Provider Registry 接口
|
||
*/
|
||
interface ProviderRegistryInterface {
|
||
init(workdir?: string): Promise<void>;
|
||
isInitialized(): boolean;
|
||
}
|
||
|
||
/**
|
||
* Agent Registry 接口
|
||
*/
|
||
interface AgentRegistryInterface {
|
||
init(workdir: string): Promise<void>;
|
||
isInitialized(): boolean;
|
||
}
|
||
|
||
/**
|
||
* Core 模块接口
|
||
*/
|
||
interface CoreModule {
|
||
Agent: AgentConstructor;
|
||
toolRegistry: ToolRegistry;
|
||
loadConfig: () => unknown;
|
||
saveConfig: (config: Record<string, unknown>) => void;
|
||
getPermissionManager: (projectRoot?: string) => PermissionManager;
|
||
getProviderRegistry: () => ProviderRegistryInterface;
|
||
agentRegistry: AgentRegistryInterface;
|
||
}
|
||
|
||
// ============================================================================
|
||
// 模块状态
|
||
// ============================================================================
|
||
|
||
// Core 模块引用
|
||
let coreModule: CoreModule | null = null;
|
||
|
||
// Agent 实例缓存(每个 session 一个)
|
||
const agentCache: Map<string, AgentInstance> = new Map();
|
||
|
||
// 配置错误缓存(用于向客户端返回友好错误)
|
||
let lastConfigError: { provider: string; message: string } | null = null;
|
||
|
||
// ============================================================================
|
||
// 公共 API
|
||
// ============================================================================
|
||
|
||
/**
|
||
* 初始化 core 模块
|
||
*/
|
||
export async function initCore(): Promise<boolean> {
|
||
try {
|
||
// 使用变量避免 TypeScript 静态分析 import 路径
|
||
const corePath = '@ai-assistant/core';
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const core = (await import(/* webpackIgnore: true */ corePath)) as unknown as CoreModule;
|
||
|
||
// 验证模块结构
|
||
if (!core.Agent || !core.toolRegistry || !core.loadConfig) {
|
||
console.warn('[Agent] Core module missing required exports');
|
||
return false;
|
||
}
|
||
|
||
// 初始化 ProviderRegistry(加载用户配置)
|
||
const providerRegistry = core.getProviderRegistry();
|
||
if (!providerRegistry.isInitialized()) {
|
||
await providerRegistry.init();
|
||
console.log('[Agent] ProviderRegistry initialized');
|
||
}
|
||
|
||
// 初始化 AgentRegistry(加载用户自定义 Agent 配置)
|
||
const agentRegistry = core.agentRegistry;
|
||
if (!agentRegistry.isInitialized()) {
|
||
await agentRegistry.init(process.cwd());
|
||
console.log('[Agent] AgentRegistry initialized');
|
||
}
|
||
|
||
coreModule = core;
|
||
console.log('[Agent] Core module loaded');
|
||
return true;
|
||
} catch (error) {
|
||
console.warn('[Agent] Core module not available:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查 core 模块是否可用
|
||
*/
|
||
export function isCoreAvailable(): boolean {
|
||
return coreModule !== null;
|
||
}
|
||
|
||
/**
|
||
* 获取或创建 Agent 实例
|
||
*
|
||
* @returns Agent 实例,或 null(Core 不可用或配置错误)
|
||
*/
|
||
export function getOrCreateAgent(sessionId: string): AgentInstance | null {
|
||
// 清除之前的配置错误
|
||
lastConfigError = null;
|
||
|
||
if (!coreModule) {
|
||
return null;
|
||
}
|
||
|
||
// 检查缓存
|
||
if (agentCache.has(sessionId)) {
|
||
return agentCache.get(sessionId)!;
|
||
}
|
||
|
||
try {
|
||
// 创建新 Agent(可能抛出 ConfigurationError)
|
||
const config = coreModule.loadConfig();
|
||
const agent = new coreModule.Agent(config);
|
||
agent.setRegistry(coreModule.toolRegistry);
|
||
|
||
// 设置权限回调,通过 WebSocket 请求用户确认
|
||
const permissionManager = coreModule.getPermissionManager();
|
||
permissionManager.setAskCallback(createServerPermissionCallback(sessionId));
|
||
|
||
agentCache.set(sessionId, agent);
|
||
return agent;
|
||
} catch (error) {
|
||
// 检测配置错误(通过 error.name 识别,避免直接依赖 Core 类型)
|
||
if (error instanceof Error && error.name === 'ConfigurationError') {
|
||
const configError = error as Error & { provider?: string };
|
||
lastConfigError = {
|
||
provider: configError.provider || 'unknown',
|
||
message: error.message,
|
||
};
|
||
console.warn(`[Agent] Configuration error: ${error.message}`);
|
||
return null;
|
||
}
|
||
// 其他错误继续抛出
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 销毁 Agent 实例
|
||
*/
|
||
export function destroyAgent(sessionId: string): void {
|
||
agentCache.delete(sessionId);
|
||
}
|
||
|
||
/**
|
||
* 处理用户消息并流式返回响应
|
||
*/
|
||
export async function processMessage(sessionId: string, content: string): Promise<void> {
|
||
const sessionManager = getSessionManager();
|
||
|
||
// 更新状态
|
||
sessionManager.updateStatus(sessionId, 'busy' as SessionStatus);
|
||
emitStatusEvent(sessionId, 'processing', { message: '正在处理...' });
|
||
|
||
// 获取 Agent
|
||
const agent = getOrCreateAgent(sessionId);
|
||
|
||
if (!agent) {
|
||
// 检查是否为配置错误
|
||
if (lastConfigError) {
|
||
// 返回配置错误,引导用户配置 Provider
|
||
broadcastToSession(sessionId, {
|
||
type: 'error',
|
||
sessionId,
|
||
payload: {
|
||
type: 'config_error',
|
||
message: lastConfigError.message,
|
||
provider: lastConfigError.provider,
|
||
action: 'open_providers_panel',
|
||
},
|
||
});
|
||
|
||
emitLogEvent(sessionId, 'error', `配置错误: ${lastConfigError.message}`);
|
||
sessionManager.updateStatus(sessionId, 'idle' as SessionStatus);
|
||
emitStatusEvent(sessionId, 'idle');
|
||
return;
|
||
}
|
||
|
||
// Core 模块不可用,返回占位响应
|
||
const errorContent = 'Agent core module not available. Please build @ai-assistant/core first.';
|
||
broadcastToSession(sessionId, {
|
||
type: 'chunk',
|
||
sessionId,
|
||
payload: { content: errorContent },
|
||
});
|
||
|
||
broadcastToSession(sessionId, {
|
||
type: 'done',
|
||
sessionId,
|
||
payload: { text: errorContent, hasToolCalls: false, messageCount: 0 },
|
||
});
|
||
|
||
sessionManager.updateStatus(sessionId, 'idle' as SessionStatus);
|
||
emitStatusEvent(sessionId, 'idle');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 调用 Agent 的 chat 方法,使用流式回调
|
||
const result = await agent.chat(content, (chunk: string) => {
|
||
// 推送流式内容
|
||
broadcastToSession(sessionId, {
|
||
type: 'chunk',
|
||
sessionId,
|
||
payload: { content: chunk },
|
||
});
|
||
|
||
// 检测工具调用
|
||
if (chunk.includes('[调用工具:')) {
|
||
const match = chunk.match(/\[调用工具: (.+?)\]/);
|
||
if (match) {
|
||
emitLogEvent(sessionId, 'info', `调用工具: ${match[1]}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 消息已由 Core Agent 自动持久化,这里只更新 Server 端的会话计数
|
||
const session = sessionManager.get(sessionId);
|
||
if (session) {
|
||
// 从 Agent 获取实际消息数
|
||
const history = agent.getHistory();
|
||
session.messageCount = history.length;
|
||
session.updatedAt = new Date().toISOString();
|
||
}
|
||
|
||
// 检查是否有工具调用
|
||
const hasToolCalls = result.messages.some((m: unknown) => {
|
||
const msg = m as { content?: unknown };
|
||
return Array.isArray(msg.content) && msg.content.some((c: unknown) => {
|
||
const block = c as { type?: string };
|
||
return block.type === 'tool-call';
|
||
});
|
||
});
|
||
|
||
// 发送完成消息
|
||
broadcastToSession(sessionId, {
|
||
type: 'done',
|
||
sessionId,
|
||
payload: {
|
||
text: result.text,
|
||
hasToolCalls,
|
||
messageCount: result.messages.length,
|
||
},
|
||
});
|
||
|
||
// 检查是否需要生成会话标题(首次对话完成后)
|
||
if (session && !session.name) {
|
||
// 异步生成标题,不阻塞响应
|
||
generateSessionTitle(sessionId, content, result.text).catch(err => {
|
||
console.error('[Agent] Failed to generate session title:', err);
|
||
});
|
||
}
|
||
|
||
emitStatusEvent(sessionId, 'idle');
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
|
||
// 发送错误
|
||
broadcastToSession(sessionId, {
|
||
type: 'error',
|
||
sessionId,
|
||
payload: { message: errorMessage },
|
||
});
|
||
|
||
emitLogEvent(sessionId, 'error', errorMessage);
|
||
} finally {
|
||
sessionManager.updateStatus(sessionId, 'idle' as SessionStatus);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消正在进行的处理
|
||
*/
|
||
export function cancelProcessing(sessionId: string): void {
|
||
// TODO: 实现取消逻辑
|
||
// 目前 AI SDK 的 streamText 不支持取消
|
||
const sessionManager = getSessionManager();
|
||
sessionManager.updateStatus(sessionId, 'idle' as SessionStatus);
|
||
emitStatusEvent(sessionId, 'cancelled');
|
||
}
|
||
|
||
/**
|
||
* 获取 Agent 统计信息
|
||
*/
|
||
export function getAgentStats(sessionId: string): {
|
||
available: boolean;
|
||
toolCount?: { core: number; discovered: number; total: number };
|
||
contextUsage?: string;
|
||
} {
|
||
if (!coreModule) {
|
||
return { available: false };
|
||
}
|
||
|
||
const agent = agentCache.get(sessionId);
|
||
if (!agent) {
|
||
return { available: true };
|
||
}
|
||
|
||
return {
|
||
available: true,
|
||
toolCount: agent.getToolCount(),
|
||
contextUsage: agent.getContextUsageFormatted(),
|
||
};
|
||
}
|
||
|
||
// ============================================================================
|
||
// 会话标题生成
|
||
// ============================================================================
|
||
|
||
/**
|
||
* 从用户消息中提取简短标题
|
||
* 使用简单的启发式方法,不依赖 LLM
|
||
*/
|
||
function extractTitleFromMessage(userMessage: string): string {
|
||
// 移除多余空白
|
||
const cleaned = userMessage.trim().replace(/\s+/g, ' ');
|
||
|
||
// 如果消息很短,直接使用
|
||
if (cleaned.length <= 30) {
|
||
return cleaned;
|
||
}
|
||
|
||
// 尝试提取第一句话
|
||
const firstSentence = cleaned.match(/^[^。!?.!?]+[。!?.!?]?/)?.[0] || cleaned;
|
||
|
||
// 如果第一句话也很长,截断
|
||
if (firstSentence.length > 40) {
|
||
return firstSentence.slice(0, 37) + '...';
|
||
}
|
||
|
||
return firstSentence;
|
||
}
|
||
|
||
/**
|
||
* 生成会话标题并更新
|
||
*/
|
||
async function generateSessionTitle(
|
||
sessionId: string,
|
||
userMessage: string,
|
||
_assistantResponse: string
|
||
): Promise<void> {
|
||
const sessionManager = getSessionManager();
|
||
|
||
// 使用简单提取方式生成标题
|
||
const title = extractTitleFromMessage(userMessage);
|
||
|
||
// 更新会话标题
|
||
const updatedSession = await sessionManager.updateSessionName(sessionId, title);
|
||
|
||
if (updatedSession) {
|
||
// 广播标题更新事件
|
||
broadcastToSession(sessionId, {
|
||
type: 'session_updated',
|
||
sessionId,
|
||
payload: {
|
||
id: updatedSession.id,
|
||
name: updatedSession.name,
|
||
},
|
||
});
|
||
|
||
console.log(`[Agent] Session title generated: "${title}"`);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 上下文压缩 API
|
||
// ============================================================================
|
||
|
||
/**
|
||
* 上下文使用情况(带额外字段)
|
||
*/
|
||
export interface ContextUsageInfo extends TokenUsage {
|
||
formatted: string;
|
||
shouldCompress: boolean;
|
||
}
|
||
|
||
/**
|
||
* 获取会话的上下文使用情况
|
||
*/
|
||
export function getContextUsage(sessionId: string): ContextUsageInfo | null {
|
||
if (!coreModule) {
|
||
return null;
|
||
}
|
||
|
||
const agent = agentCache.get(sessionId);
|
||
if (!agent) {
|
||
return null;
|
||
}
|
||
|
||
const usage = agent.getContextUsage();
|
||
const formatted = agent.getContextUsageFormatted();
|
||
|
||
return {
|
||
...usage,
|
||
formatted,
|
||
shouldCompress: usage.usagePercent >= 80, // 80% 阈值建议压缩
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 执行上下文压缩
|
||
*/
|
||
export async function compressContext(
|
||
sessionId: string,
|
||
force: boolean = false
|
||
): Promise<CompressionResult | null> {
|
||
if (!coreModule) {
|
||
return null;
|
||
}
|
||
|
||
const agent = agentCache.get(sessionId);
|
||
if (!agent) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
// 使用强制压缩或普通压缩
|
||
const result = await agent.compactHistory();
|
||
|
||
return {
|
||
type: result.type as CompressionResult['type'],
|
||
status: result.freedTokens > 0 ? 'success' : 'noop',
|
||
freedTokens: result.freedTokens,
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
type: 'none',
|
||
status: 'failed_error',
|
||
freedTokens: 0,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
}
|
||
|