mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
feat(adapters): add /resume command for switching to existing sessions
Allow IM users (Telegram & Feishu) to resume existing sessions via /resume command, enabling seamless CLI ↔ IM workflow continuity. - /resume: list recent 10 sessions with interactive picker - /resume <session-id>: UUID prefix match, direct switch - /resume <keyword>: title fuzzy search - Mark current session with ✦ in picker - Validate workDirExists before switching - Add SessionListItem type and listSessions() to AdapterHttpClient Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5a86ab097f
commit
ad2c96b783
@ -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)}天前`
|
||||
}
|
||||
|
||||
@ -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<SessionListItem[]> {
|
||||
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<GitInfo> {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`)
|
||||
if (!res.ok) {
|
||||
|
||||
@ -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<string, StreamingCard>()
|
||||
const pendingProjectSelection = new Map<string, boolean>()
|
||||
const pendingSessionSelection = new Map<string, SessionListItem[]>()
|
||||
const runtimeStates = new Map<string, ChatRuntimeState>()
|
||||
|
||||
// Per-chat outbound watchers for Agent-produced markdown image references.
|
||||
@ -709,6 +711,110 @@ async function showProjectPicker(chatId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- /resume ----------
|
||||
|
||||
async function showSessionPicker(chatId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
bridge.resetSession(chatId)
|
||||
sessionStore.delete(chatId)
|
||||
@ -721,6 +827,7 @@ async function startNewSession(chatId: string, query?: string): Promise<void> {
|
||||
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<void> {
|
||||
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)) {
|
||||
|
||||
@ -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<string, string>()
|
||||
const buffers = new Map<string, MessageBuffer>()
|
||||
// Track chats waiting for project selection
|
||||
const pendingProjectSelection = new Map<string, boolean>()
|
||||
// Track chats waiting for session selection (stores the listed sessions for lookup)
|
||||
const pendingSessionSelection = new Map<string, SessionListItem[]>()
|
||||
const runtimeStates = new Map<string, ChatRuntimeState>()
|
||||
/** Per-chat outbound image watcher for Agent-produced markdown images. */
|
||||
const tgImageWatchers = new Map<string, ImageBlockWatcher>()
|
||||
@ -281,6 +284,119 @@ async function showProjectPicker(chatId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- /resume ----------
|
||||
|
||||
async function showSessionPicker(chatId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
115
docs/im/resume-command-plan.md
Normal file
115
docs/im/resume-command-plan.md
Normal file
@ -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-id>` | 直接切换到指定 session(支持完整 UUID 或前缀匹配) |
|
||||
| `/resume <关键词>` | 按标题模糊搜索,匹配到 1 个直接切换,多个则列出 |
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### 1. AdapterHttpClient 新增 `listSessions()`
|
||||
|
||||
```ts
|
||||
async listSessions(limit = 10, project?: string): Promise<SessionListItem[]> {
|
||||
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 默认行为)
|
||||
Loading…
x
Reference in New Issue
Block a user