Skip to content

Commit 6480269

Browse files
committed
feat(会话): 实现交互式会话恢复功能
添加会话选择器组件和会话服务,支持通过 sessionId 或交互式选择恢复历史会话 重构 SessionContext 以支持会话恢复操作 修改 CLI 参数处理逻辑,支持 --resume 参数自动推断
1 parent 9f7f10f commit 6480269

5 files changed

Lines changed: 529 additions & 18 deletions

File tree

src/cli/config.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,17 @@ export const globalOptions = {
109109
},
110110
resume: {
111111
alias: 'r',
112-
type: 'string',
113-
describe: 'Resume a conversation',
112+
describe: 'Resume a conversation - provide a session ID or interactively select a conversation to resume',
114113
group: 'Session Options:',
114+
// 移除 type 声明,让 yargs 自动推断
115+
coerce: (value: string | boolean | undefined) => {
116+
// 如果没有提供值(value === undefined 或 true),返回 'true' 表示交互式选择
117+
if (value === undefined || value === true || value === '') {
118+
return 'true';
119+
}
120+
// 如果提供了值,返回 sessionId
121+
return String(value);
122+
},
115123
},
116124
'fork-session': {
117125
alias: ['forkSession'],

src/services/SessionService.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/**
2+
* 会话管理服务
3+
* 负责加载和恢复历史会话
4+
*/
5+
6+
import { readdir, readFile } from 'node:fs/promises';
7+
import * as path from 'node:path';
8+
import type { BladeJSONLEntry } from '../context/types.js';
9+
import {
10+
getBladeStorageRoot,
11+
unescapeProjectPath,
12+
} from '../context/utils/pathEscape.js';
13+
import type { Message } from './ChatServiceInterface.js';
14+
15+
/**
16+
* 会话元数据
17+
*/
18+
export interface SessionMetadata {
19+
sessionId: string;
20+
projectPath: string;
21+
gitBranch?: string;
22+
messageCount: number;
23+
firstMessageTime: string;
24+
lastMessageTime: string;
25+
hasErrors: boolean;
26+
filePath: string; // JSONL 文件路径
27+
}
28+
29+
/**
30+
* 会话管理服务
31+
*/
32+
export class SessionService {
33+
/**
34+
* 列出所有可用会话
35+
* 扫描 ~/.blade/projects/ 目录下的所有 JSONL 文件
36+
*/
37+
static async listSessions(): Promise<SessionMetadata[]> {
38+
const sessions: SessionMetadata[] = [];
39+
const projectsDir = path.join(getBladeStorageRoot(), 'projects');
40+
41+
try {
42+
// 读取所有项目目录
43+
const projectDirs = await readdir(projectsDir, { withFileTypes: true });
44+
45+
for (const dir of projectDirs) {
46+
if (!dir.isDirectory()) continue;
47+
48+
const projectDirPath = path.join(projectsDir, dir.name);
49+
const projectPath = unescapeProjectPath(dir.name);
50+
51+
// 读取项目目录下的所有 JSONL 文件
52+
const files = await readdir(projectDirPath);
53+
const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
54+
55+
for (const file of jsonlFiles) {
56+
const filePath = path.join(projectDirPath, file);
57+
const sessionId = file.replace('.jsonl', '');
58+
59+
try {
60+
const metadata = await this.extractMetadata(filePath, sessionId, projectPath);
61+
sessions.push(metadata);
62+
} catch (error) {
63+
console.warn(`[SessionService] 跳过损坏的会话文件: ${filePath}`, error);
64+
}
65+
}
66+
}
67+
68+
// 按最后消息时间降序排序
69+
sessions.sort(
70+
(a, b) => new Date(b.lastMessageTime).getTime() - new Date(a.lastMessageTime).getTime()
71+
);
72+
73+
return sessions;
74+
} catch (error) {
75+
console.error('[SessionService] 列出会话失败:', error);
76+
return [];
77+
}
78+
}
79+
80+
/**
81+
* 从 JSONL 文件提取元数据(只读取必要信息)
82+
*/
83+
private static async extractMetadata(
84+
filePath: string,
85+
sessionId: string,
86+
projectPath: string
87+
): Promise<SessionMetadata> {
88+
const content = await readFile(filePath, 'utf-8');
89+
const lines = content.trim().split('\n').filter((line) => line.trim());
90+
91+
if (lines.length === 0) {
92+
throw new Error('空的 JSONL 文件');
93+
}
94+
95+
// 解析第一行和最后一行
96+
const firstEntry = JSON.parse(lines[0]) as BladeJSONLEntry;
97+
const lastEntry = JSON.parse(lines[lines.length - 1]) as BladeJSONLEntry;
98+
99+
// 检查是否有错误消息
100+
const hasErrors = lines.some((line) => {
101+
try {
102+
const entry = JSON.parse(line) as BladeJSONLEntry;
103+
return entry.type === 'tool_result' && entry.toolResult?.error;
104+
} catch {
105+
return false;
106+
}
107+
});
108+
109+
return {
110+
sessionId,
111+
projectPath,
112+
gitBranch: firstEntry.gitBranch,
113+
messageCount: lines.length,
114+
firstMessageTime: firstEntry.timestamp,
115+
lastMessageTime: lastEntry.timestamp,
116+
hasErrors,
117+
filePath,
118+
};
119+
}
120+
121+
/**
122+
* 加载指定会话的消息历史
123+
* @param sessionId 会话 ID
124+
* @param projectPath 项目路径(可选,如果不提供则搜索所有项目)
125+
*/
126+
static async loadSession(sessionId: string, projectPath?: string): Promise<Message[]> {
127+
try {
128+
// 如果提供了项目路径,直接查找
129+
if (projectPath) {
130+
const filePath = this.getSessionFilePath(projectPath, sessionId);
131+
return await this.loadSessionFromFile(filePath);
132+
}
133+
134+
// 否则搜索所有项目
135+
const sessions = await this.listSessions();
136+
const session = sessions.find((s) => s.sessionId === sessionId);
137+
138+
if (!session) {
139+
throw new Error(`未找到会话: ${sessionId}`);
140+
}
141+
142+
return await this.loadSessionFromFile(session.filePath);
143+
} catch (error) {
144+
console.error(`[SessionService] 加载会话失败 (${sessionId}):`, error);
145+
throw error;
146+
}
147+
}
148+
149+
/**
150+
* 从 JSONL 文件加载并转换消息
151+
*/
152+
private static async loadSessionFromFile(filePath: string): Promise<Message[]> {
153+
const content = await readFile(filePath, 'utf-8');
154+
const lines = content.trim().split('\n').filter((line) => line.trim());
155+
156+
const entries: BladeJSONLEntry[] = lines.map((line) => JSON.parse(line) as BladeJSONLEntry);
157+
158+
return this.convertJSONLToMessages(entries);
159+
}
160+
161+
/**
162+
* 将 JSONL 条目转换为 OpenAI Message 格式
163+
*
164+
* 转换规则:
165+
* - user/assistant/system 消息直接转换
166+
* - tool_use 跳过(工具调用包含在 assistant 的 tool_calls 中)
167+
* - tool_result 转换为 tool 角色消息
168+
*/
169+
static convertJSONLToMessages(entries: BladeJSONLEntry[]): Message[] {
170+
const messages: Message[] = [];
171+
172+
for (const entry of entries) {
173+
switch (entry.type) {
174+
case 'user':
175+
case 'assistant':
176+
case 'system':
177+
// 直接转换用户/助手/系统消息
178+
messages.push({
179+
role: entry.message.role,
180+
content: typeof entry.message.content === 'string'
181+
? entry.message.content
182+
: JSON.stringify(entry.message.content),
183+
});
184+
break;
185+
186+
case 'tool_result':
187+
// 转换工具结果为 tool 消息
188+
if (entry.toolResult) {
189+
const content = entry.toolResult.error
190+
? `Error: ${entry.toolResult.error}`
191+
: typeof entry.toolResult.output === 'string'
192+
? entry.toolResult.output
193+
: JSON.stringify(entry.toolResult.output);
194+
195+
messages.push({
196+
role: 'tool',
197+
content,
198+
tool_call_id: entry.toolResult.id,
199+
name: entry.tool?.name, // 从对应的 tool_use 获取工具名称
200+
});
201+
}
202+
break;
203+
204+
case 'tool_use':
205+
// 跳过 tool_use,因为工具调用应该包含在 assistant 消息的 tool_calls 中
206+
// 注意:我们的实现将 tool_use 作为独立条目保存,与 Claude Code 不同
207+
// 这里简化处理,恢复会话时跳过工具调用详情
208+
break;
209+
210+
default:
211+
// 跳过其他类型(如 file-history-snapshot)
212+
break;
213+
}
214+
}
215+
216+
return messages;
217+
}
218+
219+
/**
220+
* 获取会话文件路径
221+
*/
222+
private static getSessionFilePath(projectPath: string, sessionId: string): string {
223+
const { getSessionFilePath } = require('../context/utils/pathEscape.js');
224+
return getSessionFilePath(projectPath, sessionId);
225+
}
226+
}

0 commit comments

Comments
 (0)