refactor(core): 拆分大型单体文件为模块化子组件
将三个超过 700 行的大型文件重构为模块化架构: Agent (1033 → ~400 行): - agent-tool-executor: 工具获取、过滤和执行 - agent-message-handler: 消息构建、流式处理 - agent-mode-manager: 模式切换和权限检查 - agent-vision-handler: 视觉处理委托 CheckpointManager (1015 → ~620 行): - checkpoint-store: 检查点 CRUD 操作 - checkpoint-rollback: 回滚和撤销操作 - checkpoint-session: 会话跟踪 - checkpoint-events: 事件发射系统 SessionManager (768 → 356 行): - message-converter: Part ↔ ModelMessage 转换 - session-store: 会话 CRUD 操作 - project-manager: 项目管理 - session-auto-save: 自动保存功能 重构原则: 单一职责、编排器模式、向后兼容 API
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 检查点事件系统
|
||||||
|
* 负责事件的发布和订阅
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CheckpointEvent,
|
||||||
|
CheckpointEventListener,
|
||||||
|
CheckpointMetadata,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查点事件管理器
|
||||||
|
*/
|
||||||
|
export class CheckpointEvents {
|
||||||
|
private listeners: Set<CheckpointEventListener> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加事件监听器
|
||||||
|
*/
|
||||||
|
addEventListener(listener: CheckpointEventListener): void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除事件监听器
|
||||||
|
*/
|
||||||
|
removeEventListener(listener: CheckpointEventListener): void {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发事件
|
||||||
|
*/
|
||||||
|
emit(event: CheckpointEvent): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Checkpoint event listener error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发创建事件
|
||||||
|
*/
|
||||||
|
emitCreated(checkpoint: CheckpointMetadata): void {
|
||||||
|
this.emit({
|
||||||
|
type: 'created',
|
||||||
|
checkpoint,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发恢复事件
|
||||||
|
*/
|
||||||
|
emitRestored(
|
||||||
|
checkpoint: CheckpointMetadata,
|
||||||
|
details: {
|
||||||
|
files: string[];
|
||||||
|
previousCommit: string;
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
this.emit({
|
||||||
|
type: 'restored',
|
||||||
|
checkpoint,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发删除事件
|
||||||
|
*/
|
||||||
|
emitDeleted(checkpoint: CheckpointMetadata): void {
|
||||||
|
this.emit({
|
||||||
|
type: 'deleted',
|
||||||
|
checkpoint,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发清理事件
|
||||||
|
*/
|
||||||
|
emitCleanup(deletedCount: number): void {
|
||||||
|
this.emit({
|
||||||
|
type: 'cleanup',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
details: { deletedCount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取监听器数量
|
||||||
|
*/
|
||||||
|
get listenerCount(): number {
|
||||||
|
return this.listeners.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有监听器
|
||||||
|
*/
|
||||||
|
clearListeners(): void {
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* 检查点回滚
|
||||||
|
* 负责回滚相关操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { ShadowGit } from './shadow-git.js';
|
||||||
|
import type { CheckpointLock } from './lock.js';
|
||||||
|
import type { CheckpointSafetyChecker } from './safety.js';
|
||||||
|
import type { CheckpointStore } from './checkpoint-store.js';
|
||||||
|
import {
|
||||||
|
RestoreMode,
|
||||||
|
type CheckpointMetadata,
|
||||||
|
type RollbackOptions,
|
||||||
|
type RollbackResult,
|
||||||
|
type RollbackRecord,
|
||||||
|
type UnrevertResult,
|
||||||
|
type DiffInfo,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查点回滚
|
||||||
|
*/
|
||||||
|
export class CheckpointRollback {
|
||||||
|
private shadowGit: ShadowGit;
|
||||||
|
private lock: CheckpointLock;
|
||||||
|
private safetyChecker: CheckpointSafetyChecker;
|
||||||
|
private store: CheckpointStore;
|
||||||
|
private lastRollback: RollbackRecord | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
shadowGit: ShadowGit,
|
||||||
|
lock: CheckpointLock,
|
||||||
|
safetyChecker: CheckpointSafetyChecker,
|
||||||
|
store: CheckpointStore
|
||||||
|
) {
|
||||||
|
this.shadowGit = shadowGit;
|
||||||
|
this.lock = lock;
|
||||||
|
this.safetyChecker = safetyChecker;
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚到指定检查点
|
||||||
|
*/
|
||||||
|
async rollback(
|
||||||
|
options: RollbackOptions,
|
||||||
|
onEvent?: (event: { type: string; checkpoint: CheckpointMetadata; details?: unknown }) => void
|
||||||
|
): Promise<RollbackResult> {
|
||||||
|
const checkpoint = this.store.get(options.target);
|
||||||
|
if (!checkpoint) {
|
||||||
|
throw new Error(`Checkpoint not found: ${options.target}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全检查(除非明确跳过)
|
||||||
|
if (!options.skipSafetyCheck) {
|
||||||
|
const safetyResult = await this.safetyChecker.checkBeforeRollback(checkpoint, {
|
||||||
|
getCheckpoint: (id: string) => Promise.resolve(this.store.get(id)),
|
||||||
|
listCheckpoints: () => Promise.resolve(this.store.list()),
|
||||||
|
getDiff: (checkpointId: string) => this.getDiffById(checkpointId),
|
||||||
|
});
|
||||||
|
if (!safetyResult.safe) {
|
||||||
|
const errorMsg = safetyResult.errors.join('; ');
|
||||||
|
throw new Error(`Safety check failed: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
if (safetyResult.warnings.length > 0) {
|
||||||
|
console.warn('Rollback warnings:', safetyResult.warnings.join('; '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用锁保护回滚操作
|
||||||
|
return this.lock.withLock(async () => {
|
||||||
|
const previousCommit = await this.shadowGit.getHead();
|
||||||
|
|
||||||
|
// 预览模式
|
||||||
|
if (options.dryRun) {
|
||||||
|
const diff = await this.getDiff(checkpoint);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
restoredFiles: diff.files.map((f) => f.path),
|
||||||
|
errors: [],
|
||||||
|
previousCommit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建回滚前检查点(用于 unrevert)
|
||||||
|
let preRollbackCheckpoint: CheckpointMetadata | null = null;
|
||||||
|
try {
|
||||||
|
preRollbackCheckpoint = await this.store.createInternal({
|
||||||
|
trigger: 'pre_rollback',
|
||||||
|
description: `Before rollback to ${options.target}`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 忽略创建失败
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: RollbackResult = {
|
||||||
|
success: true,
|
||||||
|
restoredFiles: [],
|
||||||
|
errors: [],
|
||||||
|
previousCommit,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mode = options.mode || RestoreMode.FULL;
|
||||||
|
|
||||||
|
if (options.files && options.files.length > 0) {
|
||||||
|
// 选择性回滚(指定文件)
|
||||||
|
await this.shadowGit.checkoutFiles(checkpoint.commitHash, options.files);
|
||||||
|
result.restoredFiles = options.files;
|
||||||
|
} else if (mode === RestoreMode.AI_CHANGES_ONLY) {
|
||||||
|
// 仅恢复 AI 修改的文件
|
||||||
|
const aiFiles = await this.getAiModifiedFiles(checkpoint);
|
||||||
|
if (aiFiles.length > 0) {
|
||||||
|
await this.shadowGit.checkoutFiles(checkpoint.commitHash, aiFiles);
|
||||||
|
result.restoredFiles = aiFiles;
|
||||||
|
}
|
||||||
|
} else if (mode === RestoreMode.WORKSPACE_ONLY) {
|
||||||
|
// 仅恢复工作区变更(不包括 AI 修改)
|
||||||
|
const workspaceFiles = await this.getWorkspaceOnlyFiles(checkpoint);
|
||||||
|
if (workspaceFiles.length > 0) {
|
||||||
|
await this.shadowGit.checkoutFiles(checkpoint.commitHash, workspaceFiles);
|
||||||
|
result.restoredFiles = workspaceFiles;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 完整回滚
|
||||||
|
await this.shadowGit.resetHard(checkpoint.commitHash);
|
||||||
|
|
||||||
|
// 获取恢复的文件列表
|
||||||
|
const diff = await this.shadowGit.getDiffSummary(
|
||||||
|
previousCommit,
|
||||||
|
checkpoint.commitHash
|
||||||
|
);
|
||||||
|
result.restoredFiles = diff.files.map((f) => f.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录回滚信息(用于 unrevert)
|
||||||
|
this.lastRollback = {
|
||||||
|
id: nanoid(10),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
targetCheckpoint: checkpoint.id,
|
||||||
|
previousCommit: preRollbackCheckpoint?.commitHash || previousCommit,
|
||||||
|
restoredFiles: result.restoredFiles,
|
||||||
|
canUnrevert: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 触发事件
|
||||||
|
onEvent?.({
|
||||||
|
type: 'restored',
|
||||||
|
checkpoint,
|
||||||
|
details: {
|
||||||
|
files: result.restoredFiles,
|
||||||
|
previousCommit,
|
||||||
|
mode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
result.success = false;
|
||||||
|
result.errors.push({
|
||||||
|
file: '*',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销操作(回滚到上一个检查点)
|
||||||
|
*/
|
||||||
|
async undo(
|
||||||
|
onEvent?: (event: { type: string; checkpoint: CheckpointMetadata; details?: unknown }) => void
|
||||||
|
): Promise<RollbackResult> {
|
||||||
|
const latest = this.store.getLatest();
|
||||||
|
if (!latest) {
|
||||||
|
throw new Error('No checkpoints available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkpoints = this.store.list();
|
||||||
|
if (checkpoints.length < 2) {
|
||||||
|
return this.rollback({ target: latest.id }, onEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.rollback({ target: checkpoints[1].id }, onEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销最近一次回滚
|
||||||
|
*/
|
||||||
|
async unrevert(): Promise<UnrevertResult> {
|
||||||
|
if (!this.lastRollback || !this.lastRollback.canUnrevert) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
restoredCommit: '',
|
||||||
|
filesRestored: 0,
|
||||||
|
error: 'No rollback to unrevert',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.lock.withLock(async () => {
|
||||||
|
try {
|
||||||
|
await this.shadowGit.resetHard(this.lastRollback!.previousCommit);
|
||||||
|
|
||||||
|
const result: UnrevertResult = {
|
||||||
|
success: true,
|
||||||
|
restoredCommit: this.lastRollback!.previousCommit,
|
||||||
|
filesRestored: this.lastRollback!.restoredFiles.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastRollback = null;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
restoredCommit: '',
|
||||||
|
filesRestored: 0,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以 unrevert
|
||||||
|
*/
|
||||||
|
canUnrevert(): boolean {
|
||||||
|
return this.lastRollback !== null && this.lastRollback.canUnrevert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最后一次回滚记录
|
||||||
|
*/
|
||||||
|
getLastRollback(): RollbackRecord | null {
|
||||||
|
return this.lastRollback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取检查点与当前工作区的差异
|
||||||
|
*/
|
||||||
|
private async getDiff(checkpoint: CheckpointMetadata): Promise<DiffInfo> {
|
||||||
|
return this.shadowGit.getDiffSummary(checkpoint.commitHash, 'HEAD');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 获取检查点与当前工作区的差异
|
||||||
|
*/
|
||||||
|
private async getDiffById(checkpointId: string): Promise<DiffInfo> {
|
||||||
|
const checkpoint = this.store.get(checkpointId);
|
||||||
|
if (!checkpoint) {
|
||||||
|
return { from: '', to: 'HEAD', files: [], totalInsertions: 0, totalDeletions: 0 };
|
||||||
|
}
|
||||||
|
return this.getDiff(checkpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AI 修改的文件列表
|
||||||
|
*/
|
||||||
|
private async getAiModifiedFiles(checkpoint: CheckpointMetadata): Promise<string[]> {
|
||||||
|
const files: string[] = [];
|
||||||
|
const checkpoints = this.store.list();
|
||||||
|
|
||||||
|
for (const cp of checkpoints) {
|
||||||
|
if (cp.timestamp > checkpoint.timestamp && cp.toolCall) {
|
||||||
|
const filePath =
|
||||||
|
(cp.toolCall.params.file_path as string) ||
|
||||||
|
(cp.toolCall.params.path as string);
|
||||||
|
if (filePath && !files.includes(filePath)) {
|
||||||
|
files.push(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取仅工作区变更的文件(不包括 AI 修改)
|
||||||
|
*/
|
||||||
|
private async getWorkspaceOnlyFiles(checkpoint: CheckpointMetadata): Promise<string[]> {
|
||||||
|
const diff = await this.getDiff(checkpoint);
|
||||||
|
const aiFiles = await this.getAiModifiedFiles(checkpoint);
|
||||||
|
|
||||||
|
return diff.files
|
||||||
|
.map((f) => f.path)
|
||||||
|
.filter((path) => !aiFiles.includes(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* 检查点会话追踪
|
||||||
|
* 负责会话与检查点的关联
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { CheckpointStore } from './checkpoint-store.js';
|
||||||
|
import type { CheckpointMetadata, CheckpointTrigger } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查点会话追踪
|
||||||
|
*/
|
||||||
|
export class CheckpointSession {
|
||||||
|
private store: CheckpointStore;
|
||||||
|
private currentSessionId: string | null = null;
|
||||||
|
private sessionCheckpoints: Map<string, string[]> = new Map();
|
||||||
|
|
||||||
|
constructor(store: CheckpointStore) {
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始新会话
|
||||||
|
*/
|
||||||
|
async startSession(
|
||||||
|
sessionId?: string,
|
||||||
|
onEvent?: (event: { type: string; checkpoint?: CheckpointMetadata }) => void
|
||||||
|
): Promise<string> {
|
||||||
|
const id = sessionId || nanoid(10);
|
||||||
|
this.currentSessionId = id;
|
||||||
|
this.sessionCheckpoints.set(id, []);
|
||||||
|
|
||||||
|
// 创建会话开始检查点
|
||||||
|
try {
|
||||||
|
const checkpoint = await this.store.create(
|
||||||
|
{
|
||||||
|
trigger: 'session_start',
|
||||||
|
description: `Session started: ${id}`,
|
||||||
|
sessionId: id,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
(checkpointId) => {
|
||||||
|
this.recordCheckpoint(checkpointId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
onEvent?.({ type: 'created', checkpoint });
|
||||||
|
} catch {
|
||||||
|
// 忽略创建失败
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束当前会话
|
||||||
|
*/
|
||||||
|
async endSession(
|
||||||
|
onEvent?: (event: { type: string; checkpoint?: CheckpointMetadata }) => void
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.currentSessionId) return;
|
||||||
|
|
||||||
|
// 创建会话结束检查点
|
||||||
|
try {
|
||||||
|
const checkpoint = await this.store.create(
|
||||||
|
{
|
||||||
|
trigger: 'session_end',
|
||||||
|
description: `Session ended: ${this.currentSessionId}`,
|
||||||
|
sessionId: this.currentSessionId,
|
||||||
|
},
|
||||||
|
this.currentSessionId,
|
||||||
|
(checkpointId) => {
|
||||||
|
this.recordCheckpoint(checkpointId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
onEvent?.({ type: 'created', checkpoint });
|
||||||
|
} catch {
|
||||||
|
// 忽略创建失败
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSessionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前会话 ID
|
||||||
|
*/
|
||||||
|
getCurrentSessionId(): string | null {
|
||||||
|
return this.currentSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前会话 ID(用于外部恢复)
|
||||||
|
*/
|
||||||
|
setCurrentSessionId(sessionId: string | null): void {
|
||||||
|
this.currentSessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话的所有检查点
|
||||||
|
*/
|
||||||
|
getSessionCheckpoints(sessionId: string): CheckpointMetadata[] {
|
||||||
|
const checkpointIds = this.sessionCheckpoints.get(sessionId);
|
||||||
|
if (!checkpointIds) return [];
|
||||||
|
|
||||||
|
const checkpoints: CheckpointMetadata[] = [];
|
||||||
|
for (const id of checkpointIds) {
|
||||||
|
const checkpoint = this.store.get(id);
|
||||||
|
if (checkpoint) {
|
||||||
|
checkpoints.push(checkpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkpoints.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建与消息关联的检查点
|
||||||
|
*/
|
||||||
|
async createMessageCheckpoint(
|
||||||
|
messageId: string,
|
||||||
|
turnIndex?: number,
|
||||||
|
options?: {
|
||||||
|
trigger?: CheckpointTrigger;
|
||||||
|
description?: string;
|
||||||
|
},
|
||||||
|
onEvent?: (event: { type: string; checkpoint: CheckpointMetadata }) => void
|
||||||
|
): Promise<CheckpointMetadata> {
|
||||||
|
const checkpoint = await this.store.create(
|
||||||
|
{
|
||||||
|
trigger: options?.trigger || 'auto',
|
||||||
|
description: options?.description || `Message checkpoint: ${messageId}`,
|
||||||
|
messageId,
|
||||||
|
sessionId: this.currentSessionId || undefined,
|
||||||
|
turnIndex,
|
||||||
|
},
|
||||||
|
this.currentSessionId,
|
||||||
|
(checkpointId) => {
|
||||||
|
this.recordCheckpoint(checkpointId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onEvent?.({ type: 'created', checkpoint });
|
||||||
|
|
||||||
|
return checkpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取与消息关联的检查点
|
||||||
|
*/
|
||||||
|
getMessageCheckpoints(messageId: string): CheckpointMetadata[] {
|
||||||
|
const checkpoints: CheckpointMetadata[] = [];
|
||||||
|
for (const checkpoint of this.store.list()) {
|
||||||
|
if (checkpoint.messageId === messageId) {
|
||||||
|
checkpoints.push(checkpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkpoints.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录检查点到当前会话
|
||||||
|
*/
|
||||||
|
recordCheckpoint(checkpointId: string): void {
|
||||||
|
if (!this.currentSessionId) return;
|
||||||
|
|
||||||
|
const sessionCps = this.sessionCheckpoints.get(this.currentSessionId) || [];
|
||||||
|
sessionCps.push(checkpointId);
|
||||||
|
this.sessionCheckpoints.set(this.currentSessionId, sessionCps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话开始的检查点
|
||||||
|
*/
|
||||||
|
getSessionStartCheckpoint(sessionId: string): CheckpointMetadata | null {
|
||||||
|
const checkpoints = this.getSessionCheckpoints(sessionId);
|
||||||
|
return checkpoints.find((cp) => cp.trigger === 'session_start') || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* 检查点存储
|
||||||
|
* 负责检查点的 CRUD 操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { ShadowGit } from './shadow-git.js';
|
||||||
|
import type { CheckpointLock } from './lock.js';
|
||||||
|
import type { CommitMessageGenerator } from './commit-message.js';
|
||||||
|
import type {
|
||||||
|
CheckpointMetadata,
|
||||||
|
CheckpointConfig,
|
||||||
|
CheckpointTrigger,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查点提交消息前缀
|
||||||
|
*/
|
||||||
|
const CHECKPOINT_PREFIX = 'checkpoint:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建检查点选项
|
||||||
|
*/
|
||||||
|
export interface CreateCheckpointOptions {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
trigger?: CheckpointTrigger;
|
||||||
|
toolCall?: { tool: string; params: Record<string, unknown> };
|
||||||
|
messageId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
turnIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查点存储
|
||||||
|
*/
|
||||||
|
export class CheckpointStore {
|
||||||
|
private shadowGit: ShadowGit;
|
||||||
|
private lock: CheckpointLock;
|
||||||
|
private commitMessageGenerator: CommitMessageGenerator;
|
||||||
|
private config: CheckpointConfig;
|
||||||
|
private index: Map<string, CheckpointMetadata> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
shadowGit: ShadowGit,
|
||||||
|
lock: CheckpointLock,
|
||||||
|
commitMessageGenerator: CommitMessageGenerator,
|
||||||
|
config: CheckpointConfig
|
||||||
|
) {
|
||||||
|
this.shadowGit = shadowGit;
|
||||||
|
this.lock = lock;
|
||||||
|
this.commitMessageGenerator = commitMessageGenerator;
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Git 历史加载检查点索引
|
||||||
|
*/
|
||||||
|
async loadIndex(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const commits = await this.shadowGit.getCommits(this.config.maxCheckpoints);
|
||||||
|
|
||||||
|
for (const commit of commits) {
|
||||||
|
if (commit.message.startsWith(CHECKPOINT_PREFIX)) {
|
||||||
|
try {
|
||||||
|
const jsonStr = commit.message.slice(CHECKPOINT_PREFIX.length);
|
||||||
|
const metadata = JSON.parse(jsonStr) as CheckpointMetadata;
|
||||||
|
metadata.commitHash = commit.hash;
|
||||||
|
this.index.set(metadata.id, metadata);
|
||||||
|
} catch {
|
||||||
|
// 解析失败,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 仓库可能是空的
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建检查点
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
options: CreateCheckpointOptions,
|
||||||
|
currentSessionId?: string | null,
|
||||||
|
onCheckpointCreated?: (id: string) => void
|
||||||
|
): Promise<CheckpointMetadata> {
|
||||||
|
return this.lock.withLock(async () => {
|
||||||
|
return this.createInternal(options, currentSessionId, onCheckpointCreated);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建内部检查点(不使用锁,供内部调用)
|
||||||
|
*/
|
||||||
|
async createInternal(
|
||||||
|
options: CreateCheckpointOptions,
|
||||||
|
currentSessionId?: string | null,
|
||||||
|
onCheckpointCreated?: (id: string) => void
|
||||||
|
): Promise<CheckpointMetadata> {
|
||||||
|
const id = nanoid(10);
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const trigger = options.trigger || 'manual';
|
||||||
|
|
||||||
|
// 创建元数据
|
||||||
|
const metadata: CheckpointMetadata = {
|
||||||
|
id,
|
||||||
|
name: options.name,
|
||||||
|
description: options.description,
|
||||||
|
timestamp,
|
||||||
|
trigger,
|
||||||
|
toolCall: options.toolCall,
|
||||||
|
commitHash: '',
|
||||||
|
filesChanged: 0,
|
||||||
|
messageId: options.messageId,
|
||||||
|
sessionId: options.sessionId || currentSessionId || undefined,
|
||||||
|
turnIndex: options.turnIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取变更文件数
|
||||||
|
let filesChanged: Array<{ path: string; type: string }> = [];
|
||||||
|
try {
|
||||||
|
const diff = await this.shadowGit.getWorkingDirDiff();
|
||||||
|
metadata.filesChanged = diff.files.length;
|
||||||
|
filesChanged = diff.files;
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成智能提交消息
|
||||||
|
const humanReadableMessage = this.commitMessageGenerator.generateMessage(
|
||||||
|
trigger,
|
||||||
|
options.toolCall,
|
||||||
|
filesChanged as Array<{ path: string; type: 'added' | 'modified' | 'deleted' | 'renamed' }>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建 commit
|
||||||
|
const commitMessage = CHECKPOINT_PREFIX + JSON.stringify({
|
||||||
|
...metadata,
|
||||||
|
_readableMessage: humanReadableMessage,
|
||||||
|
});
|
||||||
|
const commitHash = await this.shadowGit.createCommit(commitMessage);
|
||||||
|
metadata.commitHash = commitHash;
|
||||||
|
|
||||||
|
// 更新索引
|
||||||
|
this.index.set(id, metadata);
|
||||||
|
|
||||||
|
// 通知回调
|
||||||
|
onCheckpointCreated?.(id);
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出所有检查点
|
||||||
|
*/
|
||||||
|
list(): CheckpointMetadata[] {
|
||||||
|
return Array.from(this.index.values()).sort(
|
||||||
|
(a, b) => b.timestamp - a.timestamp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取检查点
|
||||||
|
*/
|
||||||
|
get(idOrHash: string): CheckpointMetadata | null {
|
||||||
|
// 先按 ID 查找
|
||||||
|
if (this.index.has(idOrHash)) {
|
||||||
|
return this.index.get(idOrHash)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再按 commit hash 查找
|
||||||
|
for (const checkpoint of this.index.values()) {
|
||||||
|
if (checkpoint.commitHash.startsWith(idOrHash)) {
|
||||||
|
return checkpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新检查点
|
||||||
|
*/
|
||||||
|
getLatest(): CheckpointMetadata | null {
|
||||||
|
const checkpoints = this.list();
|
||||||
|
return checkpoints[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除检查点
|
||||||
|
*/
|
||||||
|
delete(checkpointId: string): CheckpointMetadata | null {
|
||||||
|
const checkpoint = this.index.get(checkpointId);
|
||||||
|
if (!checkpoint) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.index.delete(checkpointId);
|
||||||
|
return checkpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否应该为指定工具创建检查点
|
||||||
|
*/
|
||||||
|
shouldCreateForTool(tool: string): boolean {
|
||||||
|
if (!this.config.enabled) return false;
|
||||||
|
|
||||||
|
const { autoCheckpoint } = this.config;
|
||||||
|
|
||||||
|
switch (tool) {
|
||||||
|
case 'write_file':
|
||||||
|
return autoCheckpoint.beforeWrite;
|
||||||
|
case 'edit_file':
|
||||||
|
return autoCheckpoint.beforeEdit;
|
||||||
|
case 'delete_file':
|
||||||
|
return autoCheckpoint.beforeDelete;
|
||||||
|
case 'move_file':
|
||||||
|
case 'copy_file':
|
||||||
|
return autoCheckpoint.beforeMove;
|
||||||
|
case 'bash':
|
||||||
|
return autoCheckpoint.beforeBash;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成检查点描述
|
||||||
|
*/
|
||||||
|
generateDescription(tool: string, params: Record<string, unknown>): string {
|
||||||
|
switch (tool) {
|
||||||
|
case 'write_file':
|
||||||
|
return `Write file: ${params.file_path || params.path}`;
|
||||||
|
case 'edit_file':
|
||||||
|
return `Edit file: ${params.file_path || params.path}`;
|
||||||
|
case 'delete_file':
|
||||||
|
return `Delete file: ${params.file_path || params.path}`;
|
||||||
|
case 'move_file':
|
||||||
|
return `Move: ${params.source} -> ${params.destination}`;
|
||||||
|
case 'copy_file':
|
||||||
|
return `Copy: ${params.source} -> ${params.destination}`;
|
||||||
|
case 'bash':
|
||||||
|
return `Bash: ${String(params.command).slice(0, 50)}`;
|
||||||
|
default:
|
||||||
|
return `Tool: ${tool}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取检查点数量
|
||||||
|
*/
|
||||||
|
get size(): number {
|
||||||
|
return this.index.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置
|
||||||
|
*/
|
||||||
|
getConfig(): CheckpointConfig {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,20 @@
|
|||||||
|
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import type { CheckpointManager } from './manager.js';
|
import type { SafetyCheckResult, CheckpointMetadata, DiffInfo } from './types.js';
|
||||||
import type { SafetyCheckResult, CheckpointMetadata } from './types.js';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全检查器所需的 CheckpointManager 接口
|
||||||
|
* 使用接口而非完整类以避免循环依赖
|
||||||
|
*/
|
||||||
|
export interface SafetyCheckManagerInterface {
|
||||||
|
listCheckpoints(): Promise<CheckpointMetadata[]>;
|
||||||
|
getDiff(checkpointId: string): Promise<DiffInfo>;
|
||||||
|
getCheckpoint(idOrHash: string): Promise<CheckpointMetadata | null>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查点安全检查器
|
* 检查点安全检查器
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +34,7 @@ export class CheckpointSafetyChecker {
|
|||||||
*/
|
*/
|
||||||
async checkBeforeRollback(
|
async checkBeforeRollback(
|
||||||
checkpoint: CheckpointMetadata,
|
checkpoint: CheckpointMetadata,
|
||||||
manager: CheckpointManager
|
manager: SafetyCheckManagerInterface
|
||||||
): Promise<SafetyCheckResult> {
|
): Promise<SafetyCheckResult> {
|
||||||
const result: SafetyCheckResult = {
|
const result: SafetyCheckResult = {
|
||||||
safe: true,
|
safe: true,
|
||||||
@@ -87,7 +96,7 @@ export class CheckpointSafetyChecker {
|
|||||||
/**
|
/**
|
||||||
* 检查工作区是否有未保存的变更
|
* 检查工作区是否有未保存的变更
|
||||||
*/
|
*/
|
||||||
private async hasUnsavedChanges(manager: CheckpointManager): Promise<boolean> {
|
private async hasUnsavedChanges(manager: SafetyCheckManagerInterface): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// 通过 manager 检查
|
// 通过 manager 检查
|
||||||
const checkpoints = await manager.listCheckpoints();
|
const checkpoints = await manager.listCheckpoints();
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* Agent 消息处理器
|
||||||
|
* 负责消息的构建、流式处理、压缩
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateText,
|
||||||
|
streamText,
|
||||||
|
stepCountIs,
|
||||||
|
type ModelMessage,
|
||||||
|
type Tool as AITool,
|
||||||
|
type LanguageModel,
|
||||||
|
} from 'ai';
|
||||||
|
import type { ToolResult, UserInput, ContentBlock, ChatResult } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
CompressionManager,
|
||||||
|
CompressionStatus,
|
||||||
|
type TokenUsage,
|
||||||
|
} from '../context/index.js';
|
||||||
|
import {
|
||||||
|
createDoomLoopDetector,
|
||||||
|
type DoomLoopDetector,
|
||||||
|
DOOM_LOOP_WARNING,
|
||||||
|
} from './doom-loop.js';
|
||||||
|
import type { ToolStartInfo, ToolEndInfo } from './agent-tool-executor.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doom Loop 检测事件信息
|
||||||
|
*/
|
||||||
|
export interface DoomLoopInfo {
|
||||||
|
toolName: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息处理配置
|
||||||
|
*/
|
||||||
|
export interface MessageHandlerConfig {
|
||||||
|
model: LanguageModel;
|
||||||
|
systemPrompt: string;
|
||||||
|
maxTokens?: number;
|
||||||
|
maxSteps?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式回调
|
||||||
|
*/
|
||||||
|
export interface StreamCallbacks {
|
||||||
|
onStream?: (text: string) => void;
|
||||||
|
onToolStart?: (info: ToolStartInfo) => void;
|
||||||
|
onToolEnd?: (info: ToolEndInfo) => void;
|
||||||
|
onDoomLoop?: (info: DoomLoopInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 消息处理器
|
||||||
|
*/
|
||||||
|
export class AgentMessageHandler {
|
||||||
|
private compressionManager: CompressionManager;
|
||||||
|
private conversationHistory: ModelMessage[] = [];
|
||||||
|
private doomLoopDetector: DoomLoopDetector = createDoomLoopDetector();
|
||||||
|
|
||||||
|
constructor(compressionManager: CompressionManager) {
|
||||||
|
this.compressionManager = compressionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置 Doom Loop 检测器
|
||||||
|
*/
|
||||||
|
resetDoomLoop(): void {
|
||||||
|
this.doomLoopDetector.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建用户消息内容(处理文本和图片)
|
||||||
|
*/
|
||||||
|
buildUserMessageContent(input: string | UserInput): string | ContentBlock[] {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks: ContentBlock[] = [];
|
||||||
|
|
||||||
|
// 添加图片
|
||||||
|
if (input.images && input.images.length > 0) {
|
||||||
|
for (const img of input.images) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'image',
|
||||||
|
image: img.data,
|
||||||
|
mimeType: img.mimeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加文本
|
||||||
|
if (input.text) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'text',
|
||||||
|
text: input.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有一个文本块,直接返回文本
|
||||||
|
if (blocks.length === 1 && blocks[0].type === 'text') {
|
||||||
|
return blocks[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加用户消息到历史
|
||||||
|
*/
|
||||||
|
addUserMessage(content: string | ContentBlock[]): void {
|
||||||
|
this.conversationHistory.push({
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
} as ModelMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式聊天
|
||||||
|
*/
|
||||||
|
async streamChat(
|
||||||
|
config: MessageHandlerConfig,
|
||||||
|
tools: Record<string, AITool>,
|
||||||
|
callbacks: StreamCallbacks,
|
||||||
|
abortSignal?: AbortSignal
|
||||||
|
): Promise<ChatResult> {
|
||||||
|
const { onStream, onToolStart, onToolEnd, onDoomLoop } = callbacks;
|
||||||
|
const maxSteps = config.maxSteps ?? 50;
|
||||||
|
|
||||||
|
// 工具调用时间跟踪
|
||||||
|
const toolStartTimes = new Map<string, number>();
|
||||||
|
// Doom loop 检测状态
|
||||||
|
let doomLoopTriggered = false;
|
||||||
|
let fullResponse = '';
|
||||||
|
let responseMessages: ModelMessage[] = [];
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: config.model,
|
||||||
|
system: config.systemPrompt,
|
||||||
|
messages: this.conversationHistory,
|
||||||
|
tools: doomLoopTriggered ? {} : tools, // doom loop 时禁用工具
|
||||||
|
maxOutputTokens: config.maxTokens,
|
||||||
|
stopWhen: stepCountIs(maxSteps),
|
||||||
|
abortSignal,
|
||||||
|
onChunk: ({ chunk }) => {
|
||||||
|
if (chunk.type === 'tool-call') {
|
||||||
|
this.handleToolCallChunk(
|
||||||
|
chunk,
|
||||||
|
toolStartTimes,
|
||||||
|
doomLoopTriggered,
|
||||||
|
onToolStart,
|
||||||
|
onDoomLoop,
|
||||||
|
onStream,
|
||||||
|
(triggered) => { doomLoopTriggered = triggered; }
|
||||||
|
);
|
||||||
|
} else if (chunk.type === 'tool-result') {
|
||||||
|
const toolResultChunk = chunk as unknown as { toolCallId: string; output?: unknown };
|
||||||
|
this.handleToolResultChunk(toolResultChunk, toolStartTimes, onToolEnd, onStream);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 流式输出文本
|
||||||
|
let aborted = false;
|
||||||
|
try {
|
||||||
|
for await (const chunk of result.textStream) {
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
aborted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fullResponse += chunk;
|
||||||
|
onStream?.(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aborted) {
|
||||||
|
onStream?.('\n[已取消]\n');
|
||||||
|
if (fullResponse) {
|
||||||
|
this.conversationHistory.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: fullResponse + '\n[已取消]',
|
||||||
|
} as ModelMessage);
|
||||||
|
}
|
||||||
|
return { text: fullResponse, messages: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await result.response;
|
||||||
|
responseMessages = response.messages as ModelMessage[];
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && (error.name === 'AbortError' || abortSignal?.aborted)) {
|
||||||
|
onStream?.('\n[已取消]\n');
|
||||||
|
if (fullResponse) {
|
||||||
|
this.conversationHistory.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: fullResponse + '\n[已取消]',
|
||||||
|
} as ModelMessage);
|
||||||
|
}
|
||||||
|
return { text: fullResponse, messages: [] };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将完整的响应消息添加到历史
|
||||||
|
this.conversationHistory.push(...responseMessages);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: fullResponse,
|
||||||
|
messages: responseMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理工具调用 chunk
|
||||||
|
*/
|
||||||
|
private handleToolCallChunk(
|
||||||
|
chunk: { toolCallId: string; toolName: string; input: unknown },
|
||||||
|
toolStartTimes: Map<string, number>,
|
||||||
|
doomLoopTriggered: boolean,
|
||||||
|
onToolStart?: (info: ToolStartInfo) => void,
|
||||||
|
onDoomLoop?: (info: DoomLoopInfo) => void,
|
||||||
|
onStream?: (text: string) => void,
|
||||||
|
setDoomLoopTriggered?: (triggered: boolean) => void
|
||||||
|
): void {
|
||||||
|
const toolCallId = chunk.toolCallId || `tool-${Date.now()}`;
|
||||||
|
|
||||||
|
// Doom Loop 检测
|
||||||
|
this.doomLoopDetector.record(chunk.toolName, chunk.input);
|
||||||
|
|
||||||
|
if (this.doomLoopDetector.isTriggered() && !doomLoopTriggered) {
|
||||||
|
setDoomLoopTriggered?.(true);
|
||||||
|
const toolName = this.doomLoopDetector.getLastToolName() || chunk.toolName;
|
||||||
|
onDoomLoop?.({ toolName, count: 3 });
|
||||||
|
onStream?.(`\n[警告: 检测到 Doom Loop - ${toolName} 被重复调用]\n`);
|
||||||
|
onStream?.(DOOM_LOOP_WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录开始时间
|
||||||
|
toolStartTimes.set(toolCallId, Date.now());
|
||||||
|
|
||||||
|
// 调用回调
|
||||||
|
if (onToolStart) {
|
||||||
|
onToolStart({
|
||||||
|
id: toolCallId,
|
||||||
|
toolName: chunk.toolName,
|
||||||
|
args: (chunk.input as Record<string, unknown>) || {},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onStream?.(`\n[调用工具: ${chunk.toolName}]\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理工具结果 chunk
|
||||||
|
*/
|
||||||
|
private handleToolResultChunk(
|
||||||
|
chunk: { toolCallId: string; output?: unknown },
|
||||||
|
toolStartTimes: Map<string, number>,
|
||||||
|
onToolEnd?: (info: ToolEndInfo) => void,
|
||||||
|
onStream?: (text: string) => void
|
||||||
|
): void {
|
||||||
|
const toolCallId = chunk.toolCallId || '';
|
||||||
|
const output = chunk.output as ToolResult | undefined;
|
||||||
|
|
||||||
|
// 计算执行时长
|
||||||
|
const startTime = toolStartTimes.get(toolCallId);
|
||||||
|
const duration = startTime ? Date.now() - startTime : undefined;
|
||||||
|
toolStartTimes.delete(toolCallId);
|
||||||
|
|
||||||
|
if (output && typeof output === 'object') {
|
||||||
|
if (onToolEnd) {
|
||||||
|
onToolEnd({
|
||||||
|
id: toolCallId,
|
||||||
|
status: output.success ? 'completed' : 'error',
|
||||||
|
result: output.success ? output.output : undefined,
|
||||||
|
error: output.success ? undefined : output.error,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (output.success) {
|
||||||
|
const displayOutput =
|
||||||
|
output.output.length > 500
|
||||||
|
? output.output.substring(0, 500) + '...(截断)'
|
||||||
|
: output.output;
|
||||||
|
onStream?.(`[结果: ${displayOutput}]\n`);
|
||||||
|
} else {
|
||||||
|
onStream?.(`[错误: ${output.error}]\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非流式聊天
|
||||||
|
*/
|
||||||
|
async generateChat(
|
||||||
|
config: MessageHandlerConfig,
|
||||||
|
tools: Record<string, AITool>,
|
||||||
|
abortSignal?: AbortSignal
|
||||||
|
): Promise<ChatResult> {
|
||||||
|
const maxSteps = config.maxSteps ?? 50;
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model: config.model,
|
||||||
|
system: config.systemPrompt,
|
||||||
|
messages: this.conversationHistory,
|
||||||
|
tools,
|
||||||
|
maxOutputTokens: config.maxTokens,
|
||||||
|
stopWhen: stepCountIs(maxSteps),
|
||||||
|
abortSignal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullResponse = result.text;
|
||||||
|
const responseMessages = result.response.messages as ModelMessage[];
|
||||||
|
|
||||||
|
// 将完整的响应消息添加到历史
|
||||||
|
this.conversationHistory.push(...responseMessages);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: fullResponse,
|
||||||
|
messages: responseMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查并执行自动压缩
|
||||||
|
*/
|
||||||
|
async autoCompress(onStream?: (text: string) => void): Promise<void> {
|
||||||
|
if (!this.compressionManager.shouldCompress(this.conversationHistory)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.compressionManager.compress(this.conversationHistory);
|
||||||
|
|
||||||
|
if (result.status === CompressionStatus.SUCCESS && result.freedTokens > 0) {
|
||||||
|
this.conversationHistory = result.messages;
|
||||||
|
if (onStream) {
|
||||||
|
const typeLabel = result.type === 'both' ? 'prune+摘要' : result.type === 'compaction' ? '摘要' : 'prune';
|
||||||
|
onStream(`\n[自动压缩(${typeLabel}): 释放了 ${(result.freedTokens / 1000).toFixed(1)}k tokens]\n`);
|
||||||
|
}
|
||||||
|
} else if (result.status === CompressionStatus.FAILED_EMPTY_SUMMARY) {
|
||||||
|
onStream?.('\n[压缩失败: 摘要生成为空,已跳过]\n');
|
||||||
|
} else if (result.status === CompressionStatus.FAILED_TOKEN_INFLATED) {
|
||||||
|
onStream?.('\n[压缩失败: 摘要反而增加了 token,已跳过]\n');
|
||||||
|
} else if (result.status === CompressionStatus.FAILED_ERROR) {
|
||||||
|
onStream?.(`\n[压缩失败: ${result.error || '未知错误'}]\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动压缩
|
||||||
|
*/
|
||||||
|
async forceCompress(): Promise<{ freedTokens: number; type: string }> {
|
||||||
|
const result = await this.compressionManager.forceCompress(this.conversationHistory);
|
||||||
|
if (result.freedTokens > 0) {
|
||||||
|
this.conversationHistory = result.messages;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
freedTokens: result.freedTokens,
|
||||||
|
type: result.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取对话历史
|
||||||
|
*/
|
||||||
|
getHistory(): ModelMessage[] {
|
||||||
|
return this.conversationHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置对话历史
|
||||||
|
*/
|
||||||
|
setHistory(messages: ModelMessage[]): void {
|
||||||
|
this.conversationHistory = [...messages];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空对话历史
|
||||||
|
*/
|
||||||
|
clearHistory(): void {
|
||||||
|
this.conversationHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上下文使用情况
|
||||||
|
*/
|
||||||
|
getContextUsage(): TokenUsage {
|
||||||
|
return this.compressionManager.calculateUsage(this.conversationHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取格式化的上下文使用情况
|
||||||
|
*/
|
||||||
|
getContextUsageFormatted(): string {
|
||||||
|
return this.compressionManager.formatUsage(this.conversationHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取压缩管理器
|
||||||
|
*/
|
||||||
|
getCompressionManager(): CompressionManager {
|
||||||
|
return this.compressionManager;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Agent 模式管理器
|
||||||
|
* 负责 Agent 模式的切换和权限管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentInfo } from '../agent/types.js';
|
||||||
|
import {
|
||||||
|
agentRegistry,
|
||||||
|
renderPromptTemplate,
|
||||||
|
createPlanContext,
|
||||||
|
} from '../agent/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 模式管理器
|
||||||
|
*/
|
||||||
|
export class AgentModeManager {
|
||||||
|
private currentMode: AgentInfo | null = null;
|
||||||
|
private originalSystemPrompt: string;
|
||||||
|
private currentSystemPrompt: string;
|
||||||
|
|
||||||
|
constructor(originalSystemPrompt: string) {
|
||||||
|
this.originalSystemPrompt = originalSystemPrompt;
|
||||||
|
this.currentSystemPrompt = originalSystemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 Agent 模式
|
||||||
|
*/
|
||||||
|
setMode(mode: AgentInfo | 'build' | 'plan' | null): void {
|
||||||
|
// 如果是字符串模式,从 registry 获取预设
|
||||||
|
if (typeof mode === 'string') {
|
||||||
|
const presetAgent = agentRegistry.get(mode);
|
||||||
|
if (presetAgent) {
|
||||||
|
this.currentMode = presetAgent;
|
||||||
|
this.currentSystemPrompt = this.resolveSystemPrompt(presetAgent);
|
||||||
|
} else {
|
||||||
|
// 如果找不到预设,回退到默认模式
|
||||||
|
this.currentMode = null;
|
||||||
|
this.currentSystemPrompt = this.originalSystemPrompt;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentMode = mode;
|
||||||
|
|
||||||
|
if (mode) {
|
||||||
|
this.currentSystemPrompt = this.resolveSystemPrompt(mode);
|
||||||
|
} else {
|
||||||
|
this.currentSystemPrompt = this.originalSystemPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析系统提示词
|
||||||
|
*/
|
||||||
|
private resolveSystemPrompt(agent: AgentInfo): string {
|
||||||
|
if (!agent.prompt) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用了模板渲染,动态解析变量
|
||||||
|
if (agent.promptTemplate) {
|
||||||
|
const context = createPlanContext({
|
||||||
|
workdir: process.cwd(),
|
||||||
|
isSubagent: agent.mode === 'subagent',
|
||||||
|
});
|
||||||
|
return renderPromptTemplate(agent.prompt, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent.prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前系统提示词
|
||||||
|
*/
|
||||||
|
getSystemPrompt(): string {
|
||||||
|
return this.currentSystemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取原始系统提示词
|
||||||
|
*/
|
||||||
|
getOriginalSystemPrompt(): string {
|
||||||
|
return this.originalSystemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否只读模式
|
||||||
|
*/
|
||||||
|
isReadOnlyMode(): boolean {
|
||||||
|
const permission = this.currentMode?.permission;
|
||||||
|
if (!permission) return false;
|
||||||
|
|
||||||
|
return permission.file?.write === 'deny' && permission.file?.edit === 'deny';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前模式信息
|
||||||
|
*/
|
||||||
|
getCurrentMode(): AgentInfo | null {
|
||||||
|
return this.currentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前模式名称
|
||||||
|
*/
|
||||||
|
getModeName(): string {
|
||||||
|
return this.currentMode?.name ?? 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前模式的 maxSteps 配置
|
||||||
|
*/
|
||||||
|
getMaxSteps(): number {
|
||||||
|
return this.currentMode?.maxSteps ?? 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前模式的权限配置
|
||||||
|
*/
|
||||||
|
getPermission(): AgentInfo['permission'] | undefined {
|
||||||
|
return this.currentMode?.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前模式的工具配置
|
||||||
|
*/
|
||||||
|
getToolConfig(): AgentInfo['tools'] | undefined {
|
||||||
|
return this.currentMode?.tools;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* Agent 工具执行器
|
||||||
|
* 负责工具的获取、过滤、转换和执行
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Tool as AITool } from 'ai';
|
||||||
|
import type { Tool, ToolResult } from '../types/index.js';
|
||||||
|
import { buildZodSchema } from '../types/index.js';
|
||||||
|
import type { ToolRegistry } from '../tools/registry.js';
|
||||||
|
import type { AgentInfo } from '../agent/types.js';
|
||||||
|
import {
|
||||||
|
checkBashPermission,
|
||||||
|
renderPromptTemplate,
|
||||||
|
createToolDescriptionContext,
|
||||||
|
type PromptContext,
|
||||||
|
} from '../agent/index.js';
|
||||||
|
import { getHookManager } from '../hooks/index.js';
|
||||||
|
import { getGitManager } from '../git/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具调用开始事件信息
|
||||||
|
*/
|
||||||
|
export interface ToolStartInfo {
|
||||||
|
id: string;
|
||||||
|
toolName: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具调用结束事件信息
|
||||||
|
*/
|
||||||
|
export interface ToolEndInfo {
|
||||||
|
id: string;
|
||||||
|
status: 'completed' | 'error';
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具执行上下文
|
||||||
|
*/
|
||||||
|
export interface ToolExecutionContext {
|
||||||
|
sessionId: string;
|
||||||
|
agentMode: AgentInfo | null;
|
||||||
|
onToolStart?: (info: ToolStartInfo) => void;
|
||||||
|
onToolEnd?: (info: ToolEndInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 工具执行器
|
||||||
|
*/
|
||||||
|
export class AgentToolExecutor {
|
||||||
|
private registry: ToolRegistry;
|
||||||
|
private discoveredTools: Set<string> = new Set();
|
||||||
|
private toolDescriptionContext: PromptContext | null = null;
|
||||||
|
private currentAgentMode: AgentInfo | null = null;
|
||||||
|
|
||||||
|
constructor(registry: ToolRegistry) {
|
||||||
|
this.registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前 Agent 模式
|
||||||
|
*/
|
||||||
|
setAgentMode(mode: AgentInfo | null): void {
|
||||||
|
this.currentAgentMode = mode;
|
||||||
|
// 清除工具描述上下文缓存
|
||||||
|
this.toolDescriptionContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可用工具(核心 + 已发现)
|
||||||
|
*/
|
||||||
|
getAvailableTools(): Tool[] {
|
||||||
|
// 核心工具 + 已发现的工具
|
||||||
|
const coreTools = this.registry.getCoreTools();
|
||||||
|
const discoveredTools = this.registry.getTools([...this.discoveredTools]);
|
||||||
|
let tools = [...coreTools, ...discoveredTools];
|
||||||
|
|
||||||
|
// 应用 Agent 模式的工具过滤
|
||||||
|
if (this.currentAgentMode?.tools) {
|
||||||
|
tools = this.filterToolsByAgentConfig(tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 Agent 配置过滤工具
|
||||||
|
*/
|
||||||
|
private filterToolsByAgentConfig(tools: Tool[]): Tool[] {
|
||||||
|
const toolConfig = this.currentAgentMode?.tools;
|
||||||
|
if (!toolConfig) return tools;
|
||||||
|
|
||||||
|
let filteredTools = tools;
|
||||||
|
|
||||||
|
// 如果设置了 enabled 列表,只保留这些工具
|
||||||
|
if (toolConfig.enabled && toolConfig.enabled.length > 0) {
|
||||||
|
const enabledSet = new Set(toolConfig.enabled);
|
||||||
|
filteredTools = filteredTools.filter((t) => enabledSet.has(t.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果设置了 disabled 列表,排除这些工具
|
||||||
|
if (toolConfig.disabled && toolConfig.disabled.length > 0) {
|
||||||
|
const disabledSet = new Set(toolConfig.disabled);
|
||||||
|
filteredTools = filteredTools.filter((t) => !disabledSet.has(t.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果禁止嵌套 Task,移除 task 工具
|
||||||
|
if (toolConfig.noTask) {
|
||||||
|
filteredTools = filteredTools.filter((t) => t.name !== 'task');
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建工具描述渲染上下文
|
||||||
|
*/
|
||||||
|
private getToolDescriptionContext(): PromptContext {
|
||||||
|
if (!this.toolDescriptionContext) {
|
||||||
|
this.toolDescriptionContext = createToolDescriptionContext({
|
||||||
|
agent: {
|
||||||
|
name: this.currentAgentMode?.name ?? 'default',
|
||||||
|
mode: this.currentAgentMode?.mode ?? 'primary',
|
||||||
|
isSubagent: this.currentAgentMode?.mode === 'subagent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.toolDescriptionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染工具描述中的模板变量
|
||||||
|
*/
|
||||||
|
private renderToolDescription(description: string): string {
|
||||||
|
const context = this.getToolDescriptionContext();
|
||||||
|
return renderPromptTemplate(description, context, {
|
||||||
|
throwOnUndefined: false,
|
||||||
|
undefinedValue: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为 Vercel AI SDK 工具格式
|
||||||
|
*/
|
||||||
|
toVercelTools(context: ToolExecutionContext): Record<string, AITool> {
|
||||||
|
const vercelTools: Record<string, AITool> = {};
|
||||||
|
const availableTools = this.getAvailableTools();
|
||||||
|
const hookManager = getHookManager();
|
||||||
|
|
||||||
|
for (const tool of availableTools) {
|
||||||
|
const schema = buildZodSchema(tool.parameters);
|
||||||
|
const renderedDescription = this.renderToolDescription(tool.description);
|
||||||
|
|
||||||
|
vercelTools[tool.name] = {
|
||||||
|
description: renderedDescription,
|
||||||
|
inputSchema: schema,
|
||||||
|
execute: async (params) => {
|
||||||
|
return this.executeTool(tool, params as Record<string, unknown>, context, hookManager);
|
||||||
|
},
|
||||||
|
} as AITool;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vercelTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行单个工具
|
||||||
|
*/
|
||||||
|
private async executeTool(
|
||||||
|
tool: Tool,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
context: ToolExecutionContext,
|
||||||
|
hookManager: ReturnType<typeof getHookManager>
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const callId = `${tool.name}-${Date.now()}`;
|
||||||
|
const { sessionId, onToolStart, onToolEnd } = context;
|
||||||
|
|
||||||
|
// 触发工具执行前 hook
|
||||||
|
let finalArgs = args;
|
||||||
|
if (hookManager) {
|
||||||
|
const beforeOutput = await hookManager.triggerToolExecuteBefore({
|
||||||
|
tool: tool.name,
|
||||||
|
sessionId,
|
||||||
|
callId,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果 hook 指定跳过,直接返回
|
||||||
|
if (beforeOutput.skip && beforeOutput.skipResult) {
|
||||||
|
return beforeOutput.skipResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalArgs = beforeOutput.args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent 级别的 Bash 权限检查
|
||||||
|
if (tool.name === 'bash' && this.currentAgentMode?.permission?.bash) {
|
||||||
|
const command = finalArgs.command as string;
|
||||||
|
if (command) {
|
||||||
|
const action = checkBashPermission(command, this.currentAgentMode.permission.bash);
|
||||||
|
if (action === 'deny') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: `[Agent 权限拒绝] 当前模式 (${this.currentAgentMode.name}) 禁止执行此命令: ${command}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知工具开始
|
||||||
|
onToolStart?.({
|
||||||
|
id: callId,
|
||||||
|
toolName: tool.name,
|
||||||
|
args: finalArgs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行工具
|
||||||
|
const startTime = Date.now();
|
||||||
|
let result = await tool.execute(finalArgs);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 触发工具执行后 hook
|
||||||
|
if (hookManager) {
|
||||||
|
const afterOutput = await hookManager.triggerToolExecuteAfter(
|
||||||
|
{
|
||||||
|
tool: tool.name,
|
||||||
|
sessionId,
|
||||||
|
callId,
|
||||||
|
args: finalArgs,
|
||||||
|
duration,
|
||||||
|
},
|
||||||
|
result
|
||||||
|
);
|
||||||
|
result = afterOutput.result;
|
||||||
|
|
||||||
|
// 对于文件操作工具,触发相应的文件 hook 和 Git 自动提交
|
||||||
|
if (result.success) {
|
||||||
|
await this.triggerFileHooksIfNeeded(tool.name, finalArgs, sessionId, hookManager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知工具结束
|
||||||
|
onToolEnd?.({
|
||||||
|
id: callId,
|
||||||
|
status: result.success ? 'completed' : 'error',
|
||||||
|
result: result.success ? result.output : undefined,
|
||||||
|
error: result.success ? undefined : result.error,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果是 tool_search 调用,解析结果并注入发现的工具
|
||||||
|
if (tool.name === 'tool_search' && result.success) {
|
||||||
|
this.handleToolSearchResult(result.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发文件相关的 Hooks
|
||||||
|
*/
|
||||||
|
private async triggerFileHooksIfNeeded(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
sessionId: string,
|
||||||
|
hookManager: NonNullable<ReturnType<typeof getHookManager>>
|
||||||
|
): Promise<void> {
|
||||||
|
const filePath = args.path as string | undefined;
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
const gitManager = getGitManager();
|
||||||
|
|
||||||
|
if (toolName === 'write_file') {
|
||||||
|
await hookManager.triggerFileCreated({
|
||||||
|
path: filePath,
|
||||||
|
tool: toolName,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
if (gitManager) {
|
||||||
|
await gitManager.onFileChanged(filePath, 'create');
|
||||||
|
}
|
||||||
|
} else if (toolName === 'edit_file') {
|
||||||
|
await hookManager.triggerFileEdited({
|
||||||
|
path: filePath,
|
||||||
|
tool: toolName,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
if (gitManager) {
|
||||||
|
await gitManager.onFileChanged(filePath, 'modify');
|
||||||
|
}
|
||||||
|
} else if (toolName === 'delete_file') {
|
||||||
|
await hookManager.triggerFileDeleted({
|
||||||
|
path: filePath,
|
||||||
|
tool: toolName,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
if (gitManager) {
|
||||||
|
await gitManager.onFileChanged(filePath, 'delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 tool_search 的结果,将发现的工具添加到可用列表
|
||||||
|
*/
|
||||||
|
handleToolSearchResult(output: string): void {
|
||||||
|
// 解析输出,提取工具名称
|
||||||
|
// 格式: "- tool_name: description [category]"
|
||||||
|
const matches = output.matchAll(/^- (\w+):/gm);
|
||||||
|
for (const match of matches) {
|
||||||
|
const toolName = match[1];
|
||||||
|
if (this.registry.has(toolName)) {
|
||||||
|
this.discoveredTools.add(toolName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已发现的工具
|
||||||
|
*/
|
||||||
|
getDiscoveredTools(): string[] {
|
||||||
|
return [...this.discoveredTools];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置已发现的工具(用于会话恢复)
|
||||||
|
*/
|
||||||
|
setDiscoveredTools(tools: string[]): void {
|
||||||
|
this.discoveredTools = new Set(tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除已发现的工具
|
||||||
|
*/
|
||||||
|
clearDiscoveredTools(): void {
|
||||||
|
this.discoveredTools.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工具数量统计
|
||||||
|
*/
|
||||||
|
getToolCount(): { core: number; discovered: number; total: number } {
|
||||||
|
const coreCount = this.registry.getCoreTools().length;
|
||||||
|
const discoveredCount = this.discoveredTools.size;
|
||||||
|
return {
|
||||||
|
core: coreCount,
|
||||||
|
discovered: discoveredCount,
|
||||||
|
total: coreCount + discoveredCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Agent Vision 处理器
|
||||||
|
* 负责 Vision 处理的委托逻辑
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentConfig } from '../types/index.js';
|
||||||
|
import type { ToolRegistry } from '../tools/registry.js';
|
||||||
|
import type { ImageData } from '../agent/types.js';
|
||||||
|
import { agentRegistry, AgentExecutor } from '../agent/index.js';
|
||||||
|
import { loadVisionConfig } from '../utils/config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Vision 处理器
|
||||||
|
*/
|
||||||
|
export class AgentVisionHandler {
|
||||||
|
private config: AgentConfig;
|
||||||
|
private registry: ToolRegistry | null = null;
|
||||||
|
|
||||||
|
constructor(config: AgentConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工具注册表
|
||||||
|
*/
|
||||||
|
setRegistry(registry: ToolRegistry): void {
|
||||||
|
this.registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前模型是否支持 Vision(图片理解)
|
||||||
|
*/
|
||||||
|
supportsVision(): boolean {
|
||||||
|
const model = this.config.model.toLowerCase();
|
||||||
|
|
||||||
|
// Anthropic Claude 模型支持 vision
|
||||||
|
if (this.config.provider === 'anthropic') {
|
||||||
|
// Claude 3 及以上版本支持 vision
|
||||||
|
return model.includes('claude-3') || model.includes('claude-4');
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI GPT-4 系列支持 vision
|
||||||
|
if (this.config.provider === 'openai') {
|
||||||
|
// GPT-4o, GPT-4 Turbo, GPT-4 Vision 等支持
|
||||||
|
return model.includes('gpt-4');
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepSeek 目前不支持 vision
|
||||||
|
if (this.config.provider === 'deepseek') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Vision Agent 处理图片
|
||||||
|
* 当主模型不支持 vision 时,委托给 Vision Agent 分析图片
|
||||||
|
* @returns 包含图片分析结果的文本消息,或 null 表示失败
|
||||||
|
*/
|
||||||
|
async processWithVisionAgent(
|
||||||
|
images: ImageData[],
|
||||||
|
userText?: string,
|
||||||
|
onStream?: (text: string) => void
|
||||||
|
): Promise<string | null> {
|
||||||
|
// 检查 Vision 配置是否可用
|
||||||
|
const visionConfig = loadVisionConfig();
|
||||||
|
if (!visionConfig) {
|
||||||
|
onStream?.('\n⚠ Vision 服务未配置,无法处理图片\n');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Vision Agent
|
||||||
|
const visionAgent = agentRegistry.get('vision');
|
||||||
|
if (!visionAgent) {
|
||||||
|
onStream?.('\n⚠ Vision Agent 未注册\n');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保有工具注册表
|
||||||
|
if (!this.registry) {
|
||||||
|
onStream?.('\n⚠ 工具注册表未初始化\n');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStream?.(`\n[委托 Vision Agent (${visionConfig.model}) 分析图片...]\n`);
|
||||||
|
|
||||||
|
// 构建 Vision 配置
|
||||||
|
const visionAgentConfig: AgentConfig = {
|
||||||
|
...this.config,
|
||||||
|
provider: visionConfig.provider,
|
||||||
|
apiKey: visionConfig.apiKey,
|
||||||
|
model: visionConfig.model,
|
||||||
|
baseUrl: visionConfig.baseUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建 Vision Agent 执行器
|
||||||
|
const executor = new AgentExecutor(visionAgent, visionAgentConfig, this.registry);
|
||||||
|
|
||||||
|
// 构建提示词
|
||||||
|
const prompt = userText || '请详细描述这张图片的内容';
|
||||||
|
|
||||||
|
// 执行 Vision 分析
|
||||||
|
const result = await executor.execute(prompt, {
|
||||||
|
workdir: process.cwd(),
|
||||||
|
images,
|
||||||
|
onStream: undefined, // Vision Agent 不使用流式输出
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
onStream?.(`\n⚠ Vision 分析失败: ${result.error}\n`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStream?.('\n[Vision 分析完成]\n');
|
||||||
|
|
||||||
|
// 构建带分析结果的文本消息
|
||||||
|
const combinedText = `[图片分析结果 - 由 ${visionConfig.model} 提供]\n${result.text}\n\n用户问题: ${userText || '(无附加问题)'}`;
|
||||||
|
|
||||||
|
return combinedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
-779
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Core 模块导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Agent 主类
|
||||||
|
export { Agent, type AgentChatOptions } from './agent.js';
|
||||||
|
|
||||||
|
// 子模块
|
||||||
|
export {
|
||||||
|
AgentToolExecutor,
|
||||||
|
type ToolStartInfo,
|
||||||
|
type ToolEndInfo,
|
||||||
|
type ToolExecutionContext,
|
||||||
|
} from './agent-tool-executor.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AgentMessageHandler,
|
||||||
|
type DoomLoopInfo,
|
||||||
|
type MessageHandlerConfig,
|
||||||
|
type StreamCallbacks,
|
||||||
|
} from './agent-message-handler.js';
|
||||||
|
|
||||||
|
export { AgentModeManager } from './agent-mode-manager.js';
|
||||||
|
|
||||||
|
export { AgentVisionHandler } from './agent-vision-handler.js';
|
||||||
|
|
||||||
|
// Doom Loop
|
||||||
|
export {
|
||||||
|
createDoomLoopDetector,
|
||||||
|
DOOM_LOOP_WARNING,
|
||||||
|
type DoomLoopDetector,
|
||||||
|
} from './doom-loop.js';
|
||||||
@@ -1,48 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 会话管理器
|
||||||
|
* 作为编排器,委托具体工作给各个子模块
|
||||||
|
*/
|
||||||
|
|
||||||
import type { ModelMessage } from 'ai';
|
import type { ModelMessage } from 'ai';
|
||||||
import * as storage from './storage/index.js';
|
import * as storage from './storage/index.js';
|
||||||
import { SessionStorage, MessageStorage, PartStorage, TodoStorage } from './storage/index.js';
|
import type { TodoItem } from './storage/index.js';
|
||||||
import type { SessionInfo, Part, TodoItem } from './storage/index.js';
|
|
||||||
import { generateSessionId } from './id.js';
|
|
||||||
import { getProjectId, isGitRepository } from './project.js';
|
|
||||||
|
|
||||||
/**
|
// 子模块
|
||||||
* 会话摘要(用于列表展示)
|
import { SessionStore, type SessionData, type SessionSummary } from './session-store.js';
|
||||||
*/
|
import { ProjectManager, type ProjectMetadata } from './project-manager.js';
|
||||||
export interface SessionSummary {
|
import { SessionAutoSave } from './session-auto-save.js';
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
workdir: string;
|
|
||||||
messageCount: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// 重新导出类型
|
||||||
* 运行时会话数据(兼容旧接口)
|
export type { SessionData, SessionSummary, ProjectMetadata };
|
||||||
*/
|
|
||||||
export interface SessionData {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
parentId?: string;
|
|
||||||
agentName?: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
workdir: string;
|
|
||||||
title?: string;
|
|
||||||
messages: ModelMessage[];
|
|
||||||
discoveredTools: string[];
|
|
||||||
todos: TodoItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 项目元数据
|
|
||||||
*/
|
|
||||||
export interface ProjectMetadata {
|
|
||||||
id: string;
|
|
||||||
workdir: string;
|
|
||||||
createdAt: string;
|
|
||||||
isGitRepo: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话管理器
|
* 会话管理器
|
||||||
@@ -50,14 +21,24 @@ export interface ProjectMetadata {
|
|||||||
*/
|
*/
|
||||||
export class SessionManager {
|
export class SessionManager {
|
||||||
private currentSession: SessionData | null = null;
|
private currentSession: SessionData | null = null;
|
||||||
private currentProject: ProjectMetadata | null = null;
|
|
||||||
private autoSaveInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private storageDir?: string;
|
private storageDir?: string;
|
||||||
|
|
||||||
|
// 子模块
|
||||||
|
private store: SessionStore;
|
||||||
|
private projectManager: ProjectManager;
|
||||||
|
private autoSave: SessionAutoSave;
|
||||||
|
|
||||||
constructor(storageDir?: string) {
|
constructor(storageDir?: string) {
|
||||||
this.storageDir = storageDir;
|
this.storageDir = storageDir;
|
||||||
|
this.store = new SessionStore();
|
||||||
|
this.projectManager = new ProjectManager();
|
||||||
|
this.autoSave = new SessionAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 初始化
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 - 尝试恢复或创建新会话
|
* 初始化 - 尝试恢复或创建新会话
|
||||||
*/
|
*/
|
||||||
@@ -66,13 +47,14 @@ export class SessionManager {
|
|||||||
await storage.initStorage(this.storageDir);
|
await storage.initStorage(this.storageDir);
|
||||||
|
|
||||||
// 获取或创建项目
|
// 获取或创建项目
|
||||||
this.currentProject = await this.getOrCreateProject(workdir);
|
await this.projectManager.getOrCreate(workdir);
|
||||||
|
|
||||||
// 尝试加载当前会话
|
// 尝试加载当前会话
|
||||||
const currentSessionId = await this.getCurrentSessionId();
|
const currentSessionId = await this.getCurrentSessionId();
|
||||||
|
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
const existing = await this.loadSession(this.currentProject.id, currentSessionId);
|
const projectId = this.projectManager.getProjectId()!;
|
||||||
|
const existing = await this.store.load(projectId, currentSessionId);
|
||||||
|
|
||||||
if (existing && existing.workdir === workdir) {
|
if (existing && existing.workdir === workdir) {
|
||||||
this.currentSession = existing;
|
this.currentSession = existing;
|
||||||
@@ -82,218 +64,18 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建新会话
|
// 创建新会话
|
||||||
this.currentSession = await this.createNewSession(workdir);
|
const projectId = this.projectManager.getProjectId()!;
|
||||||
await this.saveSessionInfo();
|
this.currentSession = await this.store.create(projectId, workdir);
|
||||||
|
await this.store.save(this.currentSession);
|
||||||
await this.setCurrentSessionPointer(this.currentSession.id);
|
await this.setCurrentSessionPointer(this.currentSession.id);
|
||||||
|
|
||||||
this.startAutoSave();
|
this.startAutoSave();
|
||||||
return this.currentSession;
|
return this.currentSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============================================================================
|
||||||
* 获取或创建项目
|
// 会话获取
|
||||||
*/
|
// ============================================================================
|
||||||
private async getOrCreateProject(workdir: string): Promise<ProjectMetadata> {
|
|
||||||
const projectId = await getProjectId(workdir);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = await storage.read<ProjectMetadata>(['project', projectId]);
|
|
||||||
return existing;
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof storage.StorageNotFoundError) {
|
|
||||||
const isGitRepo = await isGitRepository(workdir);
|
|
||||||
const project: ProjectMetadata = {
|
|
||||||
id: projectId,
|
|
||||||
workdir,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
isGitRepo,
|
|
||||||
};
|
|
||||||
await storage.write(['project', projectId], project);
|
|
||||||
return project;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建新会话
|
|
||||||
*/
|
|
||||||
private async createNewSession(workdir: string): Promise<SessionData> {
|
|
||||||
if (!this.currentProject) {
|
|
||||||
throw new Error('Project not initialized. Call init() first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionInfo = await SessionStorage.create(this.currentProject.id, workdir);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: sessionInfo.id,
|
|
||||||
projectId: sessionInfo.projectId,
|
|
||||||
createdAt: new Date(sessionInfo.createdAt).toISOString(),
|
|
||||||
updatedAt: new Date(sessionInfo.updatedAt).toISOString(),
|
|
||||||
workdir: sessionInfo.workdir,
|
|
||||||
title: sessionInfo.title,
|
|
||||||
messages: [],
|
|
||||||
discoveredTools: sessionInfo.discoveredTools,
|
|
||||||
todos: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载会话(从存储重建)
|
|
||||||
*/
|
|
||||||
private async loadSession(projectId: string, sessionId: string): Promise<SessionData | null> {
|
|
||||||
const sessionInfo = await SessionStorage.get(projectId, sessionId);
|
|
||||||
if (!sessionInfo) return null;
|
|
||||||
|
|
||||||
// 加载消息
|
|
||||||
const messages = await this.loadMessagesFromStorage(sessionId);
|
|
||||||
|
|
||||||
// 加载 todos
|
|
||||||
const todoList = await TodoStorage.get(sessionId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: sessionInfo.id,
|
|
||||||
projectId: sessionInfo.projectId,
|
|
||||||
parentId: sessionInfo.parentId,
|
|
||||||
agentName: sessionInfo.agentName,
|
|
||||||
createdAt: new Date(sessionInfo.createdAt).toISOString(),
|
|
||||||
updatedAt: new Date(sessionInfo.updatedAt).toISOString(),
|
|
||||||
workdir: sessionInfo.workdir,
|
|
||||||
title: sessionInfo.title,
|
|
||||||
messages,
|
|
||||||
discoveredTools: sessionInfo.discoveredTools,
|
|
||||||
todos: todoList?.items || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从存储加载消息并转换为 AI SDK 格式
|
|
||||||
*/
|
|
||||||
private async loadMessagesFromStorage(sessionId: string): Promise<ModelMessage[]> {
|
|
||||||
const messageInfos = await MessageStorage.listBySession(sessionId);
|
|
||||||
const messages: ModelMessage[] = [];
|
|
||||||
|
|
||||||
for (const messageInfo of messageInfos) {
|
|
||||||
const parts = await PartStorage.getByIds(messageInfo.id, messageInfo.partIds);
|
|
||||||
const modelMessages = this.partsToModelMessages(messageInfo.role, parts);
|
|
||||||
messages.push(...modelMessages);
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 Parts 转换为 AI SDK ModelMessage(用于加载历史消息)
|
|
||||||
*
|
|
||||||
* 新逻辑:
|
|
||||||
* - user 消息:直接转换
|
|
||||||
* - assistant 消息:转换文本和工具调用,然后为已完成的工具生成 tool 消息
|
|
||||||
*/
|
|
||||||
private partsToModelMessages(role: string, parts: Part[]): ModelMessage[] {
|
|
||||||
if (parts.length === 0) return [];
|
|
||||||
|
|
||||||
const result: ModelMessage[] = [];
|
|
||||||
|
|
||||||
if (role === 'user') {
|
|
||||||
// User 消息:只有文本和文件
|
|
||||||
const content: unknown[] = [];
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.type === 'text') {
|
|
||||||
content.push({ type: 'text', text: part.text });
|
|
||||||
} else if (part.type === 'file') {
|
|
||||||
content.push({
|
|
||||||
type: 'image',
|
|
||||||
image: part.data,
|
|
||||||
mimeType: part.mimeType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.length === 1 && (content[0] as { type: string }).type === 'text') {
|
|
||||||
result.push({
|
|
||||||
role: 'user',
|
|
||||||
content: (content[0] as { text: string }).text,
|
|
||||||
});
|
|
||||||
} else if (content.length > 0) {
|
|
||||||
result.push({
|
|
||||||
role: 'user',
|
|
||||||
content,
|
|
||||||
} as ModelMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (role === 'assistant') {
|
|
||||||
// Assistant 消息:文本 + 工具调用
|
|
||||||
const content: unknown[] = [];
|
|
||||||
// input 使用 unknown 类型以兼容 AI SDK(可能是对象、字符串等)
|
|
||||||
const completedTools: Array<{ toolCallId: string; toolName: string; input: unknown; output: unknown }> = [];
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.type === 'text') {
|
|
||||||
content.push({ type: 'text', text: part.text });
|
|
||||||
} else if (part.type === 'tool') {
|
|
||||||
// 只有非 pending 状态的工具调用才添加到 AI SDK 消息
|
|
||||||
if (part.state.status !== 'pending') {
|
|
||||||
// AI SDK v5 使用 input 字段(不是 args)
|
|
||||||
content.push({
|
|
||||||
type: 'tool-call',
|
|
||||||
toolCallId: part.toolCallId,
|
|
||||||
toolName: part.toolName,
|
|
||||||
input: part.state.input,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 收集已完成的工具结果
|
|
||||||
if (part.state.status === 'completed') {
|
|
||||||
completedTools.push({
|
|
||||||
toolCallId: part.toolCallId,
|
|
||||||
toolName: part.toolName,
|
|
||||||
input: part.state.input,
|
|
||||||
output: part.state.output,
|
|
||||||
});
|
|
||||||
} else if (part.state.status === 'error') {
|
|
||||||
completedTools.push({
|
|
||||||
toolCallId: part.toolCallId,
|
|
||||||
toolName: part.toolName,
|
|
||||||
input: part.state.input,
|
|
||||||
output: part.state.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (part.type === 'reasoning') {
|
|
||||||
content.push({ type: 'text', text: `[Reasoning] ${part.text}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加 assistant 消息
|
|
||||||
if (content.length === 1 && (content[0] as { type: string }).type === 'text') {
|
|
||||||
result.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: (content[0] as { text: string }).text,
|
|
||||||
});
|
|
||||||
} else if (content.length > 0) {
|
|
||||||
result.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content,
|
|
||||||
} as ModelMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加 tool 消息(如果有已完成的工具)
|
|
||||||
// AI SDK v5 要求 tool-result 必须包含 input 和 output 字段
|
|
||||||
if (completedTools.length > 0) {
|
|
||||||
result.push({
|
|
||||||
role: 'tool',
|
|
||||||
content: completedTools.map((t) => ({
|
|
||||||
type: 'tool-result',
|
|
||||||
toolCallId: t.toolCallId,
|
|
||||||
toolName: t.toolName,
|
|
||||||
input: t.input,
|
|
||||||
output: t.output,
|
|
||||||
})),
|
|
||||||
} as unknown as ModelMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前会话
|
* 获取当前会话
|
||||||
@@ -306,189 +88,108 @@ export class SessionManager {
|
|||||||
* 获取当前项目
|
* 获取当前项目
|
||||||
*/
|
*/
|
||||||
getProject(): ProjectMetadata | null {
|
getProject(): ProjectMetadata | null {
|
||||||
return this.currentProject;
|
return this.projectManager.getProject();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存会话信息
|
* 获取当前会话 ID
|
||||||
*/
|
*/
|
||||||
private async saveSessionInfo(): Promise<void> {
|
getSessionId(): string | undefined {
|
||||||
if (!this.currentSession) return;
|
return this.currentSession?.id;
|
||||||
|
|
||||||
const sessionInfo: SessionInfo = {
|
|
||||||
id: this.currentSession.id,
|
|
||||||
projectId: this.currentSession.projectId,
|
|
||||||
parentId: this.currentSession.parentId,
|
|
||||||
agentName: this.currentSession.agentName,
|
|
||||||
createdAt: new Date(this.currentSession.createdAt).getTime(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
workdir: this.currentSession.workdir,
|
|
||||||
title: this.currentSession.title,
|
|
||||||
discoveredTools: this.currentSession.discoveredTools,
|
|
||||||
stats: {
|
|
||||||
messageCount: this.currentSession.messages.length,
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await SessionStorage.save(sessionInfo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 会话操作
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存当前会话
|
* 保存当前会话
|
||||||
*/
|
*/
|
||||||
async save(): Promise<void> {
|
async save(): Promise<void> {
|
||||||
if (!this.currentSession) return;
|
if (!this.currentSession) return;
|
||||||
await this.saveSessionInfo();
|
await this.store.save(this.currentSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步消息到存储(将 AI SDK 消息转换为 Message + Parts)
|
* 清空当前会话并创建新会话
|
||||||
*
|
|
||||||
* 新逻辑:只存储 user 和 assistant 消息
|
|
||||||
* - user 消息:直接存储
|
|
||||||
* - assistant 消息:合并后续的 tool 消息中的工具结果
|
|
||||||
* - tool 消息:跳过(结果合并到 assistant)
|
|
||||||
*/
|
*/
|
||||||
async syncMessages(messages: ModelMessage[]): Promise<void> {
|
async newSession(workdir?: string): Promise<SessionData> {
|
||||||
if (!this.currentSession) return;
|
if (!this.projectManager.isInitialized()) {
|
||||||
|
throw new Error('Project not initialized. Call init() first.');
|
||||||
const sessionId = this.currentSession.id;
|
|
||||||
|
|
||||||
// 删除旧消息
|
|
||||||
await MessageStorage.removeBySession(sessionId);
|
|
||||||
|
|
||||||
// 用于跟踪当前 assistant 消息的工具调用
|
|
||||||
let currentAssistantMsgId: string | null = null;
|
|
||||||
let currentUserMsgId: string | null = null;
|
|
||||||
const toolCallPartIds = new Map<string, string>(); // toolCallId -> partId
|
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
const message = messages[i];
|
|
||||||
|
|
||||||
if (message.role === 'user') {
|
|
||||||
// User 消息
|
|
||||||
const messageInfo = await MessageStorage.create(sessionId, 'user');
|
|
||||||
currentUserMsgId = messageInfo.id;
|
|
||||||
const partIds: string[] = [];
|
|
||||||
|
|
||||||
if (typeof message.content === 'string') {
|
|
||||||
const part = await PartStorage.createText(messageInfo.id, message.content);
|
|
||||||
partIds.push(part.id);
|
|
||||||
} else if (Array.isArray(message.content)) {
|
|
||||||
for (const item of message.content) {
|
|
||||||
const itemType = (item as { type: string }).type;
|
|
||||||
if (itemType === 'text') {
|
|
||||||
const part = await PartStorage.createText(messageInfo.id, (item as { text: string }).text);
|
|
||||||
partIds.push(part.id);
|
|
||||||
} else if (itemType === 'image') {
|
|
||||||
const img = item as unknown as { image: string; mimeType: string };
|
|
||||||
const part = await PartStorage.create(messageInfo.id, 'file', {
|
|
||||||
filename: 'image',
|
|
||||||
mimeType: img.mimeType,
|
|
||||||
data: typeof img.image === 'string' ? img.image : '',
|
|
||||||
});
|
|
||||||
partIds.push(part.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partIds.length > 0) {
|
|
||||||
await MessageStorage.update(sessionId, messageInfo.id, { partIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置工具调用追踪
|
|
||||||
currentAssistantMsgId = null;
|
|
||||||
toolCallPartIds.clear();
|
|
||||||
|
|
||||||
} else if (message.role === 'assistant') {
|
|
||||||
// Assistant 消息:如果当前轮次已有 assistant 消息,则追加 Parts
|
|
||||||
let messageId: string;
|
|
||||||
let existingPartIds: string[] = [];
|
|
||||||
|
|
||||||
if (currentAssistantMsgId) {
|
|
||||||
// 同一轮对话的后续 assistant 消息,追加到现有消息
|
|
||||||
messageId = currentAssistantMsgId;
|
|
||||||
const existingMsg = await MessageStorage.get(sessionId, messageId);
|
|
||||||
existingPartIds = existingMsg?.partIds ?? [];
|
|
||||||
} else {
|
|
||||||
// 新的 assistant 消息
|
|
||||||
const messageInfo = await MessageStorage.create(sessionId, 'assistant', {
|
|
||||||
parentId: currentUserMsgId ?? undefined,
|
|
||||||
});
|
|
||||||
messageId = messageInfo.id;
|
|
||||||
currentAssistantMsgId = messageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPartIds: string[] = [];
|
|
||||||
|
|
||||||
if (typeof message.content === 'string') {
|
|
||||||
const part = await PartStorage.createText(messageId, message.content);
|
|
||||||
newPartIds.push(part.id);
|
|
||||||
} else if (Array.isArray(message.content)) {
|
|
||||||
for (const item of message.content) {
|
|
||||||
const itemType = (item as { type: string }).type;
|
|
||||||
if (itemType === 'text') {
|
|
||||||
const part = await PartStorage.createText(messageId, (item as { text: string }).text);
|
|
||||||
newPartIds.push(part.id);
|
|
||||||
} else if (itemType === 'tool-call') {
|
|
||||||
// AI SDK 的 tool-call 使用 input 字段存储参数(不是 args)
|
|
||||||
const toolCall = item as unknown as { toolCallId: string; toolName: string; input: Record<string, unknown> };
|
|
||||||
// 创建 running 状态的工具 Part
|
|
||||||
const part = await PartStorage.createToolRunning(
|
|
||||||
messageId,
|
|
||||||
toolCall.toolCallId,
|
|
||||||
toolCall.toolName,
|
|
||||||
(toolCall.input as Record<string, unknown>) ?? {}
|
|
||||||
);
|
|
||||||
newPartIds.push(part.id);
|
|
||||||
toolCallPartIds.set(toolCall.toolCallId, part.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPartIds.length > 0) {
|
|
||||||
// 合并已有的和新的 partIds
|
|
||||||
const allPartIds = [...existingPartIds, ...newPartIds];
|
|
||||||
await MessageStorage.update(sessionId, messageId, { partIds: allPartIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (message.role === 'tool' && currentAssistantMsgId) {
|
|
||||||
// Tool 消息:更新对应 assistant 消息中的工具 Part 状态
|
|
||||||
if (Array.isArray(message.content)) {
|
|
||||||
for (const item of message.content) {
|
|
||||||
const itemType = (item as { type: string }).type;
|
|
||||||
if (itemType === 'tool-result') {
|
|
||||||
// AI SDK v5 使用 output 字段存储结果(不是 result)
|
|
||||||
const toolResult = item as unknown as { toolCallId: string; toolName: string; output: unknown };
|
|
||||||
const partId = toolCallPartIds.get(toolResult.toolCallId);
|
|
||||||
if (partId) {
|
|
||||||
// 更新工具状态为 completed
|
|
||||||
// 获取原始 start time
|
|
||||||
const part = await PartStorage.get(currentAssistantMsgId, partId);
|
|
||||||
const startTime = part?.type === 'tool' && part.state.status === 'running'
|
|
||||||
? part.state.time.start
|
|
||||||
: Date.now();
|
|
||||||
await PartStorage.setToolCompleted(currentAssistantMsgId, partId, toolResult.output, startTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 不创建新消息,跳过 tool role
|
|
||||||
}
|
|
||||||
// 忽略 system 消息(system prompt 通过其他方式注入)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newWorkdir = workdir || this.currentSession?.workdir || process.cwd();
|
||||||
|
|
||||||
|
// 如果工作目录变化,需要切换项目
|
||||||
|
if (workdir && workdir !== this.projectManager.getProject()?.workdir) {
|
||||||
|
await this.projectManager.switchProject(workdir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = this.projectManager.getProjectId()!;
|
||||||
|
this.currentSession = await this.store.create(projectId, newWorkdir);
|
||||||
|
await this.store.save(this.currentSession);
|
||||||
|
await this.setCurrentSessionPointer(this.currentSession.id);
|
||||||
|
|
||||||
|
return this.currentSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复指定会话
|
||||||
|
*/
|
||||||
|
async restoreSession(sessionId: string): Promise<SessionData | null> {
|
||||||
|
if (!this.projectManager.isInitialized()) {
|
||||||
|
throw new Error('Project not initialized. Call init() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = this.projectManager.getProjectId()!;
|
||||||
|
const session = await this.store.load(projectId, sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
this.currentSession = session;
|
||||||
|
await this.setCurrentSessionPointer(sessionId);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出当前项目的历史会话
|
||||||
|
*/
|
||||||
|
async listSessions(): Promise<SessionSummary[]> {
|
||||||
|
const projectId = this.projectManager.getProjectId();
|
||||||
|
if (!projectId) {
|
||||||
|
return this.listAllSessions();
|
||||||
|
}
|
||||||
|
return this.store.listByProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出所有项目的会话
|
||||||
|
*/
|
||||||
|
async listAllSessions(): Promise<SessionSummary[]> {
|
||||||
|
return this.store.listAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除历史会话
|
||||||
|
*/
|
||||||
|
async deleteSession(sessionId: string): Promise<boolean> {
|
||||||
|
const projectId = this.projectManager.getProjectId();
|
||||||
|
if (!projectId) return false;
|
||||||
|
return this.store.delete(projectId, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 消息操作
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量设置消息(用于同步整个对话历史)
|
* 批量设置消息(用于同步整个对话历史)
|
||||||
*/
|
*/
|
||||||
async setMessages(messages: ModelMessage[]): Promise<void> {
|
async setMessages(messages: ModelMessage[]): Promise<void> {
|
||||||
if (!this.currentSession) return;
|
if (!this.currentSession) return;
|
||||||
this.currentSession.messages = messages;
|
this.currentSession.messages = messages;
|
||||||
await this.syncMessages(messages);
|
await this.store.syncMessages(this.currentSession.id, messages);
|
||||||
await this.saveSessionInfo();
|
await this.store.save(this.currentSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -507,13 +208,17 @@ export class SessionManager {
|
|||||||
return this.currentSession?.messages || [];
|
return this.currentSession?.messages || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 工具和待办操作
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置已发现的工具
|
* 设置已发现的工具
|
||||||
*/
|
*/
|
||||||
async setDiscoveredTools(tools: string[]): Promise<void> {
|
async setDiscoveredTools(tools: string[]): Promise<void> {
|
||||||
if (!this.currentSession) return;
|
if (!this.currentSession) return;
|
||||||
this.currentSession.discoveredTools = tools;
|
this.currentSession.discoveredTools = tools;
|
||||||
await this.saveSessionInfo();
|
await this.store.save(this.currentSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -526,10 +231,12 @@ export class SessionManager {
|
|||||||
/**
|
/**
|
||||||
* 更新待办事项
|
* 更新待办事项
|
||||||
*/
|
*/
|
||||||
async setTodos(todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed' }>): Promise<void> {
|
async setTodos(
|
||||||
|
todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed' }>
|
||||||
|
): Promise<void> {
|
||||||
if (!this.currentSession) return;
|
if (!this.currentSession) return;
|
||||||
const todoList = await TodoStorage.replace(this.currentSession.id, todos);
|
const items = await this.store.setTodos(this.currentSession.id, todos);
|
||||||
this.currentSession.todos = todoList.items;
|
this.currentSession.todos = items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -539,192 +246,46 @@ export class SessionManager {
|
|||||||
return this.currentSession?.todos || [];
|
return this.currentSession?.todos || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============================================================================
|
||||||
* 清空当前会话并创建新会话
|
// 子会话操作
|
||||||
*/
|
// ============================================================================
|
||||||
async newSession(workdir?: string): Promise<SessionData> {
|
|
||||||
if (!this.currentProject) {
|
|
||||||
throw new Error('Project not initialized. Call init() first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWorkdir = workdir || this.currentSession?.workdir || process.cwd();
|
|
||||||
|
|
||||||
// 如果工作目录变化,需要切换项目
|
|
||||||
if (workdir && workdir !== this.currentProject.workdir) {
|
|
||||||
this.currentProject = await this.getOrCreateProject(workdir);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSession = await this.createNewSession(newWorkdir);
|
|
||||||
await this.saveSessionInfo();
|
|
||||||
await this.setCurrentSessionPointer(this.currentSession.id);
|
|
||||||
|
|
||||||
return this.currentSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建子会话(用于 Task 工具)
|
* 创建子会话(用于 Task 工具)
|
||||||
*/
|
*/
|
||||||
createChildSession(parentId: string, agentName: string, title?: string): SessionData {
|
createChildSession(parentId: string, agentName: string, title?: string): SessionData {
|
||||||
if (!this.currentProject) {
|
if (!this.projectManager.isInitialized()) {
|
||||||
throw new Error('Project not initialized. Call init() first.');
|
throw new Error('Project not initialized. Call init() first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectId = this.projectManager.getProjectId()!;
|
||||||
const workdir = this.currentSession?.workdir || process.cwd();
|
const workdir = this.currentSession?.workdir || process.cwd();
|
||||||
return {
|
return this.store.createChildSession(projectId, parentId, agentName, workdir, title);
|
||||||
id: generateSessionId(),
|
|
||||||
projectId: this.currentProject.id,
|
|
||||||
parentId,
|
|
||||||
agentName,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
workdir,
|
|
||||||
title: title || `子任务 (@${agentName})`,
|
|
||||||
messages: [],
|
|
||||||
discoveredTools: [],
|
|
||||||
todos: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存子会话
|
* 保存子会话
|
||||||
*/
|
*/
|
||||||
async saveChildSession(session: SessionData): Promise<void> {
|
async saveChildSession(session: SessionData): Promise<void> {
|
||||||
const sessionInfo: SessionInfo = {
|
await this.store.saveChildSession(session);
|
||||||
id: session.id,
|
|
||||||
projectId: session.projectId,
|
|
||||||
parentId: session.parentId,
|
|
||||||
agentName: session.agentName,
|
|
||||||
createdAt: new Date(session.createdAt).getTime(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
workdir: session.workdir,
|
|
||||||
title: session.title,
|
|
||||||
discoveredTools: session.discoveredTools,
|
|
||||||
};
|
|
||||||
await SessionStorage.save(sessionInfo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============================================================================
|
||||||
* 获取当前会话 ID
|
// 自动保存
|
||||||
*/
|
// ============================================================================
|
||||||
getSessionId(): string | undefined {
|
|
||||||
return this.currentSession?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 恢复指定会话
|
* 启动自动保存
|
||||||
*/
|
|
||||||
async restoreSession(sessionId: string): Promise<SessionData | null> {
|
|
||||||
if (!this.currentProject) {
|
|
||||||
throw new Error('Project not initialized. Call init() first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await this.loadSession(this.currentProject.id, sessionId);
|
|
||||||
if (!session) return null;
|
|
||||||
|
|
||||||
this.currentSession = session;
|
|
||||||
await this.setCurrentSessionPointer(sessionId);
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 列出当前项目的历史会话
|
|
||||||
*/
|
|
||||||
async listSessions(): Promise<SessionSummary[]> {
|
|
||||||
if (!this.currentProject) {
|
|
||||||
return this.listAllSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessions = await SessionStorage.listByProject(this.currentProject.id);
|
|
||||||
return sessions.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title || `会话 ${s.id}`,
|
|
||||||
workdir: s.workdir,
|
|
||||||
messageCount: s.stats?.messageCount || 0,
|
|
||||||
createdAt: new Date(s.createdAt).toISOString(),
|
|
||||||
updatedAt: new Date(s.updatedAt).toISOString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 列出所有项目的会话
|
|
||||||
*/
|
|
||||||
async listAllSessions(): Promise<SessionSummary[]> {
|
|
||||||
const sessions = await SessionStorage.listAll();
|
|
||||||
return sessions.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title || `会话 ${s.id}`,
|
|
||||||
workdir: s.workdir,
|
|
||||||
messageCount: s.stats?.messageCount || 0,
|
|
||||||
createdAt: new Date(s.createdAt).toISOString(),
|
|
||||||
updatedAt: new Date(s.updatedAt).toISOString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除历史会话
|
|
||||||
*/
|
|
||||||
async deleteSession(sessionId: string): Promise<boolean> {
|
|
||||||
if (!this.currentProject) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 删除会话的消息和 Parts
|
|
||||||
const messageInfos = await MessageStorage.listBySession(sessionId);
|
|
||||||
for (const msg of messageInfos) {
|
|
||||||
await PartStorage.removeByMessage(msg.id);
|
|
||||||
}
|
|
||||||
await MessageStorage.removeBySession(sessionId);
|
|
||||||
|
|
||||||
// 删除 todos
|
|
||||||
await TodoStorage.removeBySession(sessionId);
|
|
||||||
|
|
||||||
// 删除会话信息
|
|
||||||
await SessionStorage.remove(this.currentProject.id, sessionId);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前会话 ID(从存储)
|
|
||||||
*/
|
|
||||||
private async getCurrentSessionId(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const pointer = await storage.read<{ sessionId: string }>(['current-session']);
|
|
||||||
return pointer.sessionId;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置当前会话指针
|
|
||||||
*/
|
|
||||||
private async setCurrentSessionPointer(sessionId: string): Promise<void> {
|
|
||||||
await storage.write(['current-session'], { sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动自动保存(每 30 秒)
|
|
||||||
*/
|
*/
|
||||||
private startAutoSave(): void {
|
private startAutoSave(): void {
|
||||||
if (this.autoSaveInterval) return;
|
this.autoSave.start(() => this.save());
|
||||||
|
|
||||||
this.autoSaveInterval = setInterval(async () => {
|
|
||||||
await this.save();
|
|
||||||
}, 30000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止自动保存
|
* 停止自动保存
|
||||||
*/
|
*/
|
||||||
stopAutoSave(): void {
|
stopAutoSave(): void {
|
||||||
if (this.autoSaveInterval) {
|
this.autoSave.stop();
|
||||||
clearInterval(this.autoSaveInterval);
|
|
||||||
this.autoSaveInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -735,6 +296,10 @@ export class SessionManager {
|
|||||||
await this.save();
|
await this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 清理
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理旧会话
|
* 清理旧会话
|
||||||
*/
|
*/
|
||||||
@@ -756,6 +321,29 @@ export class SessionManager {
|
|||||||
return deletedCount;
|
return deletedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 辅助方法
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前会话 ID(从存储)
|
||||||
|
*/
|
||||||
|
private async getCurrentSessionId(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const pointer = await storage.read<{ sessionId: string }>(['current-session']);
|
||||||
|
return pointer.sessionId;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前会话指针
|
||||||
|
*/
|
||||||
|
private async setCurrentSessionPointer(sessionId: string): Promise<void> {
|
||||||
|
await storage.write(['current-session'], { sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取存储目录
|
* 获取存储目录
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* 消息格式转换器
|
||||||
|
* 负责 Part ↔ ModelMessage 的转换
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ModelMessage } from 'ai';
|
||||||
|
import { MessageStorage, PartStorage } from './storage/index.js';
|
||||||
|
import type { Part } from './storage/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息格式转换器
|
||||||
|
*/
|
||||||
|
export class MessageConverter {
|
||||||
|
/**
|
||||||
|
* 从存储加载消息并转换为 AI SDK 格式
|
||||||
|
*/
|
||||||
|
async loadFromStorage(sessionId: string): Promise<ModelMessage[]> {
|
||||||
|
const messageInfos = await MessageStorage.listBySession(sessionId);
|
||||||
|
const messages: ModelMessage[] = [];
|
||||||
|
|
||||||
|
for (const messageInfo of messageInfos) {
|
||||||
|
const parts = await PartStorage.getByIds(messageInfo.id, messageInfo.partIds);
|
||||||
|
const modelMessages = this.partsToModelMessages(messageInfo.role, parts);
|
||||||
|
messages.push(...modelMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Parts 转换为 AI SDK ModelMessage(用于加载历史消息)
|
||||||
|
*
|
||||||
|
* 逻辑:
|
||||||
|
* - user 消息:直接转换
|
||||||
|
* - assistant 消息:转换文本和工具调用,然后为已完成的工具生成 tool 消息
|
||||||
|
*/
|
||||||
|
partsToModelMessages(role: string, parts: Part[]): ModelMessage[] {
|
||||||
|
if (parts.length === 0) return [];
|
||||||
|
|
||||||
|
const result: ModelMessage[] = [];
|
||||||
|
|
||||||
|
if (role === 'user') {
|
||||||
|
result.push(...this.convertUserParts(parts));
|
||||||
|
} else if (role === 'assistant') {
|
||||||
|
result.push(...this.convertAssistantParts(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换用户消息 Parts
|
||||||
|
*/
|
||||||
|
private convertUserParts(parts: Part[]): ModelMessage[] {
|
||||||
|
const content: unknown[] = [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
content.push({ type: 'text', text: part.text });
|
||||||
|
} else if (part.type === 'file') {
|
||||||
|
content.push({
|
||||||
|
type: 'image',
|
||||||
|
image: part.data,
|
||||||
|
mimeType: part.mimeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.length === 0) return [];
|
||||||
|
|
||||||
|
if (content.length === 1 && (content[0] as { type: string }).type === 'text') {
|
||||||
|
return [{
|
||||||
|
role: 'user',
|
||||||
|
content: (content[0] as { text: string }).text,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
} as ModelMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换助手消息 Parts
|
||||||
|
*/
|
||||||
|
private convertAssistantParts(parts: Part[]): ModelMessage[] {
|
||||||
|
const result: ModelMessage[] = [];
|
||||||
|
const content: unknown[] = [];
|
||||||
|
const completedTools: Array<{
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
input: unknown;
|
||||||
|
output: unknown;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
content.push({ type: 'text', text: part.text });
|
||||||
|
} else if (part.type === 'tool') {
|
||||||
|
// 只有非 pending 状态的工具调用才添加到 AI SDK 消息
|
||||||
|
if (part.state.status !== 'pending') {
|
||||||
|
content.push({
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: part.toolCallId,
|
||||||
|
toolName: part.toolName,
|
||||||
|
input: part.state.input,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 收集已完成的工具结果
|
||||||
|
if (part.state.status === 'completed') {
|
||||||
|
completedTools.push({
|
||||||
|
toolCallId: part.toolCallId,
|
||||||
|
toolName: part.toolName,
|
||||||
|
input: part.state.input,
|
||||||
|
output: part.state.output,
|
||||||
|
});
|
||||||
|
} else if (part.state.status === 'error') {
|
||||||
|
completedTools.push({
|
||||||
|
toolCallId: part.toolCallId,
|
||||||
|
toolName: part.toolName,
|
||||||
|
input: part.state.input,
|
||||||
|
output: part.state.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (part.type === 'reasoning') {
|
||||||
|
content.push({ type: 'text', text: `[Reasoning] ${part.text}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 assistant 消息
|
||||||
|
if (content.length === 1 && (content[0] as { type: string }).type === 'text') {
|
||||||
|
result.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: (content[0] as { text: string }).text,
|
||||||
|
});
|
||||||
|
} else if (content.length > 0) {
|
||||||
|
result.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
} as ModelMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 tool 消息(如果有已完成的工具)
|
||||||
|
if (completedTools.length > 0) {
|
||||||
|
result.push({
|
||||||
|
role: 'tool',
|
||||||
|
content: completedTools.map((t) => ({
|
||||||
|
type: 'tool-result',
|
||||||
|
toolCallId: t.toolCallId,
|
||||||
|
toolName: t.toolName,
|
||||||
|
input: t.input,
|
||||||
|
output: t.output,
|
||||||
|
})),
|
||||||
|
} as unknown as ModelMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步消息到存储(将 AI SDK 消息转换为 Message + Parts)
|
||||||
|
*
|
||||||
|
* 逻辑:只存储 user 和 assistant 消息
|
||||||
|
* - user 消息:直接存储
|
||||||
|
* - assistant 消息:合并后续的 tool 消息中的工具结果
|
||||||
|
* - tool 消息:跳过(结果合并到 assistant)
|
||||||
|
*/
|
||||||
|
async syncToStorage(sessionId: string, messages: ModelMessage[]): Promise<void> {
|
||||||
|
// 删除旧消息
|
||||||
|
await MessageStorage.removeBySession(sessionId);
|
||||||
|
|
||||||
|
// 用于跟踪当前 assistant 消息的工具调用
|
||||||
|
let currentAssistantMsgId: string | null = null;
|
||||||
|
let currentUserMsgId: string | null = null;
|
||||||
|
const toolCallPartIds = new Map<string, string>(); // toolCallId -> partId
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === 'user') {
|
||||||
|
await this.syncUserMessage(sessionId, message);
|
||||||
|
currentUserMsgId = (await MessageStorage.listBySession(sessionId)).slice(-1)[0]?.id ?? null;
|
||||||
|
currentAssistantMsgId = null;
|
||||||
|
toolCallPartIds.clear();
|
||||||
|
} else if (message.role === 'assistant') {
|
||||||
|
const result = await this.syncAssistantMessage(
|
||||||
|
sessionId,
|
||||||
|
message,
|
||||||
|
currentAssistantMsgId,
|
||||||
|
currentUserMsgId,
|
||||||
|
toolCallPartIds
|
||||||
|
);
|
||||||
|
currentAssistantMsgId = result.messageId;
|
||||||
|
result.toolCallPartIds.forEach((v, k) => toolCallPartIds.set(k, v));
|
||||||
|
} else if (message.role === 'tool' && currentAssistantMsgId) {
|
||||||
|
await this.syncToolMessage(currentAssistantMsgId, message, toolCallPartIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步用户消息
|
||||||
|
*/
|
||||||
|
private async syncUserMessage(sessionId: string, message: ModelMessage): Promise<void> {
|
||||||
|
const messageInfo = await MessageStorage.create(sessionId, 'user');
|
||||||
|
const partIds: string[] = [];
|
||||||
|
|
||||||
|
if (typeof message.content === 'string') {
|
||||||
|
const part = await PartStorage.createText(messageInfo.id, message.content);
|
||||||
|
partIds.push(part.id);
|
||||||
|
} else if (Array.isArray(message.content)) {
|
||||||
|
for (const item of message.content) {
|
||||||
|
const itemType = (item as { type: string }).type;
|
||||||
|
if (itemType === 'text') {
|
||||||
|
const part = await PartStorage.createText(messageInfo.id, (item as { text: string }).text);
|
||||||
|
partIds.push(part.id);
|
||||||
|
} else if (itemType === 'image') {
|
||||||
|
const img = item as unknown as { image: string; mimeType: string };
|
||||||
|
const part = await PartStorage.create(messageInfo.id, 'file', {
|
||||||
|
filename: 'image',
|
||||||
|
mimeType: img.mimeType,
|
||||||
|
data: typeof img.image === 'string' ? img.image : '',
|
||||||
|
});
|
||||||
|
partIds.push(part.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partIds.length > 0) {
|
||||||
|
await MessageStorage.update(sessionId, messageInfo.id, { partIds });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步助手消息
|
||||||
|
*/
|
||||||
|
private async syncAssistantMessage(
|
||||||
|
sessionId: string,
|
||||||
|
message: ModelMessage,
|
||||||
|
currentAssistantMsgId: string | null,
|
||||||
|
currentUserMsgId: string | null,
|
||||||
|
existingToolCallPartIds: Map<string, string>
|
||||||
|
): Promise<{ messageId: string; toolCallPartIds: Map<string, string> }> {
|
||||||
|
let messageId: string;
|
||||||
|
let existingPartIds: string[] = [];
|
||||||
|
const newToolCallPartIds = new Map<string, string>();
|
||||||
|
|
||||||
|
if (currentAssistantMsgId) {
|
||||||
|
// 同一轮对话的后续 assistant 消息,追加到现有消息
|
||||||
|
messageId = currentAssistantMsgId;
|
||||||
|
const existingMsg = await MessageStorage.get(sessionId, messageId);
|
||||||
|
existingPartIds = existingMsg?.partIds ?? [];
|
||||||
|
} else {
|
||||||
|
// 新的 assistant 消息
|
||||||
|
const messageInfo = await MessageStorage.create(sessionId, 'assistant', {
|
||||||
|
parentId: currentUserMsgId ?? undefined,
|
||||||
|
});
|
||||||
|
messageId = messageInfo.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPartIds: string[] = [];
|
||||||
|
|
||||||
|
if (typeof message.content === 'string') {
|
||||||
|
const part = await PartStorage.createText(messageId, message.content);
|
||||||
|
newPartIds.push(part.id);
|
||||||
|
} else if (Array.isArray(message.content)) {
|
||||||
|
for (const item of message.content) {
|
||||||
|
const itemType = (item as { type: string }).type;
|
||||||
|
if (itemType === 'text') {
|
||||||
|
const part = await PartStorage.createText(messageId, (item as { text: string }).text);
|
||||||
|
newPartIds.push(part.id);
|
||||||
|
} else if (itemType === 'tool-call') {
|
||||||
|
const toolCall = item as unknown as {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const part = await PartStorage.createToolRunning(
|
||||||
|
messageId,
|
||||||
|
toolCall.toolCallId,
|
||||||
|
toolCall.toolName,
|
||||||
|
(toolCall.input as Record<string, unknown>) ?? {}
|
||||||
|
);
|
||||||
|
newPartIds.push(part.id);
|
||||||
|
newToolCallPartIds.set(toolCall.toolCallId, part.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPartIds.length > 0) {
|
||||||
|
const allPartIds = [...existingPartIds, ...newPartIds];
|
||||||
|
await MessageStorage.update(sessionId, messageId, { partIds: allPartIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并工具调用 ID
|
||||||
|
existingToolCallPartIds.forEach((v, k) => newToolCallPartIds.set(k, v));
|
||||||
|
|
||||||
|
return { messageId, toolCallPartIds: newToolCallPartIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步工具消息(更新工具状态)
|
||||||
|
*/
|
||||||
|
private async syncToolMessage(
|
||||||
|
assistantMsgId: string,
|
||||||
|
message: ModelMessage,
|
||||||
|
toolCallPartIds: Map<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!Array.isArray(message.content)) return;
|
||||||
|
|
||||||
|
for (const item of message.content) {
|
||||||
|
const itemType = (item as { type: string }).type;
|
||||||
|
if (itemType === 'tool-result') {
|
||||||
|
const toolResult = item as unknown as {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
output: unknown;
|
||||||
|
};
|
||||||
|
const partId = toolCallPartIds.get(toolResult.toolCallId);
|
||||||
|
if (partId) {
|
||||||
|
const part = await PartStorage.get(assistantMsgId, partId);
|
||||||
|
const startTime =
|
||||||
|
part?.type === 'tool' && part.state.status === 'running'
|
||||||
|
? part.state.time.start
|
||||||
|
: Date.now();
|
||||||
|
await PartStorage.setToolCompleted(assistantMsgId, partId, toolResult.output, startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 项目管理器
|
||||||
|
* 负责项目的创建和管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as storage from './storage/index.js';
|
||||||
|
import { getProjectId, isGitRepository } from './project.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目元数据
|
||||||
|
*/
|
||||||
|
export interface ProjectMetadata {
|
||||||
|
id: string;
|
||||||
|
workdir: string;
|
||||||
|
createdAt: string;
|
||||||
|
isGitRepo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目管理器
|
||||||
|
*/
|
||||||
|
export class ProjectManager {
|
||||||
|
private currentProject: ProjectMetadata | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前项目
|
||||||
|
*/
|
||||||
|
getProject(): ProjectMetadata | null {
|
||||||
|
return this.currentProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前项目
|
||||||
|
*/
|
||||||
|
setProject(project: ProjectMetadata | null): void {
|
||||||
|
this.currentProject = project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建项目
|
||||||
|
*/
|
||||||
|
async getOrCreate(workdir: string): Promise<ProjectMetadata> {
|
||||||
|
const projectId = await getProjectId(workdir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await storage.read<ProjectMetadata>(['project', projectId]);
|
||||||
|
this.currentProject = existing;
|
||||||
|
return existing;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof storage.StorageNotFoundError) {
|
||||||
|
const isGitRepo = await isGitRepository(workdir);
|
||||||
|
const project: ProjectMetadata = {
|
||||||
|
id: projectId,
|
||||||
|
workdir,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
isGitRepo,
|
||||||
|
};
|
||||||
|
await storage.write(['project', projectId], project);
|
||||||
|
this.currentProject = project;
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换项目
|
||||||
|
*/
|
||||||
|
async switchProject(workdir: string): Promise<ProjectMetadata> {
|
||||||
|
return this.getOrCreate(workdir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查项目是否初始化
|
||||||
|
*/
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return this.currentProject !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取项目 ID
|
||||||
|
*/
|
||||||
|
getProjectId(): string | null {
|
||||||
|
return this.currentProject?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* 会话自动保存
|
||||||
|
* 负责定期保存会话
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存回调类型
|
||||||
|
*/
|
||||||
|
export type SaveCallback = () => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动保存配置
|
||||||
|
*/
|
||||||
|
export interface AutoSaveConfig {
|
||||||
|
/** 保存间隔(毫秒),默认 30000 */
|
||||||
|
interval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: AutoSaveConfig = {
|
||||||
|
interval: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话自动保存管理
|
||||||
|
*/
|
||||||
|
export class SessionAutoSave {
|
||||||
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private saveCallback: SaveCallback | null = null;
|
||||||
|
private config: AutoSaveConfig;
|
||||||
|
|
||||||
|
constructor(config?: Partial<AutoSaveConfig>) {
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动自动保存
|
||||||
|
*/
|
||||||
|
start(saveCallback: SaveCallback): void {
|
||||||
|
if (this.intervalId) return;
|
||||||
|
|
||||||
|
this.saveCallback = saveCallback;
|
||||||
|
this.intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await this.saveCallback?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Auto-save failed:', error);
|
||||||
|
}
|
||||||
|
}, this.config.interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止自动保存
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
this.saveCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否正在运行
|
||||||
|
*/
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.intervalId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立即触发保存(不影响定时器)
|
||||||
|
*/
|
||||||
|
async saveNow(): Promise<void> {
|
||||||
|
await this.saveCallback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新配置
|
||||||
|
*/
|
||||||
|
setConfig(config: Partial<AutoSaveConfig>): void {
|
||||||
|
const wasRunning = this.isRunning();
|
||||||
|
const callback = this.saveCallback;
|
||||||
|
|
||||||
|
if (wasRunning) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
|
||||||
|
if (wasRunning && callback) {
|
||||||
|
this.start(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* 会话存储管理
|
||||||
|
* 负责会话的 CRUD 操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ModelMessage } from 'ai';
|
||||||
|
import { SessionStorage, MessageStorage, PartStorage, TodoStorage } from './storage/index.js';
|
||||||
|
import type { SessionInfo, TodoItem } from './storage/index.js';
|
||||||
|
import { MessageConverter } from './message-converter.js';
|
||||||
|
import { generateSessionId } from './id.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话摘要(用于列表展示)
|
||||||
|
*/
|
||||||
|
export interface SessionSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
workdir: string;
|
||||||
|
messageCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时会话数据
|
||||||
|
*/
|
||||||
|
export interface SessionData {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
parentId?: string;
|
||||||
|
agentName?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workdir: string;
|
||||||
|
title?: string;
|
||||||
|
messages: ModelMessage[];
|
||||||
|
discoveredTools: string[];
|
||||||
|
todos: TodoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话存储管理
|
||||||
|
*/
|
||||||
|
export class SessionStore {
|
||||||
|
private messageConverter: MessageConverter;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.messageConverter = new MessageConverter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新会话
|
||||||
|
*/
|
||||||
|
async create(projectId: string, workdir: string): Promise<SessionData> {
|
||||||
|
const sessionInfo = await SessionStorage.create(projectId, workdir);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sessionInfo.id,
|
||||||
|
projectId: sessionInfo.projectId,
|
||||||
|
createdAt: new Date(sessionInfo.createdAt).toISOString(),
|
||||||
|
updatedAt: new Date(sessionInfo.updatedAt).toISOString(),
|
||||||
|
workdir: sessionInfo.workdir,
|
||||||
|
title: sessionInfo.title,
|
||||||
|
messages: [],
|
||||||
|
discoveredTools: sessionInfo.discoveredTools,
|
||||||
|
todos: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载会话(从存储重建)
|
||||||
|
*/
|
||||||
|
async load(projectId: string, sessionId: string): Promise<SessionData | null> {
|
||||||
|
const sessionInfo = await SessionStorage.get(projectId, sessionId);
|
||||||
|
if (!sessionInfo) return null;
|
||||||
|
|
||||||
|
// 加载消息
|
||||||
|
const messages = await this.messageConverter.loadFromStorage(sessionId);
|
||||||
|
|
||||||
|
// 加载 todos
|
||||||
|
const todoList = await TodoStorage.get(sessionId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sessionInfo.id,
|
||||||
|
projectId: sessionInfo.projectId,
|
||||||
|
parentId: sessionInfo.parentId,
|
||||||
|
agentName: sessionInfo.agentName,
|
||||||
|
createdAt: new Date(sessionInfo.createdAt).toISOString(),
|
||||||
|
updatedAt: new Date(sessionInfo.updatedAt).toISOString(),
|
||||||
|
workdir: sessionInfo.workdir,
|
||||||
|
title: sessionInfo.title,
|
||||||
|
messages,
|
||||||
|
discoveredTools: sessionInfo.discoveredTools,
|
||||||
|
todos: todoList?.items || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存会话信息
|
||||||
|
*/
|
||||||
|
async save(session: SessionData): Promise<void> {
|
||||||
|
const sessionInfo: SessionInfo = {
|
||||||
|
id: session.id,
|
||||||
|
projectId: session.projectId,
|
||||||
|
parentId: session.parentId,
|
||||||
|
agentName: session.agentName,
|
||||||
|
createdAt: new Date(session.createdAt).getTime(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
workdir: session.workdir,
|
||||||
|
title: session.title,
|
||||||
|
discoveredTools: session.discoveredTools,
|
||||||
|
stats: {
|
||||||
|
messageCount: session.messages.length,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await SessionStorage.save(sessionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步消息到存储
|
||||||
|
*/
|
||||||
|
async syncMessages(sessionId: string, messages: ModelMessage[]): Promise<void> {
|
||||||
|
await this.messageConverter.syncToStorage(sessionId, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新待办事项
|
||||||
|
*/
|
||||||
|
async setTodos(
|
||||||
|
sessionId: string,
|
||||||
|
todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed' }>
|
||||||
|
): Promise<TodoItem[]> {
|
||||||
|
const todoList = await TodoStorage.replace(sessionId, todos);
|
||||||
|
return todoList.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出项目的所有会话
|
||||||
|
*/
|
||||||
|
async listByProject(projectId: string): Promise<SessionSummary[]> {
|
||||||
|
const sessions = await SessionStorage.listByProject(projectId);
|
||||||
|
return this.toSummaries(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出所有会话
|
||||||
|
*/
|
||||||
|
async listAll(): Promise<SessionSummary[]> {
|
||||||
|
const sessions = await SessionStorage.listAll();
|
||||||
|
return this.toSummaries(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除会话
|
||||||
|
*/
|
||||||
|
async delete(projectId: string, sessionId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// 删除会话的消息和 Parts
|
||||||
|
const messageInfos = await MessageStorage.listBySession(sessionId);
|
||||||
|
for (const msg of messageInfos) {
|
||||||
|
await PartStorage.removeByMessage(msg.id);
|
||||||
|
}
|
||||||
|
await MessageStorage.removeBySession(sessionId);
|
||||||
|
|
||||||
|
// 删除 todos
|
||||||
|
await TodoStorage.removeBySession(sessionId);
|
||||||
|
|
||||||
|
// 删除会话信息
|
||||||
|
await SessionStorage.remove(projectId, sessionId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建子会话(用于 Task 工具)
|
||||||
|
*/
|
||||||
|
createChildSession(
|
||||||
|
projectId: string,
|
||||||
|
parentId: string,
|
||||||
|
agentName: string,
|
||||||
|
workdir: string,
|
||||||
|
title?: string
|
||||||
|
): SessionData {
|
||||||
|
return {
|
||||||
|
id: generateSessionId(),
|
||||||
|
projectId,
|
||||||
|
parentId,
|
||||||
|
agentName,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
workdir,
|
||||||
|
title: title || `子任务 (@${agentName})`,
|
||||||
|
messages: [],
|
||||||
|
discoveredTools: [],
|
||||||
|
todos: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存子会话
|
||||||
|
*/
|
||||||
|
async saveChildSession(session: SessionData): Promise<void> {
|
||||||
|
const sessionInfo: SessionInfo = {
|
||||||
|
id: session.id,
|
||||||
|
projectId: session.projectId,
|
||||||
|
parentId: session.parentId,
|
||||||
|
agentName: session.agentName,
|
||||||
|
createdAt: new Date(session.createdAt).getTime(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
workdir: session.workdir,
|
||||||
|
title: session.title,
|
||||||
|
discoveredTools: session.discoveredTools,
|
||||||
|
};
|
||||||
|
await SessionStorage.save(sessionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为摘要列表
|
||||||
|
*/
|
||||||
|
private toSummaries(sessions: SessionInfo[]): SessionSummary[] {
|
||||||
|
return sessions.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title || `会话 ${s.id}`,
|
||||||
|
workdir: s.workdir,
|
||||||
|
messageCount: s.stats?.messageCount || 0,
|
||||||
|
createdAt: new Date(s.createdAt).toISOString(),
|
||||||
|
updatedAt: new Date(s.updatedAt).toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user