diff --git a/docs/feishu-architecture.md b/docs/feishu-architecture.md index 704331781d99..f57431966b25 100644 --- a/docs/feishu-architecture.md +++ b/docs/feishu-architecture.md @@ -49,53 +49,29 @@ Aether 通过飞书官方 SDK 的 **WebSocket 长连接模式**,在本地与 **WebSocket 推送** — 一种网络连接方式。普通 HTTP 像「发短信」,你问一次服务器答一次。WebSocket 像「打电话」,连接建立后一直保持通着,服务器有新消息随时推过来,不需要你反复去问。这就是为什么 Aether 不需要公网地址——是你的电脑主动打电话给飞书服务器,而不是反过来。 -**Aether 本地进程** — 运行在你电脑上的 Aether 程序,就是你双击打开的那个应用。整个大框就是它。 +**Bus 事件** — Aether 内部的事件总线,`FeishuManager` 通过它广播状态变更(如 `feishu.connected`、`feishu.status`),前端 SSE 路由监听这些事件并转发给浏览器。 -**FeishuManager (manager.ts)** — Aether 里专门负责和飞书打交道的模块。它做三件事:收飞书消息、交给 AI 处理、把回复发回飞书。同时它还记住「飞书的哪个聊天 = Aether 的哪个 AI 会话」。 +**Instance.bind()** — Aether 用 AsyncLocalStorage 存储当前项目上下文(目录、工作区等)。飞书 SDK 的回调跑在独立的异步上下文里,需要用 `Instance.bind()` 手动恢复上下文,否则 `Session.create` 等 API 无法知道当前项目。 -**Bus 事件** — 程序内部的广播机制。像对讲机:FeishuManager 喊一句「我已连上飞书了」,所有在听的模块都能收到。这里主要用来把连接状态(连接中/已连接/出错)传给前端。 - -**SSE 事件流 (routes/feishu.ts)** — Server-Sent Events,一种服务器向浏览器单向推送数据的技术。Aether 后端通过 SSE 持续向前端推送状态更新。前端收到后就能实时刷新界面,显示「正在连接...」或「已连接」。 - -**HTTP** — 前端和后端之间最基本的请求/响应通信。你在界面上点击「连接飞书」按钮 → 前端发一个 HTTP 请求到后端 → 后端执行连接操作并返回结果。图中 `routes/feishu.ts` 定义了后端能响应哪些请求(开始连接、断开、查状态等)。 - -**Instance.bind()** — 这是一个技术细节。飞书的消息回调运行在「另一个执行空间」里,访问不到 Aether 的项目信息(比如当前工作目录)。`Instance.bind()` 的作用是在连接飞书时把项目信息「打包」起来,等消息回调触发时再「解包」恢复,这样回调里就能正常创建 AI 会话了。 - -**Session API** — Aether 的 AI 会话接口。`Session.create` = 新建一个 AI 对话;`Prompt.prompt` = 把用户说的话发给 AI 模型,等 AI 想好了返回回答。 - -**前端 UI (dialog-feishu.tsx)** — 你在 Aether 界面里看到的「飞书连接」弹窗。用来输入 App ID/Secret、显示连接状态、提供断开/重连按钮。 - -**larkClient.im.message.reply()** — 飞书 SDK 提供的「回复消息」方法。AI 生成回答后,通过这个方法把文字发回飞书,用户就能在飞书聊天里看到回复了。 - -### 一句话总结 - -飞书用户发消息 → 飞书服务器通过 WebSocket 推给你电脑上的 Aether → FeishuManager 收到后交给 AI 处理 → AI 回复后通过飞书 SDK 发回去 → 飞书用户看到回复。与此同时,连接状态通过 Bus → SSE 实时推给前端界面显示。 - -## 文件结构 +## 代码结构 ``` -packages/opencode/src/ - feishu/ - manager.ts # 核心:连接管理、消息处理、会话映射 +packages/opencode/src/feishu/ + manager.ts # 连接管理器(SDK 初始化、消息处理、会话映射、模型管理) packages/opencode/src/server/routes/ - feishu.ts # HTTP API 路由(start/stop/status/events) + feishu.ts # HTTP API(start/stop/status/events/session) packages/app/src/ - context/feishu.ts # 前端全局状态信号 - components/ - dialog-feishu.tsx # 连接对话框 UI - prompt-input.tsx # 工具栏飞书按钮(状态指示) - -packages/ui/src/components/ - icon.tsx # feishu 图标定义 + context/feishu.ts # 前端全局状态(feishuStatus 信号) + components/dialog-feishu.tsx # 连接对话框 UI ``` -## 核心模块详解 +## 各模块详解 -### 1. FeishuManager (`packages/opencode/src/feishu/manager.ts`) +### 1. 连接管理器 (`packages/opencode/src/feishu/manager.ts`) -单例模式的连接管理器,负责全部飞书交互逻辑。 +全局单例 `FeishuManager`,负责整个飞书连接的生命周期。 #### 状态机 @@ -117,12 +93,16 @@ idle ──▶ starting ──▶ connected | 方法 | 职责 | |------|------| -| `start(config?)` | 入口。加载或接收配置,触发连接 | -| `_doStart(config)` | 实际连接逻辑:创建 SDK 客户端、注册事件、启动 WebSocket | -| `handleMessage(data)` | 接收飞书消息 → 过滤 @mention → 映射会话 → 调用 AI → 回复 | -| `handleCommand(text)` | 处理 `/new`、`/help` 等斜杠命令 | -| `replyText(messageId, text)` | 通过飞书 REST API(原生 fetch)回复消息 | -| `stop()` | 断开 WebSocket,清理客户端 | +| `start(config?, model?)` | 入口。加载或接收配置和模型,触发连接 | +| `_doStart(config, model)` | 实际连接逻辑:创建 SDK 客户端、注册事件、启动 WebSocket、设置连接模型 | +| `handleMessage(data)` | 接收飞书消息 → 过滤 @mention → 映射会话 → 解析模型 → 调用 AI → 回复 | +| `handleCommand(text)` | 分发 `/new`、`/model`、`/help` 等斜杠命令 | +| `cmdNew(messageId, chatId)` | 清除会话映射和本聊天的模型 override,立即新建会话 | +| `cmdModel(messageId, chatId, args)` | 无参数列出模型,有参数切换本聊天的模型 | +| `buildModelList()` | 调用 `Provider.list()` 展平成编号列表,供 `/model` 使用 | +| `resolveModel(chatId)` | 三级模型解析:per-chat override → 连接快照 → undefined | +| `replyText(messageId, text)` | 通过飞书 REST API 回复消息 | +| `stop()` | 断开 WebSocket,清理客户端和所有模型状态 | | `clearSession()` | 删除本地配置和会话映射文件 | #### AsyncLocalStorage 上下文绑定 @@ -135,7 +115,7 @@ idle ──▶ starting ──▶ connected ```typescript // _doStart 由 HTTP 请求触发,此时 Instance 上下文可用 -private async _doStart(config: FeishuConfig): Promise { +private async _doStart(config: FeishuConfig, model: ModelRef | null): Promise { // 捕获 Instance 上下文,绑定到事件回调 const boundHandleMessage = Instance.bind((data: any) => { void this.handleMessage(data) // 不 await,立即返回 @@ -189,7 +169,7 @@ private async _doStart(config: FeishuConfig): Promise { | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/feishu/start` | 启动连接。body 可选传 `appId`/`appSecret`,否则用已保存配置 | +| POST | `/feishu/start` | 启动连接。body 可选传 `appId`/`appSecret` 和 `model`,否则用已保存配置 | | POST | `/feishu/stop` | 断开连接 | | GET | `/feishu/status` | 返回 `{ status, appId, hasConfig, error }` | | GET | `/feishu/events` | SSE 事件流,推送状态变更和心跳 | @@ -235,7 +215,7 @@ SolidJS 对话框组件,提供完整的连接管理界面。 │ ├─ 1. connectSSE() 先建立 SSE 连接,避免遗漏事件 │ - └─ 2. fetch POST /start 触发后端连接 + └─ 2. fetch POST /start 携带当前模型(providerID + modelID)触发后端连接 │ └─ SSE 接收事件 ──▶ 更新 UI 状态 ``` @@ -264,15 +244,74 @@ export const [feishuStatus, setFeishuStatus] = createSignal("idle" 4. handleMessage 在后台异步执行: a. 非文本消息 → 回复"暂时只支持文本消息" b. 过滤 @mention 占位符(群聊中的 `@_user_1 ` 前缀) - c. 斜杠命令 → handleCommand 处理 + c. 斜杠命令 → handleCommand 分发处理 d. 普通文本 → 继续 5. 根据 chatId + rootId 查找已有 Aether 会话 a. 有映射 → 复用已映射的会话 b. 无映射 → 复用最近的会话;若无任何会话则新建 -6. SessionPrompt.prompt() 将文本发送给 AI -7. 提取 AI 回复的文本部分 -8. larkClient.im.message.reply() 回复到飞书 -9. 如果任何步骤报错 → catch 中通过 replyText 将错误信息发回飞书,用户会收到"处理消息时出错: xxx" +6. resolveModel(chatId) 解析本次使用的模型 +7. SessionPrompt.prompt() 将文本发送给 AI(携带模型参数) +8. 提取 AI 回复的文本部分,拼接项目/会话标题头部 +9. larkClient.im.message.reply() 回复到飞书 +10. 如果任何步骤报错 → catch 中通过 replyText 将错误信息发回飞书 +``` + +## 模型选择逻辑 + +### 设计原则 + +- 连接飞书时,前端把 web UI 底栏当前选中的模型一并发给后端,作为连接快照 +- 连接期间 web UI 切换模型不影响飞书端(快照冻结) +- 用户可通过 `/model n` 为当前聊天设置独立的模型 override +- `/new` 只清除本聊天的 override,连接快照保持不变 +- `/stop` 清除所有模型状态 + +### 三级解析(`resolveModel(chatId)`) + +``` +per-chat override (_modelOverrides[chatId]) + ↓ 无 +连接快照 (_connectedModel) + ↓ 无 +undefined → SessionPrompt 内部默认逻辑 +``` + +### 状态字段 + +| 字段 | 类型 | 生命周期 | +|------|------|---------| +| `_connectedModel` | `{ providerID, modelID } \| null` | 连接时由前端传入,`stop()` 时清除 | +| `_modelOverrides` | `Record` | `/model n` 设置,`/new` 或 `stop()` 清除 | +| `_modelList` | `ModelEntry[]` | 连接时预构建,`stop()` 时清除 | + +### 连接时传递模型 + +前端 `dialog-feishu.tsx` 在调用 `/feishu/start` 时,读取 `useLocal().model.current()`,将 `{ providerID, modelID }` 放入 POST body。后端路由解析后传给 `FeishuManager.start(config, model)`,直接赋值给 `_connectedModel`。 + +这比从 session 消息历史反推更可靠:用户刚切换模型未发消息时,历史记录里没有新模型的 assistant 消息,反推会拿到错误的模型。 + +### `/model` 命令格式 + +``` +/model → 列出所有可用模型 +/model → 将本聊天的模型 override 设为第 n 号 +``` + +列表格式(参考微信端): + +``` +🤖 当前:anthropic/claude-sonnet-4-6 + +📦 可用模型: + +【anthropic】 + 1. anthropic/claude-opus-4-6 + 2. anthropic/claude-sonnet-4-6 ★ + +【openai】 + 3. openai/gpt-4o + +💡 /model n 切换模型 ``` ## 依赖 @@ -298,13 +337,14 @@ SDK 使用方式: | 上下文处理 | HTTP API 自带中间件上下文 | Instance.bind() 手动绑定上下文 | | 需要公网 | 否 | 否 | | 首次配置 | 扫码 | 飞书开放平台创建应用 | +| 模型传递 | 环境变量 `AETHER_MODEL` | POST body `model` 字段 | ## 已知限制 1. **仅支持文本消息**:图片、文件等消息类型暂不处理 2. **无自动重连**:WebSocket 断开后需手动重新连接 3. **单实例**:FeishuManager 是全局单例,不支持同时连接多个飞书应用 -4. **群聊限制**:当前设计面向私聊场景,群聊中 @机器人 需要额外的消息过滤逻辑 +4. **群聊限制**:当前设计面向私聊场景,群聊中 @机器人 已过滤 @mention 占位符,但未经大规模验证 ## 变更记录 @@ -315,3 +355,5 @@ SDK 使用方式: | 2026-04-06 16:03 | 首次发消息优先复用最近会话,而非总是新建 | 飞书每条消息都新建会话,web 端体验混乱 | | 2026-04-06 16:15 | 每条 AI 回复顶部追加项目和会话标题(`📁 项目名\n💬 会话名\n───`) | 用户无法感知当前处于哪个项目/会话 | | 2026-04-06 16:25 | `/new` 改为立即创建新会话(`Session.create`),不再等到下一条消息才新建 | web 端侧边栏需实时出现新会话,旧实现只清除映射延迟到下条消息才生效 | +| 2026-04-06 18:30 | 新增模型选择逻辑:连接时前端传模型 + per-chat override + `/model` 命令 | 飞书端应沿用 web UI 当前选中的模型,连接后 web UI 切换模型不应影响飞书端 | +| 2026-04-06 19:00 | `/model` 列表格式改为按 provider 分组,参考微信端风格 | 原格式不直观,统一两端体验 | diff --git a/docs/feishu-setup-guide.md b/docs/feishu-setup-guide.md index 798f7db53357..c5ec3413454e 100644 --- a/docs/feishu-setup-guide.md +++ b/docs/feishu-setup-guide.md @@ -64,12 +64,14 @@ Aether 支持通过飞书进行对话,体验与微信连接一致: ### 通过界面操作 -1. 打开 Aether +1. 打开 Aether,在 web UI 底栏选择好想使用的模型 2. 点击输入框旁边的菜单按钮 3. 点击「飞书连接」 4. 首次使用:输入 App ID 和 App Secret,点击「连接」 5. 之后使用:直接点击「连接飞书」 +> **提示**:连接时会自动快照当前 web UI 选中的模型。连接后切换 web UI 模型不会影响飞书端,如需更换模型请在飞书中使用 `/model n`。 + ### 连接状态 | 状态 | 图标颜色 | 说明 | @@ -92,9 +94,32 @@ Aether 支持通过飞书进行对话,体验与微信连接一致: | 命令 | 说明 | |------|------| -| `/new` | 开始新对话(清除当前会话上下文) | +| `/new` | 开始新对话(清除当前会话上下文和本聊天的模型设置) | +| `/model` | 列出所有可用模型,当前选中标 ★ | +| `/model ` | 将本聊天切换到第 n 号模型 | | `/help` | 显示帮助信息 | +### 模型切换 + +发送 `/model` 查看可用模型列表: + +``` +🤖 当前:anthropic/claude-sonnet-4-6 + +📦 可用模型: + +【anthropic】 + 1. anthropic/claude-opus-4-6 + 2. anthropic/claude-sonnet-4-6 ★ + +【openai】 + 3. openai/gpt-4o + +💡 /model n 切换模型 +``` + +发送 `/model 3` 切换到第 3 号模型,仅对当前聊天生效,`/new` 后重置为连接快照模型。 + ### 会话映射规则 飞书对话到 Aether 会话的映射: @@ -123,6 +148,7 @@ Aether 支持通过飞书进行对话,体验与微信连接一致: | 首次配置 | 扫码即可 | 需先在飞书平台创建应用 | | 后续使用 | 点击连接 | 点击连接 | | 实现语言 | Python 子进程 | TypeScript(内置) | +| 模型传递 | 环境变量 | 连接时前端传入 | ## 架构说明 @@ -135,6 +161,8 @@ Aether 本地 (FeishuManager) ↓ 会话映射 → 创建/复用 Aether Session ↓ +resolveModel() → per-chat override / 连接快照 / 默认 + ↓ SessionPrompt.prompt() → AI 处理 ↓ 飞书 SDK → 回复消息 @@ -146,7 +174,7 @@ SessionPrompt.prompt() → AI 处理 ``` packages/opencode/src/feishu/ - manager.ts # 连接管理器(SDK 初始化、消息处理、会话映射) + manager.ts # 连接管理器(SDK 初始化、消息处理、会话映射、模型管理) packages/opencode/src/server/routes/ feishu.ts # HTTP API(start/stop/status/events) @@ -160,7 +188,7 @@ packages/app/src/ | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/feishu/start` | 启动飞书连接 | +| POST | `/feishu/start` | 启动飞书连接(body 可传 appId/appSecret/model) | | POST | `/feishu/stop` | 断开连接 | | GET | `/feishu/status` | 获取连接状态 | | GET | `/feishu/events` | SSE 事件流 | @@ -179,7 +207,13 @@ packages/app/src/ 1. 确认已添加 `im.message.receive_v1` 事件订阅 2. 确认已开通 `im:message` 权限 -3. 确认在飞书中直接给机器人发消息(非群聊中 @机器人,群聊需额外配置) +3. 确认在飞书中直接给机器人发消息(群聊中 @机器人 也支持,但需开通群聊权限) + +### 模型不对 + +1. 连接前先在 web UI 底栏切换到目标模型,再点击连接 +2. 已连接时使用 `/model n` 切换 +3. 重新连接(断开后重连)也会重新快照当前 web UI 模型 ### 断线重连 diff --git a/packages/app/src/components/dialog-feishu.tsx b/packages/app/src/components/dialog-feishu.tsx index 8d858802e330..66e895696355 100644 --- a/packages/app/src/components/dialog-feishu.tsx +++ b/packages/app/src/components/dialog-feishu.tsx @@ -6,6 +6,7 @@ import { Component, Show, Switch, Match, createSignal, onCleanup, onMount } from import { useSDK } from "@/context/sdk" import { useServer } from "@/context/server" import { setFeishuStatus } from "@/context/feishu" +import { useLocal } from "@/context/local" type FeishuStatus = "idle" | "loading" | "config" | "connected" | "error" @@ -23,6 +24,7 @@ export const DialogFeishu: Component = () => { const dialog = useDialog() const sdk = useSDK() const server = useServer() + const local = useLocal() const [status, setStatus] = createSignal("idle") const [error, setError] = createSignal<{ code: string; message: string } | null>(null) const [appId, setAppId] = createSignal("") @@ -77,6 +79,11 @@ export const DialogFeishu: Component = () => { body.appId = appId() body.appSecret = appSecret() } + // Pass current web UI model so Feishu uses the same model after connecting + const currentModel = local.model.current() + if (currentModel) { + body.model = { providerID: currentModel.provider.id, modelID: currentModel.id } + } const response = await fetch(`${sdk.url}/feishu/start`, { method: "POST", diff --git a/packages/opencode/src/feishu/manager.ts b/packages/opencode/src/feishu/manager.ts index c4e0368d6f4b..be4822d3cf7f 100644 --- a/packages/opencode/src/feishu/manager.ts +++ b/packages/opencode/src/feishu/manager.ts @@ -10,6 +10,9 @@ import { Instance } from "@/project/instance" import { Session } from "@/session" import { SessionPrompt } from "@/session/prompt" import { SessionID } from "@/session/schema" +import { Provider } from "@/provider/provider" +import { ProviderID } from "@/provider/schema" +import { ModelID } from "@/provider/schema" const FEISHU_DATA_DIR = process.platform === "darwin" @@ -59,6 +62,20 @@ export const FeishuEvent = { // Session mapping: feishu chat key -> aether session ID type SessionMap = Record +// Model reference: providerID + modelID +interface ModelRef { + providerID: string + modelID: string +} + +// Flat model entry for listing +interface ModelEntry { + index: number + providerID: string + modelID: string + name: string +} + class FeishuManagerImpl { private wsClient: any = null private larkClient: any = null @@ -67,6 +84,18 @@ class FeishuManagerImpl { private _error: { code: string; message: string } | null = null private sessionMap: SessionMap = {} + // ── Model state ────────────────────────────────────────────────────────── + // Snapshot of the model active in the web UI at connect time. + // Frozen after connection — web UI changes don't affect it. + private _connectedModel: ModelRef | null = null + + // Per-chat overrides set via /model n. Cleared by /new, not by /stop. + private _modelOverrides: Record = {} + + // Cached flat model list, built once at connect time (and on demand). + private _modelList: ModelEntry[] = [] + // ───────────────────────────────────────────────────────────────────────── + get status() { return this._status } @@ -89,7 +118,49 @@ class FeishuManagerImpl { Bus.publish(FeishuEvent.StatusChanged, { status: value, message }) } - async start(config?: FeishuConfig): Promise<{ + // ── Model helpers ───────────────────────────────────────────────────────── + + /** + * Build a flat numbered list of all available models. + * Used by /model to display options and /model n to select. + */ + private async buildModelList(): Promise { + try { + const providers = await Provider.list() + const entries: ModelEntry[] = [] + let index = 1 + for (const [providerID, info] of Object.entries(providers)) { + for (const [modelID, model] of Object.entries(info.models)) { + entries.push({ index, providerID, modelID, name: model.name || modelID }) + index++ + } + } + return entries + } catch (err) { + console.error("[feishu] buildModelList error:", err) + return [] + } + } + + /** + * Resolve the model to use for a given chat. + * Priority: per-chat override > connection snapshot > undefined (SessionPrompt default) + */ + private resolveModel(chatId: string): { providerID: ProviderID; modelID: ModelID } | undefined { + const override = this._modelOverrides[chatId] ?? this._connectedModel + if (!override) return undefined + return { + providerID: ProviderID.make(override.providerID), + modelID: ModelID.make(override.modelID), + } + } + + // ───────────────────────────────────────────────────────────────────────── + + async start( + config?: FeishuConfig, + model?: ModelRef, + ): Promise<{ success: boolean message?: string code?: string @@ -118,11 +189,11 @@ class FeishuManagerImpl { // Load session map this.sessionMap = await this.loadSessionMap() - void this._doStart(cfg) + void this._doStart(cfg, model ?? null) return { success: true } } - private async _doStart(config: FeishuConfig): Promise { + private async _doStart(config: FeishuConfig, model: ModelRef | null): Promise { try { this.statusMsg("starting", "正在连接飞书...") console.log("[feishu] _doStart called") @@ -158,6 +229,13 @@ class FeishuManagerImpl { await this.wsClient.start({ eventDispatcher }) console.log("[feishu] wsClient.start() resolved") + // Use the model passed from the web UI at connect time (frozen until stop) + this._connectedModel = model + console.log("[feishu] connected model:", this._connectedModel) + + // Pre-build model list for /model command + this._modelList = await this.buildModelList() + this._session = { connected: true, appId: config.appId, @@ -203,10 +281,13 @@ class FeishuManagerImpl { return } - if (!text?.trim()) return + // Strip @mention tags (group chat) + text = text.replace(/@_\w+\s*/g, "").trim() + + if (!text) return console.log("[feishu] text:", text) - // Handle commands + // Handle slash commands if (text.startsWith("/")) { await this.handleCommand(text, messageId, chatId) return @@ -234,11 +315,16 @@ class FeishuManagerImpl { await this.saveSessionMap() } + // Resolve model: per-chat override > connection snapshot > undefined + const model = this.resolveModel(chatId) + console.log("[feishu] using model:", model ?? "(default)") + // Send to Aether console.log("[feishu] sending to aether, session:", sessionId) const msg = await SessionPrompt.prompt({ sessionID: SessionID.make(sessionId), parts: [{ type: "text", text }], + ...(model ? { model } : {}), }) console.log("[feishu] aether responded, parts:", msg?.parts?.length) @@ -273,31 +359,108 @@ class FeishuManagerImpl { return textParts.map((p: any) => p.text).join("\n") } - private async handleCommand(text: string, messageId: string, chatId: string): Promise { - const cmd = text.trim().toLowerCase() + // ── Command handlers ────────────────────────────────────────────────────── - if (cmd === "/new") { - // Clear session mapping for this chat - for (const key of Object.keys(this.sessionMap)) { - if (key.startsWith(`${chatId}:`)) { - delete this.sessionMap[key] - } - } - // Create new session immediately so it appears in the web UI right away. - // The next message will reuse it via the "most recent session" logic. - const session = await Session.create({ title: `飞书对话 ${chatId.slice(-6)}` }) - await this.saveSessionMap() - await this.replyText(messageId, `✅ 已开启新对话\n💬 ${session.title}`) - } else if (cmd === "/help") { + private async handleCommand(text: string, messageId: string, chatId: string): Promise { + const [cmd, ...args] = text.trim().split(/\s+/) + const command = cmd.toLowerCase() + + if (command === "/new") { + await this.cmdNew(messageId, chatId) + } else if (command === "/model") { + await this.cmdModel(messageId, chatId, args) + } else if (command === "/help") { await this.replyText( messageId, - "可用命令:\n/new - 开始新对话\n/help - 显示帮助\n\n直接发送消息即可与 Aether AI 对话。", + "可用命令:\n/new - 开始新对话\n/model - 查看/切换模型\n/help - 显示帮助\n\n直接发送消息即可与 Aether AI 对话。", ) } else { - await this.replyText(messageId, `未知命令: ${cmd}\n发送 /help 查看可用命令。`) + await this.replyText(messageId, `未知命令: ${command}\n发送 /help 查看可用命令。`) } } + /** /new — clear session and per-chat model override, keep connection snapshot */ + private async cmdNew(messageId: string, chatId: string): Promise { + // Clear session mapping for this chat + for (const key of Object.keys(this.sessionMap)) { + if (key.startsWith(`${chatId}:`)) { + delete this.sessionMap[key] + } + } + // Clear per-chat model override (connection snapshot stays) + delete this._modelOverrides[chatId] + + // Create new session immediately so it appears in the web UI right away + const session = await Session.create({ title: `飞书对话 ${chatId.slice(-6)}` }) + await this.saveSessionMap() + await this.replyText(messageId, `✅ 已开启新对话\n💬 ${session.title}`) + } + + /** + * /model — list all models with current selection + * /model — set per-chat model override to entry n + */ + private async cmdModel(messageId: string, chatId: string, args: string[]): Promise { + // Ensure model list is loaded + if (this._modelList.length === 0) { + this._modelList = await this.buildModelList() + } + + if (args.length === 0) { + // List models + await this.replyText(messageId, this.formatModelList(chatId)) + return + } + + const n = parseInt(args[0], 10) + if (isNaN(n) || n < 1 || n > this._modelList.length) { + await this.replyText(messageId, `无效编号,请输入 1 到 ${this._modelList.length} 之间的数字。`) + return + } + + const entry = this._modelList[n - 1] + this._modelOverrides[chatId] = { providerID: entry.providerID, modelID: entry.modelID } + await this.replyText(messageId, `✅ 已切换模型:${entry.providerID}/${entry.modelID}\n(仅对当前对话生效,/new 后将重置)`) + } + + private formatModelList(chatId: string): string { + const current = this._modelOverrides[chatId] ?? this._connectedModel + const currentStr = current ? `${current.providerID}/${current.modelID}` : "(全局默认)" + + const lines: string[] = [] + lines.push(`🤖 当前:${currentStr}`) + lines.push("") + lines.push("📦 可用模型:") + + // Group by provider + const byProvider = new Map() + for (const entry of this._modelList) { + const group = byProvider.get(entry.providerID) ?? [] + group.push(entry) + byProvider.set(entry.providerID, group) + } + + for (const [providerID, entries] of byProvider) { + lines.push("") + lines.push(`【${providerID}】`) + for (const entry of entries) { + const active = + current && current.providerID === entry.providerID && current.modelID === entry.modelID ? " ★" : "" + lines.push(` ${entry.index}. ${entry.providerID}/${entry.modelID}${active}`) + } + } + + if (this._modelList.length === 0) { + lines.push("(暂无可用模型,请先在 Aether 中配置 provider)") + } + + lines.push("") + lines.push("💡 /model n 切换模型") + return lines.join("\n") + } + + // ───────────────────────────────────────────────────────────────────────── + private async replyText(messageId: string, text: string): Promise { if (!this.larkClient) return try { @@ -316,8 +479,6 @@ class FeishuManagerImpl { async stop(): Promise { if (this.wsClient) { try { - // The SDK's ws client doesn't have a formal stop method in all versions - // so we try to clean up gracefully if (typeof this.wsClient.stop === "function") { this.wsClient.stop() } @@ -326,6 +487,9 @@ class FeishuManagerImpl { this.larkClient = null } this._session = null + this._connectedModel = null + this._modelOverrides = {} + this._modelList = [] this.status = "idle" } diff --git a/packages/opencode/src/server/routes/feishu.ts b/packages/opencode/src/server/routes/feishu.ts index 7954d0a6c02a..4436259e5810 100644 --- a/packages/opencode/src/server/routes/feishu.ts +++ b/packages/opencode/src/server/routes/feishu.ts @@ -38,7 +38,11 @@ export const FeishuRoutes = lazy(() => const body = await c.req.json().catch(() => ({})) const config = body?.appId && body?.appSecret ? { appId: body.appId, appSecret: body.appSecret } : undefined - const result = await FeishuManager.start(config) + const model = + body?.model?.providerID && body?.model?.modelID + ? { providerID: body.model.providerID as string, modelID: body.model.modelID as string } + : undefined + const result = await FeishuManager.start(config, model) return c.json(result) }, )