diff --git a/adapters/common/format.ts b/adapters/common/format.ts index 3002e6dad..8515927c4 100644 --- a/adapters/common/format.ts +++ b/adapters/common/format.ts @@ -2,6 +2,8 @@ * 消息格式化工具 */ +import type { SessionListItem } from './http-client.js' + type AdapterChatState = | 'idle' | 'thinking' @@ -27,6 +29,7 @@ type ImStatusSummary = { const IM_HELP_LINES = [ '/new [项目] — 新建会话或切换项目', + '/resume [会话] — 恢复已有会话', '/projects — 查看最近项目', '/status — 查看当前会话状态', '/clear — 清空当前会话上下文', @@ -227,3 +230,26 @@ function formatAdapterChatState( function shortSessionId(sessionId: string): string { return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId } + +/** Format a list of sessions for the /resume picker. */ +export function formatSessionList(sessions: SessionListItem[], currentSessionId?: string): string { + const lines = sessions.map((s, i) => { + const marker = s.id === currentSessionId ? ' ✦' : '' + const title = truncate(s.title || '(无标题)', 50) + const shortId = shortSessionId(s.id) + const relTime = formatRelativeTime(s.modifiedAt) + return `${i + 1}. ${title}${marker}\n ${s.workDir} · ${s.messageCount}条消息 · ${relTime}` + }) + + return `选择会话(回复编号):\n\n${lines.join('\n\n')}\n\n💡 也可直接 /resume <会话ID或关键词>` +} + +function formatRelativeTime(iso: string): string { + const now = Date.now() + const then = new Date(iso).getTime() + const diffSec = Math.floor((now - then) / 1000) + if (diffSec < 60) return '刚刚' + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}分钟前` + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}小时前` + return `${Math.floor(diffSec / 86400)}天前` +} diff --git a/adapters/common/http-client.ts b/adapters/common/http-client.ts index 104460034..3143cb655 100644 --- a/adapters/common/http-client.ts +++ b/adapters/common/http-client.ts @@ -1,3 +1,14 @@ +export type SessionListItem = { + id: string + title: string + createdAt: string + modifiedAt: string + messageCount: number + projectPath: string + workDir: string + workDirExists: boolean +} + export type RecentProject = { projectPath: string realPath: string @@ -85,6 +96,18 @@ export class AdapterHttpClient { return {} } + async listSessions(limit = 10, project?: string): Promise { + const params = new URLSearchParams() + params.set('limit', String(limit)) + if (project) params.set('project', project) + const res = await fetch(`${this.httpBaseUrl}/api/sessions?${params}`) + if (!res.ok) { + throw new Error(`Failed to list sessions: ${res.statusText}`) + } + const data = (await res.json()) as { sessions: SessionListItem[] } + return data.sessions + } + async getGitInfo(sessionId: string): Promise { const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`) if (!res.ok) { diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index b08875ab1..5f55ad585 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -18,10 +18,11 @@ import { loadConfig } from '../common/config.js' import { formatImHelp, formatImStatus, + formatSessionList, splitMessage, } from '../common/format.js' import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' +import { AdapterHttpClient, type RecentProject, type SessionListItem } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { optimizeMarkdownForFeishu } from './markdown-style.js' import { extractInboundPayload } from './extract-payload.js' @@ -61,6 +62,7 @@ attachmentStore.gc().catch((err) => { // One streaming card lifecycle per chatId (CardKit main + patch fallback). const streamingCards = new Map() const pendingProjectSelection = new Map() +const pendingSessionSelection = new Map() const runtimeStates = new Map() // Per-chat outbound watchers for Agent-produced markdown image references. @@ -709,6 +711,110 @@ async function showProjectPicker(chatId: string): Promise { } } +// ---------- /resume ---------- + +async function showSessionPicker(chatId: string): Promise { + try { + const sessions = await httpClient.listSessions(10) + if (sessions.length === 0) { + await sendText(chatId, '没有找到已有会话。发送 /new 新建会话。') + return + } + const currentSessionId = sessionStore.get(chatId)?.sessionId + pendingSessionSelection.set(chatId, sessions) + pendingProjectSelection.delete(chatId) + const text = formatSessionList(sessions, currentSessionId) + await sendText(chatId, text) + } catch (err) { + await sendText(chatId, `❌ 无法获取会话列表: ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function resumeSession(chatId: string, query?: string): Promise { + if (!query) { + await showSessionPicker(chatId) + return + } + + try { + const sessions = await httpClient.listSessions(20) + const q = query.trim().toLowerCase() + + // Try as 1-based index from pendingSessionSelection + const num = parseInt(q, 10) + const pendingSessions = pendingSessionSelection.get(chatId) + if (!isNaN(num) && num >= 1 && pendingSessions && num <= pendingSessions.length && String(num) === q.trim()) { + const target = pendingSessions[num - 1]! + pendingSessionSelection.delete(chatId) + await switchToSession(chatId, target) + return + } + + // Try UUID prefix match + const uuidMatch = sessions.filter(s => s.id.toLowerCase().startsWith(q)) + if (uuidMatch.length === 1) { + await switchToSession(chatId, uuidMatch[0]!) + return + } + if (uuidMatch.length > 1) { + const list = uuidMatch.map((s, i) => `${i + 1}. ${truncate(s.title, 40)} (${shortSessionId(s.id)})`).join('\n') + pendingSessionSelection.set(chatId, uuidMatch) + await sendText(chatId, `匹配到多个会话,回复编号选择:\n\n${list}`) + return + } + + // Try title keyword match + const titleMatch = sessions.filter(s => s.title.toLowerCase().includes(q)) + if (titleMatch.length === 1) { + await switchToSession(chatId, titleMatch[0]!) + return + } + if (titleMatch.length > 1) { + const list = titleMatch.map((s, i) => `${i + 1}. ${truncate(s.title, 40)} (${shortSessionId(s.id)})`).join('\n') + pendingSessionSelection.set(chatId, titleMatch) + await sendText(chatId, `匹配到多个会话,回复编号选择:\n\n${list}`) + return + } + + await sendText(chatId, `未找到匹配 "${query}" 的会话。发送 /resume 查看完整列表。`) + } catch (err) { + await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function switchToSession(chatId: string, session: SessionListItem): Promise { + if (!session.workDirExists) { + await sendText(chatId, `⚠️ 会话工作目录不存在: ${session.workDir},无法恢复。`) + return + } + + // Clean up existing state + clearTransientChatState(chatId) + bridge.resetSession(chatId) + + // Bind to the target session + sessionStore.set(chatId, session.id, session.workDir) + bridge.connectSession(chatId, session.id) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + + const opened = await bridge.waitForOpen(chatId) + if (!opened) { + await sendText(chatId, '⚠️ 连接会话超时,请重试。') + return + } + + const title = truncate(session.title || '(无标题)', 40) + await sendText(chatId, `✅ 已切换到会话:**${title}** (${shortSessionId(session.id)})`) +} + +function shortSessionId(sessionId: string): string { + return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) + '…' : s +} + async function startNewSession(chatId: string, query?: string): Promise { bridge.resetSession(chatId) sessionStore.delete(chatId) @@ -721,6 +827,7 @@ async function startNewSession(chatId: string, query?: string): Promise { imageWatchers.delete(chatId) uploadedImageKeys.delete(chatId) pendingProjectSelection.delete(chatId) + pendingSessionSelection.delete(chatId) runtimeStates.delete(chatId) if (query) { @@ -1034,6 +1141,17 @@ async function handleMessage(data: any): Promise { await showProjectPicker(chatId) return } + if (!hasAttachments && (msgText === '/resume' || msgText.startsWith('/resume '))) { + const arg = msgText.startsWith('/resume ') ? msgText.slice(8).trim() : '' + await resumeSession(chatId, arg || undefined) + return + } + + // User is replying to a session picker prompt + if (!hasAttachments && pendingSessionSelection.has(chatId)) { + await resumeSession(chatId, msgText.trim()) + return + } // User is replying to a project picker prompt if (!hasAttachments && pendingProjectSelection.has(chatId)) { diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index aa84676d3..77e21116a 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -16,10 +16,11 @@ import { formatImHelp, formatImStatus, formatPermissionRequest, + formatSessionList, splitMessage, } from '../common/format.js' import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient } from '../common/http-client.js' +import { AdapterHttpClient, type SessionListItem } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { TelegramMediaService } from './media.js' import { AttachmentStore } from '../common/attachment/attachment-store.js' @@ -58,6 +59,8 @@ const accumulatedText = new Map() const buffers = new Map() // Track chats waiting for project selection const pendingProjectSelection = new Map() +// Track chats waiting for session selection (stores the listed sessions for lookup) +const pendingSessionSelection = new Map() const runtimeStates = new Map() /** Per-chat outbound image watcher for Agent-produced markdown images. */ const tgImageWatchers = new Map() @@ -281,6 +284,119 @@ async function showProjectPicker(chatId: string): Promise { } } +// ---------- /resume ---------- + +async function showSessionPicker(chatId: string): Promise { + const numericChatId = Number(chatId) + try { + const sessions = await httpClient.listSessions(10) + if (sessions.length === 0) { + await bot.api.sendMessage(numericChatId, '没有找到已有会话。发送 /new 新建会话。') + return + } + const currentSessionId = sessionStore.get(chatId)?.sessionId + pendingSessionSelection.set(chatId, sessions) + pendingProjectSelection.delete(chatId) + const text = formatSessionList(sessions, currentSessionId) + await bot.api.sendMessage(numericChatId, text) + } catch (err) { + await bot.api.sendMessage(numericChatId, + `❌ 无法获取会话列表: ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function resumeSession(chatId: string, query?: string): Promise { + const numericChatId = Number(chatId) + + if (!query) { + await showSessionPicker(chatId) + return + } + + try { + const sessions = await httpClient.listSessions(20) + const q = query.trim().toLowerCase() + + // Try as 1-based index from pendingSessionSelection + const num = parseInt(q, 10) + const pendingSessions = pendingSessionSelection.get(chatId) + if (!isNaN(num) && num >= 1 && pendingSessions && num <= pendingSessions.length && String(num) === q.trim()) { + const target = pendingSessions[num - 1]! + pendingSessionSelection.delete(chatId) + await switchToSession(chatId, target) + return + } + + // Try UUID prefix match + const uuidMatch = sessions.filter(s => s.id.toLowerCase().startsWith(q)) + if (uuidMatch.length === 1) { + await switchToSession(chatId, uuidMatch[0]!) + return + } + if (uuidMatch.length > 1) { + const list = uuidMatch.map((s, i) => `${i + 1}. ${truncate(s.title, 40)} (${shortSessionId(s.id)})`).join('\n') + pendingSessionSelection.set(chatId, uuidMatch) + await bot.api.sendMessage(numericChatId, `匹配到多个会话,回复编号选择:\n\n${list}`) + return + } + + // Try title keyword match + const titleMatch = sessions.filter(s => s.title.toLowerCase().includes(q)) + if (titleMatch.length === 1) { + await switchToSession(chatId, titleMatch[0]!) + return + } + if (titleMatch.length > 1) { + const list = titleMatch.map((s, i) => `${i + 1}. ${truncate(s.title, 40)} (${shortSessionId(s.id)})`).join('\n') + pendingSessionSelection.set(chatId, titleMatch) + await bot.api.sendMessage(numericChatId, `匹配到多个会话,回复编号选择:\n\n${list}`) + return + } + + await bot.api.sendMessage(numericChatId, `未找到匹配 "${query}" 的会话。发送 /resume 查看完整列表。`) + } catch (err) { + await bot.api.sendMessage(numericChatId, + `❌ ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function switchToSession(chatId: string, session: SessionListItem): Promise { + const numericChatId = Number(chatId) + + if (!session.workDirExists) { + await bot.api.sendMessage(numericChatId, + `⚠️ 会话工作目录不存在: ${session.workDir},无法恢复。`) + return + } + + // Clean up existing state + clearTransientChatState(chatId) + bridge.resetSession(chatId) + + // Bind to the target session + sessionStore.set(chatId, session.id, session.workDir) + bridge.connectSession(chatId, session.id) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + + const opened = await bridge.waitForOpen(chatId) + if (!opened) { + await bot.api.sendMessage(numericChatId, '⚠️ 连接会话超时,请重试。') + return + } + + const title = truncate(session.title || '(无标题)', 40) + await bot.api.sendMessage(numericChatId, + `✅ 已切换到会话:${title} (${shortSessionId(session.id)})`) +} + +function shortSessionId(sessionId: string): string { + return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) + '…' : s +} + // ---------- outbound media dispatch ---------- /** Upload a PendingUpload found in streaming output and send it via @@ -497,6 +613,7 @@ async function startNewSession(chatId: string, query?: string): Promise { buffers.get(chatId)?.reset() buffers.delete(chatId) pendingProjectSelection.delete(chatId) + pendingSessionSelection.delete(chatId) runtimeStates.delete(chatId) tgImageWatchers.delete(chatId) @@ -544,6 +661,11 @@ bot.command('projects', async (ctx) => { await showProjectPicker(chatId) }) +bot.command('resume', async (ctx) => { + const chatId = String(ctx.chat.id) + await resumeSession(chatId, ctx.match?.trim() || undefined) +}) + bot.command('stop', (ctx) => { const chatId = String(ctx.chat.id) void (async () => { @@ -606,6 +728,10 @@ async function routeUserMessage( } enqueue(chatId, async () => { + if (pendingSessionSelection.has(chatId)) { + if (text.trim()) await resumeSession(chatId, text.trim()) + return + } if (pendingProjectSelection.has(chatId)) { if (text.trim()) await startNewSession(chatId, text.trim()) return diff --git a/docs/im/resume-command-plan.md b/docs/im/resume-command-plan.md new file mode 100644 index 000000000..00172c0c7 --- /dev/null +++ b/docs/im/resume-command-plan.md @@ -0,0 +1,115 @@ +# /resume 命令适配方案 + +## 目标 + +在 Telegram(及飞书)适配器中新增 `/resume` 命令,让用户可以在 IM 端切换回已有会话,实现 CLI ↔ IM 无缝衔接。 + +## 现有基础设施 + +- `GET /api/sessions` — 已有,返回 `{ sessions: SessionListItem[], total }`,支持 `project`/`limit`/`offset` 参数 +- `SessionStore` — 管理 chatId → sessionId 映射,`set()`/`get()`/`delete()` +- `WsBridge` — `resetSession(chatId)` 断旧连接 + `connectSession(chatId, sessionId)` 建新连接 +- `AdapterHttpClient` — 需新增 `listSessions()` 方法 + +## 命令设计 + +| 用法 | 行为 | +|---|---| +| `/resume` | 列出最近 10 个会话,用户回复编号选择 | +| `/resume ` | 直接切换到指定 session(支持完整 UUID 或前缀匹配) | +| `/resume <关键词>` | 按标题模糊搜索,匹配到 1 个直接切换,多个则列出 | + +## 实现步骤 + +### 1. AdapterHttpClient 新增 `listSessions()` + +```ts +async listSessions(limit = 10, project?: string): Promise { + const params = new URLSearchParams() + params.set('limit', String(limit)) + if (project) params.set('project', project) + const res = await fetch(`${this.httpBaseUrl}/api/sessions?${params}`) + if (!res.ok) throw new Error(`Failed to list sessions: ${res.statusText}`) + const data = await res.json() as { sessions: SessionListItem[] } + return data.sessions +} +``` + +类型定义(同 server 返回): +```ts +export type SessionListItem = { + id: string + title: string + createdAt: string + modifiedAt: string + messageCount: number + projectPath: string + workDir: string + workDirExists: boolean +} +``` + +### 2. telegram/index.ts 新增 `/resume` 命令 + +核心逻辑函数 `resumeSession(chatId, query?)`: + +1. 如果有 query: + - 尝试 UUID 前缀匹配 / 标题关键词搜索 + - 唯一匹配 → 直接切换 + - 多个匹配 → 列出供选择 + - 无匹配 → 提示 +2. 如果无 query: + - 调用 `httpClient.listSessions(10)` + - 格式化为编号列表发送 + +切换流程(复用 `/new` 的模式): +1. `clearTransientChatState(chatId)` — 清理流式状态 +2. `bridge.resetSession(chatId)` — 断开旧 WS +3. `sessionStore.set(chatId, sessionId, workDir)` — 更新映射 +4. `bridge.connectSession(chatId, sessionId)` — 建新 WS +5. `bridge.onServerMessage(chatId, handler)` — 注册消息处理 +6. 等待 `bridge.waitForOpen(chatId)` + +### 3. 待选会话交互状态 + +复用 `pendingProjectSelection` 的模式,新增 `pendingSessionSelection` Map。 +当用户回复编号时,查找对应 session 并执行切换。 + +### 4. format.ts 更新 + +- `IM_HELP_LINES` 增加 `/resume [会话] — 恢复已有会话` +- 新增 `formatSessionList(sessions)` 格式化函数 + +### 5. 飞书适配器同步 + +`adapters/feishu/index.ts` 同步添加相同命令(结构与 TG 一致)。 + +## 交互示例 + +``` +用户: /resume + +Bot: 选择会话(回复编号): + +1. 本机安装了claude-haha… (4401c2a9) + /Users/hcq · 44条消息 · 2分钟前 + +2. test (86f832df) + /Users/hcq · 253条消息 · 1小时前 + +3. 默认会话开启在哪个目录下 (ef1d2333) + /Users/hcq · 66条消息 · 1小时前 + +💡 也可直接 /resume <会话ID或关键词> + +用户: 1 + +Bot: ✅ 已切换到会话:本机安装了claude-haha… (4401c2a9) +``` + +## 注意事项 + +- 切换会话前必须 `bridge.resetSession()` 彻底断旧连接,否则消息会路由到旧 session +- `workDir` 从 session 元数据获取,不需要用户再指定 +- 列表中标注当前已连接的会话(带 ✦ 标记) +- session 列表按 modifiedAt 降序排列(API 默认行为) \ No newline at end of file