feat(checkpoint): 添加 Checkpoint 可视化管理功能

Core 层增强:
- 添加 safety.ts: 7点安全检查机制
- 添加 session-tracker.ts: 会话级检查点跟踪
- 添加 lock.ts: 并发控制文件锁
- 添加 lfs.ts: Git LFS 大文件支持
- 添加 path-validator.ts: 路径验证
- 添加 commit-message.ts: 智能提交消息生成
- 增强 manager.ts: 支持三种恢复模式、unrevert 撤销回滚

Server 层:
- 添加 checkpoints.ts: 16个 REST API 端点
  - GET/POST /checkpoints: 列表/创建检查点
  - GET/DELETE /checkpoints/🆔 获取/删除检查点
  - GET /checkpoints/:id/diff: 获取差异
  - POST /checkpoints/:id/restore: 恢复到检查点
  - POST /checkpoints/unrevert: 撤销回滚
  - GET /checkpoints/:id/safety-check: 安全检查

UI 层:
- 添加 CheckpointPanel.tsx: 检查点列表面板
- 添加 CheckpointDiffViewer.tsx: 差异查看器
- 添加 RestoreDialog.tsx: 恢复确认对话框
- 添加 16 个 API 客户端函数
- 添加完整的 TypeScript 类型定义

Web/Desktop 集成:
- 添加 History 按钮到工具栏
- 集成 CheckpointPanel 组件
This commit is contained in:
2025-12-12 22:52:27 +08:00
parent a225e66ad7
commit cb554c65b4
23 changed files with 4970 additions and 116 deletions
@@ -0,0 +1,275 @@
/**
* 智能提交消息生成模块
* 参考 Aider 的 AI 提交消息生成
*/
import * as path from 'path';
import type { CheckpointTrigger, FileChange } from './types.js';
/**
* 工具调用信息
*/
interface ToolCallInfo {
tool: string;
params: Record<string, unknown>;
}
/**
* 提交消息生成器
* 生成人类可读的检查点提交消息
*/
export class CommitMessageGenerator {
/**
* 生成提交消息
*/
generateMessage(
trigger: CheckpointTrigger,
toolCall?: ToolCallInfo,
filesChanged?: FileChange[]
): string {
const prefix = this.getTriggerPrefix(trigger);
const description = this.getDescription(trigger, toolCall, filesChanged);
const body = this.generateBody(trigger, toolCall, filesChanged);
if (body) {
return `${prefix}: ${description}\n\n${body}`;
}
return `${prefix}: ${description}`;
}
/**
* 获取触发类型的前缀
*/
private getTriggerPrefix(trigger: CheckpointTrigger): string {
const prefixes: Record<CheckpointTrigger, string> = {
auto: 'auto',
manual: 'checkpoint',
'tool:write_file': 'write',
'tool:edit_file': 'edit',
'tool:delete_file': 'delete',
'tool:move_file': 'move',
'tool:copy_file': 'copy',
'tool:bash': 'bash',
task_start: 'session-start',
task_complete: 'session-end',
pre_rollback: 'pre-rollback',
session_start: 'session-start',
session_end: 'session-end',
};
return prefixes[trigger] || 'checkpoint';
}
/**
* 生成描述
*/
private getDescription(
trigger: CheckpointTrigger,
toolCall?: ToolCallInfo,
filesChanged?: FileChange[]
): string {
// 从工具调用参数获取文件路径
if (toolCall) {
const filePath =
(toolCall.params.file_path as string) ||
(toolCall.params.path as string);
if (filePath) {
return this.formatFilePath(filePath);
}
// bash 命令
if (toolCall.tool === 'bash') {
const command = String(toolCall.params.command || '');
return this.formatCommand(command);
}
// 移动/复制操作
if (toolCall.params.source && toolCall.params.destination) {
const source = this.formatFilePath(String(toolCall.params.source));
const dest = this.formatFilePath(String(toolCall.params.destination));
return `${source} -> ${dest}`;
}
}
// 从文件变更列表获取描述
if (filesChanged && filesChanged.length > 0) {
if (filesChanged.length === 1) {
return this.formatFilePath(filesChanged[0].path);
}
return `${filesChanged.length} files`;
}
// 根据触发类型生成描述
switch (trigger) {
case 'task_start':
case 'session_start':
return 'begin session';
case 'task_complete':
case 'session_end':
return 'end session';
case 'pre_rollback':
return 'state before rollback';
case 'manual':
return 'manual checkpoint';
default:
return 'state snapshot';
}
}
/**
* 生成消息正文
*/
private generateBody(
trigger: CheckpointTrigger,
toolCall?: ToolCallInfo,
filesChanged?: FileChange[]
): string {
const lines: string[] = [];
// 时间戳
lines.push(`Time: ${new Date().toISOString()}`);
// 触发类型
lines.push(`Trigger: ${trigger}`);
// 工具信息
if (toolCall) {
lines.push(`Tool: ${toolCall.tool}`);
}
// 文件变更统计
if (filesChanged && filesChanged.length > 0) {
const stats = this.calculateStats(filesChanged);
lines.push(`Files: ${filesChanged.length}`);
if (stats.insertions > 0 || stats.deletions > 0) {
lines.push(`Changes: +${stats.insertions} -${stats.deletions}`);
}
}
return lines.join('\n');
}
/**
* 格式化文件路径(只保留文件名和父目录)
*/
private formatFilePath(filePath: string): string {
const parts = filePath.split(path.sep);
if (parts.length <= 2) {
return filePath;
}
// 返回 父目录/文件名 格式
return parts.slice(-2).join('/');
}
/**
* 格式化命令(截断过长的命令)
*/
private formatCommand(command: string): string {
const maxLength = 50;
const trimmed = command.trim();
if (trimmed.length <= maxLength) {
return `"${trimmed}"`;
}
return `"${trimmed.slice(0, maxLength - 3)}..."`;
}
/**
* 计算文件变更统计
*/
private calculateStats(filesChanged: FileChange[]): {
insertions: number;
deletions: number;
added: number;
modified: number;
deleted: number;
renamed: number;
} {
let insertions = 0;
let deletions = 0;
let added = 0;
let modified = 0;
let deleted = 0;
let renamed = 0;
for (const file of filesChanged) {
insertions += file.insertions || 0;
deletions += file.deletions || 0;
switch (file.type) {
case 'added':
added++;
break;
case 'modified':
modified++;
break;
case 'deleted':
deleted++;
break;
case 'renamed':
renamed++;
break;
}
}
return { insertions, deletions, added, modified, deleted, renamed };
}
/**
* 生成简短的提交消息(单行)
*/
generateShortMessage(
trigger: CheckpointTrigger,
toolCall?: ToolCallInfo
): string {
const prefix = this.getTriggerPrefix(trigger);
const description = this.getDescription(trigger, toolCall);
return `${prefix}: ${description}`;
}
/**
* 从提交消息解析元数据
*/
parseMessage(message: string): {
prefix: string;
description: string;
metadata: Record<string, string>;
} {
const lines = message.split('\n');
const firstLine = lines[0] || '';
// 解析第一行: prefix: description
const [prefix, ...descParts] = firstLine.split(':');
const description = descParts.join(':').trim();
// 解析元数据
const metadata: Record<string, string> = {};
for (let i = 2; i < lines.length; i++) {
const line = lines[i];
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
metadata[key] = value;
}
}
return {
prefix: prefix.trim(),
description,
metadata,
};
}
}
/**
* 创建提交消息生成器实例
*/
export function createCommitMessageGenerator(): CommitMessageGenerator {
return new CommitMessageGenerator();
}
+74 -17
View File
@@ -2,7 +2,16 @@
* 检查点系统模块 * 检查点系统模块
* *
* 提供工作区快照和回滚功能,使用 Shadow Git 架构 * 提供工作区快照和回滚功能,使用 Shadow Git 架构
* 参考 Cline 的实现 * 参考 Cline 的实现,并增强了以下功能:
* - Unrevert 撤销回滚
* - 7 点安全检查机制
* - 三种恢复模式
* - 消息级检查点关联
* - 会话级跟踪
* - 并发控制(文件锁)
* - LFS 大文件支持
* - 工作区路径验证
* - 智能提交消息生成
*/ */
// 检查点管理器 // 检查点管理器
@@ -16,20 +25,68 @@ export {
// Shadow Git // Shadow Git
export { ShadowGit, createShadowGit, hashWorkingDir } from './shadow-git.js'; export { ShadowGit, createShadowGit, hashWorkingDir } from './shadow-git.js';
// 类型 // 安全检查器
export type { export {
CheckpointMetadata, CheckpointSafetyChecker,
CheckpointConfig, createSafetyChecker,
CheckpointTrigger, } from './safety.js';
FileChange,
FileChangeType,
DiffInfo,
FileDiff,
RollbackOptions,
RollbackResult,
CheckpointEvent,
CheckpointEventType,
CheckpointEventListener,
} from './types.js';
export { DEFAULT_CHECKPOINT_CONFIG } from './types.js'; // 会话跟踪器
export {
SessionTracker,
createSessionTracker,
} from './session-tracker.js';
// 并发锁
export { CheckpointLock } from './lock.js';
// LFS 支持
export {
LFSPatternLoader,
createLFSPatternLoader,
isCommonLargeFile,
COMMON_LARGE_FILE_EXTENSIONS,
} from './lfs.js';
// 路径验证器
export {
WorkspacePathValidator,
createPathValidator,
} from './path-validator.js';
// 提交消息生成器
export {
CommitMessageGenerator,
createCommitMessageGenerator,
} from './commit-message.js';
// 类型
export {
// 基础类型
type CheckpointMetadata,
type CheckpointConfig,
type CheckpointTrigger,
type FileChange,
type FileChangeType,
type DiffInfo,
type FileDiff,
type RollbackOptions,
type RollbackResult,
type CheckpointEvent,
type CheckpointEventType,
type CheckpointEventListener,
// 恢复模式
RestoreMode,
// Unrevert 相关
type RollbackRecord,
type UnrevertResult,
// 安全检查
type SafetyCheckResult,
// 会话跟踪
type SessionState,
type SessionStats,
// 路径验证
type PathValidationResult,
// 默认配置
DEFAULT_CHECKPOINT_CONFIG,
} from './types.js';
+189
View File
@@ -0,0 +1,189 @@
/**
* Git LFS 大文件支持模块
* 参考 Cline 的 LFS 模式检测
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { minimatch } from 'minimatch';
/**
* LFS 模式加载器
* 从 .gitattributes 文件中加载 LFS 模式
*/
export class LFSPatternLoader {
private patterns: string[] = [];
private loaded = false;
/**
* 从工作目录加载 LFS 模式
*/
async loadPatterns(workDir: string): Promise<void> {
const gitattributesPath = path.join(workDir, '.gitattributes');
try {
const content = await fs.readFile(gitattributesPath, 'utf-8');
this.patterns = this.parseLfsPatterns(content);
this.loaded = true;
} catch {
// .gitattributes 不存在或无法读取
this.patterns = [];
this.loaded = true;
}
}
/**
* 解析 .gitattributes 内容,提取 LFS 模式
*/
private parseLfsPatterns(content: string): string[] {
const patterns: string[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
// 跳过空行和注释
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
// 匹配 LFS 配置行: pattern filter=lfs diff=lfs merge=lfs -text
// 或简单形式: pattern filter=lfs
if (trimmed.includes('filter=lfs')) {
// 提取模式(第一个空白字符前的部分)
const match = trimmed.match(/^(\S+)/);
if (match) {
patterns.push(match[1]);
}
}
}
return patterns;
}
/**
* 检查文件是否为 LFS 管理的文件
*/
isLfsFile(filePath: string): boolean {
if (!this.loaded) {
return false;
}
// 规范化路径(使用正斜杠)
const normalizedPath = filePath.replace(/\\/g, '/');
return this.patterns.some((pattern) =>
minimatch(normalizedPath, pattern, { matchBase: true })
);
}
/**
* 获取所有 LFS 模式
*/
getPatterns(): string[] {
return [...this.patterns];
}
/**
* 获取排除模式(用于 .gitignore
*/
getExcludePatterns(): string[] {
return this.patterns.map((p) => {
// 确保模式以 / 开头(相对于仓库根目录)
if (!p.startsWith('/') && !p.startsWith('*')) {
return `/${p}`;
}
return p;
});
}
/**
* 检查是否已加载
*/
isLoaded(): boolean {
return this.loaded;
}
/**
* 重置(清除已加载的模式)
*/
reset(): void {
this.patterns = [];
this.loaded = false;
}
/**
* 过滤文件列表,排除 LFS 文件
*/
filterNonLfsFiles(files: string[]): string[] {
return files.filter((file) => !this.isLfsFile(file));
}
/**
* 获取 LFS 文件列表
*/
filterLfsFiles(files: string[]): string[] {
return files.filter((file) => this.isLfsFile(file));
}
}
/**
* 常见的大文件扩展名(作为 LFS 候选)
*/
export const COMMON_LARGE_FILE_EXTENSIONS = [
// 图片
'.psd',
'.ai',
'.eps',
'.tiff',
'.raw',
'.cr2',
'.nef',
// 视频
'.mp4',
'.mov',
'.avi',
'.mkv',
'.wmv',
'.flv',
// 音频
'.mp3',
'.wav',
'.flac',
'.aac',
'.ogg',
// 压缩文件
'.zip',
'.tar',
'.gz',
'.rar',
'.7z',
// 二进制
'.exe',
'.dll',
'.so',
'.dylib',
// 数据文件
'.db',
'.sqlite',
'.mdb',
// 其他
'.pdf',
'.docx',
'.xlsx',
'.pptx',
];
/**
* 检查文件扩展名是否为常见大文件类型
*/
export function isCommonLargeFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return COMMON_LARGE_FILE_EXTENSIONS.includes(ext);
}
/**
* 创建 LFS 模式加载器实例
*/
export function createLFSPatternLoader(): LFSPatternLoader {
return new LFSPatternLoader();
}
+229
View File
@@ -0,0 +1,229 @@
/**
* 检查点并发控制模块
* 参考 Cline 的 proper-lockfile 机制
*/
import * as fs from 'fs/promises';
import * as path from 'path';
/**
* 锁文件内容
*/
interface LockInfo {
pid: number;
hostname: string;
timestamp: number;
}
/**
* 锁配置
*/
interface LockOptions {
/** 锁过期时间(毫秒) */
stale?: number;
/** 重试次数 */
retries?: number;
/** 重试间隔(毫秒) */
retryDelay?: number;
}
const DEFAULT_OPTIONS: Required<LockOptions> = {
stale: 30000, // 30 秒过期
retries: 10,
retryDelay: 100,
};
/**
* 检查点锁管理器
* 防止多个实例同时操作同一工作区的检查点
*/
export class CheckpointLock {
private lockPath: string;
private locked = false;
private options: Required<LockOptions>;
constructor(shadowGitDir: string, options: LockOptions = {}) {
this.lockPath = path.join(shadowGitDir, '.checkpoint.lock');
this.options = { ...DEFAULT_OPTIONS, ...options };
}
/**
* 获取锁
*/
async acquire(): Promise<void> {
let lastError: Error | null = null;
for (let i = 0; i < this.options.retries; i++) {
try {
await this.tryAcquire();
this.locked = true;
return;
} catch (error) {
lastError = error as Error;
// 检查是否为过期锁
if (await this.isLockStale()) {
await this.forceRelease();
continue;
}
// 等待后重试
await this.sleep(this.options.retryDelay);
}
}
throw new Error(
`Failed to acquire checkpoint lock after ${this.options.retries} retries: ${lastError?.message}`
);
}
/**
* 尝试获取锁
*/
private async tryAcquire(): Promise<void> {
const lockInfo: LockInfo = {
pid: process.pid,
hostname: this.getHostname(),
timestamp: Date.now(),
};
// 确保目录存在
const lockDir = path.dirname(this.lockPath);
await fs.mkdir(lockDir, { recursive: true });
// 使用 exclusive 标志创建锁文件
const handle = await fs.open(this.lockPath, 'wx');
try {
await handle.writeFile(JSON.stringify(lockInfo, null, 2));
} finally {
await handle.close();
}
}
/**
* 释放锁
*/
async release(): Promise<void> {
if (!this.locked) {
return;
}
try {
// 验证锁是否属于当前进程
const lockInfo = await this.readLockInfo();
if (lockInfo && lockInfo.pid === process.pid) {
await fs.unlink(this.lockPath);
}
} catch {
// 忽略错误
} finally {
this.locked = false;
}
}
/**
* 强制释放锁(用于清理过期锁)
*/
private async forceRelease(): Promise<void> {
try {
await fs.unlink(this.lockPath);
} catch {
// 忽略错误
}
}
/**
* 检查锁是否过期
*/
private async isLockStale(): Promise<boolean> {
try {
const lockInfo = await this.readLockInfo();
if (!lockInfo) {
return true;
}
const age = Date.now() - lockInfo.timestamp;
return age > this.options.stale;
} catch {
return true;
}
}
/**
* 读取锁信息
*/
private async readLockInfo(): Promise<LockInfo | null> {
try {
const content = await fs.readFile(this.lockPath, 'utf-8');
return JSON.parse(content) as LockInfo;
} catch {
return null;
}
}
/**
* 获取主机名
*/
private getHostname(): string {
try {
return require('os').hostname();
} catch {
return 'unknown';
}
}
/**
* 等待
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 带锁执行操作
*/
async withLock<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire();
try {
return await fn();
} finally {
await this.release();
}
}
/**
* 检查是否已锁定
*/
isLocked(): boolean {
return this.locked;
}
/**
* 检查锁文件是否存在
*/
async lockExists(): Promise<boolean> {
try {
await fs.access(this.lockPath);
return true;
} catch {
return false;
}
}
/**
* 获取锁持有者信息
*/
async getLockHolder(): Promise<LockInfo | null> {
return this.readLockInfo();
}
}
/**
* 创建检查点锁实例
*/
export function createCheckpointLock(
shadowGitDir: string,
options?: LockOptions
): CheckpointLock {
return new CheckpointLock(shadowGitDir, options);
}
+495 -93
View File
@@ -3,22 +3,29 @@
* 管理检查点的创建、回滚、清理等操作 * 管理检查点的创建、回滚、清理等操作
*/ */
import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { ShadowGit, createShadowGit } from './shadow-git.js'; import { ShadowGit, createShadowGit } from './shadow-git.js';
import type { import { CheckpointLock } from './lock.js';
CheckpointMetadata, import { CheckpointSafetyChecker } from './safety.js';
CheckpointConfig, import { WorkspacePathValidator } from './path-validator.js';
CheckpointTrigger, import { CommitMessageGenerator } from './commit-message.js';
RollbackOptions, import { LFSPatternLoader } from './lfs.js';
RollbackResult, import {
DiffInfo, RestoreMode,
FileDiff, type CheckpointMetadata,
CheckpointEvent, type CheckpointConfig,
CheckpointEventListener, type CheckpointTrigger,
DEFAULT_CHECKPOINT_CONFIG, type RollbackOptions,
type RollbackResult,
type DiffInfo,
type FileDiff,
type CheckpointEvent,
type CheckpointEventListener,
type RollbackRecord,
type UnrevertResult,
type SafetyCheckResult,
} from './types.js'; } from './types.js';
/** /**
@@ -38,6 +45,20 @@ export class CheckpointManager {
private lastCheckpointTime = 0; private lastCheckpointTime = 0;
private eventListeners: Set<CheckpointEventListener> = new Set(); private eventListeners: Set<CheckpointEventListener> = new Set();
// 新增:增强功能组件
private lock: CheckpointLock;
private safetyChecker: CheckpointSafetyChecker;
private pathValidator: WorkspacePathValidator;
private commitMessageGenerator: CommitMessageGenerator;
private lfsLoader: LFSPatternLoader;
// 新增:Unrevert 支持
private lastRollback: RollbackRecord | null = null;
// 新增:会话跟踪
private currentSessionId: string | null = null;
private sessionCheckpoints: Map<string, string[]> = new Map();
// 防止重复创建检查点的最小间隔 (毫秒) // 防止重复创建检查点的最小间隔 (毫秒)
private static readonly MIN_CHECKPOINT_INTERVAL = 1000; private static readonly MIN_CHECKPOINT_INTERVAL = 1000;
@@ -59,6 +80,13 @@ export class CheckpointManager {
}; };
this.shadowGit = createShadowGit(this.workDir, this.config.storageDir); this.shadowGit = createShadowGit(this.workDir, this.config.storageDir);
// 初始化增强组件
this.lock = new CheckpointLock(this.shadowGit.getShadowGitDir());
this.safetyChecker = new CheckpointSafetyChecker(this.workDir);
this.pathValidator = new WorkspacePathValidator();
this.commitMessageGenerator = new CommitMessageGenerator();
this.lfsLoader = new LFSPatternLoader();
} }
/** /**
@@ -72,6 +100,15 @@ export class CheckpointManager {
return; return;
} }
// 验证工作目录路径
const pathValidation = this.pathValidator.validate(this.workDir);
if (!pathValidation.valid) {
throw new Error(`Invalid workspace path: ${pathValidation.reason}`);
}
// 加载 LFS 模式
await this.lfsLoader.loadPatterns(this.workDir);
// 初始化 Shadow Git // 初始化 Shadow Git
await this.shadowGit.initialize(); await this.shadowGit.initialize();
@@ -195,6 +232,9 @@ export class CheckpointManager {
description?: string; description?: string;
trigger?: CheckpointTrigger; trigger?: CheckpointTrigger;
toolCall?: { tool: string; params: Record<string, unknown> }; toolCall?: { tool: string; params: Record<string, unknown> };
messageId?: string;
sessionId?: string;
turnIndex?: number;
}): Promise<CheckpointMetadata> { }): Promise<CheckpointMetadata> {
await this.initialize(); await this.initialize();
@@ -202,48 +242,75 @@ export class CheckpointManager {
throw new Error('Checkpoint system is disabled'); throw new Error('Checkpoint system is disabled');
} }
const id = nanoid(10); // 使用锁保护检查点创建
const timestamp = Date.now(); return this.lock.withLock(async () => {
const id = nanoid(10);
const timestamp = Date.now();
const trigger = options.trigger || 'manual';
// 创建元数据 // 创建元数据
const metadata: CheckpointMetadata = { const metadata: CheckpointMetadata = {
id, id,
name: options.name, name: options.name,
description: options.description, description: options.description,
timestamp, timestamp,
trigger: options.trigger || 'manual', trigger,
toolCall: options.toolCall, toolCall: options.toolCall,
commitHash: '', // 待填充 commitHash: '', // 待填充
filesChanged: 0, // 待填充 filesChanged: 0, // 待填充
}; // 新增:消息和会话关联
messageId: options.messageId,
sessionId: options.sessionId || this.currentSessionId || undefined,
turnIndex: options.turnIndex,
};
// 获取变更文件数 // 获取变更文件数
try { let filesChanged: Array<{ path: string; type: string }> = [];
const diff = await this.shadowGit.getWorkingDirDiff(); try {
metadata.filesChanged = diff.files.length; const diff = await this.shadowGit.getWorkingDirDiff();
} catch { metadata.filesChanged = diff.files.length;
// 忽略 filesChanged = diff.files;
} } catch {
// 忽略
}
// 创建 commit // 生成智能提交消息
const commitMessage = CHECKPOINT_PREFIX + JSON.stringify(metadata); const humanReadableMessage = this.commitMessageGenerator.generateMessage(
const commitHash = await this.shadowGit.createCommit(commitMessage); trigger,
metadata.commitHash = commitHash; options.toolCall,
filesChanged as any
);
// 更新索引 // 创建 commit(使用 JSON 元数据作为 commit message,但包含可读描述)
this.checkpointsIndex.set(id, metadata); const commitMessage = CHECKPOINT_PREFIX + JSON.stringify({
...metadata,
_readableMessage: humanReadableMessage,
});
const commitHash = await this.shadowGit.createCommit(commitMessage);
metadata.commitHash = commitHash;
// 触发事件 // 更新索引
this.emitEvent({ this.checkpointsIndex.set(id, metadata);
type: 'created',
checkpoint: metadata, // 记录到当前会话
timestamp, if (this.currentSessionId) {
const sessionCps = this.sessionCheckpoints.get(this.currentSessionId) || [];
sessionCps.push(id);
this.sessionCheckpoints.set(this.currentSessionId, sessionCps);
}
// 触发事件
this.emitEvent({
type: 'created',
checkpoint: metadata,
timestamp,
});
// 异步清理
this.cleanupAsync();
return metadata;
}); });
// 异步清理
this.cleanupAsync();
return metadata;
} }
/** /**
@@ -359,63 +426,253 @@ export class CheckpointManager {
throw new Error(`Checkpoint not found: ${options.target}`); throw new Error(`Checkpoint not found: ${options.target}`);
} }
// 获取当前 HEAD 用于可能的撤销 // 安全检查(除非明确跳过)
const previousCommit = await this.shadowGit.getHead(); if (!options.skipSafetyCheck) {
const safetyResult = await this.safetyChecker.checkBeforeRollback(checkpoint, this);
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('; '));
}
}
// 预览模式 // 使用锁保护回滚操作
if (options.dryRun) { return this.lock.withLock(async () => {
const diff = await this.getDiff(checkpoint.id); // 获取当前 HEAD 用于可能的撤销
return { const previousCommit = await this.shadowGit.getHead();
// 预览模式
if (options.dryRun) {
const diff = await this.getDiff(checkpoint.id);
return {
success: true,
restoredFiles: diff.files.map((f) => f.path),
errors: [],
previousCommit,
};
}
// 创建回滚前检查点(用于 unrevert)
let preRollbackCheckpoint: CheckpointMetadata | null = null;
try {
preRollbackCheckpoint = await this.createCheckpointInternal({
trigger: 'pre_rollback',
description: `Before rollback to ${options.target}`,
});
} catch {
// 忽略创建失败
}
const result: RollbackResult = {
success: true, success: true,
restoredFiles: diff.files.map((f) => f.path), restoredFiles: [],
errors: [], errors: [],
previousCommit, previousCommit,
}; };
}
const result: RollbackResult = { try {
success: true, const mode = options.mode || RestoreMode.FULL;
restoredFiles: [],
errors: [],
previousCommit,
};
try { if (options.files && options.files.length > 0) {
if (options.files && options.files.length > 0) { // 选择性回滚(指定文件)
// 选择性回滚 await this.shadowGit.checkoutFiles(checkpoint.commitHash, options.files);
await this.shadowGit.checkoutFiles(checkpoint.commitHash, options.files); result.restoredFiles = options.files;
result.restoredFiles = options.files; } else if (mode === RestoreMode.AI_CHANGES_ONLY) {
} else { // 仅恢复 AI 修改的文件
// 完整回滚 const aiFiles = await this.getAiModifiedFiles(checkpoint);
await this.shadowGit.resetHard(checkpoint.commitHash); 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( const diff = await this.shadowGit.getDiffSummary(
previousCommit, previousCommit,
checkpoint.commitHash checkpoint.commitHash
); );
result.restoredFiles = diff.files.map((f) => f.path); 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,
};
// 触发事件
this.emitEvent({
type: 'restored',
checkpoint,
timestamp: Date.now(),
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;
this.emitEvent({ });
type: 'restored', }
checkpoint,
timestamp: Date.now(), /**
details: { * 撤销最近一次回滚(Unrevert)
files: result.restoredFiles, */
previousCommit, async unrevert(): Promise<UnrevertResult> {
}, await this.initialize();
});
} catch (error) { if (!this.lastRollback || !this.lastRollback.canUnrevert) {
result.success = false; return {
result.errors.push({ success: false,
file: '*', restoredCommit: '',
error: error instanceof Error ? error.message : String(error), filesRestored: 0,
}); error: 'No rollback to unrevert',
};
} }
return result; 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,
};
// 清除 unrevert 记录
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;
}
/**
* 执行安全检查
*/
async checkSafety(checkpointId: string): Promise<SafetyCheckResult> {
await this.initialize();
const checkpoint = await this.getCheckpoint(checkpointId);
if (!checkpoint) {
return {
safe: false,
warnings: [],
errors: ['Checkpoint not found'],
};
}
return this.safetyChecker.checkBeforeRollback(checkpoint, this);
}
/**
* 内部创建检查点(不使用锁,供 rollback 内部调用)
*/
private async createCheckpointInternal(options: {
trigger: CheckpointTrigger;
description?: string;
}): Promise<CheckpointMetadata> {
const id = nanoid(10);
const timestamp = Date.now();
const metadata: CheckpointMetadata = {
id,
description: options.description,
timestamp,
trigger: options.trigger,
commitHash: '',
filesChanged: 0,
};
const commitMessage = CHECKPOINT_PREFIX + JSON.stringify(metadata);
const commitHash = await this.shadowGit.createCommit(commitMessage);
metadata.commitHash = commitHash;
this.checkpointsIndex.set(id, metadata);
return metadata;
}
/**
* 获取 AI 修改的文件列表
*/
private async getAiModifiedFiles(checkpoint: CheckpointMetadata): Promise<string[]> {
const files: string[] = [];
const checkpoints = await this.listCheckpoints();
// 找到该检查点之后的所有检查点
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.id);
const aiFiles = await this.getAiModifiedFiles(checkpoint);
// 返回不在 AI 修改列表中的文件
return diff.files
.map((f) => f.path)
.filter((path) => !aiFiles.includes(path));
} }
/** /**
@@ -578,6 +835,151 @@ export class CheckpointManager {
getConfig(): CheckpointConfig { getConfig(): CheckpointConfig {
return { ...this.config }; return { ...this.config };
} }
// ==================== 会话管理方法 ====================
/**
* 开始新会话
*/
async startSession(sessionId?: string): Promise<string> {
await this.initialize();
const id = sessionId || nanoid(10);
this.currentSessionId = id;
this.sessionCheckpoints.set(id, []);
// 创建会话开始检查点
try {
await this.createCheckpoint({
trigger: 'session_start',
description: `Session started: ${id}`,
sessionId: id,
});
} catch {
// 忽略创建失败
}
return id;
}
/**
* 结束当前会话
*/
async endSession(): Promise<void> {
if (!this.currentSessionId) return;
// 创建会话结束检查点
try {
await this.createCheckpoint({
trigger: 'session_end',
description: `Session ended: ${this.currentSessionId}`,
sessionId: this.currentSessionId,
});
} catch {
// 忽略创建失败
}
this.currentSessionId = null;
}
/**
* 获取当前会话 ID
*/
getCurrentSessionId(): string | null {
return this.currentSessionId;
}
/**
* 获取会话的所有检查点
*/
async getSessionCheckpoints(sessionId: string): Promise<CheckpointMetadata[]> {
await this.initialize();
const checkpointIds = this.sessionCheckpoints.get(sessionId);
if (!checkpointIds) return [];
const checkpoints: CheckpointMetadata[] = [];
for (const id of checkpointIds) {
const checkpoint = this.checkpointsIndex.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;
}
): Promise<CheckpointMetadata> {
return this.createCheckpoint({
trigger: options?.trigger || 'auto',
description: options?.description || `Message checkpoint: ${messageId}`,
messageId,
sessionId: this.currentSessionId || undefined,
turnIndex,
});
}
/**
* 获取与消息关联的检查点
*/
async getMessageCheckpoints(messageId: string): Promise<CheckpointMetadata[]> {
await this.initialize();
const checkpoints: CheckpointMetadata[] = [];
for (const checkpoint of this.checkpointsIndex.values()) {
if (checkpoint.messageId === messageId) {
checkpoints.push(checkpoint);
}
}
return checkpoints.sort((a, b) => a.timestamp - b.timestamp);
}
/**
* 撤销整个会话的修改
*/
async undoSession(sessionId: string): Promise<RollbackResult> {
const sessionCheckpoints = await this.getSessionCheckpoints(sessionId);
if (sessionCheckpoints.length === 0) {
throw new Error(`No checkpoints found for session: ${sessionId}`);
}
// 找到会话开始的检查点
const startCheckpoint = sessionCheckpoints.find(
(cp) => cp.trigger === 'session_start'
);
if (!startCheckpoint) {
// 如果没有明确的开始检查点,使用第一个检查点
return this.rollback({ target: sessionCheckpoints[0].id });
}
return this.rollback({ target: startCheckpoint.id });
}
/**
* 获取 LFS 模式加载器
*/
getLfsLoader(): LFSPatternLoader {
return this.lfsLoader;
}
/**
* 检查文件是否由 LFS 管理
*/
isLfsFile(filePath: string): boolean {
return this.lfsLoader.isLfsFile(filePath);
}
} }
// 全局检查点管理器实例 // 全局检查点管理器实例
@@ -0,0 +1,254 @@
/**
* 工作区路径验证模块
* 参考 Cline 的路径安全检查机制
*/
import * as os from 'os';
import * as path from 'path';
import type { PathValidationResult } from './types.js';
/**
* 工作区路径验证器
* 阻止在敏感目录(home、desktop、documents 等)创建检查点
*/
export class WorkspacePathValidator {
private blockedPaths: string[];
private blockedPathsLower: string[];
constructor() {
const home = os.homedir();
this.blockedPaths = [
// Home 目录
home,
// 常见敏感目录
path.join(home, 'Desktop'),
path.join(home, 'Documents'),
path.join(home, 'Downloads'),
path.join(home, 'Pictures'),
path.join(home, 'Music'),
path.join(home, 'Videos'),
// 系统根目录
'/',
'/tmp',
'/var',
'/etc',
'/usr',
'/bin',
'/sbin',
'/opt',
];
// Windows 特殊路径
if (process.platform === 'win32') {
this.blockedPaths.push(
'C:\\',
'C:\\Windows',
'C:\\Windows\\System32',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\Users',
'C:\\ProgramData'
);
}
// macOS 特殊路径
if (process.platform === 'darwin') {
this.blockedPaths.push(
'/Applications',
'/System',
'/Library',
path.join(home, 'Library')
);
}
// 创建小写版本用于不区分大小写的比较
this.blockedPathsLower = this.blockedPaths.map((p) => p.toLowerCase());
}
/**
* 验证工作目录是否安全
*/
validate(workDir: string): PathValidationResult {
const normalizedPath = path.resolve(workDir);
const normalizedLower = normalizedPath.toLowerCase();
// 1. 检查是否为阻止的路径
for (let i = 0; i < this.blockedPaths.length; i++) {
const blocked = this.blockedPaths[i];
const blockedLower = this.blockedPathsLower[i];
// 精确匹配
if (
normalizedPath === blocked ||
normalizedLower === blockedLower
) {
return {
valid: false,
reason: `Cannot create checkpoints in "${blocked}" - this is a protected directory`,
};
}
}
// 2. 检查是否为系统目录的直接子目录
if (this.isDirectChildOfBlocked(normalizedPath)) {
return {
valid: false,
reason: `Cannot create checkpoints directly under system directories`,
};
}
// 3. 检查路径是否过短(可能是根目录或系统目录)
const pathDepth = this.getPathDepth(normalizedPath);
if (pathDepth < 2) {
return {
valid: false,
reason: 'Path is too shallow - please use a project subdirectory',
};
}
// 4. 检查是否包含危险字符
if (this.containsDangerousChars(normalizedPath)) {
return {
valid: false,
reason: 'Path contains invalid characters',
};
}
return { valid: true };
}
/**
* 检查是否为阻止路径的直接子目录
*/
private isDirectChildOfBlocked(targetPath: string): boolean {
const parent = path.dirname(targetPath);
const parentLower = parent.toLowerCase();
for (let i = 0; i < this.blockedPaths.length; i++) {
if (
parent === this.blockedPaths[i] ||
parentLower === this.blockedPathsLower[i]
) {
// 特殊情况:允许 home 目录下的项目目录
const home = os.homedir();
if (parent === home || parentLower === home.toLowerCase()) {
// 但不允许一些特定的目录名
const dirName = path.basename(targetPath).toLowerCase();
const protectedNames = [
'desktop',
'documents',
'downloads',
'pictures',
'music',
'videos',
'library',
'.trash',
'.cache',
];
if (!protectedNames.includes(dirName)) {
return false; // 允许 home 下的其他目录
}
}
return true;
}
}
return false;
}
/**
* 获取路径深度
*/
private getPathDepth(targetPath: string): number {
const normalized = path.normalize(targetPath);
const parts = normalized.split(path.sep).filter((p) => p.length > 0);
// Windows 盘符算一层
if (process.platform === 'win32' && /^[A-Za-z]:$/.test(parts[0] || '')) {
return parts.length - 1;
}
return parts.length;
}
/**
* 检查是否包含危险字符
*/
private containsDangerousChars(targetPath: string): boolean {
// 检查空字节和其他控制字符
if (/[\x00-\x1f]/.test(targetPath)) {
return true;
}
// 检查 .. 路径遍历
if (targetPath.includes('..')) {
const resolved = path.resolve(targetPath);
if (resolved !== targetPath) {
return true;
}
}
return false;
}
/**
* 获取阻止的路径列表
*/
getBlockedPaths(): string[] {
return [...this.blockedPaths];
}
/**
* 添加自定义阻止路径
*/
addBlockedPath(blockedPath: string): void {
const normalized = path.resolve(blockedPath);
if (!this.blockedPaths.includes(normalized)) {
this.blockedPaths.push(normalized);
this.blockedPathsLower.push(normalized.toLowerCase());
}
}
/**
* 检查路径是否为有效的项目目录
* 额外检查是否包含常见的项目标识文件
*/
async isProjectDirectory(workDir: string): Promise<boolean> {
const fs = await import('fs/promises');
const projectIndicators = [
'package.json',
'Cargo.toml',
'go.mod',
'pom.xml',
'build.gradle',
'requirements.txt',
'setup.py',
'pyproject.toml',
'Gemfile',
'composer.json',
'.git',
'Makefile',
'CMakeLists.txt',
];
for (const indicator of projectIndicators) {
try {
await fs.access(path.join(workDir, indicator));
return true;
} catch {
// 继续检查下一个
}
}
return false;
}
}
/**
* 创建路径验证器实例
*/
export function createPathValidator(): WorkspacePathValidator {
return new WorkspacePathValidator();
}
+214
View File
@@ -0,0 +1,214 @@
/**
* 检查点安全检查模块
* 参考 Aider 的 7 点安全检查机制
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import type { CheckpointManager } from './manager.js';
import type { SafetyCheckResult, CheckpointMetadata } from './types.js';
const execAsync = promisify(exec);
/**
* 检查点安全检查器
*/
export class CheckpointSafetyChecker {
private workDir: string;
constructor(workDir: string) {
this.workDir = workDir;
}
/**
* 在回滚前执行安全检查
*/
async checkBeforeRollback(
checkpoint: CheckpointMetadata,
manager: CheckpointManager
): Promise<SafetyCheckResult> {
const result: SafetyCheckResult = {
safe: true,
warnings: [],
errors: [],
};
// 1. 检查检查点是否存在
if (!checkpoint) {
result.safe = false;
result.errors.push('Checkpoint not found');
return result;
}
// 2. 检查工作区是否有未保存的变更
const hasUnsaved = await this.hasUnsavedChanges(manager);
if (hasUnsaved) {
result.warnings.push('Working directory has unsaved changes that will be lost');
}
// 3. 检查主仓库状态
const mainRepoStatus = await this.checkMainRepoStatus();
if (mainRepoStatus.hasChanges) {
result.warnings.push('Main repository has uncommitted changes');
}
// 4. 检查是否会影响已推送的文件
const pushedCheck = await this.checkPushedFiles(checkpoint);
if (pushedCheck.hasPushed) {
result.warnings.push(
`Some changes may have been pushed to remote. Files: ${pushedCheck.files.join(', ')}`
);
}
// 5. 检查时间跨度
const age = Date.now() - checkpoint.timestamp;
const oneDay = 24 * 60 * 60 * 1000;
if (age > oneDay) {
const days = Math.floor(age / oneDay);
result.warnings.push(`Checkpoint is ${days} day(s) old`);
}
// 6. 检查是否为 AI 创建的检查点
const isAiCreated = this.isAiCreatedCheckpoint(checkpoint);
if (!isAiCreated) {
result.warnings.push('This checkpoint was not created by AI tools');
}
// 7. 检查影响的文件数量
if (checkpoint.filesChanged > 50) {
result.warnings.push(
`This rollback will affect ${checkpoint.filesChanged} files`
);
}
return result;
}
/**
* 检查工作区是否有未保存的变更
*/
private async hasUnsavedChanges(manager: CheckpointManager): Promise<boolean> {
try {
// 通过 manager 检查
const checkpoints = await manager.listCheckpoints();
if (checkpoints.length === 0) return false;
const latest = checkpoints[0];
const diff = await manager.getDiff(latest.id);
return diff.files.length > 0;
} catch {
return false;
}
}
/**
* 检查主仓库状态
*/
private async checkMainRepoStatus(): Promise<{ hasChanges: boolean; isGitRepo: boolean }> {
try {
const { stdout } = await execAsync('git status --porcelain', {
cwd: this.workDir,
});
return {
hasChanges: stdout.trim().length > 0,
isGitRepo: true,
};
} catch {
return {
hasChanges: false,
isGitRepo: false,
};
}
}
/**
* 检查是否有文件已被推送到远程
*/
private async checkPushedFiles(
checkpoint: CheckpointMetadata
): Promise<{ hasPushed: boolean; files: string[] }> {
try {
// 检查主仓库中自检查点时间后是否有推送
const since = new Date(checkpoint.timestamp).toISOString();
const { stdout } = await execAsync(
`git log --oneline --since="${since}" --name-only origin/HEAD..HEAD 2>/dev/null || true`,
{ cwd: this.workDir }
);
if (!stdout.trim()) {
return { hasPushed: false, files: [] };
}
// 解析文件列表
const lines = stdout.trim().split('\n');
const files: string[] = [];
for (const line of lines) {
// 跳过 commit 行(包含空格)
if (!line.includes(' ') && line.length > 0) {
files.push(line);
}
}
return {
hasPushed: files.length > 0,
files: [...new Set(files)],
};
} catch {
return { hasPushed: false, files: [] };
}
}
/**
* 检查是否为 AI 创建的检查点
*/
private isAiCreatedCheckpoint(checkpoint: CheckpointMetadata): boolean {
// AI 创建的检查点通常有 toolCall 或特定的 trigger
const aiTriggers = [
'tool:write_file',
'tool:edit_file',
'tool:delete_file',
'tool:move_file',
'tool:copy_file',
'tool:bash',
'task_start',
'task_complete',
'auto',
];
return aiTriggers.includes(checkpoint.trigger) || !!checkpoint.toolCall;
}
/**
* 格式化安全检查结果为用户友好的消息
*/
formatResult(result: SafetyCheckResult): string {
const lines: string[] = [];
if (result.errors.length > 0) {
lines.push('❌ Errors:');
for (const error of result.errors) {
lines.push(` - ${error}`);
}
}
if (result.warnings.length > 0) {
lines.push('⚠️ Warnings:');
for (const warning of result.warnings) {
lines.push(` - ${warning}`);
}
}
if (result.safe && result.warnings.length === 0) {
lines.push('✅ All safety checks passed');
}
return lines.join('\n');
}
}
/**
* 创建安全检查器实例
*/
export function createSafetyChecker(workDir: string): CheckpointSafetyChecker {
return new CheckpointSafetyChecker(workDir);
}
@@ -0,0 +1,222 @@
/**
* 会话级检查点跟踪
* 参考 Aider 的 aider_commit_hashes 和 commit_before_message 机制
*/
import { nanoid } from 'nanoid';
import type { CheckpointManager } from './manager.js';
import type {
SessionState,
SessionStats,
CheckpointMetadata,
RollbackResult,
} from './types.js';
/**
* 会话跟踪器
* 跟踪当前会话的所有检查点和文件修改
*/
export class SessionTracker {
private currentSession: SessionState | null = null;
private checkpointManager: CheckpointManager;
constructor(checkpointManager: CheckpointManager) {
this.checkpointManager = checkpointManager;
}
/**
* 开始新会话
*/
async startSession(): Promise<string> {
// 创建会话开始检查点
const startCp = await this.checkpointManager.createCheckpoint({
trigger: 'session_start',
description: 'Session start',
});
const sessionId = nanoid(10);
this.currentSession = {
id: sessionId,
startTime: Date.now(),
startCheckpoint: startCp.id,
checkpoints: [startCp.id],
modifiedFiles: [],
};
return sessionId;
}
/**
* 结束当前会话
*/
async endSession(): Promise<void> {
if (!this.currentSession) return;
// 创建会话结束检查点
await this.checkpointManager.createCheckpoint({
trigger: 'session_end',
description: 'Session end',
});
this.currentSession = null;
}
/**
* 获取当前会话 ID
*/
getCurrentSessionId(): string | null {
return this.currentSession?.id ?? null;
}
/**
* 检查是否有活跃会话
*/
hasActiveSession(): boolean {
return this.currentSession !== null;
}
/**
* 记录检查点到当前会话
*/
recordCheckpoint(checkpointId: string): void {
if (this.currentSession) {
this.currentSession.checkpoints.push(checkpointId);
}
}
/**
* 记录文件修改
*/
recordFileChange(filePath: string): void {
if (this.currentSession) {
if (!this.currentSession.modifiedFiles.includes(filePath)) {
this.currentSession.modifiedFiles.push(filePath);
}
}
}
/**
* 记录多个文件修改
*/
recordFileChanges(filePaths: string[]): void {
for (const filePath of filePaths) {
this.recordFileChange(filePath);
}
}
/**
* 获取会话中修改的所有文件
*/
getModifiedFiles(): string[] {
return this.currentSession?.modifiedFiles ?? [];
}
/**
* 获取会话中的所有检查点
*/
async getSessionCheckpoints(): Promise<CheckpointMetadata[]> {
if (!this.currentSession) {
return [];
}
const checkpoints: CheckpointMetadata[] = [];
for (const id of this.currentSession.checkpoints) {
const cp = await this.checkpointManager.getCheckpoint(id);
if (cp) {
checkpoints.push(cp);
}
}
return checkpoints.sort((a, b) => a.timestamp - b.timestamp);
}
/**
* 撤销当前会话的所有修改
*/
async undoSession(): Promise<RollbackResult> {
if (!this.currentSession) {
throw new Error('No active session');
}
return this.checkpointManager.rollback({
target: this.currentSession.startCheckpoint,
skipSafetyCheck: true, // 会话级撤销跳过安全检查
});
}
/**
* 回滚到会话中的指定检查点
*/
async rollbackToCheckpoint(checkpointId: string): Promise<RollbackResult> {
if (!this.currentSession) {
throw new Error('No active session');
}
// 验证检查点属于当前会话
if (!this.currentSession.checkpoints.includes(checkpointId)) {
throw new Error('Checkpoint does not belong to current session');
}
return this.checkpointManager.rollback({
target: checkpointId,
});
}
/**
* 获取会话统计信息
*/
getSessionStats(): SessionStats {
if (!this.currentSession) {
throw new Error('No active session');
}
return {
duration: Date.now() - this.currentSession.startTime,
checkpointCount: this.currentSession.checkpoints.length,
modifiedFilesCount: this.currentSession.modifiedFiles.length,
modifiedFiles: [...this.currentSession.modifiedFiles],
};
}
/**
* 获取当前会话状态
*/
getSessionState(): SessionState | null {
if (!this.currentSession) {
return null;
}
return {
...this.currentSession,
modifiedFiles: [...this.currentSession.modifiedFiles],
checkpoints: [...this.currentSession.checkpoints],
};
}
/**
* 获取会话开始时的检查点
*/
getStartCheckpoint(): string | null {
return this.currentSession?.startCheckpoint ?? null;
}
/**
* 获取会话持续时间(毫秒)
*/
getSessionDuration(): number {
if (!this.currentSession) {
return 0;
}
return Date.now() - this.currentSession.startTime;
}
}
/**
* 创建会话跟踪器实例
*/
export function createSessionTracker(
checkpointManager: CheckpointManager
): SessionTracker {
return new SessionTracker(checkpointManager);
}
+110 -1
View File
@@ -16,7 +16,10 @@ export type CheckpointTrigger =
| 'tool:copy_file' // 复制文件前 | 'tool:copy_file' // 复制文件前
| 'tool:bash' // bash 命令前 | 'tool:bash' // bash 命令前
| 'task_start' // 任务开始 | 'task_start' // 任务开始
| 'task_complete'; // 任务完成 | 'task_complete' // 任务完成
| 'pre_rollback' // 回滚前(用于 unrevert
| 'session_start' // 会话开始
| 'session_end'; // 会话结束
/** /**
* 检查点元数据 * 检查点元数据
@@ -41,6 +44,12 @@ export interface CheckpointMetadata {
commitHash: string; commitHash: string;
/** 受影响的文件数 */ /** 受影响的文件数 */
filesChanged: number; filesChanged: number;
/** 关联的消息 ID */
messageId?: string;
/** 会话 ID */
sessionId?: string;
/** 对话轮次 */
turnIndex?: number;
} }
/** /**
@@ -140,6 +149,18 @@ export interface FileDiff {
patch?: string; patch?: string;
} }
/**
* 恢复模式
*/
export enum RestoreMode {
/** 仅恢复 AI 修改的文件 */
AI_CHANGES_ONLY = 'ai_changes_only',
/** 仅恢复工作区变更 */
WORKSPACE_ONLY = 'workspace_only',
/** 完整恢复(AI + 工作区) */
FULL = 'full',
}
/** /**
* 回滚选项 * 回滚选项
*/ */
@@ -150,6 +171,10 @@ export interface RollbackOptions {
files?: string[]; files?: string[];
/** 预览模式 (不实际执行) */ /** 预览模式 (不实际执行) */
dryRun?: boolean; dryRun?: boolean;
/** 恢复模式 */
mode?: RestoreMode;
/** 跳过安全检查 */
skipSafetyCheck?: boolean;
} }
/** /**
@@ -189,3 +214,87 @@ export interface CheckpointEvent {
* 检查点事件监听器 * 检查点事件监听器
*/ */
export type CheckpointEventListener = (event: CheckpointEvent) => void; export type CheckpointEventListener = (event: CheckpointEvent) => void;
/**
* 回滚记录(用于 unrevert
*/
export interface RollbackRecord {
/** 唯一标识 */
id: string;
/** 时间戳 */
timestamp: number;
/** 目标检查点 */
targetCheckpoint: string;
/** 回滚前的 commit hash */
previousCommit: string;
/** 恢复的文件列表 */
restoredFiles: string[];
/** 是否可撤销 */
canUnrevert: boolean;
}
/**
* 撤销回滚结果
*/
export interface UnrevertResult {
/** 是否成功 */
success: boolean;
/** 恢复的 commit */
restoredCommit: string;
/** 恢复的文件数 */
filesRestored: number;
/** 错误信息 */
error?: string;
}
/**
* 安全检查结果
*/
export interface SafetyCheckResult {
/** 是否安全 */
safe: boolean;
/** 警告列表 */
warnings: string[];
/** 错误列表 */
errors: string[];
}
/**
* 会话状态
*/
export interface SessionState {
/** 会话 ID */
id: string;
/** 开始时间 */
startTime: number;
/** 会话开始时的检查点 ID */
startCheckpoint: string;
/** 会话中创建的检查点 ID 列表 */
checkpoints: string[];
/** 会话中修改的文件 */
modifiedFiles: string[];
}
/**
* 会话统计
*/
export interface SessionStats {
/** 持续时间(毫秒) */
duration: number;
/** 检查点数量 */
checkpointCount: number;
/** 修改的文件数 */
modifiedFilesCount: number;
/** 修改的文件列表 */
modifiedFiles: string[];
}
/**
* 路径验证结果
*/
export interface PathValidationResult {
/** 是否有效 */
valid: boolean;
/** 原因 */
reason?: string;
}
+47
View File
@@ -44,6 +44,53 @@ export type {
CommandOperationResult, CommandOperationResult,
} from './commands/index.js'; } from './commands/index.js';
// Checkpoint
export {
CheckpointManager,
getCheckpointManager,
initCheckpointManager,
resetCheckpointManager,
ShadowGit,
createShadowGit,
hashWorkingDir,
CheckpointSafetyChecker,
createSafetyChecker,
SessionTracker,
createSessionTracker,
CheckpointLock,
LFSPatternLoader,
createLFSPatternLoader,
isCommonLargeFile,
COMMON_LARGE_FILE_EXTENSIONS,
WorkspacePathValidator,
createPathValidator,
CommitMessageGenerator,
createCommitMessageGenerator,
RestoreMode,
DEFAULT_CHECKPOINT_CONFIG,
} from './checkpoint/index.js';
export type {
CheckpointMetadata,
CheckpointConfig,
CheckpointTrigger,
FileChange,
FileChangeType,
DiffInfo,
FileDiff,
RollbackOptions,
RollbackResult,
CheckpointEvent,
CheckpointEventType,
CheckpointEventListener,
RollbackRecord,
UnrevertResult,
SafetyCheckResult,
SessionState,
SessionStats,
PathValidationResult,
} from './checkpoint/index.js';
const program = new Command(); const program = new Command();
// MCP 管理器实例 // MCP 管理器实例
+6
View File
@@ -11,6 +11,7 @@ import {
MCPPanel, MCPPanel,
HooksPanel, HooksPanel,
AgentsPanel, AgentsPanel,
CheckpointPanel,
Toaster, Toaster,
listSessions, listSessions,
createSession, createSession,
@@ -27,6 +28,7 @@ export function App() {
const [showMCP, setShowMCP] = useState(false); const [showMCP, setShowMCP] = useState(false);
const [showHooks, setShowHooks] = useState(false); const [showHooks, setShowHooks] = useState(false);
const [showAgents, setShowAgents] = useState(false); const [showAgents, setShowAgents] = useState(false);
const [showCheckpoints, setShowCheckpoints] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话 // 初始化:加载或创建会话
@@ -101,6 +103,7 @@ export function App() {
onOpenMCP={() => setShowMCP(true)} onOpenMCP={() => setShowMCP(true)}
onOpenHooks={() => setShowHooks(true)} onOpenHooks={() => setShowHooks(true)}
onOpenAgents={() => setShowAgents(true)} onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(true)}
/> />
) : ( ) : (
<div className="flex-1 flex items-center justify-center h-full"> <div className="flex-1 flex items-center justify-center h-full">
@@ -136,6 +139,9 @@ export function App() {
{/* Agents 面板 */} {/* Agents 面板 */}
{showAgents && <AgentsPanel onClose={() => setShowAgents(false)} />} {showAgents && <AgentsPanel onClose={() => setShowAgents(false)} />}
{/* Checkpoints 面板 */}
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} />}
{/* Toast 通知 */} {/* Toast 通知 */}
<Toaster /> <Toaster />
</div> </div>
+17 -2
View File
@@ -3,7 +3,7 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot } from 'lucide-react'; import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot, History } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
useChat, useChat,
@@ -24,6 +24,7 @@ interface ChatPageProps {
onOpenMCP?: () => void; onOpenMCP?: () => void;
onOpenHooks?: () => void; onOpenHooks?: () => void;
onOpenAgents?: () => void; onOpenAgents?: () => void;
onOpenCheckpoints?: () => void;
} }
export function ChatPage({ export function ChatPage({
@@ -36,6 +37,7 @@ export function ChatPage({
onOpenMCP, onOpenMCP,
onOpenHooks, onOpenHooks,
onOpenAgents, onOpenAgents,
onOpenCheckpoints,
}: ChatPageProps) { }: ChatPageProps) {
const { const {
messages, messages,
@@ -129,8 +131,21 @@ export function ChatPage({
<ConnectionStatus /> <ConnectionStatus />
{/* 工具栏按钮 */} {/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents) && ( {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints) && (
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3"> <div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
{/* Checkpoints 按钮 */}
{onOpenCheckpoints && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenCheckpoints}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Checkpoints"
>
<History size={20} />
</motion.button>
)}
{/* Agents 按钮 */} {/* Agents 按钮 */}
{onOpenAgents && ( {onOpenAgents && (
<motion.button <motion.button
+2 -1
View File
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger'; import { logger } from 'hono/logger';
import { createBunWebSocket } from 'hono/bun'; import { createBunWebSocket } from 'hono/bun';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter } from './routes/index.js'; import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter } from './routes/index.js';
import { import {
handleWebSocket, handleWebSocket,
handleWebSocketMessage, handleWebSocketMessage,
@@ -86,6 +86,7 @@ api.route('/commands', commandsRouter);
api.route('/mcp', mcpRouter); api.route('/mcp', mcpRouter);
api.route('/hooks', hooksRouter); api.route('/hooks', hooksRouter);
api.route('/agents', agentsRouter); api.route('/agents', agentsRouter);
api.route('/checkpoints', checkpointsRouter);
// SSE 事件流 // SSE 事件流
api.get('/sessions/:id/events', handleSSE); api.get('/sessions/:id/events', handleSSE);
+902
View File
@@ -0,0 +1,902 @@
/**
* Checkpoints API Routes
*
* 提供 Checkpoint 管理的 REST API
*/
import { Hono } from 'hono';
import { getConfig } from './config.js';
// Core Checkpoint 模块类型
interface CheckpointModule {
getCheckpointManager: () => CheckpointManager;
initCheckpointManager: (
workDir: string,
config?: Partial<CheckpointConfig>
) => Promise<CheckpointManager>;
RestoreMode: typeof RestoreMode;
}
interface CheckpointManager {
initialize(): Promise<void>;
isEnabled(): boolean;
getConfig(): CheckpointConfig;
listCheckpoints(): Promise<CheckpointMetadata[]>;
getCheckpoint(idOrHash: string): Promise<CheckpointMetadata | null>;
getLatestCheckpoint(): Promise<CheckpointMetadata | null>;
createCheckpoint(options: {
name?: string;
description?: string;
trigger?: CheckpointTrigger;
}): Promise<CheckpointMetadata>;
deleteCheckpoint(id: string): Promise<boolean>;
getDiff(checkpointId: string): Promise<DiffInfo>;
getFileDiff(checkpointId: string, filePath: string): Promise<FileDiff>;
rollback(options: RollbackOptions): Promise<RollbackResult>;
checkSafety(checkpointId: string): Promise<SafetyCheckResult>;
unrevert(): Promise<UnrevertResult>;
canUnrevert(): boolean;
getLastRollback(): RollbackRecord | null;
cleanup(): Promise<number>;
getStats(): Promise<CheckpointStats>;
getSessionCheckpoints(sessionId: string): Promise<CheckpointMetadata[]>;
getMessageCheckpoints(messageId: string): Promise<CheckpointMetadata[]>;
}
interface CheckpointConfig {
enabled: boolean;
autoCheckpoint: {
beforeWrite: boolean;
beforeEdit: boolean;
beforeDelete: boolean;
beforeMove: boolean;
beforeBash: boolean;
};
maxCheckpoints: number;
maxAge: number;
storageDir: string;
}
type CheckpointTrigger =
| 'auto'
| 'manual'
| 'tool:write_file'
| 'tool:edit_file'
| 'tool:delete_file'
| 'tool:move_file'
| 'tool:copy_file'
| 'tool:bash'
| 'task_start'
| 'task_complete'
| 'pre_rollback'
| 'session_start'
| 'session_end';
interface CheckpointMetadata {
id: string;
name?: string;
description?: string;
timestamp: number;
trigger: CheckpointTrigger;
toolCall?: {
tool: string;
params: Record<string, unknown>;
};
commitHash: string;
filesChanged: number;
messageId?: string;
sessionId?: string;
turnIndex?: number;
}
type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed';
interface FileChange {
path: string;
type: FileChangeType;
oldPath?: string;
insertions?: number;
deletions?: number;
}
interface DiffInfo {
from: string;
to: string;
files: FileChange[];
totalInsertions: number;
totalDeletions: number;
}
interface FileDiff {
path: string;
type: FileChangeType;
oldContent?: string;
newContent?: string;
patch?: string;
}
enum RestoreMode {
AI_CHANGES_ONLY = 'ai_changes_only',
WORKSPACE_ONLY = 'workspace_only',
FULL = 'full',
}
interface RollbackOptions {
target: string;
files?: string[];
dryRun?: boolean;
mode?: RestoreMode;
skipSafetyCheck?: boolean;
}
interface RollbackResult {
success: boolean;
restoredFiles: string[];
errors: Array<{ file: string; error: string }>;
previousCommit?: string;
}
interface SafetyCheckResult {
safe: boolean;
warnings: string[];
errors: string[];
}
interface RollbackRecord {
id: string;
timestamp: number;
targetCheckpoint: string;
previousCommit: string;
restoredFiles: string[];
canUnrevert: boolean;
}
interface UnrevertResult {
success: boolean;
restoredCommit: string;
filesRestored: number;
error?: string;
}
interface CheckpointStats {
count: number;
oldestTimestamp: number | null;
newestTimestamp: number | null;
}
export const checkpointsRouter = new Hono();
// Core 模块缓存
let checkpointModule: CheckpointModule | null = null;
let managerInitialized = false;
/**
* 初始化 Checkpoint 模块
*/
async function initCheckpointModule(): Promise<CheckpointModule | null> {
if (checkpointModule && managerInitialized) return checkpointModule;
try {
const corePath = '@ai-assistant/core';
const core = (await import(corePath)) as Record<string, unknown>;
if (
typeof core.getCheckpointManager !== 'function' ||
typeof core.initCheckpointManager !== 'function'
) {
console.warn('[Checkpoints] Core module missing Checkpoint exports');
return null;
}
checkpointModule = {
getCheckpointManager: core.getCheckpointManager as () => CheckpointManager,
initCheckpointManager: core.initCheckpointManager as (
workDir: string,
config?: Partial<CheckpointConfig>
) => Promise<CheckpointManager>,
RestoreMode: core.RestoreMode as typeof RestoreMode,
};
// 初始化 Checkpoint Manager
const config = getConfig();
await checkpointModule.initCheckpointManager(config.workdir);
managerInitialized = true;
console.log('[Checkpoints] Checkpoint module initialized');
return checkpointModule;
} catch (error) {
console.warn('[Checkpoints] Failed to load Checkpoint module:', error);
return null;
}
}
/**
* GET /checkpoints - 获取所有检查点列表
*/
checkpointsRouter.get('/', async (c) => {
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const checkpoints = await manager.listCheckpoints();
return c.json({
success: true,
data: checkpoints.map((cp) => ({
id: cp.id,
name: cp.name,
description: cp.description,
timestamp: cp.timestamp,
trigger: cp.trigger,
filesChanged: cp.filesChanged,
commitHash: cp.commitHash,
messageId: cp.messageId,
sessionId: cp.sessionId,
})),
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to list checkpoints',
},
500
);
}
});
/**
* GET /checkpoints/stats - 获取检查点统计信息
*/
checkpointsRouter.get('/stats', async (c) => {
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const stats = await manager.getStats();
return c.json({
success: true,
data: stats,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get stats',
},
500
);
}
});
/**
* GET /checkpoints/latest - 获取最新检查点
*/
checkpointsRouter.get('/latest', async (c) => {
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const checkpoint = await manager.getLatestCheckpoint();
return c.json({
success: true,
data: checkpoint,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get latest checkpoint',
},
500
);
}
});
/**
* GET /checkpoints/unrevert/status - 检查是否可撤销回滚
*/
checkpointsRouter.get('/unrevert/status', async (c) => {
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const canUnrevert = manager.canUnrevert();
const lastRollback = manager.getLastRollback();
return c.json({
success: true,
data: {
canUnrevert,
lastRollback: lastRollback
? {
id: lastRollback.id,
timestamp: lastRollback.timestamp,
targetCheckpoint: lastRollback.targetCheckpoint,
restoredFiles: lastRollback.restoredFiles,
}
: null,
},
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get unrevert status',
},
500
);
}
});
/**
* GET /checkpoints/:id - 获取单个检查点详情
*/
checkpointsRouter.get('/:id', async (c) => {
const id = c.req.param('id');
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const checkpoint = await manager.getCheckpoint(id);
if (!checkpoint) {
return c.json(
{
success: false,
error: `Checkpoint not found: ${id}`,
},
404
);
}
return c.json({
success: true,
data: checkpoint,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get checkpoint',
},
500
);
}
});
/**
* POST /checkpoints - 创建手动检查点
*/
checkpointsRouter.post('/', async (c) => {
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const body = await c.req.json<{ name?: string; description?: string }>();
const manager = module.getCheckpointManager();
const checkpoint = await manager.createCheckpoint({
name: body.name,
description: body.description,
trigger: 'manual',
});
return c.json({
success: true,
data: checkpoint,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create checkpoint',
},
500
);
}
});
/**
* DELETE /checkpoints/:id - 删除检查点
*/
checkpointsRouter.delete('/:id', async (c) => {
const id = c.req.param('id');
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const deleted = await manager.deleteCheckpoint(id);
if (!deleted) {
return c.json(
{
success: false,
error: `Checkpoint not found: ${id}`,
},
404
);
}
return c.json({
success: true,
data: { deleted: true },
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to delete checkpoint',
},
500
);
}
});
/**
* GET /checkpoints/:id/diff - 获取检查点与当前工作区的差异
*/
checkpointsRouter.get('/:id/diff', async (c) => {
const id = c.req.param('id');
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const diff = await manager.getDiff(id);
return c.json({
success: true,
data: diff,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get diff',
},
500
);
}
});
/**
* GET /checkpoints/:id/file-diff - 获取单个文件的详细差异
*/
checkpointsRouter.get('/:id/file-diff', async (c) => {
const id = c.req.param('id');
const filePath = c.req.query('path');
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
if (!filePath) {
return c.json(
{
success: false,
error: 'File path is required',
},
400
);
}
try {
const manager = module.getCheckpointManager();
const fileDiff = await manager.getFileDiff(id, filePath);
return c.json({
success: true,
data: fileDiff,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get file diff',
},
500
);
}
});
/**
* POST /checkpoints/:id/restore - 回滚到检查点
*/
checkpointsRouter.post('/:id/restore', async (c) => {
const id = c.req.param('id');
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const body = await c.req.json<{
mode?: 'ai_changes_only' | 'workspace_only' | 'full';
files?: string[];
skipSafetyCheck?: boolean;
}>();
const manager = module.getCheckpointManager();
// 转换 mode 字符串为枚举值
let mode: RestoreMode | undefined;
if (body.mode) {
switch (body.mode) {
case 'ai_changes_only':
mode = module.RestoreMode.AI_CHANGES_ONLY;
break;
case 'workspace_only':
mode = module.RestoreMode.WORKSPACE_ONLY;
break;
case 'full':
mode = module.RestoreMode.FULL;
break;
}
}
const result = await manager.rollback({
target: id,
mode,
files: body.files,
skipSafetyCheck: body.skipSafetyCheck,
});
return c.json({
success: true,
data: result,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to restore checkpoint',
},
500
);
}
});
/**
* GET /checkpoints/:id/restore/preview - 预览回滚(dry run
*/
checkpointsRouter.get('/:id/restore/preview', async (c) => {
const id = c.req.param('id');
const modeParam = c.req.query('mode');
const filesParam = c.req.query('files');
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
// 转换 mode 字符串为枚举值
let mode: RestoreMode | undefined;
if (modeParam) {
switch (modeParam) {
case 'ai_changes_only':
mode = module.RestoreMode.AI_CHANGES_ONLY;
break;
case 'workspace_only':
mode = module.RestoreMode.WORKSPACE_ONLY;
break;
case 'full':
mode = module.RestoreMode.FULL;
break;
}
}
const files = filesParam ? filesParam.split(',') : undefined;
const result = await manager.rollback({
target: id,
mode,
files,
dryRun: true,
});
return c.json({
success: true,
data: result,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to preview restore',
},
500
);
}
});
/**
* POST /checkpoints/unrevert - 撤销最近一次回滚
*/
checkpointsRouter.post('/unrevert', async (c) => {
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const result = await manager.unrevert();
if (!result.success) {
return c.json(
{
success: false,
error: result.error || 'Failed to unrevert',
},
400
);
}
return c.json({
success: true,
data: result,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to unrevert',
},
500
);
}
});
/**
* GET /checkpoints/:id/safety-check - 执行安全检查
*/
checkpointsRouter.get('/:id/safety-check', async (c) => {
const id = c.req.param('id');
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const result = await manager.checkSafety(id);
return c.json({
success: true,
data: result,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to check safety',
},
500
);
}
});
/**
* POST /checkpoints/cleanup - 清理过期检查点
*/
checkpointsRouter.post('/cleanup', async (c) => {
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const deleted = await manager.cleanup();
return c.json({
success: true,
data: { deleted },
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to cleanup',
},
500
);
}
});
/**
* GET /checkpoints/sessions/:sessionId - 获取会话的所有检查点
*/
checkpointsRouter.get('/sessions/:sessionId', async (c) => {
const sessionId = c.req.param('sessionId');
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const checkpoints = await manager.getSessionCheckpoints(sessionId);
return c.json({
success: true,
data: checkpoints,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get session checkpoints',
},
500
);
}
});
/**
* GET /checkpoints/messages/:messageId - 获取消息关联的检查点
*/
checkpointsRouter.get('/messages/:messageId', async (c) => {
const messageId = c.req.param('messageId');
const module = await initCheckpointModule();
if (!module) {
return c.json(
{
success: false,
error: 'Checkpoint module not available',
},
503
);
}
try {
const manager = module.getCheckpointManager();
const checkpoints = await manager.getMessageCheckpoints(messageId);
return c.json({
success: true,
data: checkpoints,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get message checkpoints',
},
500
);
}
});
+1
View File
@@ -12,3 +12,4 @@ export { commandsRouter } from './commands.js';
export { mcpRouter } from './mcp.js'; export { mcpRouter } from './mcp.js';
export { hooksRouter } from './hooks.js'; export { hooksRouter } from './hooks.js';
export { agentsRouter } from './agents.js'; export { agentsRouter } from './agents.js';
export { checkpointsRouter } from './checkpoints.js';
+216
View File
@@ -28,6 +28,16 @@ import type {
AgentDetail, AgentDetail,
AgentInput, AgentInput,
AgentDefaults, AgentDefaults,
CheckpointListItem,
CheckpointDetail,
CheckpointStats,
DiffInfo,
FileDiffDetail,
RestoreOptions,
RestoreResult,
SafetyCheckResult,
UnrevertStatus,
UnrevertResult,
} from './types.js'; } from './types.js';
// Re-export types // Re-export types
@@ -71,6 +81,21 @@ export type {
AgentDetail, AgentDetail,
AgentInput, AgentInput,
AgentDefaults, AgentDefaults,
// Checkpoint types
CheckpointTrigger,
FileChangeType,
CheckpointListItem,
CheckpointDetail,
FileChange,
DiffInfo,
FileDiffDetail,
RestoreMode,
RestoreOptions,
RestoreResult,
SafetyCheckResult,
CheckpointStats,
UnrevertStatus,
UnrevertResult,
} from './types.js'; } from './types.js';
// API Configuration // API Configuration
@@ -588,3 +613,194 @@ export async function updateAgentDefaults(defaults: AgentDefaults): Promise<{
}> { }> {
return request('PUT', '/agents/defaults', defaults); return request('PUT', '/agents/defaults', defaults);
} }
// ============ Checkpoints API ============
/**
* 获取所有检查点列表
*/
export async function listCheckpoints(): Promise<{
success: boolean;
data: CheckpointListItem[];
error?: string;
}> {
return request('GET', '/checkpoints');
}
/**
* 获取检查点统计信息
*/
export async function getCheckpointStats(): Promise<{
success: boolean;
data: CheckpointStats;
error?: string;
}> {
return request('GET', '/checkpoints/stats');
}
/**
* 获取最新检查点
*/
export async function getLatestCheckpoint(): Promise<{
success: boolean;
data: CheckpointDetail | null;
error?: string;
}> {
return request('GET', '/checkpoints/latest');
}
/**
* 获取单个检查点详情
*/
export async function getCheckpoint(id: string): Promise<{
success: boolean;
data?: CheckpointDetail;
error?: string;
}> {
return request('GET', `/checkpoints/${encodeURIComponent(id)}`);
}
/**
* 创建手动检查点
*/
export async function createCheckpoint(options?: {
name?: string;
description?: string;
}): Promise<{
success: boolean;
data?: CheckpointDetail;
error?: string;
}> {
return request('POST', '/checkpoints', options || {});
}
/**
* 删除检查点
*/
export async function deleteCheckpoint(id: string): Promise<{
success: boolean;
data?: { deleted: boolean };
error?: string;
}> {
return request('DELETE', `/checkpoints/${encodeURIComponent(id)}`);
}
/**
* 获取检查点与当前工作区的差异
*/
export async function getCheckpointDiff(id: string): Promise<{
success: boolean;
data?: DiffInfo;
error?: string;
}> {
return request('GET', `/checkpoints/${encodeURIComponent(id)}/diff`);
}
/**
* 获取单个文件的详细差异
*/
export async function getFileDiff(checkpointId: string, filePath: string): Promise<{
success: boolean;
data?: FileDiffDetail;
error?: string;
}> {
const params = new URLSearchParams({ path: filePath });
return request('GET', `/checkpoints/${encodeURIComponent(checkpointId)}/file-diff?${params}`);
}
/**
* 回滚到检查点
*/
export async function restoreCheckpoint(
id: string,
options?: RestoreOptions
): Promise<{
success: boolean;
data?: RestoreResult;
error?: string;
}> {
return request('POST', `/checkpoints/${encodeURIComponent(id)}/restore`, options || {});
}
/**
* 预览回滚(dry run
*/
export async function previewRestore(
id: string,
options?: { mode?: RestoreOptions['mode']; files?: string[] }
): Promise<{
success: boolean;
data?: RestoreResult;
error?: string;
}> {
const params = new URLSearchParams();
if (options?.mode) params.set('mode', options.mode);
if (options?.files) params.set('files', options.files.join(','));
return request('GET', `/checkpoints/${encodeURIComponent(id)}/restore/preview?${params}`);
}
/**
* 撤销最近一次回滚
*/
export async function unrevert(): Promise<{
success: boolean;
data?: UnrevertResult;
error?: string;
}> {
return request('POST', '/checkpoints/unrevert');
}
/**
* 检查是否可撤销回滚
*/
export async function getUnrevertStatus(): Promise<{
success: boolean;
data?: UnrevertStatus;
error?: string;
}> {
return request('GET', '/checkpoints/unrevert/status');
}
/**
* 执行安全检查
*/
export async function checkSafety(id: string): Promise<{
success: boolean;
data?: SafetyCheckResult;
error?: string;
}> {
return request('GET', `/checkpoints/${encodeURIComponent(id)}/safety-check`);
}
/**
* 清理过期检查点
*/
export async function cleanupCheckpoints(): Promise<{
success: boolean;
data?: { deleted: number };
error?: string;
}> {
return request('POST', '/checkpoints/cleanup');
}
/**
* 获取会话的所有检查点
*/
export async function getSessionCheckpoints(sessionId: string): Promise<{
success: boolean;
data: CheckpointListItem[];
error?: string;
}> {
return request('GET', `/checkpoints/sessions/${encodeURIComponent(sessionId)}`);
}
/**
* 获取消息关联的检查点
*/
export async function getMessageCheckpoints(messageId: string): Promise<{
success: boolean;
data: CheckpointListItem[];
error?: string;
}> {
return request('GET', `/checkpoints/messages/${encodeURIComponent(messageId)}`);
}
+166
View File
@@ -424,3 +424,169 @@ export interface AgentDefaults {
/** 权限配置 */ /** 权限配置 */
permission?: AgentPermission; permission?: AgentPermission;
} }
// ============ Checkpoint 相关 ============
/** 检查点触发类型 */
export type CheckpointTrigger =
| 'auto'
| 'manual'
| 'tool:write_file'
| 'tool:edit_file'
| 'tool:delete_file'
| 'tool:move_file'
| 'tool:copy_file'
| 'tool:bash'
| 'task_start'
| 'task_complete'
| 'pre_rollback'
| 'session_start'
| 'session_end';
/** 文件变更类型 */
export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed';
/** 检查点列表项 */
export interface CheckpointListItem {
/** 唯一标识 */
id: string;
/** 用户可读名称 */
name?: string;
/** 描述信息 */
description?: string;
/** 创建时间戳 */
timestamp: number;
/** 触发类型 */
trigger: CheckpointTrigger;
/** 受影响的文件数 */
filesChanged: number;
/** Git commit hash */
commitHash: string;
/** 关联的消息 ID */
messageId?: string;
/** 会话 ID */
sessionId?: string;
}
/** 检查点详情 */
export interface CheckpointDetail extends CheckpointListItem {
/** 关联的工具调用 */
toolCall?: {
tool: string;
params: Record<string, unknown>;
};
/** 对话轮次 */
turnIndex?: number;
}
/** 文件变更信息 */
export interface FileChange {
/** 文件路径 */
path: string;
/** 变更类型 */
type: FileChangeType;
/** 旧路径 (重命名时) */
oldPath?: string;
/** 添加的行数 */
insertions?: number;
/** 删除的行数 */
deletions?: number;
}
/** 差异信息 */
export interface DiffInfo {
/** 源检查点/commit */
from: string;
/** 目标检查点/commit */
to: string;
/** 变更的文件列表 */
files: FileChange[];
/** 总添加行数 */
totalInsertions: number;
/** 总删除行数 */
totalDeletions: number;
}
/** 文件差异详情 */
export interface FileDiffDetail {
/** 文件路径 */
path: string;
/** 变更类型 */
type: FileChangeType;
/** 旧内容 */
oldContent?: string;
/** 新内容 */
newContent?: string;
/** 差异补丁 (unified diff 格式) */
patch?: string;
}
/** 恢复模式 */
export type RestoreMode = 'ai_changes_only' | 'workspace_only' | 'full';
/** 恢复选项 */
export interface RestoreOptions {
/** 恢复模式 */
mode?: RestoreMode;
/** 只恢复指定文件 */
files?: string[];
/** 跳过安全检查 */
skipSafetyCheck?: boolean;
}
/** 恢复结果 */
export interface RestoreResult {
/** 是否成功 */
success: boolean;
/** 恢复的文件列表 */
restoredFiles: string[];
/** 错误列表 */
errors: Array<{ file: string; error: string }>;
/** 回滚前的 commit hash */
previousCommit?: string;
}
/** 安全检查结果 */
export interface SafetyCheckResult {
/** 是否安全 */
safe: boolean;
/** 警告列表 */
warnings: string[];
/** 错误列表 */
errors: string[];
}
/** 检查点统计信息 */
export interface CheckpointStats {
/** 检查点数量 */
count: number;
/** 最早时间戳 */
oldestTimestamp: number | null;
/** 最新时间戳 */
newestTimestamp: number | null;
}
/** Unrevert 状态 */
export interface UnrevertStatus {
/** 是否可撤销回滚 */
canUnrevert: boolean;
/** 最后一次回滚记录 */
lastRollback?: {
id: string;
timestamp: number;
targetCheckpoint: string;
restoredFiles: string[];
};
}
/** Unrevert 结果 */
export interface UnrevertResult {
/** 是否成功 */
success: boolean;
/** 恢复的 commit */
restoredCommit: string;
/** 恢复的文件数 */
filesRestored: number;
/** 错误信息 */
error?: string;
}
@@ -0,0 +1,493 @@
/**
* CheckpointDiffViewer Component
*
* 检查点差异查看器:显示检查点与当前工作区的差异
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
RefreshCw,
ChevronDown,
ChevronRight,
Eye,
Check,
RotateCcw,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
import { Button } from '../primitives/Button';
import { Skeleton } from './Skeleton';
import {
getCheckpoint,
getCheckpointDiff,
getFileDiff,
type CheckpointDetail,
type DiffInfo,
type FileDiffDetail,
type FileChangeType,
} from '../api/client.js';
interface CheckpointDiffViewerProps {
/** 检查点 ID */
checkpointId: string;
/** 关闭回调 */
onClose: () => void;
/** 恢复选中文件 */
onRestoreSelected?: (checkpointId: string, files: string[]) => void;
/** 恢复全部 */
onRestoreAll?: (checkpointId: string) => void;
/** 是否启用响应式布局 */
responsive?: boolean;
}
// 文件变更类型标签
function getChangeLabel(type: FileChangeType) {
switch (type) {
case 'added':
return 'A';
case 'modified':
return 'M';
case 'deleted':
return 'D';
case 'renamed':
return 'R';
default:
return '?';
}
}
// 文件变更类型颜色
function getChangeColor(type: FileChangeType) {
switch (type) {
case 'added':
return 'text-green-400 bg-green-400/10';
case 'modified':
return 'text-yellow-400 bg-yellow-400/10';
case 'deleted':
return 'text-red-400 bg-red-400/10';
case 'renamed':
return 'text-blue-400 bg-blue-400/10';
default:
return 'text-gray-400 bg-gray-400/10';
}
}
export function CheckpointDiffViewer({
checkpointId,
onClose,
onRestoreSelected,
onRestoreAll,
responsive = false,
}: CheckpointDiffViewerProps) {
// 数据状态
const [checkpoint, setCheckpoint] = useState<CheckpointDetail | null>(null);
const [diff, setDiff] = useState<DiffInfo | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [fileDiff, setFileDiff] = useState<FileDiffDetail | null>(null);
// UI 状态
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingFileDiff, setLoadingFileDiff] = useState(false);
// 加载数据
const loadData = useCallback(async () => {
try {
const [cpResult, diffResult] = await Promise.all([
getCheckpoint(checkpointId),
getCheckpointDiff(checkpointId),
]);
if (cpResult.success && cpResult.data) {
setCheckpoint(cpResult.data);
} else {
toast.error(cpResult.error || 'Failed to load checkpoint');
}
if (diffResult.success && diffResult.data) {
setDiff(diffResult.data);
// 默认全选
setSelectedFiles(new Set(diffResult.data.files.map((f) => f.path)));
} else {
toast.error(diffResult.error || 'Failed to load diff');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load data');
}
}, [checkpointId]);
// 初始加载
useEffect(() => {
setLoading(true);
loadData().finally(() => setLoading(false));
}, [loadData]);
// 刷新
const handleRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
toast.success('Diff refreshed');
};
// 切换文件选择
const toggleFileSelection = (path: string) => {
const newSelected = new Set(selectedFiles);
if (newSelected.has(path)) {
newSelected.delete(path);
} else {
newSelected.add(path);
}
setSelectedFiles(newSelected);
};
// 全选/取消全选
const toggleSelectAll = () => {
if (diff) {
if (selectedFiles.size === diff.files.length) {
setSelectedFiles(new Set());
} else {
setSelectedFiles(new Set(diff.files.map((f) => f.path)));
}
}
};
// 查看文件差异
const handleViewFileDiff = async (path: string) => {
if (expandedFile === path) {
setExpandedFile(null);
setFileDiff(null);
return;
}
setExpandedFile(path);
setLoadingFileDiff(true);
try {
const result = await getFileDiff(checkpointId, path);
if (result.success && result.data) {
setFileDiff(result.data);
} else {
toast.error(result.error || 'Failed to load file diff');
setFileDiff(null);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load file diff');
setFileDiff(null);
} finally {
setLoadingFileDiff(false);
}
};
// 恢复选中文件
const handleRestoreSelected = () => {
if (onRestoreSelected && selectedFiles.size > 0) {
onRestoreSelected(checkpointId, Array.from(selectedFiles));
}
};
// 恢复全部
const handleRestoreAll = () => {
if (onRestoreAll) {
onRestoreAll(checkpointId);
}
};
// Loading 骨架屏
const LoadingSkeleton = () => (
<div className="space-y-3 p-4">
<Skeleton className="h-6 w-64" />
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3 p-2 bg-gray-900/50 rounded">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-16 ml-auto" />
</div>
))}
</div>
</div>
);
// 渲染 diff 补丁
const renderPatch = (patch: string) => {
const lines = patch.split('\n');
return (
<pre className="text-xs font-mono overflow-x-auto">
{lines.map((line, index) => {
let className = 'px-2 py-0.5';
if (line.startsWith('+') && !line.startsWith('+++')) {
className += ' bg-green-500/10 text-green-400';
} else if (line.startsWith('-') && !line.startsWith('---')) {
className += ' bg-red-500/10 text-red-400';
} else if (line.startsWith('@@')) {
className += ' bg-blue-500/10 text-blue-400';
} else {
className += ' text-gray-400';
}
return (
<div key={index} className={className}>
{line || ' '}
</div>
);
})}
</pre>
);
};
return (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/50 flex z-50',
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
)}
onClick={onClose}
>
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={(e) => e.stopPropagation()}
className={cn(
'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
responsive
? 'w-full md:w-full md:max-w-3xl md:mx-4 rounded-t-2xl md:rounded-lg'
: 'rounded-lg w-full max-w-3xl mx-4'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between border-b border-gray-700',
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
)}
>
{responsive && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Eye size={20} className="text-primary-400" />
Checkpoint Diff
</h2>
<p className="text-xs text-gray-500">
{checkpoint ? (
<>
Comparing <code className="bg-gray-700 px-1 rounded">{checkpoint.commitHash.slice(0, 7)}</code> Current
</>
) : (
'Loading...'
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
title="Refresh"
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
</div>
{/* Summary */}
{diff && (
<div className="px-4 py-3 bg-gray-900/50 border-b border-gray-700">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<span className="text-green-400">+{diff.totalInsertions}</span>
<span className="text-red-400">-{diff.totalDeletions}</span>
<span className="text-gray-400">across {diff.files.length} files</span>
</div>
<button
onClick={toggleSelectAll}
className="text-xs text-primary-400 hover:text-primary-300"
>
{selectedFiles.size === diff.files.length ? 'Deselect All' : 'Select All'}
</button>
</div>
</div>
)}
{/* File List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<LoadingSkeleton />
) : !diff || diff.files.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<Eye size={48} className="mb-4 opacity-50" />
<p className="text-center">No changes detected</p>
<p className="text-xs text-gray-600 mt-2 text-center">
The workspace matches this checkpoint
</p>
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="divide-y divide-gray-700/50"
>
{diff.files.map((file) => {
const isSelected = selectedFiles.has(file.path);
const isExpanded = expandedFile === file.path;
return (
<div key={file.path}>
{/* File Header */}
<div
className={cn(
'flex items-center gap-3 px-4 py-2',
'hover:bg-gray-900/50 transition-colors cursor-pointer'
)}
>
{/* Checkbox */}
<button
onClick={() => toggleFileSelection(file.path)}
className={cn(
'w-4 h-4 rounded border transition-colors flex items-center justify-center',
isSelected
? 'bg-primary-500 border-primary-500'
: 'border-gray-600 hover:border-gray-500'
)}
>
{isSelected && <Check size={12} className="text-white" />}
</button>
{/* Expand Icon */}
<button
onClick={() => handleViewFileDiff(file.path)}
className="text-gray-500 hover:text-gray-300"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{/* Change Type */}
<span
className={cn(
'w-5 h-5 rounded text-xs font-bold flex items-center justify-center',
getChangeColor(file.type)
)}
>
{getChangeLabel(file.type)}
</span>
{/* File Path */}
<span
className="flex-1 text-sm font-mono truncate text-gray-300"
onClick={() => handleViewFileDiff(file.path)}
>
{file.path}
</span>
{/* Stats */}
{(file.insertions !== undefined || file.deletions !== undefined) && (
<span className="text-xs text-gray-500">
{file.insertions !== undefined && (
<span className="text-green-400 mr-2">+{file.insertions}</span>
)}
{file.deletions !== undefined && (
<span className="text-red-400">-{file.deletions}</span>
)}
</span>
)}
</div>
{/* File Diff Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden bg-gray-900/30"
>
{loadingFileDiff ? (
<div className="p-4">
<Skeleton className="h-32 w-full" />
</div>
) : fileDiff?.patch ? (
<div className="max-h-64 overflow-auto border-t border-gray-700/50">
{renderPatch(fileDiff.patch)}
</div>
) : (
<div className="p-4 text-center text-gray-500 text-sm">
No diff content available
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</motion.div>
)}
</div>
{/* Footer Actions */}
{diff && diff.files.length > 0 && (onRestoreSelected || onRestoreAll) && (
<div
className={cn(
'flex items-center justify-between border-t border-gray-700',
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
)}
>
<span className="text-xs text-gray-500">
{selectedFiles.size} of {diff.files.length} files selected
</span>
<div className="flex items-center gap-2">
{onRestoreSelected && (
<Button
variant="outline"
size="sm"
onClick={handleRestoreSelected}
disabled={selectedFiles.size === 0}
className="text-green-400 border-green-400/50 hover:border-green-400"
>
<RotateCcw size={14} className="mr-1" />
Restore Selected ({selectedFiles.size})
</Button>
)}
{onRestoreAll && (
<Button
variant="default"
size="sm"
onClick={handleRestoreAll}
>
<RotateCcw size={14} className="mr-1" />
Restore All
</Button>
)}
</div>
</div>
)}
</motion.div>
</motion.div>
</AnimatePresence>
);
}
@@ -0,0 +1,609 @@
/**
* CheckpointPanel Component
*
* 检查点管理面板:显示所有检查点、创建/删除、查看差异、恢复
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
RefreshCw,
History,
Plus,
Trash2,
ChevronDown,
ChevronRight,
FileText,
Clock,
AlertTriangle,
RotateCcw,
Undo2,
Eye,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
import { Button } from '../primitives/Button';
import { Skeleton } from './Skeleton';
import {
listCheckpoints,
getCheckpointStats,
createCheckpoint,
deleteCheckpoint,
getUnrevertStatus,
unrevert,
cleanupCheckpoints,
type CheckpointListItem,
type CheckpointStats,
type CheckpointTrigger,
type UnrevertStatus,
} from '../api/client.js';
interface CheckpointPanelProps {
onClose: () => void;
/** 点击查看差异时触发 */
onViewDiff?: (checkpointId: string) => void;
/** 点击恢复时触发 */
onRestore?: (checkpointId: string) => void;
/** 是否启用响应式布局 */
responsive?: boolean;
}
// 触发类型图标和颜色
function getTriggerInfo(trigger: CheckpointTrigger) {
switch (trigger) {
case 'tool:write_file':
return { icon: '🟢', label: 'Write File', color: 'text-green-400' };
case 'tool:edit_file':
return { icon: '🟡', label: 'Edit File', color: 'text-yellow-400' };
case 'tool:delete_file':
return { icon: '🔴', label: 'Delete File', color: 'text-red-400' };
case 'tool:move_file':
case 'tool:copy_file':
return { icon: '🟠', label: 'Move/Copy', color: 'text-orange-400' };
case 'tool:bash':
return { icon: '⚡', label: 'Bash', color: 'text-purple-400' };
case 'manual':
return { icon: '🔵', label: 'Manual', color: 'text-blue-400' };
case 'session_start':
return { icon: '▶️', label: 'Session Start', color: 'text-cyan-400' };
case 'session_end':
return { icon: '⏹️', label: 'Session End', color: 'text-cyan-400' };
case 'pre_rollback':
return { icon: '🔙', label: 'Pre-Rollback', color: 'text-gray-400' };
case 'auto':
default:
return { icon: '⚪', label: 'Auto', color: 'text-gray-400' };
}
}
// 格式化时间
function formatTime(timestamp: number) {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
// 格式化完整时间
function formatFullTime(timestamp: number) {
return new Date(timestamp).toLocaleString();
}
// 按日期分组
function groupByDate(checkpoints: CheckpointListItem[]) {
const groups: { label: string; items: CheckpointListItem[] }[] = [];
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
let currentLabel = '';
let currentItems: CheckpointListItem[] = [];
for (const cp of checkpoints) {
const cpDate = new Date(cp.timestamp);
const cpDay = new Date(cpDate.getFullYear(), cpDate.getMonth(), cpDate.getDate());
let label: string;
if (cpDay.getTime() === today.getTime()) {
label = 'Today';
} else if (cpDay.getTime() === yesterday.getTime()) {
label = 'Yesterday';
} else {
label = cpDate.toLocaleDateString();
}
if (label !== currentLabel) {
if (currentItems.length > 0) {
groups.push({ label: currentLabel, items: currentItems });
}
currentLabel = label;
currentItems = [cp];
} else {
currentItems.push(cp);
}
}
if (currentItems.length > 0) {
groups.push({ label: currentLabel, items: currentItems });
}
return groups;
}
export function CheckpointPanel({
onClose,
onViewDiff,
onRestore,
responsive = false,
}: CheckpointPanelProps) {
// 数据状态
const [checkpoints, setCheckpoints] = useState<CheckpointListItem[]>([]);
const [stats, setStats] = useState<CheckpointStats | null>(null);
const [unrevertStatus, setUnrevertStatus] = useState<UnrevertStatus | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['Today']));
// UI 状态
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [cleaningUp, setCleaningUp] = useState(false);
// 加载数据
const loadData = useCallback(async (showToast = false) => {
try {
const [cpResult, statsResult, unrevertResult] = await Promise.all([
listCheckpoints(),
getCheckpointStats(),
getUnrevertStatus(),
]);
if (cpResult.success) {
setCheckpoints(cpResult.data);
} else {
toast.error(cpResult.error || 'Failed to load checkpoints');
}
if (statsResult.success) {
setStats(statsResult.data);
}
if (unrevertResult.success) {
setUnrevertStatus(unrevertResult.data || null);
}
if (showToast) {
toast.success('Checkpoints refreshed');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load checkpoints');
}
}, []);
// 初始加载
useEffect(() => {
setLoading(true);
loadData().finally(() => setLoading(false));
}, [loadData]);
// 刷新
const handleRefresh = async () => {
setRefreshing(true);
await loadData(true);
setRefreshing(false);
};
// 创建检查点
const handleCreate = async () => {
setCreating(true);
try {
const result = await createCheckpoint({
description: 'Manual checkpoint',
});
if (result.success) {
toast.success('Checkpoint created');
await loadData();
} else {
toast.error(result.error || 'Failed to create checkpoint');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create checkpoint');
} finally {
setCreating(false);
}
};
// 删除检查点
const handleDelete = async (id: string) => {
setActionLoading(id);
try {
const result = await deleteCheckpoint(id);
if (result.success) {
toast.success('Checkpoint deleted');
await loadData();
} else {
toast.error(result.error || 'Failed to delete checkpoint');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete checkpoint');
} finally {
setActionLoading(null);
}
};
// 撤销回滚
const handleUnrevert = async () => {
setActionLoading('unrevert');
try {
const result = await unrevert();
if (result.success) {
toast.success(`Unrevert successful: ${result.data?.filesRestored} files restored`);
await loadData();
} else {
toast.error(result.error || 'Failed to unrevert');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to unrevert');
} finally {
setActionLoading(null);
}
};
// 清理检查点
const handleCleanup = async () => {
setCleaningUp(true);
try {
const result = await cleanupCheckpoints();
if (result.success) {
toast.success(`Cleaned up ${result.data?.deleted || 0} checkpoints`);
await loadData();
} else {
toast.error(result.error || 'Failed to cleanup');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to cleanup');
} finally {
setCleaningUp(false);
}
};
// 切换分组展开
const toggleGroup = (label: string) => {
const newExpanded = new Set(expandedGroups);
if (newExpanded.has(label)) {
newExpanded.delete(label);
} else {
newExpanded.add(label);
}
setExpandedGroups(newExpanded);
};
// 分组数据
const groups = groupByDate(checkpoints);
// Loading 骨架屏
const LoadingSkeleton = () => (
<div className="space-y-3 p-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg">
<Skeleton className="h-4 w-4 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
);
return (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/50 flex z-50',
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
)}
onClick={onClose}
>
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={(e) => e.stopPropagation()}
className={cn(
'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
responsive
? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
: 'rounded-lg w-full max-w-2xl mx-4'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between border-b border-gray-700',
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
)}
>
{responsive && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold flex items-center gap-2">
<History size={20} className="text-primary-400" />
Checkpoints
</h2>
<p className="text-xs text-gray-500">
{stats ? `${stats.count} checkpoints` : 'Loading...'}
{stats?.oldestTimestamp && (
<> · Oldest: {formatTime(stats.oldestTimestamp)}</>
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCreate}
disabled={creating}
title="Create checkpoint"
className={cn('text-green-400 hover:text-green-300', responsive && 'min-h-[44px]')}
>
{creating ? (
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-green-500" />
) : (
<>
<Plus size={16} className="mr-1" />
Create
</>
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
title="Refresh"
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
</div>
{/* Unrevert Banner */}
{unrevertStatus?.canUnrevert && unrevertStatus.lastRollback && (
<div className="mx-4 mt-4 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-yellow-400">
<AlertTriangle size={16} />
<span className="text-sm">
Last rollback affected {unrevertStatus.lastRollback.restoredFiles.length} files
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleUnrevert}
disabled={actionLoading === 'unrevert'}
className="text-yellow-400 hover:text-yellow-300"
>
{actionLoading === 'unrevert' ? (
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-yellow-500" />
) : (
<>
<Undo2 size={14} className="mr-1" />
Unrevert
</>
)}
</Button>
</div>
</div>
)}
{/* Checkpoint List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<LoadingSkeleton />
) : checkpoints.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<History size={48} className="mb-4 opacity-50" />
<p className="text-center">No checkpoints yet</p>
<p className="text-xs text-gray-600 mt-2 text-center max-w-xs">
Checkpoints are created automatically when files are modified
</p>
<Button
variant="outline"
size="sm"
onClick={handleCreate}
className="mt-4"
disabled={creating}
>
<Plus size={14} className="mr-1" />
Create First Checkpoint
</Button>
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
>
{groups.map((group) => {
const isExpanded = expandedGroups.has(group.label);
return (
<div key={group.label} className="space-y-1">
{/* Group Header */}
<button
className="flex items-center gap-2 text-sm text-gray-400 hover:text-gray-300 transition-colors w-full"
onClick={() => toggleGroup(group.label)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="font-medium">{group.label}</span>
<span className="text-xs text-gray-500">({group.items.length})</span>
</button>
{/* Group Items */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="space-y-1 overflow-hidden"
>
{group.items.map((cp) => {
const triggerInfo = getTriggerInfo(cp.trigger);
const isLoading = actionLoading === cp.id;
return (
<motion.div
key={cp.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="bg-gray-900/50 rounded-lg p-3 hover:bg-gray-900/80 transition-colors"
>
<div className="flex items-start gap-3">
{/* Trigger Icon */}
<span className="text-lg" title={triggerInfo.label}>
{triggerInfo.icon}
</span>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn('text-sm font-medium', triggerInfo.color)}>
{triggerInfo.label}
</span>
<span className="text-xs text-gray-500">
{formatTime(cp.timestamp)}
</span>
</div>
{cp.description && (
<p className="text-xs text-gray-400 mt-0.5 truncate">
{cp.description}
</p>
)}
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
<span className="flex items-center gap-1">
<FileText size={10} />
{cp.filesChanged} files
</span>
<span className="flex items-center gap-1">
<Clock size={10} />
{formatFullTime(cp.timestamp)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
{isLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary-500" />
) : (
<>
{onViewDiff && (
<Button
variant="ghost"
size="icon"
onClick={() => onViewDiff(cp.id)}
title="View diff"
className="text-blue-400 hover:text-blue-300"
>
<Eye size={14} />
</Button>
)}
{onRestore && (
<Button
variant="ghost"
size="icon"
onClick={() => onRestore(cp.id)}
title="Restore"
className="text-green-400 hover:text-green-300"
>
<RotateCcw size={14} />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(cp.id)}
title="Delete"
className="text-red-400 hover:text-red-300"
>
<Trash2 size={14} />
</Button>
</>
)}
</div>
</div>
</motion.div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</motion.div>
)}
</div>
{/* Footer */}
<div
className={cn(
'flex items-center justify-between border-t border-gray-700',
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
)}
>
<span className="text-xs text-gray-500">
Auto-cleanup enabled (7 days / 100 max)
</span>
<Button
variant="ghost"
size="sm"
onClick={handleCleanup}
disabled={cleaningUp}
className="text-gray-400 hover:text-gray-300"
>
{cleaningUp ? (
<div className="animate-spin rounded-full h-3 w-3 border-t-2 border-b-2 border-gray-500 mr-1" />
) : (
<Trash2 size={12} className="mr-1" />
)}
Cleanup Now
</Button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
@@ -0,0 +1,391 @@
/**
* RestoreDialog Component
*
* 检查点恢复确认对话框:显示安全检查、选择恢复模式、确认操作
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
AlertTriangle,
AlertCircle,
CheckCircle,
RotateCcw,
FileText,
Loader2,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
import { Button } from '../primitives/Button';
import { Skeleton } from './Skeleton';
import {
getCheckpoint,
checkSafety,
previewRestore,
restoreCheckpoint,
type CheckpointDetail,
type SafetyCheckResult,
type RestoreResult,
type RestoreMode,
} from '../api/client.js';
interface RestoreDialogProps {
/** 检查点 ID */
checkpointId: string;
/** 要恢复的文件列表(可选,为空则恢复全部) */
files?: string[];
/** 关闭回调 */
onClose: () => void;
/** 恢复成功回调 */
onRestored?: (result: RestoreResult) => void;
/** 是否启用响应式布局 */
responsive?: boolean;
}
// 恢复模式选项
const RESTORE_MODES: { value: RestoreMode; label: string; description: string }[] = [
{
value: 'ai_changes_only',
label: 'AI Changes Only',
description: 'Only restore files that were modified by AI',
},
{
value: 'workspace_only',
label: 'Workspace Only',
description: 'Only restore workspace changes (not AI modifications)',
},
{
value: 'full',
label: 'Full Restore',
description: 'Restore all files to checkpoint state',
},
];
export function RestoreDialog({
checkpointId,
files,
onClose,
onRestored,
responsive = false,
}: RestoreDialogProps) {
// 数据状态
const [checkpoint, setCheckpoint] = useState<CheckpointDetail | null>(null);
const [safetyResult, setSafetyResult] = useState<SafetyCheckResult | null>(null);
const [previewResult, setPreviewResult] = useState<RestoreResult | null>(null);
// 表单状态
const [selectedMode, setSelectedMode] = useState<RestoreMode>('full');
const [skipSafetyCheck, setSkipSafetyCheck] = useState(false);
// UI 状态
const [loading, setLoading] = useState(true);
const [restoring, setRestoring] = useState(false);
// 加载数据
const loadData = useCallback(async () => {
try {
const [cpResult, safetyRes, previewRes] = await Promise.all([
getCheckpoint(checkpointId),
checkSafety(checkpointId),
previewRestore(checkpointId, { mode: selectedMode, files }),
]);
if (cpResult.success && cpResult.data) {
setCheckpoint(cpResult.data);
} else {
toast.error(cpResult.error || 'Failed to load checkpoint');
}
if (safetyRes.success && safetyRes.data) {
setSafetyResult(safetyRes.data);
}
if (previewRes.success && previewRes.data) {
setPreviewResult(previewRes.data);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load data');
}
}, [checkpointId, selectedMode, files]);
// 初始加载
useEffect(() => {
setLoading(true);
loadData().finally(() => setLoading(false));
}, [loadData]);
// 模式变化时重新预览
useEffect(() => {
if (!loading) {
previewRestore(checkpointId, { mode: selectedMode, files })
.then((result) => {
if (result.success && result.data) {
setPreviewResult(result.data);
}
})
.catch(() => {});
}
}, [checkpointId, selectedMode, files, loading]);
// 执行恢复
const handleRestore = async () => {
setRestoring(true);
try {
const result = await restoreCheckpoint(checkpointId, {
mode: selectedMode,
files,
skipSafetyCheck,
});
if (result.success && result.data) {
toast.success(`Restored ${result.data.restoredFiles.length} files`);
onRestored?.(result.data);
onClose();
} else {
toast.error(result.error || 'Failed to restore checkpoint');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to restore');
} finally {
setRestoring(false);
}
};
// 格式化时间
const formatTime = (timestamp: number) => new Date(timestamp).toLocaleString();
// Loading 骨架屏
const LoadingSkeleton = () => (
<div className="space-y-4 p-4">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
return (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/50 flex z-50',
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
)}
onClick={onClose}
>
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={(e) => e.stopPropagation()}
className={cn(
'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
responsive
? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg'
: 'rounded-lg w-full max-w-lg mx-4'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between border-b border-gray-700',
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
)}
>
{responsive && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold flex items-center gap-2">
<RotateCcw size={20} className="text-primary-400" />
Restore Checkpoint
</h2>
{checkpoint && (
<p className="text-xs text-gray-500">
{formatTime(checkpoint.timestamp)}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{loading ? (
<LoadingSkeleton />
) : (
<>
{/* Safety Check Results */}
{safetyResult && (
<div className="space-y-2">
{/* Errors */}
{safetyResult.errors.length > 0 && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div className="flex items-center gap-2 text-red-400 mb-2">
<AlertCircle size={16} />
<span className="font-medium">Safety Errors</span>
</div>
<ul className="text-sm text-red-300 space-y-1">
{safetyResult.errors.map((error, i) => (
<li key={i} className="flex items-start gap-2">
<span className="text-red-500"></span>
{error}
</li>
))}
</ul>
</div>
)}
{/* Warnings */}
{safetyResult.warnings.length > 0 && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2 text-yellow-400 mb-2">
<AlertTriangle size={16} />
<span className="font-medium">Warnings</span>
</div>
<ul className="text-sm text-yellow-300 space-y-1">
{safetyResult.warnings.map((warning, i) => (
<li key={i} className="flex items-start gap-2">
<span className="text-yellow-500"></span>
{warning}
</li>
))}
</ul>
</div>
)}
{/* Safe */}
{safetyResult.safe && safetyResult.errors.length === 0 && safetyResult.warnings.length === 0 && (
<div className="p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 text-green-400">
<CheckCircle size={16} />
<span className="font-medium">Safety check passed</span>
</div>
</div>
)}
</div>
)}
{/* Restore Mode Selection */}
{!files && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-300">Restore Mode</label>
<div className="space-y-2">
{RESTORE_MODES.map((mode) => (
<label
key={mode.value}
className={cn(
'flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-colors',
selectedMode === mode.value
? 'bg-primary-500/10 border border-primary-500/50'
: 'bg-gray-900/50 border border-gray-700 hover:border-gray-600'
)}
>
<input
type="radio"
name="restoreMode"
value={mode.value}
checked={selectedMode === mode.value}
onChange={(e) => setSelectedMode(e.target.value as RestoreMode)}
className="mt-1"
/>
<div>
<div className="font-medium text-gray-200">{mode.label}</div>
<div className="text-xs text-gray-500">{mode.description}</div>
</div>
</label>
))}
</div>
</div>
)}
{/* Files to Restore */}
{previewResult && previewResult.restoredFiles.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
<FileText size={14} />
Files to restore ({previewResult.restoredFiles.length})
</label>
<div className="max-h-40 overflow-y-auto bg-gray-900/50 rounded-lg p-2 space-y-1">
{previewResult.restoredFiles.map((file) => (
<div
key={file}
className="text-xs font-mono text-gray-400 px-2 py-1 hover:bg-gray-800 rounded"
>
{file}
</div>
))}
</div>
</div>
)}
{/* Skip Safety Check Option */}
{safetyResult && !safetyResult.safe && (
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={skipSafetyCheck}
onChange={(e) => setSkipSafetyCheck(e.target.checked)}
className="rounded"
/>
Skip safety check and proceed anyway
</label>
)}
</>
)}
</div>
{/* Footer */}
<div
className={cn(
'flex items-center justify-end gap-3 border-t border-gray-700',
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
)}
>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
variant="default"
onClick={handleRestore}
disabled={
loading ||
restoring ||
(safetyResult && !safetyResult.safe && !skipSafetyCheck) ||
!previewResult ||
previewResult.restoredFiles.length === 0
}
>
{restoring ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
Restoring...
</>
) : (
<>
<RotateCcw size={16} className="mr-2" />
Restore Now
</>
)}
</Button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
+35
View File
@@ -63,6 +63,23 @@ export {
listPresetAgents, listPresetAgents,
getAgentDefaults, getAgentDefaults,
updateAgentDefaults, updateAgentDefaults,
// Checkpoints API
listCheckpoints,
getCheckpointStats,
getLatestCheckpoint,
getCheckpoint,
createCheckpoint,
deleteCheckpoint,
getCheckpointDiff,
getFileDiff,
restoreCheckpoint,
previewRestore,
unrevert,
getUnrevertStatus,
checkSafety,
cleanupCheckpoints,
getSessionCheckpoints,
getMessageCheckpoints,
} from './api/client.js'; } from './api/client.js';
// Types // Types
@@ -109,6 +126,21 @@ export type {
AgentDetail, AgentDetail,
AgentInput, AgentInput,
AgentDefaults, AgentDefaults,
// Checkpoint types
CheckpointTrigger,
FileChangeType,
CheckpointListItem,
CheckpointDetail,
FileChange,
DiffInfo,
FileDiffDetail,
RestoreMode,
RestoreOptions,
RestoreResult,
SafetyCheckResult,
CheckpointStats,
UnrevertStatus,
UnrevertResult,
} from './api/client.js'; } from './api/client.js';
// Primitives (shadcn/ui style) // Primitives (shadcn/ui style)
@@ -130,6 +162,9 @@ export { HookEditor } from './components/HookEditor.js';
export { AgentsPanel } from './components/AgentsPanel.js'; export { AgentsPanel } from './components/AgentsPanel.js';
export { AgentEditor } from './components/AgentEditor.js'; export { AgentEditor } from './components/AgentEditor.js';
export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js'; export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
export { CheckpointPanel } from './components/CheckpointPanel.js';
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
export { RestoreDialog } from './components/RestoreDialog.js';
export { Sidebar } from './components/Sidebar.js'; export { Sidebar } from './components/Sidebar.js';
export { FileBrowser } from './components/FileBrowser.js'; export { FileBrowser } from './components/FileBrowser.js';
export { ConfigPanel } from './components/ConfigPanel.js'; export { ConfigPanel } from './components/ConfigPanel.js';
+6
View File
@@ -13,6 +13,7 @@ import {
MCPPanel, MCPPanel,
HooksPanel, HooksPanel,
AgentsPanel, AgentsPanel,
CheckpointPanel,
Toaster, Toaster,
listSessions, listSessions,
createSession, createSession,
@@ -29,6 +30,7 @@ export function App() {
const [showMCP, setShowMCP] = useState(false); const [showMCP, setShowMCP] = useState(false);
const [showHooks, setShowHooks] = useState(false); const [showHooks, setShowHooks] = useState(false);
const [showAgents, setShowAgents] = useState(false); const [showAgents, setShowAgents] = useState(false);
const [showCheckpoints, setShowCheckpoints] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话 // 初始化:加载或创建会话
@@ -117,6 +119,7 @@ export function App() {
onOpenMCP={() => setShowMCP(true)} onOpenMCP={() => setShowMCP(true)}
onOpenHooks={() => setShowHooks(true)} onOpenHooks={() => setShowHooks(true)}
onOpenAgents={() => setShowAgents(true)} onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(true)}
/> />
) : ( ) : (
<div className="flex-1 flex items-center justify-center h-full"> <div className="flex-1 flex items-center justify-center h-full">
@@ -177,6 +180,9 @@ export function App() {
{/* Agents 面板 */} {/* Agents 面板 */}
{showAgents && <AgentsPanel onClose={() => setShowAgents(false)} responsive />} {showAgents && <AgentsPanel onClose={() => setShowAgents(false)} responsive />}
{/* Checkpoints 面板 */}
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} responsive />}
{/* 移动端底部文件按钮 */} {/* 移动端底部文件按钮 */}
<button <button
onClick={() => setShowFileBrowser(true)} onClick={() => setShowFileBrowser(true)}
+17 -2
View File
@@ -3,7 +3,7 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot } from 'lucide-react'; import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot, History } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
useChat, useChat,
@@ -26,6 +26,7 @@ interface ChatPageProps {
onOpenMCP?: () => void; onOpenMCP?: () => void;
onOpenHooks?: () => void; onOpenHooks?: () => void;
onOpenAgents?: () => void; onOpenAgents?: () => void;
onOpenCheckpoints?: () => void;
} }
export function ChatPage({ export function ChatPage({
@@ -40,6 +41,7 @@ export function ChatPage({
onOpenMCP, onOpenMCP,
onOpenHooks, onOpenHooks,
onOpenAgents, onOpenAgents,
onOpenCheckpoints,
}: ChatPageProps) { }: ChatPageProps) {
const { const {
messages, messages,
@@ -134,8 +136,21 @@ export function ChatPage({
<ConnectionStatus /> <ConnectionStatus />
{/* 工具栏按钮 */} {/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents) && ( {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints) && (
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3"> <div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
{/* Checkpoints 按钮 */}
{onOpenCheckpoints && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenCheckpoints}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Checkpoints"
>
<History size={20} />
</motion.button>
)}
{/* Agents 按钮 */} {/* Agents 按钮 */}
{onOpenAgents && ( {onOpenAgents && (
<motion.button <motion.button