From 970549460268c338ff7672c19acf01153deae6e7 Mon Sep 17 00:00:00 2001 From: zhenghuiwen <974862648@qq.com> Date: Tue, 7 Apr 2026 12:56:31 +0800 Subject: [PATCH] feat: add /session command for managing chat sessions, including listing and switching sessions --- docs/feishu-architecture.md | 20 +++++ packages/opencode/src/feishu/manager.ts | 99 +++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/docs/feishu-architecture.md b/docs/feishu-architecture.md index 7fe37974f7..8ef1ee8f18 100644 --- a/docs/feishu-architecture.md +++ b/docs/feishu-architecture.md @@ -280,6 +280,25 @@ export const [feishuStatus, setFeishuStatus] = createSignal("idle" | `/project n` | 切换到第 n 个项目,自动复用/新建该项目的会话 | | `/project hide n` | 隐藏第 n 个项目;该项目有新活动后自动恢复 | +## 会话切换 + +### 命令 + +| 命令 | 行为 | +|------|------| +| `/session` | 显示当前项目前 10 个会话,当前会话标 `◀` | +| `/session list` | 显示全部会话 | +| `/session n` | 切换到第 n 个会话 | + +### 设计 + +每个 `chatId` 维护一个"当前会话"状态(`_chatSessions[chatId]`),优先级高于线程级 `sessionMap[chatId:rootId]`。 + +- `/session n` 设置 `_chatSessions[chatId]`,后续所有消息都发往该会话 +- `/new` 清除并立即新建,新会话存入 `_chatSessions[chatId]` +- `/project n` 切换项目时,将目标项目的最近会话存入 `_chatSessions[chatId]` +- `stop()` 清除所有 `_chatSessions`(不持久化) + ### 数据来源 `getProjects()` 直接调用 `Project.recentList()`,这与微信端调用的 `GET /project/recent` HTTP 接口是**同一个函数**,因此两端看到的项目列表完全一致。 @@ -409,3 +428,4 @@ SDK 使用方式: | 2026-04-06 19:17 | `/model` 列表格式改为按 provider 分组,参考微信端风格 | 原格式不直观,统一两端体验 | | 2026-04-06 19:17 | 新增 `/project` 命令:查看/切换/隐藏项目,数据源与微信端一致 | 多项目场景下需在飞书端切换工作目录;切换后消息自动在对应项目上下文中执行 | | 2026-04-06 19:17 | 取消隐藏改为双机制:飞书消息直接判断 + GlobalBus 监听 web 端活动 | `time.activity` 只在项目初始化时更新,不反映 session 消息,原方案对活跃项目无效 | +| 2026-04-07 | 新增 `/session` 命令:查看/切换会话,数据源与微信端一致;`_chatSessions` 存储 per-chat 当前会话 | 微信端支持多会话切换,飞书端功能对齐 | diff --git a/packages/opencode/src/feishu/manager.ts b/packages/opencode/src/feishu/manager.ts index 28298a855b..0f657ca557 100644 --- a/packages/opencode/src/feishu/manager.ts +++ b/packages/opencode/src/feishu/manager.ts @@ -99,6 +99,8 @@ class FeishuManagerImpl { // ── Project state ───────────────────────────────────────────────────────── // Per-chat current directory (set by /project n). Empty = use connect-time Instance.directory. private _chatDirs: Record = {} + // Per-chat pinned session (set by /session n or /new). Overrides thread-based sessionMap. + private _chatSessions: Record = {} // Hidden project directories: directory -> timestamp when hidden. Persisted to disk. private _hiddenDirs: Record = {} // GlobalBus listener for detecting web UI activity on hidden projects. @@ -372,7 +374,8 @@ class FeishuManagerImpl { directory: effectiveDir, fn: async () => { const sessionKey = `${chatId}:${rootId}` - let sessionId = this.sessionMap[sessionKey] + // Pinned session (/session n) takes priority over thread-based mapping + let sessionId = this._chatSessions[chatId] ?? this.sessionMap[sessionKey] if (!sessionId) { // Reuse most recent session in this directory, or create one @@ -388,6 +391,7 @@ class FeishuManagerImpl { sessionId = session.id console.log("[feishu] session created:", sessionId) } + this._chatSessions[chatId] = sessionId this.sessionMap[sessionKey] = sessionId await this.saveSessionMap() } @@ -449,10 +453,12 @@ class FeishuManagerImpl { await this.cmdModel(messageId, chatId, parts.slice(1)) } else if (command === "/project") { await this.cmdProject(messageId, chatId, rest) + } else if (command === "/session") { + await this.cmdSession(messageId, chatId, rest) } else if (command === "/help") { await this.replyText( messageId, - "可用命令:\n/new - 开始新对话\n/model - 查看/切换模型\n/project - 查看/切换项目\n/help - 显示帮助\n\n直接发送消息即可与 Aether AI 对话。", + "📋 可用命令:\n\n/new 开启新对话(清除当前会话上下文)\n/model 查看可用模型列表及当前模型\n/model n 切换到编号 n 的模型\n/project 查看/切换项目\n/project n 切换到编号 n 的项目\n/project hide n 隐藏项目\n/session 查看当前项目下的会话列表\n/session list 查看全部会话\n/session n 切换到编号 n 的会话\n/help 显示此帮助信息", ) } else { await this.replyText(messageId, `未知命令: ${command}\n发送 /help 查看可用命令。`) @@ -465,12 +471,14 @@ class FeishuManagerImpl { if (key.startsWith(`${chatId}:`)) delete this.sessionMap[key] } delete this._modelOverrides[chatId] + delete this._chatSessions[chatId] const effectiveDir = this._chatDirs[chatId] ?? Instance.directory const session = await Instance.provide({ directory: effectiveDir, fn: () => Session.create({ title: `飞书对话 ${chatId.slice(-6)}` }), }) + this._chatSessions[chatId] = session.id await this.saveSessionMap() await this.replyText(messageId, `✅ 已开启新对话\n💬 ${session.title}`) } @@ -617,18 +625,19 @@ class FeishuManagerImpl { } // Find or create a session in the new project - const { sessionTitle, created } = await Instance.provide({ + const { sessionId: newSessionId, sessionTitle, created } = await Instance.provide({ directory: newDir, fn: async () => { const recent = [...Session.list({ directory: newDir, roots: true, limit: 1 })] if (recent.length > 0) { - return { sessionTitle: recent[0].title ?? recent[0].id.slice(0, 8), created: false } + return { sessionId: recent[0].id, sessionTitle: recent[0].title ?? recent[0].id.slice(0, 8), created: false } } else { const session = await Session.create({ title: `飞书对话 ${chatId.slice(-6)}` }) - return { sessionTitle: session.title, created: true } + return { sessionId: session.id, sessionTitle: session.title, created: true } } }, }) + this._chatSessions[chatId] = newSessionId await this.saveSessionMap() const name = this.projectName(chosen) @@ -670,6 +679,85 @@ class FeishuManagerImpl { await this.replyText(messageId, lines.join("\n")) } + private formatSessionTime(timestamp: number): string { + const d = new Date(timestamp) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` + } + + /** + * /session — list top-10 sessions in current project + * /session list — list all sessions + * /session — switch to session n + */ + private async cmdSession(messageId: string, chatId: string, arg: string): Promise { + const effectiveDir = this._chatDirs[chatId] ?? Instance.directory + let items: Session.Info[] = [] + await Instance.provide({ + directory: effectiveDir, + fn: async () => { + items = [...Session.list({ directory: effectiveDir, roots: true, limit: 100 })] + }, + }) + + const currentId = this._chatSessions[chatId] + + if (arg === "list") { + const currentItem = items.find((i) => i.id === currentId) + const currentTitle = currentItem?.title ?? currentId?.slice(0, 8) ?? "无" + const lines = [`🗂 当前会话:${currentTitle}`, "", "🗂 会话列表:", ""] + for (let i = 0; i < items.length; i++) { + const item = items[i] + const tag = item.id === currentId ? " ◀" : "" + lines.push(`${i + 1}. ${item.title}${tag}`) + lines.push(` ${this.formatSessionTime(item.time.updated)}`) + } + if (!items.length) lines.push("(当前项目下还没有任何会话)") + await this.replyText(messageId, lines.join("\n")) + return + } + + if (arg) { + const idx = parseInt(arg, 10) - 1 + if (isNaN(idx) || idx < 0 || idx >= items.length) { + await this.replyText( + messageId, + items.length ? `❌ 请输入 1~${items.length} 之间的数字。` : "❌ 当前项目下还没有任何会话。", + ) + return + } + const chosen = items[idx] + this._chatSessions[chatId] = chosen.id + await this.replyText( + messageId, + `✅ 已切换到会话:${chosen.title}\n 更新时间:${this.formatSessionTime(chosen.time.updated)}`, + ) + return + } + + // /session — list top-10, auto-create if empty + if (!items.length) { + const session = await Instance.provide({ + directory: effectiveDir, + fn: () => Session.create({ title: `飞书对话 ${chatId.slice(-6)}` }), + }) + this._chatSessions[chatId] = session.id + await this.replyText(messageId, "📂 当前项目下还没有任何会话,已自动创建一个新会话并切换。") + return + } + + const currentItem = items.find((i) => i.id === currentId) ?? items[0] + const lines = [`🗂 当前会话:${currentItem.title}`, "", "🗂 会话列表:", ""] + for (let i = 0; i < Math.min(items.length, 10); i++) { + const item = items[i] + const tag = item.id === currentId ? " ◀" : "" + lines.push(`${i + 1}. ${item.title}${tag}`) + lines.push(` ${this.formatSessionTime(item.time.updated)}`) + } + lines.push("") + lines.push("💡 /session n 切换会话 | /session list 查看全部") + await this.replyText(messageId, lines.join("\n")) + } + // ───────────────────────────────────────────────────────────────────────── private async replyText(messageId: string, text: string): Promise { @@ -706,6 +794,7 @@ class FeishuManagerImpl { this._modelOverrides = {} this._modelList = [] this._chatDirs = {} + this._chatSessions = {} // _hiddenDirs intentionally kept (persisted, survives reconnect) this.status = "idle" }