diff --git a/docs/feishu-architecture.md b/docs/feishu-architecture.md index f57431966b..7fe37974f7 100644 --- a/docs/feishu-architecture.md +++ b/docs/feishu-architecture.md @@ -99,8 +99,10 @@ idle ──▶ starting ──▶ connected | `handleCommand(text)` | 分发 `/new`、`/model`、`/help` 等斜杠命令 | | `cmdNew(messageId, chatId)` | 清除会话映射和本聊天的模型 override,立即新建会话 | | `cmdModel(messageId, chatId, args)` | 无参数列出模型,有参数切换本聊天的模型 | +| `cmdProject(messageId, chatId, arg)` | 查看/切换/隐藏项目,完整对齐微信端逻辑 | | `buildModelList()` | 调用 `Provider.list()` 展平成编号列表,供 `/model` 使用 | | `resolveModel(chatId)` | 三级模型解析:per-chat override → 连接快照 → undefined | +| `getProjects()` | 调用 `Project.recentList()` 并过滤根目录,与微信端 `GET /project/recent` 数据一致 | | `replyText(messageId, text)` | 通过飞书 REST API 回复消息 | | `stop()` | 断开 WebSocket,清理客户端和所有模型状态 | | `clearSession()` | 删除本地配置和会话映射文件 | @@ -157,6 +159,7 @@ private async _doStart(config: FeishuConfig, model: ModelRef | null): Promise("idle" - `/new` 只清除本聊天的 override,连接快照保持不变 - `/stop` 清除所有模型状态 +## 项目切换逻辑 + +### 命令 + +| 命令 | 行为 | +|------|------| +| `/project` | 显示前 10 个非隐藏项目,当前项目标 `◀` | +| `/project list` | 显示全部项目,隐藏项目标 `[已隐藏]` | +| `/project n` | 切换到第 n 个项目,自动复用/新建该项目的会话 | +| `/project hide n` | 隐藏第 n 个项目;该项目有新活动后自动恢复 | + +### 数据来源 + +`getProjects()` 直接调用 `Project.recentList()`,这与微信端调用的 `GET /project/recent` HTTP 接口是**同一个函数**,因此两端看到的项目列表完全一致。 + +### 每聊天目录(`_chatDirs`) + +`/project n` 切换后,该 chatId 的目标目录保存在 `_chatDirs[chatId]`。后续每条消息都在该目录的 Instance 上下文中执行: + +```typescript +const effectiveDir = this._chatDirs[chatId] ?? Instance.directory +await Instance.provide({ + directory: effectiveDir, + fn: async () => { + // Session 查找/创建、SessionPrompt.prompt() 均在此上下文内运行 + }, +}) +``` + +这样 `Session.create()` 和 AI 回复都归属于正确的项目,而不是连接时的默认目录。 + +### 隐藏项目 + +- 隐藏状态持久化到 `hidden_projects.json`,重连后保留 +- **取消隐藏双机制**: + - 飞书消息直接触发:`handleMessage` 中若本条消息的目标目录在 `_hiddenDirs` 中,立即删除 + - GlobalBus 监听:连接后注册 `GlobalBus.on("event", listener)`,web 端对该目录有任何活动时自动取消隐藏 +- `/project n` 切换到已隐藏的项目时也会自动取消隐藏 +- `autoUnhide()` 作为兜底:每条消息和 `/project` 命令时运行,基于 `time.activity` 时间戳对比 + +### 状态字段 + +| 字段 | 类型 | 生命周期 | +|------|------|---------| +| `_chatDirs` | `Record` | `/project n` 设置,`stop()` 时清除 | +| `_hiddenDirs` | `Record` | `/project hide n` 设置,持久化,重连保留 | + ### 三级解析(`resolveModel(chatId)`) ``` @@ -350,10 +400,12 @@ SDK 使用方式: | 日期 | 修改内容 | 原因 | |------|---------|------| -| 2026-04-06 11:25 | 事件回调从 `await` 改为 `void`(非阻塞) | 飞书服务器在回调未及时返回时会重发消息,导致用户收到重复回复 | +| 2026-04-06 11:39 | 事件回调从 `await` 改为 `void`(非阻塞) | 飞书服务器在回调未及时返回时会重发消息,导致用户收到重复回复 | | 2026-04-06 11:39 | `handleMessage` 的 catch 中增加 `replyText` 错误反馈 | 报错时飞书用户无任何提示,现在会收到错误信息 | -| 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 分组,参考微信端风格 | 原格式不直观,统一两端体验 | +| 2026-04-06 16:05 | 首次发消息优先复用最近会话,而非总是新建 | 飞书每条消息都新建会话,web 端体验混乱 | +| 2026-04-06 16:25 | 每条 AI 回复顶部追加项目和会话标题(`📁 项目名\n💬 会话名\n───`) | 用户无法感知当前处于哪个项目/会话 | +| 2026-04-06 17:09 | `/new` 改为立即创建新会话(`Session.create`),不再等到下一条消息才新建 | web 端侧边栏需实时出现新会话,旧实现只清除映射延迟到下条消息才生效 | +| 2026-04-06 18:54 | 新增模型选择逻辑:连接时前端传模型 + per-chat override + `/model` 命令 | 飞书端应沿用 web UI 当前选中的模型,连接后 web UI 切换模型不应影响飞书端 | +| 2026-04-06 19:17 | `/model` 列表格式改为按 provider 分组,参考微信端风格 | 原格式不直观,统一两端体验 | +| 2026-04-06 19:17 | 新增 `/project` 命令:查看/切换/隐藏项目,数据源与微信端一致 | 多项目场景下需在飞书端切换工作目录;切换后消息自动在对应项目上下文中执行 | +| 2026-04-06 19:17 | 取消隐藏改为双机制:飞书消息直接判断 + GlobalBus 监听 web 端活动 | `time.activity` 只在项目初始化时更新,不反映 session 消息,原方案对活跃项目无效 | diff --git a/docs/feishu-setup-guide.md b/docs/feishu-setup-guide.md index c5ec341345..5d3d2e75c4 100644 --- a/docs/feishu-setup-guide.md +++ b/docs/feishu-setup-guide.md @@ -97,8 +97,33 @@ Aether 支持通过飞书进行对话,体验与微信连接一致: | `/new` | 开始新对话(清除当前会话上下文和本聊天的模型设置) | | `/model` | 列出所有可用模型,当前选中标 ★ | | `/model ` | 将本聊天切换到第 n 号模型 | +| `/project` | 查看项目列表(前 10 个,当前标 ◀) | +| `/project list` | 查看全部项目(含隐藏) | +| `/project ` | 切换到第 n 个项目 | +| `/project hide ` | 隐藏第 n 个项目(有新活动后自动恢复) | | `/help` | 显示帮助信息 | +### 项目切换 + +发送 `/project` 查看项目列表: + +``` +📂 当前项目:opencode + +📂 项目列表: + +1. opencode ◀ + /home/user/code/openresearch/opencode +2. my-app + /home/user/code/my-app + +💡 /project n 切换 | /project list 查看全部 | /project hide n 隐藏 +``` + +发送 `/project 2` 切换到第 2 个项目,之后所有消息都在该项目上下文中执行,回复头部也会显示对应项目名。 + +> **提示**:项目列表与微信端完全一致(同一数据来源),隐藏状态跨重连保留。 + ### 模型切换 发送 `/model` 查看可用模型列表: @@ -209,6 +234,12 @@ packages/app/src/ 2. 确认已开通 `im:message` 权限 3. 确认在飞书中直接给机器人发消息(群聊中 @机器人 也支持,但需开通群聊权限) +### 项目不对 / 回复的不是我想要的项目 + +1. 发送 `/project` 查看当前项目和列表 +2. 发送 `/project n` 切换到目标项目 +3. 切换后发的消息会在新项目上下文中执行 + ### 模型不对 1. 连接前先在 web UI 底栏切换到目标模型,再点击连接 diff --git a/packages/opencode/src/feishu/manager.ts b/packages/opencode/src/feishu/manager.ts index be4822d3cf..28298a855b 100644 --- a/packages/opencode/src/feishu/manager.ts +++ b/packages/opencode/src/feishu/manager.ts @@ -6,7 +6,9 @@ import z from "zod" import * as lark from "@larksuiteoapi/node-sdk" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" +import { GlobalBus } from "@/bus/global" import { Instance } from "@/project/instance" +import { Project } from "@/project/project" import { Session } from "@/session" import { SessionPrompt } from "@/session/prompt" import { SessionID } from "@/session/schema" @@ -22,6 +24,7 @@ const FEISHU_DATA_DIR = : join(homedir(), ".local", "share", "opencode", "feishu") const CONFIG_FILE = join(FEISHU_DATA_DIR, "config.json") const SESSION_MAP_FILE = join(FEISHU_DATA_DIR, "sessions.json") +const HIDDEN_DIRS_FILE = join(FEISHU_DATA_DIR, "hidden_projects.json") export type FeishuStatus = "idle" | "starting" | "connected" | "error" @@ -85,17 +88,23 @@ class FeishuManagerImpl { 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. + // Snapshot of the model active in the web UI at connect time (frozen). private _connectedModel: ModelRef | null = null - - // Per-chat overrides set via /model n. Cleared by /new, not by /stop. + // Per-chat overrides set via /model n. Cleared by /new. private _modelOverrides: Record = {} - // Cached flat model list, built once at connect time (and on demand). private _modelList: ModelEntry[] = [] // ───────────────────────────────────────────────────────────────────────── + // ── Project state ───────────────────────────────────────────────────────── + // Per-chat current directory (set by /project n). Empty = use connect-time Instance.directory. + private _chatDirs: Record = {} + // Hidden project directories: directory -> timestamp when hidden. Persisted to disk. + private _hiddenDirs: Record = {} + // GlobalBus listener for detecting web UI activity on hidden projects. + private _globalBusListener: ((event: { directory?: string; payload: any }) => void) | null = null + // ───────────────────────────────────────────────────────────────────────── + get status() { return this._status } @@ -118,12 +127,68 @@ class FeishuManagerImpl { Bus.publish(FeishuEvent.StatusChanged, { status: value, message }) } - // ── Model helpers ───────────────────────────────────────────────────────── + // ── Project helpers (mirrors WeChat _get_projects / _project_dir / _project_name) ── + + private normDir(dir: string): string { + const text = (dir || "").replace(/\\/g, "/") + if (!text) return "" + if (/^\/+$/.test(text)) return "/" + if (/^[A-Za-z]:\/?$/.test(text)) return `${text[0].toLowerCase()}:/` + return text.replace(/\/+$/, "") + } + + private isRootDir(dir: string): boolean { + const text = this.normDir(dir) + if (text === "/") return true + return /^[a-z]:\//.test(text) && text.length <= 3 + } + + private projectDir(item: Project.RecentInfo): string { + return this.normDir(item.directory || item.worktree || "") + } + + private projectName(item: Project.RecentInfo): string { + const dir = this.projectDir(item) + return item.name || dir.replace(/\\/g, "/").replace(/\/+$/, "").split("/").at(-1) || dir + } + + private clip(text: string, limit: number): string { + if (text.length <= limit) return text + return text.slice(0, limit - 3).trimEnd() + "..." + } + + /** Same data source as WeChat: GET /project/recent → Project.recentList() */ + private getProjects(): Project.RecentInfo[] { + return Project.recentList().filter((item) => { + const dir = this.projectDir(item) + return dir && !this.isRootDir(dir) + }) + } /** - * Build a flat numbered list of all available models. - * Used by /model to display options and /model n to select. + * Check all hidden projects against current time.activity. + * If a project has new activity (from any source: Feishu or web UI) since it was hidden, + * automatically restore it. Mirrors WeChat's auto-unhide logic. + * Called on every incoming message so web UI activity is also detected promptly. */ + private async autoUnhide(): Promise { + if (Object.keys(this._hiddenDirs).length === 0) return + const allProjects = this.getProjects() + let changed = false + for (const [directory, hideTime] of Object.entries(this._hiddenDirs)) { + const item = allProjects.find((p) => this.projectDir(p) === directory) + const activity = item?.time?.activity ?? 0 + if (activity > hideTime) { + delete this._hiddenDirs[directory] + changed = true + console.log("[feishu] auto-restored hidden project:", directory) + } + } + if (changed) await this.saveHiddenDirs() + } + + // ── Model helpers ───────────────────────────────────────────────────────── + private async buildModelList(): Promise { try { const providers = await Provider.list() @@ -131,7 +196,7 @@ class FeishuManagerImpl { 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 }) + entries.push({ index, providerID, modelID, name: (model as any).name || modelID }) index++ } } @@ -142,10 +207,7 @@ class FeishuManagerImpl { } } - /** - * Resolve the model to use for a given chat. - * Priority: per-chat override > connection snapshot > undefined (SessionPrompt default) - */ + /** Three-level model resolution: per-chat override → connection snapshot → undefined */ private resolveModel(chatId: string): { providerID: ProviderID; modelID: ModelID } | undefined { const override = this._modelOverrides[chatId] ?? this._connectedModel if (!override) return undefined @@ -171,7 +233,6 @@ class FeishuManagerImpl { return { success: false, message: "Feishu bridge is already running" } } - // If no config provided, try loading saved config const cfg = config || (await this.loadConfig()) if (!cfg?.appId || !cfg?.appSecret) { this._error = { code: "config_missing", message: "请提供飞书应用的 App ID 和 App Secret" } @@ -183,11 +244,9 @@ class FeishuManagerImpl { this.status = "starting" this._error = null - // Save config for future use await this.saveConfig(cfg) - - // Load session map this.sessionMap = await this.loadSessionMap() + this._hiddenDirs = await this.loadHiddenDirs() void this._doStart(cfg, model ?? null) return { success: true } @@ -198,44 +257,49 @@ class FeishuManagerImpl { this.statusMsg("starting", "正在连接飞书...") console.log("[feishu] _doStart called") - // Capture Instance context so event callbacks can access Session/Instance APIs const boundHandleMessage = Instance.bind((data: any) => { console.log("[feishu] >>> event received!") void this.handleMessage(data) }) - // Create Feishu API client this.larkClient = new lark.Client({ appId: config.appId, appSecret: config.appSecret, disableTokenCache: false, }) - // Create event dispatcher const eventDispatcher = new lark.EventDispatcher({}) eventDispatcher.register({ "im.message.receive_v1": boundHandleMessage, }) - // Create WebSocket client with debug logging this.wsClient = new lark.WSClient({ appId: config.appId, appSecret: config.appSecret, loggerLevel: lark.LoggerLevel.debug, }) - // eventDispatcher is passed to start(), not constructor console.log("[feishu] calling wsClient.start()...") 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() + // Listen to all Bus events across instances. + // If any event comes from a hidden project directory, auto-unhide it. + // This catches web UI activity on hidden projects without polling. + this._globalBusListener = (event) => { + const dir = event.directory ? this.normDir(event.directory) : null + if (!dir || !(dir in this._hiddenDirs)) return + delete this._hiddenDirs[dir] + console.log("[feishu] auto-unhide via GlobalBus activity:", dir) + void this.saveHiddenDirs() + } + GlobalBus.on("event", this._globalBusListener) + this._session = { connected: true, appId: config.appId, @@ -260,18 +324,19 @@ class FeishuManagerImpl { return } + // Check for hidden projects with new activity (web UI or prior Feishu messages) + await this.autoUnhide() + const chatId = message.chat_id const messageId = message.message_id const rootId = message.root_id || message.parent_id || messageId console.log("[feishu] message:", { chatId, messageId, type: message.message_type }) - // Only handle text messages for now if (message.message_type !== "text") { await this.replyText(messageId, "暂时只支持文本消息") return } - // Parse message content let text: string try { const content = JSON.parse(message.content) @@ -283,7 +348,6 @@ class FeishuManagerImpl { // Strip @mention tags (group chat) text = text.replace(/@_\w+\s*/g, "").trim() - if (!text) return console.log("[feishu] text:", text) @@ -293,55 +357,68 @@ class FeishuManagerImpl { return } - // Map to Aether session - const sessionKey = `${chatId}:${rootId}` - let sessionId = this.sessionMap[sessionKey] - - if (!sessionId) { - // Reuse the most recent session if available, otherwise create one - const recent = [...Session.list({ roots: true, limit: 1 })] - if (recent.length > 0) { - sessionId = recent[0].id - console.log("[feishu] reusing existing session:", sessionId) - } else { - console.log("[feishu] creating new session...") - const session = await Session.create({ - title: `飞书对话 ${chatId.slice(-6)}`, - }) - sessionId = session.id - console.log("[feishu] session created:", sessionId) - } - this.sessionMap[sessionKey] = sessionId - await this.saveSessionMap() - } + // Effective directory for this chat (may differ from connect-time Instance.directory) + const effectiveDir = this._chatDirs[chatId] ?? Instance.directory - // Resolve model: per-chat override > connection snapshot > undefined - const model = this.resolveModel(chatId) - console.log("[feishu] using model:", model ?? "(default)") + // Feishu message to a hidden project → unhide immediately + if (effectiveDir in this._hiddenDirs) { + delete this._hiddenDirs[effectiveDir] + console.log("[feishu] auto-unhide via Feishu message:", effectiveDir) + void this.saveHiddenDirs() + } - // 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 } : {}), + // Run session lookup and AI prompt in the effective directory's Instance context + await Instance.provide({ + directory: effectiveDir, + fn: async () => { + const sessionKey = `${chatId}:${rootId}` + let sessionId = this.sessionMap[sessionKey] + + if (!sessionId) { + // Reuse most recent session in this directory, or create one + const recent = [...Session.list({ directory: effectiveDir, roots: true, limit: 1 })] + if (recent.length > 0) { + sessionId = recent[0].id + console.log("[feishu] reusing existing session:", sessionId) + } else { + console.log("[feishu] creating new session...") + const session = await Session.create({ + title: `飞书对话 ${chatId.slice(-6)}`, + }) + sessionId = session.id + console.log("[feishu] session created:", sessionId) + } + this.sessionMap[sessionKey] = sessionId + await this.saveSessionMap() + } + + const model = this.resolveModel(chatId) + console.log("[feishu] using model:", model ?? "(default)") + + 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) + + const responseText = this.extractResponseText(msg) + if (responseText) { + const projectName = effectiveDir.split("/").at(-1) ?? effectiveDir + const sessionInfo = [...Session.list({ directory: effectiveDir, roots: true, limit: 100 })].find( + (s) => s.id === sessionId, + ) + const sessionTitle = sessionInfo?.title ?? sessionId.slice(0, 8) + const header = `📁 ${projectName}\n💬 ${sessionTitle}\n${"─".repeat(20)}\n` + + console.log("[feishu] replying:", responseText.slice(0, 100)) + await this.replyText(messageId, header + responseText) + } else { + console.log("[feishu] no text in response") + } + }, }) - console.log("[feishu] aether responded, parts:", msg?.parts?.length) - - // Extract text response - const responseText = this.extractResponseText(msg) - if (responseText) { - // Build context header so user knows which project/session is active - const projectName = Instance.directory.split("/").at(-1) ?? Instance.directory - const sessionInfo = [...Session.list({ roots: true, limit: 100 })].find((s) => s.id === sessionId) - const sessionTitle = sessionInfo?.title ?? sessionId.slice(0, 8) - const header = `📁 ${projectName}\n💬 ${sessionTitle}\n${"─".repeat(20)}\n` - - console.log("[feishu] replying:", responseText.slice(0, 100)) - await this.replyText(messageId, header + responseText) - } else { - console.log("[feishu] no text in response") - } } catch (err) { console.error("[feishu] handleMessage error:", err) const messageId = data?.message?.message_id @@ -362,17 +439,20 @@ class FeishuManagerImpl { // ── Command handlers ────────────────────────────────────────────────────── private async handleCommand(text: string, messageId: string, chatId: string): Promise { - const [cmd, ...args] = text.trim().split(/\s+/) - const command = cmd.toLowerCase() + const parts = text.trim().split(/\s+/) + const command = parts[0].toLowerCase() + const rest = parts.slice(1).join(" ") if (command === "/new") { await this.cmdNew(messageId, chatId) } else if (command === "/model") { - await this.cmdModel(messageId, chatId, args) + await this.cmdModel(messageId, chatId, parts.slice(1)) + } else if (command === "/project") { + await this.cmdProject(messageId, chatId, rest) } else if (command === "/help") { await this.replyText( messageId, - "可用命令:\n/new - 开始新对话\n/model - 查看/切换模型\n/help - 显示帮助\n\n直接发送消息即可与 Aether AI 对话。", + "可用命令:\n/new - 开始新对话\n/model - 查看/切换模型\n/project - 查看/切换项目\n/help - 显示帮助\n\n直接发送消息即可与 Aether AI 对话。", ) } else { await this.replyText(messageId, `未知命令: ${command}\n发送 /help 查看可用命令。`) @@ -381,17 +461,16 @@ class FeishuManagerImpl { /** /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] - } + 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)}` }) + const effectiveDir = this._chatDirs[chatId] ?? Instance.directory + const session = await Instance.provide({ + directory: effectiveDir, + fn: () => Session.create({ title: `飞书对话 ${chatId.slice(-6)}` }), + }) await this.saveSessionMap() await this.replyText(messageId, `✅ 已开启新对话\n💬 ${session.title}`) } @@ -401,13 +480,11 @@ class FeishuManagerImpl { * /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 } @@ -432,7 +509,6 @@ class FeishuManagerImpl { lines.push("") lines.push("📦 可用模型:") - // Group by provider const byProvider = new Map() for (const entry of this._modelList) { const group = byProvider.get(entry.providerID) ?? [] @@ -459,6 +535,141 @@ class FeishuManagerImpl { return lines.join("\n") } + /** + * /project — list top-10 non-hidden projects (current marked ◀) + * /project list — list ALL projects including hidden ones + * /project — switch to project n + * /project hide — hide project n + */ + private async cmdProject(messageId: string, chatId: string, arg: string): Promise { + const allProjects = this.getProjects() + if (allProjects.length === 0) { + await this.replyText(messageId, "❌ 无法获取项目列表,请检查 Aether 是否正常运行。") + return + } + + // autoUnhide already ran at message entry; run again here to catch + // activity created by the current message batch before /project was typed + await this.autoUnhide() + + const visibleProjects = allProjects.filter((p) => !(this.projectDir(p) in this._hiddenDirs)) + const currentDir = this._chatDirs[chatId] ?? Instance.directory + + // /project hide + if (arg.startsWith("hide ")) { + const delArg = arg.slice(5).trim() + const idx = parseInt(delArg, 10) - 1 + if (isNaN(idx) || idx < 0 || idx >= allProjects.length) { + await this.replyText(messageId, `❌ 用法:/project hide n(n 为 1~${allProjects.length})`) + return + } + const target = allProjects[idx] + const directory = this.projectDir(target) + this._hiddenDirs[directory] = Date.now() + await this.saveHiddenDirs() + const name = this.projectName(target) + await this.replyText(messageId, `✅ 已隐藏:${name}\n(在桌面端或飞书端重新使用后自动恢复)`) + return + } + + // /project list — all projects including hidden + if (arg === "list") { + const currentItem = allProjects.find((p) => this.projectDir(p) === currentDir) ?? allProjects[0] + const lines = [ + `📂 当前项目:${this.clip(this.projectName(currentItem), 24)}`, + "", + "📂 项目列表:", + "", + ] + for (let i = 0; i < allProjects.length; i++) { + const item = allProjects[i] + const directory = this.projectDir(item) + const tag = directory === currentDir ? " ◀" : "" + const mark = directory in this._hiddenDirs ? " [已隐藏]" : "" + lines.push(`${i + 1}. ${this.projectName(item)}${tag}${mark}`) + lines.push(` ${directory}`) + } + await this.replyText(messageId, lines.join("\n")) + return + } + + // /project — switch to project n (1-indexed into allProjects) + if (arg) { + const idx = parseInt(arg, 10) - 1 + if (isNaN(idx) || idx < 0 || idx >= allProjects.length) { + await this.replyText(messageId, `❌ 请输入 1~${allProjects.length} 之间的编号。`) + return + } + const chosen = allProjects[idx] + const newDir = this.projectDir(chosen) + + // Clear session mapping for this chat so next message uses the new project + for (const key of Object.keys(this.sessionMap)) { + if (key.startsWith(`${chatId}:`)) delete this.sessionMap[key] + } + delete this._modelOverrides[chatId] + this._chatDirs[chatId] = newDir + + // Auto-unhide if it was hidden + if (newDir in this._hiddenDirs) { + delete this._hiddenDirs[newDir] + await this.saveHiddenDirs() + } + + // Find or create a session in the new project + const { 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 } + } else { + const session = await Session.create({ title: `飞书对话 ${chatId.slice(-6)}` }) + return { sessionTitle: session.title, created: true } + } + }, + }) + await this.saveSessionMap() + + const name = this.projectName(chosen) + const note = created ? "已创建新会话" : `已进入该项目最新会话:${sessionTitle}` + console.log("[feishu] /project switched:", chatId, "->", newDir) + await this.replyText(messageId, `✅ 已切换到:${name}\n ${newDir}\n(${note})`) + return + } + + // /project — list top-10 non-hidden projects + if (visibleProjects.length === 0) { + const hint = Object.keys(this._hiddenDirs).length > 0 ? `(有 ${Object.keys(this._hiddenDirs).length} 个项目已隐藏)` : "" + await this.replyText(messageId, `❌ 未找到任何项目。${hint}`) + return + } + + const currentItem2 = allProjects.find((p) => this.projectDir(p) === currentDir) ?? allProjects[0] + const lines = [ + `📂 当前项目:${this.clip(this.projectName(currentItem2), 24)}`, + "", + "📂 项目列表:", + "", + ] + let count = 0 + for (let i = 0; i < allProjects.length && count < 10; i++) { + const item = allProjects[i] + const directory = this.projectDir(item) + if (directory in this._hiddenDirs) continue + const tag = directory === currentDir ? " ◀" : "" + lines.push(`${i + 1}. ${this.projectName(item)}${tag}`) + lines.push(` ${directory}`) + count++ + } + lines.push("") + lines.push("💡 /project n 切换 | /project list 查看全部 | /project hide n 隐藏") + if (Object.keys(this._hiddenDirs).length > 0) { + lines.push(`ℹ️ 已隐藏 ${Object.keys(this._hiddenDirs).length} 个项目(重新使用后自动恢复)`) + } + await this.replyText(messageId, lines.join("\n")) + } + // ───────────────────────────────────────────────────────────────────────── private async replyText(messageId: string, text: string): Promise { @@ -477,6 +688,10 @@ class FeishuManagerImpl { } async stop(): Promise { + if (this._globalBusListener) { + GlobalBus.off("event", this._globalBusListener) + this._globalBusListener = null + } if (this.wsClient) { try { if (typeof this.wsClient.stop === "function") { @@ -490,6 +705,8 @@ class FeishuManagerImpl { this._connectedModel = null this._modelOverrides = {} this._modelList = [] + this._chatDirs = {} + // _hiddenDirs intentionally kept (persisted, survives reconnect) this.status = "idle" } @@ -497,8 +714,10 @@ class FeishuManagerImpl { try { await rm(CONFIG_FILE, { force: true }) await rm(SESSION_MAP_FILE, { force: true }) + await rm(HIDDEN_DIRS_FILE, { force: true }) this._session = null this.sessionMap = {} + this._hiddenDirs = {} } catch {} } @@ -532,8 +751,22 @@ class FeishuManagerImpl { await writeFile(SESSION_MAP_FILE, JSON.stringify(this.sessionMap, null, 2)) } + private async loadHiddenDirs(): Promise> { + try { + if (existsSync(HIDDEN_DIRS_FILE)) { + const data = await readFile(HIDDEN_DIRS_FILE, "utf-8") + return JSON.parse(data) + } + } catch {} + return {} + } + + private async saveHiddenDirs(): Promise { + await mkdir(FEISHU_DATA_DIR, { recursive: true }) + await writeFile(HIDDEN_DIRS_FILE, JSON.stringify(this._hiddenDirs, null, 2)) + } + async loadSession(): Promise { - // Check if there's a saved config (means user has configured before) const config = await this.loadConfig() if (config && this._session) return this._session return null