Files
ai-terminal-assistant/packages/server/src/agent/adapter.ts
T
kurihada 6342a46e59 refactor(storage): 统一消息存储到 Core 层
问题: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 端点
2025-12-15 10:04:22 +08:00

523 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 实例,或 nullCore 不可用或配置错误)
*/
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),
};
}
}