diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..196e94c16 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,36 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "semi": false, + "printWidth": 100, + "trailingComma": "none", + "sortPackageJson": false, + "ignorePatterns": [ + "out", + "dist", + "pnpm-lock.yaml", + "LICENSE.md", + "tsconfig.json", + "tsconfig.*.json", + "CONTRIBUTING*.md", + "README*.md", + "docs", + "resources", + "runtime", + "scripts", + "build", + "*.yaml", + "tailwind.config.js", + "vitest.config.*", + "Dockerfile*", + ".env*", + "src/renderer/src/components/ui/*", + ".github", + ".cursor", + ".vscode", + "electron.vite.config.ts", + "*.md", + "scripts/*", + "src/shadcn/**/*" + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 15ed9cf1a..000000000 --- a/.prettierignore +++ /dev/null @@ -1,26 +0,0 @@ -out -dist -pnpm-lock.yaml -LICENSE.md -tsconfig.json -tsconfig.*.json -CONTRIBUTING*.md -README*.md -docs -resources -runtime -scripts -build -*.yaml -tailwind.config.js -vitest.config.* -Dockerfile* -.env* -src/renderer/src/components/ui/* -.github -.cursor -.vscode -electron.vite.config.ts -*.md -scripts/* -src/shadcn/**/* diff --git a/.prettierrc.yaml b/.prettierrc.yaml deleted file mode 100644 index 35893b3be..000000000 --- a/.prettierrc.yaml +++ /dev/null @@ -1,4 +0,0 @@ -singleQuote: true -semi: false -printWidth: 100 -trailingComma: none diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 347bc4222..faedcd1a2 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["dbaeumer.vscode-eslint", "lokalise.i18n-ally", "esbenp.prettier-vscode", "TypeScriptTeam.native-preview"] + "recommendations": ["dbaeumer.vscode-eslint", "lokalise.i18n-ally", "oxc.oxc-vscode", "TypeScriptTeam.native-preview"] } diff --git a/AGENTS.md b/AGENTS.md index a791b851b..4625e17de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ ## Coding Style & Naming Conventions - TypeScript + Vue 3 Composition API; Pinia for state; Tailwind for styles. - i18n: all user-facing strings use vue-i18n keys in `src/renderer/src/i18n`. -- Prettier: single quotes, no semicolons, width 100. Run `pnpm run format`. +- Oxfmt: single quotes, no semicolons, width 100. Run `pnpm run format`. - OxLint for JS/TS; hooks run `lint-staged` and `typecheck`. - Names: Vue components PascalCase (`ChatInput.vue`); variables/functions `camelCase`; types/classes `PascalCase`; constants `SCREAMING_SNAKE_CASE`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5506ae77d..6d5fb03ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v1.0.0-beta.7 (2026-03-27) +- 新增 Novita AI LLM 提供商接入 +- 新增 Provider 配置导入能力(Deeplink 导入) +- 新增 Feishu Bot 远端接入能力 +- 改进悬浮窗与侧边栏交互体验:SessionItem 由右键菜单切换为 hover/浮层交互,浮动按钮 hover 与透明度细节优化 +- 修复消息标题选择与 MCP 生命周期相关稳定性问题,并清理已过期 MCP Server + ## v1.0.0-beta.6 (2026-03-24) - 新增 Telegram Remote Control,可通过 Telegram 远程查看与驱动会话,远程控制配置也已接入设置页 - 统一 DeepChat Agent 与 ACP Agent 的 Agent 能力和入口,补齐欢迎页、本地化文案与默认配置,整体使用路径更一致 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7dddb53ed..cf3e7bb7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -168,7 +168,7 @@ We use GitHub to host code, to track issues and feature requests, as well as acc ## Code Style - TypeScript + Vue 3 Composition API + Pinia; Tailwind + shadcn/ui for styling. -- Prettier enforces single quotes and no semicolons; `pnpm run format` before committing. +- Oxfmt enforces single quotes, no semicolons, and width 100; `pnpm run format` before committing. - OxLint is used for linting (`pnpm run lint`). Type checking via `pnpm run typecheck` (node + web targets). - Tests use Vitest (`test/main`, `test/renderer`). Name tests `*.test.ts`/`*.spec.ts`. - Follow naming conventions: PascalCase components/types, camelCase variables/functions, SCREAMING_SNAKE_CASE constants. diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md index 81465a38b..32224b6f5 100644 --- a/CONTRIBUTING.zh.md +++ b/CONTRIBUTING.zh.md @@ -168,7 +168,7 @@ pnpm run dev ## 代码风格 - TypeScript + Vue 3 Composition API + Pinia;样式使用 Tailwind + shadcn/ui。 -- Prettier:单引号、无分号;提交前请执行 `pnpm run format`。 +- Oxfmt:单引号、无分号、宽度 100;提交前请执行 `pnpm run format`。 - OxLint 用于代码检查(`pnpm run lint`);类型检查 `pnpm run typecheck`(node + web 双目标)。 - 测试使用 Vitest(`test/main`、`test/renderer`),命名 `*.test.ts` / `*.spec.ts`。 - 命名约定:组件/类型 PascalCase,变量/函数 camelCase,常量 SCREAMING_SNAKE_CASE。 diff --git a/archives/code/dead-code-batch-3/README.md b/archives/code/dead-code-batch-3/README.md new file mode 100644 index 000000000..bea975789 --- /dev/null +++ b/archives/code/dead-code-batch-3/README.md @@ -0,0 +1,14 @@ +# Dead Code Batch 3 + +- Purpose: archive retired MCP runtime code that is no longer part of the active in-memory server set. +- Archived at: 2026-03-26 +- Rationale: `meetingServer.ts` has been removed from live MCP registration and default config, but is retained in source form for precise rollback if the feature is rebuilt later. + +## Archived Paths + +- `src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts` + +## Notes + +- This directory is not part of the runtime, build, typecheck, or test target set. +- Restore by moving files back to their original paths only if a future audit proves the retired MCP server is needed again. diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts b/archives/code/dead-code-batch-3/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts similarity index 100% rename from src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts rename to archives/code/dead-code-batch-3/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts diff --git a/docs/specs/provider-deeplink-import/plan.md b/docs/specs/provider-deeplink-import/plan.md new file mode 100644 index 000000000..170d4a237 --- /dev/null +++ b/docs/specs/provider-deeplink-import/plan.md @@ -0,0 +1,180 @@ +# Provider Deeplink Import 实施计划 + +## 1. 当前实现基线 + +### 1.1 Deeplink 现状 + +1. `src/main/presenter/deeplinkPresenter/index.ts` 已支持 `deepchat://start` 和 `deepchat://mcp/install`。 +2. 设置窗口已经支持通过 `SETTINGS_EVENTS.NAVIGATE` 进行页面跳转。 +3. 设置 App 已有 MCP deeplink 的初始化处理,可复用设置窗口 ready 后接收事件的模式。 + +### 1.2 Provider 设置页现状 + +1. Provider 列表与配置由 `providerStore` 驱动。 +2. Provider 详情页可基于路由参数 `providerId` 切换目标 provider。 +3. 自定义 provider 已有手动新增流程,可复用新增后的选中逻辑。 + +## 2. 设计决策 + +### 2.1 Payload 与共享类型 + +新增共享模块 `src/shared/providerDeeplink.ts`: + +1. 常量: + - `PROVIDER_INSTALL_ROUTE` + - `PROVIDER_INSTALL_VERSION` +2. 类型: + - `ProviderInstallDeeplinkPayload` + - `ProviderInstallPreview` +3. 工具函数: + - `maskApiKey` + - custom type 校验 + +### 2.2 主进程事件流 + +入口:`deepchat://provider/install?v=1&data=...` + +处理顺序: + +1. `DeeplinkPresenter.handleDeepLink` 识别 `provider/install` +2. Base64 解码 + JSON 解析 + 字段校验 +3. built-in: + - 校验 `id` + - 拒绝 `acp` +4. custom: + - 校验 `name/type` + - 校验 `type` 在允许列表中 + - 拒绝 `acp` +5. 创建/聚焦设置窗口 +6. 发送: + - `SETTINGS_EVENTS.NAVIGATE -> settings-provider` + - `SETTINGS_EVENTS.PROVIDER_INSTALL -> preview` + +错误策略: + +1. 解析失败或 payload 不合法时,发 `NOTIFICATION_EVENTS.SHOW_ERROR` +2. 失败时不写任何 provider 配置 + +### 2.3 渲染进程事件流 + +`src/renderer/settings/App.vue`: + +1. 监听 `SETTINGS_EVENTS.PROVIDER_INSTALL` +2. 确保 provider store 已初始化 +3. built-in 导入时切到 `settings-provider/:providerId` +4. custom 导入时切到 `settings-provider` +5. 把 preview 放入新的 pending import store + +`src/renderer/src/stores/providerDeeplinkImport.ts`: + +1. 只维护当前 pending preview +2. 对话框开关由 preview 是否存在推导 + +### 2.4 对话框与落库行为 + +`ProviderDeeplinkImportDialog` 只负责展示解析结果,不自行写配置。 + +展示规则: + +1. built-in:`icon + id` +2. custom:`icon + name`,并额外显示 `type` +3. 两类都展示 `baseUrl` +4. 两类都展示脱敏 `apiKey` +5. built-in 额外显示覆盖 warning + +确认逻辑放在 `ModelProviderSettings.vue`: + +1. built-in: + - 更新 `baseUrl/apiKey` + - 若未启用则自动启用 + - 刷新该 provider 模型 + - 切换到对应 provider 页面 +2. custom: + - 生成新 `id` + - 创建 `custom: true` provider + - 默认 `enable: true` + - 刷新新 provider 模型 + - 切换到新 provider 页面 +3. cancel: + - 清空 pending preview + - 不写配置 + +### 2.5 Provider 兼容策略 + +1. built-in provider 以 `id` 作为唯一匹配键,因此导入是覆盖语义。 +2. custom provider 以 `type/apiType` 校验,但确认后总是新增实例,因此是追加语义。 +3. `vertex`、`aws-bedrock`、`github-copilot` 等允许部分导入,即使后续仍需补专属字段,也不阻塞 `baseUrl/apiKey` 导入。 +4. `acp` 独立于本流程,不进入 `settings-provider` 导入链路。 + +## 3. Manual Playground + +新增: + +- `test/manual/deeplink-playground.html` + +页面结构: + +1. `start` +2. `mcp/install` +3. `provider/install` +4. `provider/install builder` + +规则: + +1. built-in 列出当前所有默认 provider `id`,排除 `acp` +2. custom 列出当前所有允许导入的 `apiType`,排除 `acp` +3. 每项展示: + - label + - raw JSON + - deeplink + - `Open` + - `Copy` +4. 示例数据全部使用假地址和假 key + +## 4. 测试策略 + +### 4.1 Main + +1. built-in payload 成功时: + - 打开设置窗 + - 发送 `NAVIGATE` + - 发送 `PROVIDER_INSTALL` +2. custom payload 成功时: + - 发送 custom preview +3. 非法 payload: + - 不发送导入事件 + - 发送错误通知 + +### 4.2 Renderer + +1. `App.vue` 收到 `PROVIDER_INSTALL` 后正确导航并写入 preview store +2. `ModelProviderSettings.vue`: + - built-in confirm 覆盖并启用 provider + - custom confirm 新增并选中新 provider + - cancel 不写配置 +3. `ProviderDeeplinkImportDialog.vue` 正确展示 built-in/custom 解析结果 + +### 4.3 Manual + +1. playground 中三类 deeplink 都能生成合法协议链接 +2. built-in/custom 列表覆盖范围正确 +3. builder 输出格式与应用解析格式一致 + +## 5. 风险与缓解 + +1. 风险:设置窗口创建后事件发送早于页面监听注册。 +缓解:复用现有 settings 事件通道,并在 App 侧做独立导航兜底。 + +2. 风险:部分 provider 启用后仍缺专属字段,模型刷新可能失败。 +缓解:允许部分导入;模型刷新失败只记录日志,不回滚导入。 + +3. 风险:手工验证页 provider 列表与真实支持集合漂移。 +缓解:built-in 与 custom 列表以当前代码中的 provider 集合为准,变更时同步更新此页。 + +## 6. 质量门槛 + +1. `pnpm run format` +2. `pnpm run i18n` +3. `pnpm run lint` +4. `pnpm run typecheck` +5. 关键 main/renderer 测试通过 diff --git a/docs/specs/provider-deeplink-import/spec.md b/docs/specs/provider-deeplink-import/spec.md new file mode 100644 index 000000000..4f383bb5d --- /dev/null +++ b/docs/specs/provider-deeplink-import/spec.md @@ -0,0 +1,136 @@ +# Provider Deeplink Import 规格 + +## 概述 + +新增 provider 导入 deeplink: + +- `deepchat://provider/install?v=1&data=` + +其中 `data` 只接受两种结构,且 `id` 与 `type` 必须二选一: + +1. `{ id, baseUrl, apiKey }` +2. `{ name, type, baseUrl, apiKey }` + +导入后统一进入 Provider Settings,先展示确认对话框;用户确认后才写入配置,取消则直接丢弃。 + +## 背景与动机 + +1. 用户经常需要在多个 built-in provider 与 custom provider 之间切换配置。 +2. 当前 provider 配置主要依赖手动录入,分享和一键导入成本高。 +3. DeepLink 已经用于 `start` 和 `mcp/install`,provider 导入应沿用同一套唤起能力。 +4. 需要一个独立的手工验证页,降低联调和回归验证成本。 + +## 用户故事 + +### US-1:一键导入内置 Provider + +作为用户,我希望点击一个 deeplink 后直接进入对应 provider 设置,并在确认后覆盖它的 `baseUrl` 与 `apiKey`。 + +### US-2:一键新增 Custom Provider + +作为用户,我希望通过 deeplink 快速新增一个 custom provider,而不是手动新建并逐项填写。 + +### US-3:导入前确认 + +作为用户,我希望在真正写入前看到解析结果,避免误覆盖现有配置。 + +### US-4:手工验证入口 + +作为开发者或测试者,我希望仓库里有一个静态网页,能集中打开所有支持的 deeplink。 + +## 功能需求 + +### A. Provider Deeplink 协议 + +- [ ] 新增 `deepchat://provider/install?v=1&data=` +- [ ] `v=1` 是当前唯一支持版本 +- [ ] `data` Base64 解码后必须是 JSON object +- [ ] payload 只允许两种结构: + - [ ] `{ id, baseUrl, apiKey }` + - [ ] `{ name, type, baseUrl, apiKey }` +- [ ] `id` 与 `type` 同时存在或同时缺失时,必须拒绝 + +### B. 内置 Provider 导入 + +- [ ] 当 payload 包含 `id` 时,按内置 provider id 匹配 +- [ ] `id='acp'` 必须拒绝 +- [ ] unknown `id` 必须拒绝 +- [ ] 确认后覆盖目标 provider 的 `baseUrl` 与 `apiKey` +- [ ] 若目标 provider 当前未启用,确认后自动启用 +- [ ] 完成后停留在对应 provider 设置页 +- [ ] 若是 `vertex`、`aws-bedrock`、`github-copilot` 等仍需额外字段的 provider,允许部分导入,不阻塞确认 + +### C. Custom Provider 导入 + +- [ ] 当 payload 包含 `type` 时,按 provider `apiType` 匹配 +- [ ] `type='acp'` 必须拒绝 +- [ ] unknown `type` 必须拒绝 +- [ ] custom payload 必须包含 `name` +- [ ] 确认后总是新增一条 custom provider,不复用旧条目 +- [ ] 新 provider 默认 `enable=true` +- [ ] 完成后停留在新 provider 设置页 + +### D. 设置页行为 + +- [ ] deeplink 唤起后自动进入 `settings-provider` +- [ ] 在真正写入前弹出 `Import Provider` 对话框 +- [ ] built-in 对话框只展示: + - [ ] `id + icon` + - [ ] `baseUrl` + - [ ] 脱敏 `apiKey` +- [ ] custom 对话框只展示: + - [ ] `name + icon` + - [ ] `type` + - [ ] `baseUrl` + - [ ] 脱敏 `apiKey` +- [ ] built-in 导入需要展示“将覆盖当前配置”的提示 +- [ ] 取消后不写入任何 provider 配置 + +### E. 错误处理 + +- [ ] 非法 Base64、非法 JSON、非法版本、缺字段、unknown `id/type` 均必须拒绝 +- [ ] 非法 deeplink 需要有可见错误提示 +- [ ] 拒绝场景不得写入 provider 配置 + +### F. Manual Playground + +- [ ] 新增 `test/manual/deeplink-playground.html` +- [ ] 页面覆盖三类 deeplink: + - [ ] `start` + - [ ] `mcp/install` + - [ ] `provider/install` +- [ ] `provider/install` 区块必须列出: + - [ ] 所有 built-in provider `id`,排除 `acp` + - [ ] 所有允许的 custom `apiType`,排除 `acp` +- [ ] 每项都提供 `Open` 和 `Copy` +- [ ] 每项都展示原始 JSON 与最终 deeplink +- [ ] 页面提供一个可编辑 builder,用于临时生成 deeplink +- [ ] 所有示例数据必须为 fake data + +## 验收标准 + +- [ ] 打开 built-in provider deeplink 时,设置窗进入对应 provider,并弹出确认对话框 +- [ ] 确认 built-in provider 导入后,`baseUrl/apiKey` 被覆盖,provider 被自动启用 +- [ ] 打开 custom provider deeplink 时,设置窗进入 provider 设置页,并弹出确认对话框 +- [ ] 确认 custom provider 导入后,会新增一条启用中的 custom provider +- [ ] 取消导入时,不产生任何配置写入 +- [ ] 非法 payload 只显示错误,不进入确认流程 +- [ ] 手工验证页可直接生成并打开三类 deeplink + +## 非目标 + +1. 不扩展 provider deeplink 的版本协商机制,本次仅支持 `v=1`。 +2. 不新增 provider 专属迁移脚本或持久化 schema。 +3. 不为 `acp` provider 引入导入能力。 +4. 不修改现有 `start` 与 `mcp/install` 的协议格式。 + +## 约束 + +1. 保持现有 Presenter + EventBus 架构。 +2. 所有用户可见文案必须走 i18n。 +3. 不破坏现有 provider 配置存储结构。 +4. Manual playground 不打包进应用,仅作为仓库内测试辅助页。 + +## 开放问题 + +无。 diff --git a/docs/specs/provider-deeplink-import/tasks.md b/docs/specs/provider-deeplink-import/tasks.md new file mode 100644 index 000000000..ca53fb43e --- /dev/null +++ b/docs/specs/provider-deeplink-import/tasks.md @@ -0,0 +1,59 @@ +# Provider Deeplink Import Tasks + +## T0 规格与设计 + +- [x] 完成 `spec.md` +- [x] 完成 `plan.md` +- [x] 完成 `tasks.md` + +## T1 共享协议与事件 + +- [x] 新增 provider deeplink 共享类型与协议常量 +- [x] 新增 `SETTINGS_EVENTS.PROVIDER_INSTALL` +- [x] 补共享类型导出 + +## T2 主进程解析与分发 + +- [x] 在 `deeplinkPresenter` 新增 `provider/install` 入口 +- [x] 校验 `v=1` +- [x] 校验 Base64 / JSON / 字段结构 +- [x] built-in 导入按 `id` 匹配 +- [x] custom 导入按 `type` 校验 +- [x] 拒绝 `acp` +- [x] 发送设置页导航与 preview 事件 +- [x] 非法 payload 显示错误通知 + +## T3 设置页预览与确认 + +- [x] 新增 pending import store +- [x] `App.vue` 监听 `PROVIDER_INSTALL` +- [x] built-in 导航到目标 provider +- [x] 新增 `ProviderDeeplinkImportDialog` +- [x] built-in confirm 覆盖 `baseUrl/apiKey` 并自动启用 +- [x] custom confirm 新增启用中的 custom provider +- [x] cancel 只清空 pending preview + +## T4 i18n 与测试 + +- [x] 补齐 provider import 对话框文案 +- [x] 新增 main deeplink 测试 +- [x] 新增 settings app 事件处理测试 +- [x] 新增 provider settings confirm 测试 + +## T5 Manual Playground + +- [x] 新增 `test/manual/deeplink-playground.html` +- [x] 覆盖 `start` +- [x] 覆盖 `mcp/install` +- [x] 覆盖 built-in provider import,排除 `acp` +- [x] 覆盖 custom provider import,排除 `acp` +- [x] 提供 builder、Open、Copy、raw JSON、deeplink 展示 +- [x] 更新 `test/README.md` + +## T6 质量检查 + +- [ ] `pnpm run format` +- [ ] `pnpm run i18n` +- [ ] `pnpm run lint` +- [ ] `pnpm run typecheck` +- [ ] 运行相关测试并记录结果 diff --git a/docs/specs/remote-multi-channel/plan.md b/docs/specs/remote-multi-channel/plan.md new file mode 100644 index 000000000..560df832a --- /dev/null +++ b/docs/specs/remote-multi-channel/plan.md @@ -0,0 +1,51 @@ +# Remote Multi-Channel Plan + +## Main Process + +- Expand `remoteControl` config normalization to include `telegram` and `feishu`. +- Keep the existing Telegram runtime, but add Feishu runtime management beside it. +- Reuse `RemoteConversationRunner` for both channels by passing endpoint binding metadata. +- Add a Feishu WebSocket runtime with: + - bot identity probe + - inbound message parsing + - endpoint-scoped serial queue + - text command routing + - final-text response delivery + +## Shared Contracts + +- Add `RemoteChannel = 'telegram' | 'feishu'`. +- Add channel-aware presenter methods: + - `getChannelSettings` + - `saveChannelSettings` + - `getChannelStatus` + - `getChannelBindings` + - `removeChannelBinding` + - `getChannelPairingSnapshot` + - `createChannelPairCode` + - `clearChannelPairCode` + - `clearChannelBindings` + - `getRemoteOverview` +- Keep Telegram hook test API separate. + +## Renderer + +- Rebuild `RemoteSettings.vue` into: + - shared overview header + - Telegram tab + - Feishu tab +- Telegram tab keeps hooks UI. +- Feishu tab only shows remote-control related sections. +- Binding rows display endpoint badges (`DM`, `Group`, `Topic`) instead of raw endpoint keys only. + +## Telegram Fix + +- Restrict draft-stream text extraction to stable visible content blocks. +- When no visible draft-safe text exists, do not send draft updates; keep typing/final-message behavior only. + +## Testing + +- Extend config normalization tests for legacy Telegram-only data plus new Feishu config. +- Add presenter/runtime tests for Feishu settings, bindings, pairing, and runtime enable/disable. +- Add Telegram regression tests proving reasoning/tool-call/pending-action states do not call `sendMessageDraft`. +- Update renderer tests for tab layout, overview, and per-channel dialogs. diff --git a/docs/specs/remote-multi-channel/spec.md b/docs/specs/remote-multi-channel/spec.md new file mode 100644 index 000000000..66e6d6f8b --- /dev/null +++ b/docs/specs/remote-multi-channel/spec.md @@ -0,0 +1,49 @@ +# Remote Multi-Channel + +## Summary + +Extend the existing Remote settings and runtime from Telegram-only to a fixed two-channel model: Telegram and Feishu. Telegram keeps hook notifications, while Feishu adds remote control only. Both channels continue to bind one remote endpoint to one DeepChat session and reuse the existing detached-session flow in Electron main. + +This iteration also fixes the Telegram "deleted message" draft issue by preventing draft streaming from using reasoning-only, tool-call-only, or permission/question-request blocks as visible text. + +## User Stories + +- As a desktop user, I can configure Telegram and Feishu remote control from one Remote page without mixing their credentials and rules together. +- As a Telegram user, I can continue using the existing private-chat pairing flow and hook notifications. +- As a Feishu user, I can pair in a bot DM, then continue the same DeepChat session from DM, group chat, or topic thread. +- As an admin of the desktop app, I can see per-channel runtime health and binding counts from a shared overview area. +- As a paired Feishu user, I can trigger a remote session in group/topic only when I explicitly `@bot`. +- As a Telegram user, I no longer see bot replies visually attached to a deleted draft/reference while the assistant is only reasoning or issuing tool calls. + +## Acceptance Criteria + +- The Remote page renders a shared overview plus separate Telegram and Feishu tabs. +- Telegram settings continue to support bot token, remote pairing, allowlist, default agent, hook settings, and hook test actions. +- Feishu settings support app credentials, pairing, paired user management, default agent selection, and binding management. +- Feishu runtime runs in Electron main via WebSocket event subscription and does not require a renderer window. +- Feishu endpoints are keyed by `chatId + optional threadId`, with topic/thread replies isolated from the group root conversation. +- Feishu authorization requires DM pairing first; in groups/topics, only paired users who `@bot` may send commands or plain text to the bound session. +- `/pair`, `/new`, `/sessions`, `/use`, `/stop`, `/status`, `/open`, and `/model` work for Feishu remote control. +- Telegram `/model` continues to use inline keyboard menus; Feishu `/model` uses text commands only. +- Telegram draft streaming only uses stable visible content blocks. Reasoning-only, tool-call-only, and pending action-only assistant states never call `sendMessageDraft`. +- Existing local desktop chat behavior remains unchanged. + +## Constraints + +- Keep a fixed two-channel architecture. Do not introduce a generic plugin registry for remote channels. +- Telegram hook notifications remain under the shared Remote page; Feishu hook notifications are out of scope. +- Remote sessions continue to use the existing `RemoteConversationRunner` and detached session creation path. +- Feishu v1 supports DM, group, and topic/thread input; media upload, cards, approvals, and user-OAuth automation remain out of scope. + +## Non-Goals + +- A general remote channel SDK or third-party channel plugin system. +- Feishu user-OAuth flows, approval cards, or hook notifications. +- Rich Feishu card-based model switching. +- Telegram group chat support. + +## Compatibility + +- Existing `remoteControl.telegram` store data stays valid and is normalized into the new dual-channel config. +- Existing Telegram hook settings remain valid and continue to be saved through the Remote page. +- New Feishu-specific state is additive under `remoteControl.feishu`. diff --git a/docs/specs/remote-multi-channel/tasks.md b/docs/specs/remote-multi-channel/tasks.md new file mode 100644 index 000000000..5b3036eb2 --- /dev/null +++ b/docs/specs/remote-multi-channel/tasks.md @@ -0,0 +1,11 @@ +# Remote Multi-Channel Tasks + +1. Expand shared remote-control presenter types for channel-aware APIs and overview snapshots. +2. Extend `remoteControl` config normalization for Telegram + Feishu runtime state. +3. Update `RemoteBindingStore` to read/write both channels and persist endpoint metadata. +4. Add Feishu client/runtime/parser/auth/router files under `src/main/presenter/remoteControlPresenter/feishu/`. +5. Update `RemoteControlPresenter` to manage both runtimes and expose channel-aware IPC methods. +6. Update Telegram runtime draft gating so reasoning/tool-call/pending-action states never stream drafts. +7. Rebuild `RemoteSettings.vue` into overview + tabs and refresh the sidebar remote indicator. +8. Update main/renderer tests and add Feishu runtime coverage. +9. Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, and targeted Vitest suites. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d8955694c..54d47a976 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ optimizeDeps: { exclude: ['markstream-vue', 'stream-monaco'], include: [ + '@antv/infographic', 'monaco-editor', 'axios' ] diff --git a/package.json b/package.json index 1a98750ba..16abbc9d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DeepChat", - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.7", "description": "DeepChat,一个简单易用的 Agent 客户端", "main": "./out/main/index.js", "author": "ThinkInAIXYZ", @@ -19,8 +19,8 @@ "test:coverage": "vitest --coverage", "test:watch": "vitest --watch", "test:ui": "vitest --ui", - "format:check": "prettier --check .", - "format": "prettier --cache --write .", + "format:check": "oxfmt --check .", + "format": "oxfmt .", "lint": "pnpm run lint:agent-cleanup && oxlint .", "lint:agent-cleanup": "node scripts/agent-cleanup-guard.mjs", "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", @@ -62,42 +62,42 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.16.1", "@anthropic-ai/sdk": "^0.53.0", - "@antv/infographic": "^0.2.7", "@aws-sdk/client-bedrock": "^3.958.0", "@aws-sdk/client-bedrock-runtime": "^3.958.0", "@duckdb/node-api": "1.3.2-alpha.25", "@e2b/code-interpreter": "^1.5.1", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", - "@google/genai": "^1.34.0", + "@google/genai": "^1.46.0", "@jxa/run": "^1.4.0", - "@modelcontextprotocol/sdk": "^1.25.1", - "axios": "^1.13.2", + "@larksuiteoapi/node-sdk": "^1.60.0", + "@modelcontextprotocol/sdk": "^1.28.0", + "axios": "^1.13.6", "better-sqlite3-multiple-ciphers": "12.4.1", - "cheerio": "^1.1.2", + "cheerio": "^1.2.0", "chokidar": "^5.0.0", "compare-versions": "^6.1.1", "cross-spawn": "^7.0.6", - "diff": "^8.0.3", + "diff": "^8.0.4", "electron-log": "^5.4.3", "electron-store": "^8.2.0", - "electron-updater": "^6.6.2", + "electron-updater": "^6.8.3", "electron-window-state": "^5.0.3", "es-mime-types": "^0.1.4", "fflate": "^0.8.2", - "font-list": "^2.0.1", - "glob": "^13.0.0", + "font-list": "^2.0.2", + "glob": "^13.0.6", "gray-matter": "^4.0.3", "https-proxy-agent": "^7.0.6", - "jsonrepair": "^3.13.1", - "mammoth": "^1.11.0", - "nanoid": "^5.1.6", + "jsonrepair": "^3.13.3", + "mammoth": "^1.12.0", + "nanoid": "^5.1.7", "node-pty": "^1.1.0", "ollama": "^0.5.18", - "openai": "^6.32.0", + "openai": "^6.33.0", "pdf-parse-new": "^1.4.1", "run-applescript": "^7.1.0", - "safe-regex2": "^5.0.0", + "safe-regex2": "^5.1.0", "sharp": "^0.33.5", "together-ai": "^0.16.0", "tokenx": "^0.4.1", @@ -110,13 +110,13 @@ "devDependencies": { "@electron-toolkit/tsconfig": "^1.0.1", "@electron/notarize": "^3.1.1", - "@iconify-json/lucide": "^1.2.82", - "@iconify-json/vscode-icons": "^1.2.37", + "@iconify-json/lucide": "^1.2.99", + "@iconify-json/vscode-icons": "^1.2.45", "@iconify/vue": "^5.0.0", "@lingual/i18n-check": "0.8.12", "@pinia/colada": "^0.20.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.18", + "@tailwindcss/vite": "^4.2.2", "@tiptap/core": "^2.11.7", "@tiptap/extension-code-block": "^2.11.9", "@tiptap/extension-document": "^2.11.7", @@ -131,13 +131,13 @@ "@tiptap/vue-3": "^2.11.7", "@types/better-sqlite3": "^7.6.13", "@types/mime-types": "^3.0.1", - "@types/node": "^22.19.3", + "@types/node": "^22.19.15", "@types/xlsx": "^0.0.35", "@typescript/native-preview": "7.0.0-dev.20260115.1", "@unovis/ts": "1.6.4", "@unovis/vue": "1.6.4", "@vee-validate/zod": "^4.15.1", - "@vitejs/plugin-vue": "^6.0.3", + "@vitejs/plugin-vue": "^6.0.5", "@vitest/ui": "^3.2.4", "@vue/test-utils": "^2.4.6", "@vueuse/core": "^12.8.2", @@ -153,52 +153,50 @@ "electron-vite": "^4.0.1", "jsdom": "^26.1.0", "katex": "^0.16.27", - "lint-staged": "^16.2.7", + "lint-staged": "^16.4.0", "lucide-vue-next": "^0.544.0", "markstream-vue": "0.0.8-beta.1", - "mermaid": "^11.12.2", - "minimatch": "^10.1.1", + "mermaid": "^11.13.0", + "minimatch": "^10.2.4", "monaco-editor": "^0.52.2", - "oxlint": "^1.35.0", + "oxfmt": "^0.42.0", + "oxlint": "^1.57.0", "picocolors": "^1.1.1", "pinia": "^3.0.4", - "prettier": "^3.7.4", - "reka-ui": "^2.7.0", + "reka-ui": "^2.9.2", "simple-git-hooks": "^2.13.1", - "stream-monaco": "^0.0.15", - "tailwind-merge": "^3.4.0", + "stream-monaco": "^0.0.20", + "tailwind-merge": "^3.5.0", "tailwind-scrollbar-hide": "^4.0.0", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.2", "tailwindcss-animate": "^1.0.7", "tippy.js": "^6.3.7", "tw-animate-css": "^1.4.0", - "typescript": "^5.8.3", + "typescript": "^5.9.3", "vee-validate": "^4.15.1", - "vite": "^7.1.11", + "vite": "^7.3.1", "vite-plugin-monaco-editor-esm": "^2.0.2", - "vite-plugin-vue-devtools": "^8.0.5", - "vite-svg-loader": "^5.1.0", + "vite-plugin-vue-devtools": "^8.1.1", + "vite-svg-loader": "^5.1.1", "vitest": "^3.2.4", - "vue": "^3.5.26", - "vue-i18n": "^11.2.7", + "vue": "^3.5.31", + "vue-i18n": "^11.3.0", "vue-router": "4", "vue-sonner": "^2.0.9", "vue-tsgo": "0.0.1-yggdrasill.11", - "vue-virtual-scroller": "^2.0.0-beta.8", + "vue-virtual-scroller": "^2.0.0-beta.10", "vuedraggable": "^4.1.0", - "yaml": "^2.8.2", - "zod-to-json-schema": "^3.25.0" + "yaml": "^2.8.3", + "@antv/infographic": "^0.2.7", + "zod-to-json-schema": "^3.25.1" }, "simple-git-hooks": { "pre-commit": "node scripts/pre-commit.mjs", "commit-msg": "node scripts/verify-commit.js \"$1\"" }, "lint-staged": { - "*.js": [ - "prettier --write" - ], - "*.ts": [ - "prettier --parser=typescript --write" + "*.{js,ts}": [ + "oxfmt --no-error-on-unmatched-pattern" ] }, "pnpm": { diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 513eb435e..d0af1c912 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -184267,6 +184267,180 @@ "type": "chat" } ] + }, + + "novita": { + "id": "novita", + "name": "Novita AI", + "display_name": "Novita AI", + "api": "https://api.novita.ai/openai", + "doc": "https://novita.ai/docs", + "models": [ + { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "display_name": "Kimi K2.5", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-01-27", + "last_updated": "2026-01-27", + "cost": { + "input": 0.6, + "output": 3, + "cache_read": 0.1 + }, + "type": "chat" + }, + { + "id": "zai-org/glm-5", + "name": "GLM-5", + "display_name": "GLM-5", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 202800, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-02-11", + "last_updated": "2026-02-11", + "cost": { + "input": 1, + "output": 3.2, + "cache_read": 0.2 + }, + "type": "chat" + }, + { + "id": "minimax/minimax-m2.5", + "name": "MiniMax M2.5", + "display_name": "MiniMax M2.5", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 131100 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-02-12", + "last_updated": "2026-02-12", + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.03 + }, + "type": "chat" + }, + { + "id": "qwen/qwen3-embedding-0.6b", + "name": "Qwen3 Embedding 0.6B", + "display_name": "Qwen3 Embedding 0.6B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 1024 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "release_date": "2025-06-01", + "last_updated": "2025-06-01", + "cost": { + "input": 0.014 + }, + "type": "embedding" + } + ] } } -} \ No newline at end of file +} diff --git a/scripts/generate-i18n-types.js b/scripts/generate-i18n-types.js index 9e24b7c3a..5c212e7c7 100644 --- a/scripts/generate-i18n-types.js +++ b/scripts/generate-i18n-types.js @@ -1,6 +1,6 @@ import fs from 'fs' import path from 'path' -import { fileURLToPath } from 'url' +import { fileURLToPath, pathToFileURL } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -57,6 +57,6 @@ async function main() { } // 仅需要在本地开发时执行 -if (import.meta.url === `file://${process.argv[1]}`) { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main() } diff --git a/src/main/events.ts b/src/main/events.ts index 1d959c698..77da1c471 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -123,7 +123,8 @@ export const WINDOW_EVENTS = { export const SETTINGS_EVENTS = { READY: 'settings:ready', NAVIGATE: 'settings:navigate', - CHECK_FOR_UPDATES: 'settings:check-for-updates' + CHECK_FOR_UPDATES: 'settings:check-for-updates', + PROVIDER_INSTALL: 'settings:provider-install' } // ollama 相关事件 @@ -218,11 +219,6 @@ export const TRAY_EVENTS = { CHECK_FOR_UPDATES: 'tray:check-for-updates' // 托盘检查更新 } -// MCP会议专用事件 -export const MEETING_EVENTS = { - INSTRUCTION: 'mcp:meeting-instruction' // 主进程向渲染进程发送指令 -} - // 悬浮按钮相关事件 export const FLOATING_BUTTON_EVENTS = { CLICKED: 'floating-button:clicked', // 悬浮按钮被点击 @@ -230,6 +226,7 @@ export const FLOATING_BUTTON_EVENTS = { VISIBILITY_CHANGED: 'floating-button:visibility-changed', // 悬浮按钮显示状态改变 POSITION_CHANGED: 'floating-button:position-changed', // 悬浮按钮位置改变 ENABLED_CHANGED: 'floating-button:enabled-changed', // 悬浮按钮启用状态改变 + HOVER_STATE_CHANGED: 'floating-button:hover-state-changed', SNAPSHOT_REQUEST: 'floating-button:snapshot-request', SNAPSHOT_UPDATED: 'floating-button:snapshot-updated', LANGUAGE_REQUEST: 'floating-button:language-request', diff --git a/src/main/index.ts b/src/main/index.ts index 48a625422..77c0a4fe3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,12 @@ import log from 'electron-log' import { eventBus, SendTarget } from './eventbus' import { NOTIFICATION_EVENTS } from './events' import { registerWorkspacePreviewSchemes } from './presenter/workspacePresenter/workspacePreviewProtocol' +import { + findDeepLinkArg, + findStartupDeepLink, + isDeepLinkUrl, + storeStartupDeepLink +} from './lib/startupDeepLink' registerWorkspacePreviewSchemes() @@ -56,69 +62,78 @@ if (process.platform === 'darwin') { app.commandLine.appendSwitch('disable-features', 'DesktopCaptureMacV2,IOSurfaceCapturer') } -// Check for startup deeplink before any other initialization -let startupDeepLink: string | null = null +const gotSingleInstanceLock = app.requestSingleInstanceLock() +if (!gotSingleInstanceLock) { + console.log('Another DeepChat instance is already running. Exiting current process.') + app.quit() +} -console.log('Main process starting, checking for deeplink...') +// Initialize presenter after ready +let presenter: Presenter | undefined -// Check command line arguments for deeplink first +console.log('Main process starting, checking for deeplink...') console.log('Full command line arguments:', process.argv) -const deepLinkArg = process.argv.find((arg) => { - return arg.startsWith('deepchat://') || arg.includes('deepchat://') || arg.match(/^deepchat:/) -}) - -if (deepLinkArg) { - console.log('Found startup deeplink in command line:', deepLinkArg) - startupDeepLink = deepLinkArg +const startupDeepLink = findStartupDeepLink(process.argv, process.env) +if (startupDeepLink) { + console.log('Found startup deeplink during initialization:', startupDeepLink) + storeStartupDeepLink(startupDeepLink) } else { - console.log('No startup deeplink found in command line arguments') + console.log('No startup deeplink detected during initialization') } -// Check for deeplink in environment variables (macOS sometimes passes it this way) -const envDeepLink = process.env.DEEPLINK_URL || process.env.deepchat_deeplink -if (envDeepLink) { - console.log('Found deeplink in environment variables:', envDeepLink) - startupDeepLink = envDeepLink +const focusExistingAppWindow = () => { + const targetWindow = presenter?.windowPresenter.getAllWindows()[0] + if (!targetWindow || targetWindow.isDestroyed()) { + return + } + + if (targetWindow.isMinimized()) { + targetWindow.restore() + } + targetWindow.show() + targetWindow.focus() +} + +const routeIncomingDeeplink = (url: string, source: string) => { + if (!isDeepLinkUrl(url)) { + return + } + + console.log(`${source}:`, url) + const normalizedUrl = storeStartupDeepLink(url) + if (!normalizedUrl) { + return + } + + if (presenter && app.isReady()) { + void presenter.deeplinkPresenter.handleDeepLink(normalizedUrl) + } } // Listen for open-url events that might occur during startup // This must be set before app.whenReady() because open-url events can fire before that app.on('open-url', (event, url) => { event.preventDefault() - console.log('Received open-url event during startup:', url) - if (url.startsWith('deepchat://')) { - console.log('Setting startup deeplink from open-url event:', url) - startupDeepLink = url - process.env.STARTUP_DEEPLINK = url - } + routeIncomingDeeplink(url, 'Received open-url event') }) // Also listen for second-instance events (Windows/Linux) -app.on('second-instance', (_event, commandLine) => { - console.log('Received second-instance event with command line:', commandLine) - const deepLinkUrl = commandLine.find((arg) => arg.startsWith('deepchat://')) - if (deepLinkUrl) { - console.log('Found deeplink in second-instance command line:', deepLinkUrl) - startupDeepLink = deepLinkUrl - process.env.STARTUP_DEEPLINK = deepLinkUrl - } -}) - -// Store the startup deeplink for later use -if (startupDeepLink) { - console.log('Final startup deeplink detected:', startupDeepLink) - process.env.STARTUP_DEEPLINK = startupDeepLink -} else { - console.log('No startup deeplink detected during initialization') +if (gotSingleInstanceLock) { + app.on('second-instance', (_event, commandLine) => { + console.log('Received second-instance event with command line:', commandLine) + focusExistingAppWindow() + + const deepLinkUrl = findDeepLinkArg(commandLine) + if (deepLinkUrl) { + routeIncomingDeeplink(deepLinkUrl, 'Received second-instance deeplink') + } + }) } // Initialize lifecycle manager and register core hooks const lifecycleManager = new LifecycleManager() registerCoreHooks(lifecycleManager) -// Initialize presenter after ready -let presenter: Presenter - function clearPresenterPermissionCaches(activePresenter?: Presenter): void { if (!activePresenter) return diff --git a/src/main/lib/startupDeepLink.ts b/src/main/lib/startupDeepLink.ts new file mode 100644 index 000000000..20f170793 --- /dev/null +++ b/src/main/lib/startupDeepLink.ts @@ -0,0 +1,71 @@ +const STARTUP_DEEPLINK_ENV_KEY = 'STARTUP_DEEPLINK' +const SECONDARY_STARTUP_ENV_KEYS = ['DEEPLINK_URL', 'deepchat_deeplink'] as const +let pendingStartupDeepLink: string | null = null + +export const isDeepLinkUrl = (value: string | null | undefined): value is string => { + if (typeof value !== 'string') { + return false + } + + const normalized = value.trim() + return normalized.startsWith('deepchat://') || normalized.startsWith('deepchat:') +} + +export const normalizeDeepLinkUrl = (value: string): string => value.trim() + +export const findDeepLinkArg = (argv: readonly string[]): string | null => { + const matched = argv.find((arg) => isDeepLinkUrl(arg)) + return matched ? normalizeDeepLinkUrl(matched) : null +} + +export const readStartupDeepLinkFromEnv = (env: NodeJS.ProcessEnv = process.env): string | null => { + if (pendingStartupDeepLink) { + return pendingStartupDeepLink + } + + const stored = env[STARTUP_DEEPLINK_ENV_KEY] + return isDeepLinkUrl(stored) ? normalizeDeepLinkUrl(stored) : null +} + +export const findStartupDeepLink = ( + argv: readonly string[] = process.argv, + env: NodeJS.ProcessEnv = process.env +): string | null => { + const stored = readStartupDeepLinkFromEnv(env) + if (stored) { + return stored + } + + const deepLinkArg = findDeepLinkArg(argv) + if (deepLinkArg) { + return deepLinkArg + } + + for (const key of SECONDARY_STARTUP_ENV_KEYS) { + const value = env[key] + if (isDeepLinkUrl(value)) { + return normalizeDeepLinkUrl(value) + } + } + + return null +} + +export const storeStartupDeepLink = ( + url: string, + _env: NodeJS.ProcessEnv = process.env +): string | null => { + if (!isDeepLinkUrl(url)) { + return null + } + + const normalized = normalizeDeepLinkUrl(url) + pendingStartupDeepLink = normalized + return normalized +} + +export const consumeStartupDeepLink = (_env: NodeJS.ProcessEnv = process.env): string | null => { + const stored = pendingStartupDeepLink + pendingStartupDeepLink = null + return stored +} diff --git a/src/main/presenter/browser/BrowserTab.ts b/src/main/presenter/browser/BrowserTab.ts index 7e434cf23..4ef88a215 100644 --- a/src/main/presenter/browser/BrowserTab.ts +++ b/src/main/presenter/browser/BrowserTab.ts @@ -8,6 +8,10 @@ import { import { CDPManager } from './CDPManager' import { ScreenshotManager } from './ScreenshotManager' +const INTERACTIVE_READY_WAIT_TIMEOUT_MS = 2000 +const INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX = 'Timed out waiting for dom-ready:' +const NAVIGATION_CDP_METHODS = new Set(['Page.navigate', 'Page.reload']) + export class BrowserTab { readonly pageId: string readonly createdAt: number @@ -20,7 +24,10 @@ export class BrowserTab { private readonly cdpManager: CDPManager private readonly screenshotManager: ScreenshotManager private isAttached = false + private awaitingMainFrameInteractive = false private interactiveReady = false + private lastInteractiveAt: number | null = null + private loadingStartedAt: number | null = null private fullReady = false constructor( @@ -48,7 +55,7 @@ export class BrowserTab { } async navigate(url: string, timeoutMs?: number): Promise { - this.prepareForNavigation(url) + this.beginMainFrameNavigation(url) try { await this.withTimeout(this.webContents.loadURL(url), timeoutMs ?? 30000) this.title = this.webContents.getTitle() || url @@ -62,7 +69,7 @@ export class BrowserTab { } async navigateUntilDomReady(url: string, timeoutMs: number = 30000): Promise { - this.prepareForNavigation(url) + this.beginMainFrameNavigation(url) const loadPromise = this.webContents.loadURL(url) void loadPromise.catch((error) => { @@ -88,25 +95,51 @@ export class BrowserTab { } async extractDOM(selector?: string): Promise { - this.ensureInteractiveReady('extract DOM') + await this.ensureInteractiveReadyOrWait('extract DOM') const session = await this.ensureSession() return await this.cdpManager.getDOM(session, selector) } async evaluateScript(script: string): Promise { - this.ensureInteractiveReady('evaluate script') + await this.ensureInteractiveReadyOrWait('evaluate script') const session = await this.ensureSession() return await this.cdpManager.evaluateScript(session, script) } async sendCdpCommand(method: string, params?: Record): Promise { - this.ensureInteractiveReady(`send CDP command ${method}`) + if (NAVIGATION_CDP_METHODS.has(method)) { + this.ensureAvailable() + } else { + await this.ensureInteractiveReadyOrWait(`send CDP command ${method}`) + } + const session = await this.ensureSession() - return await session.sendCommand(method, params ?? {}) + const response = await session.sendCommand(method, params ?? {}) + + if (method === 'Page.navigate') { + const navigationResponse = response as { + loaderId?: string + errorText?: string + } + const hasCommittedCrossDocumentNavigation = + typeof navigationResponse.loaderId === 'string' && + navigationResponse.loaderId.trim() !== '' && + !navigationResponse.errorText + + if (hasCommittedCrossDocumentNavigation) { + this.beginMainFrameNavigation( + typeof params?.url === 'string' && params.url.trim() ? params.url : this.url + ) + } + } else if (method === 'Page.reload') { + this.beginMainFrameNavigation(this.url) + } + + return response } async takeScreenshot(options?: ScreenshotOptions): Promise { - this.ensureInteractiveReady('capture screenshot') + await this.ensureInteractiveReadyOrWait('capture screenshot') await this.ensureSession() this.ensureAvailable() @@ -491,7 +524,7 @@ export class BrowserTab { private async evaluate(fn: (...args: any[]) => T, ...args: any[]): Promise { this.ensureAvailable() - this.ensureInteractiveReady('evaluate script') + await this.ensureInteractiveReadyOrWait('evaluate script') const session = await this.ensureSession() const serializedArgs = JSON.stringify(args, (_key, value) => value === undefined ? null : value @@ -507,23 +540,35 @@ export class BrowserTab { } } - private ensureInteractiveReady(action: string): void { + private async ensureInteractiveReadyOrWait( + action: string, + timeoutMs: number = INTERACTIVE_READY_WAIT_TIMEOUT_MS + ): Promise { this.ensureAvailable() if (this.interactiveReady) { return } - const error = new Error( - `YoBrowser page is not ready to ${action}. Retry this request. url=${this.url} status=${this.status}` - ) - error.name = 'YoBrowserNotReadyError' - Object.assign(error, { - retryable: true, - url: this.url, - status: this.status - }) - throw error + if (this.awaitingMainFrameInteractive || this.status === BrowserPageStatus.Loading) { + try { + await this.waitForInteractiveReady(timeoutMs) + } catch (error) { + if (!this.isInteractiveReadyTimeoutError(error)) { + throw error + } + } + + if (this.interactiveReady) { + return + } + + if (await this.probeInteractiveReadiness()) { + return + } + } + + throw this.buildNotReadyError(action) } private validateKeyInput(key: string, count: number): string { @@ -693,17 +738,22 @@ export class BrowserTab { return this.webContents.debugger } - private prepareForNavigation(url: string): void { + private beginMainFrameNavigation(url: string): void { + const now = Date.now() this.url = url + this.awaitingMainFrameInteractive = true this.interactiveReady = false this.fullReady = false + this.loadingStartedAt = now this.status = BrowserPageStatus.Loading - this.updatedAt = Date.now() + this.updatedAt = now } private markNavigationError(error: unknown): void { + this.awaitingMainFrameInteractive = false this.interactiveReady = false this.fullReady = false + this.loadingStartedAt = null this.status = BrowserPageStatus.Error this.updatedAt = Date.now() console.error(`[YoBrowser][${this.pageId}] navigation failed`, { @@ -759,7 +809,7 @@ export class BrowserTab { timeoutId = setTimeout(() => { cleanup() - reject(new Error(`Timed out waiting for dom-ready: ${this.url}`)) + reject(new Error(`${INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX} ${this.url}`)) }, timeoutMs) this.webContents.once('dom-ready', onDomReady) @@ -768,6 +818,68 @@ export class BrowserTab { }) } + private isInteractiveReadyTimeoutError(error: unknown): error is Error { + return ( + error instanceof Error && error.message.startsWith(INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX) + ) + } + + private async probeInteractiveReadiness(): Promise { + try { + const session = await this.ensureSession() + const probe = (await this.cdpManager.evaluateScript( + session, + `(() => { + try { + return { + readyState: document.readyState, + hasBody: Boolean(document.body), + href: location.href + } + } catch { + return null + } + })()` + )) as { readyState?: unknown; hasBody?: unknown; href?: unknown } | null + + const readyState = typeof probe?.readyState === 'string' ? probe.readyState : '' + const hasBody = probe?.hasBody === true + if (readyState !== 'interactive' && readyState !== 'complete') { + return false + } + + if (!hasBody) { + return false + } + + this.awaitingMainFrameInteractive = false + this.interactiveReady = true + this.lastInteractiveAt = Date.now() + this.updatedAt = this.lastInteractiveAt + if (typeof probe?.href === 'string' && probe.href) { + this.url = probe.href + } + return true + } catch { + return false + } + } + + private buildNotReadyError(action: string): Error { + const error = new Error( + `YoBrowser page is not ready to ${action}. Retry this request. url=${this.url} status=${this.status}` + ) + error.name = 'YoBrowserNotReadyError' + Object.assign(error, { + retryable: true, + url: this.url, + status: this.status, + lastInteractiveAt: this.lastInteractiveAt, + loadingStartedAt: this.loadingStartedAt + }) + return error + } + private async withTimeout(promise: Promise, timeoutMs: number): Promise { return await new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { @@ -788,28 +900,59 @@ export class BrowserTab { } private bindLifecycleEvents(): void { + this.webContents.on('did-start-navigation', (details) => { + if (!details.isMainFrame || details.isSameDocument) { + return + } + + this.beginMainFrameNavigation(details.url || this.url) + }) + + this.webContents.on('did-navigate-in-page', (_event, url: string, isMainFrame: boolean) => { + if (!isMainFrame) { + return + } + + this.url = url || this.url + this.updatedAt = Date.now() + }) + this.webContents.on('did-start-loading', () => { - this.interactiveReady = false - this.fullReady = false + this.loadingStartedAt = Date.now() this.status = BrowserPageStatus.Loading - this.updatedAt = Date.now() + this.updatedAt = this.loadingStartedAt }) this.webContents.on('dom-ready', () => { + const now = Date.now() + this.awaitingMainFrameInteractive = false this.interactiveReady = true - this.updatedAt = Date.now() + this.lastInteractiveAt = now + this.updatedAt = now console.info(`[YoBrowser][${this.pageId}] page dom-ready`, { url: this.url, status: this.status }) }) + this.webContents.on('did-stop-loading', () => { + this.loadingStartedAt = null + if (this.interactiveReady) { + this.fullReady = true + this.status = BrowserPageStatus.Ready + } + this.updatedAt = Date.now() + }) + this.webContents.on('did-finish-load', () => { + const now = Date.now() + this.awaitingMainFrameInteractive = false this.interactiveReady = true + this.lastInteractiveAt = now this.fullReady = true this.status = BrowserPageStatus.Ready this.title = this.webContents.getTitle() || this.url - this.updatedAt = Date.now() + this.updatedAt = now console.info(`[YoBrowser][${this.pageId}] page did-finish-load`, { url: this.url, status: this.status @@ -830,8 +973,10 @@ export class BrowserTab { } this.url = validatedURL || this.url + this.awaitingMainFrameInteractive = false this.interactiveReady = false this.fullReady = false + this.loadingStartedAt = null this.status = BrowserPageStatus.Error this.updatedAt = Date.now() console.error(`[YoBrowser][${this.pageId}] navigation failed`, { diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index 325878d69..3cb3b884e 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -735,7 +735,7 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { canGoBack: state.page.contents.navigationHistory.canGoBack(), canGoForward: state.page.contents.navigationHistory.canGoForward(), visible: state.visible, - loading: state.page.status === BrowserPageStatus.Loading + loading: state.page.contents.isLoading() || state.page.status === BrowserPageStatus.Loading } } diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 12a9a7fb9..353f3ac99 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -102,7 +102,7 @@ interface IAppSettings { enableSkills?: boolean // Skills system global toggle hooksNotifications?: HooksNotificationsSettings // Hooks & notifications settings defaultModel?: { providerId: string; modelId: string } // Default model for new conversations - defaultVisionModel?: { providerId: string; modelId: string } // Default vision model for image tools + defaultVisionModel?: { providerId: string; modelId: string } // Legacy vision model setting for migration only defaultProjectPath?: string | null acpRegistryMigrationVersion?: number unifiedAgentsMigrationVersion?: number @@ -153,6 +153,15 @@ const isModelSelection = (value: unknown): value is ModelSelection => { return typeof record.providerId === 'string' && typeof record.modelId === 'string' } +const normalizeKnownModelId = (modelId: string): string => { + const normalizedModelId = modelId.trim().toLowerCase() + return normalizedModelId.replace(/^models\//, '') +} + +const normalizeKnownProviderId = (providerId: string): string => + modelCapabilities.resolveProviderId(providerId.trim().toLowerCase()) || + providerId.trim().toLowerCase() + export const getAnthropicModelSelectionKeysToClear = ( settings: Partial< Record< @@ -362,6 +371,7 @@ export class ConfigPresenter implements IConfigPresenter { setAgentRepository(agentRepository: AgentRepository): void { this.agentRepository = agentRepository this.initializeUnifiedAgents() + this.migrateLegacyDefaultVisionModelToBuiltinAgent() } private getAgentRepositoryOrThrow(): AgentRepository { @@ -396,6 +406,35 @@ export class ConfigPresenter implements IConfigPresenter { this.syncRegistryAgentsToRepository() } + private migrateLegacyDefaultVisionModelToBuiltinAgent(): void { + const legacySelection = this.store.get('defaultVisionModel') as unknown + if (legacySelection === undefined) { + return + } + + const builtinVisionModel = this.getBuiltinDeepChatConfig().visionModel + + if ( + isModelSelection(legacySelection) && + (!builtinVisionModel?.providerId || !builtinVisionModel?.modelId) + ) { + const providerId = legacySelection.providerId.trim() + const modelId = legacySelection.modelId.trim() + + if (providerId && modelId) { + this.updateBuiltinDeepChatConfig({ + visionModel: { + providerId, + modelId + } + }) + } + } + + this.store.delete('defaultVisionModel') + eventBus.sendToMain(CONFIG_EVENTS.SETTING_CHANGED, 'defaultVisionModel', undefined) + } + private buildLegacyBuiltinDeepChatConfig(): DeepChatAgentConfig { const defaultModel = this.store.get('defaultModel') as ModelSelection | undefined const assistantModel = this.store.get('assistantModel') as ModelSelection | undefined @@ -760,7 +799,9 @@ export class ConfigPresenter implements IConfigPresenter { const keysToClear = getAnthropicModelSelectionKeysToClear({ defaultModel: this.getSetting('defaultModel'), assistantModel: this.getSetting('assistantModel'), - defaultVisionModel: this.getSetting('defaultVisionModel'), + defaultVisionModel: this.store.get('defaultVisionModel') as + | { providerId: string; modelId: string } + | undefined, preferredModel: this.getSetting('preferredModel') }) @@ -780,9 +821,6 @@ export class ConfigPresenter implements IConfigPresenter { if (key === 'assistantModel') { return this.getBuiltinDeepChatConfig().assistantModel as T | undefined } - if (key === 'defaultVisionModel') { - return this.getDefaultVisionModel() as T | undefined - } if (key === 'default_system_prompt') { return this.getBuiltinDeepChatConfig().systemPrompt as T | undefined } @@ -808,10 +846,6 @@ export class ConfigPresenter implements IConfigPresenter { eventBus.sendToMain(CONFIG_EVENTS.SETTING_CHANGED, key, value) return } - if (key === 'defaultVisionModel') { - this.setDefaultVisionModel(value as { providerId: string; modelId: string } | undefined) - return - } if (key === 'default_system_prompt') { this.updateBuiltinDeepChatConfig({ systemPrompt: typeof value === 'string' ? value : '' @@ -1015,6 +1049,26 @@ export class ConfigPresenter implements IConfigPresenter { return this.providerModelHelper.getCustomModels(providerId) } + isKnownModel(providerId: string, modelId: string): boolean { + const normalizedProviderId = normalizeKnownProviderId(providerId) + const normalizedModelId = normalizeKnownModelId(modelId) + + if (!normalizedProviderId || !normalizedModelId) { + return false + } + + const hasKnownModel = (models: Array<{ id: string }> | undefined): boolean => + Array.isArray(models) && + models.some((model) => normalizeKnownModelId(model.id) === normalizedModelId) + + return ( + this.hasUserModelConfig(normalizedModelId, normalizedProviderId) || + hasKnownModel(this.getProviderModels(normalizedProviderId)) || + hasKnownModel(this.getCustomModels(normalizedProviderId)) || + hasKnownModel(this.getDbProviderModels(normalizedProviderId)) + ) + } + setCustomModels(providerId: string, models: MODEL_META[]): void { this.providerModelHelper.setCustomModels(providerId, models) } @@ -1688,6 +1742,18 @@ export class ConfigPresenter implements IConfigPresenter { ) } + async agentSupportsCapability(agentId: string, capability: 'vision'): Promise { + if (capability !== 'vision') { + return false + } + + const agentConfig = await this.resolveDeepChatAgentConfig(agentId) + const providerId = agentConfig.visionModel?.providerId?.trim() + const modelId = agentConfig.visionModel?.modelId?.trim() + + return Boolean(providerId && modelId && this.getModelConfig(modelId, providerId)?.vision) + } + async createDeepChatAgent(input: CreateDeepChatAgentInput): Promise { const created = this.getAgentRepositoryOrThrow().createDeepChatAgent(input) this.notifyAcpAgentsChanged() @@ -2312,32 +2378,6 @@ export class ConfigPresenter implements IConfigPresenter { eventBus.sendToMain(CONFIG_EVENTS.SETTING_CHANGED, 'defaultModel', model) } - getDefaultVisionModel(): { providerId: string; modelId: string } | undefined { - const selection = this.getBuiltinDeepChatConfig().visionModel - if (selection?.providerId && selection?.modelId) { - return { - providerId: selection.providerId, - modelId: selection.modelId - } - } - return this.store.get('defaultVisionModel') as - | { providerId: string; modelId: string } - | undefined - } - - setDefaultVisionModel(model: { providerId: string; modelId: string } | undefined): void { - this.updateBuiltinDeepChatConfig({ - visionModel: - model?.providerId && model?.modelId - ? { - providerId: model.providerId, - modelId: model.modelId - } - : null - }) - eventBus.sendToMain(CONFIG_EVENTS.SETTING_CHANGED, 'defaultVisionModel', model) - } - getDefaultProjectPath(): string | null { const path = this.getSetting('defaultProjectPath') return path?.trim() ? path.trim() : null diff --git a/src/main/presenter/configPresenter/mcpConfHelper.ts b/src/main/presenter/configPresenter/mcpConfHelper.ts index b3c5f0a5b..4fd1a2543 100644 --- a/src/main/presenter/configPresenter/mcpConfHelper.ts +++ b/src/main/presenter/configPresenter/mcpConfHelper.ts @@ -165,16 +165,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> }, disable: false }, - imageServer: { - args: [], - descriptions: 'Image processing MCP service', - icons: '🖼️', - autoApprove: ['read_image_base64', 'read_multiple_images_base64'], // Auto-approve reading, require confirmation for uploads - type: 'inmemory' as MCPServerType, - command: 'image', // We need to map this command to the ImageServer class later - env: {}, - disable: false - }, ragflowKnowledge: { args: [], descriptions: 'DeepChat内置RAGFlow知识库检索服务', @@ -258,16 +248,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> env: {}, disable: false }, - 'deepchat-inmemory/meeting-server': { - args: [], - descriptions: 'DeepChat内置会议服务,用于组织多Agent讨论', - icons: '👥', - autoApprove: ['all'], - type: 'inmemory' as MCPServerType, - command: 'deepchat-inmemory/meeting-server', - env: {}, - disable: false - }, // Merge platform-specific services ...PLATFORM_SPECIFIC_SERVERS } @@ -384,15 +364,35 @@ export class McpConfHelper { private removeDeprecatedBuiltInServers( servers: Record ): Record { - const deprecatedBuiltInServers = ['powerpack'] + const deprecatedBuiltInServers = [ + 'powerpack', + 'deepchat-inmemory/meeting-server', + 'imageServer' + ] + let hasChanges = false + const removedBuiltInServers = new Set(this.getRemovedBuiltInServers()) + let removedListChanged = false for (const serverName of deprecatedBuiltInServers) { if (servers[serverName]) { console.log(`Removing deprecated built-in MCP service: ${serverName}`) delete servers[serverName] + hasChanges = true + } + + if (removedBuiltInServers.delete(serverName)) { + removedListChanged = true } } + if (hasChanges) { + this.mcpStore.set('mcpServers', servers) + } + + if (removedListChanged) { + this.setRemovedBuiltInServers(Array.from(removedBuiltInServers)) + } + return servers } @@ -913,15 +913,9 @@ export class McpConfHelper { } try { - const mcpServers = this.mcpStore.get('mcpServers') || {} - - if (mcpServers.powerpack) { - console.log('Removing deprecated powerpack MCP server') - delete mcpServers.powerpack - this.mcpStore.set('mcpServers', mcpServers) - } + this.removeDeprecatedBuiltInServers(this.mcpStore.get('mcpServers') || {}) } catch (error) { - console.error('Error occurred while removing deprecated powerpack server:', error) + console.error('Error occurred while removing deprecated built-in MCP servers:', error) } // 升级后检查并添加平台特有服务 diff --git a/src/main/presenter/configPresenter/providers.ts b/src/main/presenter/configPresenter/providers.ts index 13bcc84ed..dc1b89c74 100644 --- a/src/main/presenter/configPresenter/providers.ts +++ b/src/main/presenter/configPresenter/providers.ts @@ -776,5 +776,20 @@ export const DEFAULT_PROVIDERS: LLM_PROVIDER_BASE[] = [ models: 'https://o3.fan/info/models', defaultBaseUrl: 'https://api.o3.fan/v1' } + }, + { + id: 'novita', + name: 'Novita AI', + apiType: 'openai-completions', + apiKey: '', + baseUrl: 'https://api.novita.ai/openai', + enable: false, + websites: { + official: 'https://novita.ai/', + apiKey: 'https://novita.ai/', + docs: 'https://novita.ai/docs', + models: 'https://novita.ai/models', + defaultBaseUrl: 'https://api.novita.ai/openai' + } } ] diff --git a/src/main/presenter/deepchatAgentPresenter/dispatch.ts b/src/main/presenter/deepchatAgentPresenter/dispatch.ts index 6874379db..37fdd809e 100644 --- a/src/main/presenter/deepchatAgentPresenter/dispatch.ts +++ b/src/main/presenter/deepchatAgentPresenter/dispatch.ts @@ -679,6 +679,20 @@ export async function executeTools( } } + if (hooks?.normalizeToolResult) { + toolRawData = { + ...toolRawData, + content: await hooks.normalizeToolResult({ + sessionId: io.sessionId, + toolCallId: tc.id, + toolName: tc.name, + toolArgs: tc.arguments, + content: toolRawData.content, + isError: toolRawData.isError === true + }) + } + } + const searchPayload = extractSearchPayload( toolRawData.content, toolContext.name, diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts index 6971a592e..eaf527c24 100644 --- a/src/main/presenter/deepchatAgentPresenter/index.ts +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -52,6 +52,7 @@ import { ToolOutputGuard } from './toolOutputGuard' import type { ProviderRequestTracePayload } from '../llmProviderPresenter/requestTrace' import type { NewSessionHooksBridge } from '../hooksNotifications/newSessionBridge' import { providerDbLoader } from '../configPresenter/providerDbLoader' +import { resolveSessionVisionTarget } from '../vision/sessionVisionResolver' type PendingInteractionEntry = { interaction: PendingToolInteraction @@ -107,6 +108,16 @@ const isReasoningEffort = (value: unknown): value is 'minimal' | 'low' | 'medium const isVerbosity = (value: unknown): value is 'low' | 'medium' | 'high' => value === 'low' || value === 'medium' || value === 'high' +const createAbortError = (): Error => { + if (typeof DOMException !== 'undefined') { + return new DOMException('Aborted', 'AbortError') + } + + const error = new Error('Aborted') + error.name = 'AbortError' + return error +} + export class DeepChatAgentPresenter implements IAgentImplementation { private readonly llmProviderPresenter: ILlmProviderPresenter private readonly configPresenter: IConfigPresenter @@ -1011,6 +1022,23 @@ export class DeepChatAgentPresenter implements IAgentImplementation { return undefined } + private getAbortSignalForSession(sessionId: string): AbortSignal | undefined { + return ( + this.activeGenerations.get(sessionId)?.abortController.signal ?? + this.abortControllers.get(sessionId)?.signal + ) + } + + private throwIfAbortRequested(signal?: AbortSignal): void { + if (signal?.aborted) { + throw createAbortError() + } + } + + private isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || error.name === 'CanceledError') + } + private dispatchResolvedToolHook(params: { sessionId: string messageId: string @@ -1424,7 +1452,17 @@ export class DeepChatAgentPresenter implements IAgentImplementation { body: gap } }) - } + }, + normalizeToolResult: async (tool) => + await this.normalizeToolResultContent({ + sessionId: tool.sessionId, + toolCallId: tool.toolCallId, + toolName: tool.toolName, + toolArgs: tool.toolArgs, + content: tool.content, + isError: tool.isError, + abortSignal: abortController.signal + }) }, io: { sessionId, @@ -2867,7 +2905,16 @@ export class DeepChatAgentPresenter implements IAgentImplementation { permissionRequest: rawData.permissionRequest as PendingToolInteraction['permission'] } } - const responseText = this.toolContentToText(rawData.content) + const normalizedContent = await this.normalizeToolResultContent({ + sessionId, + toolCallId: toolCall.id || '', + toolName, + toolArgs: toolCall.params || '{}', + content: rawData.content, + isError: rawData.isError === true, + abortSignal: this.getAbortSignalForSession(sessionId) + }) + const responseText = this.toolContentToText(normalizedContent) const prepared = await this.toolOutputGuard.prepareToolOutput({ sessionId, toolCallId: toolCall.id || '', @@ -2956,6 +3003,199 @@ export class DeepChatAgentPresenter implements IAgentImplementation { }) } + private async normalizeToolResultContent(params: { + sessionId: string + toolCallId: string + toolName: string + toolArgs: string + content: MCPToolResponse['content'] + isError: boolean + abortSignal?: AbortSignal + }): Promise { + if (params.isError) { + return params.content + } + + const abortSignal = params.abortSignal ?? this.getAbortSignalForSession(params.sessionId) + const screenshotPayload = this.extractScreenshotToolPayload( + params.toolName, + params.toolArgs, + params.content + ) + if (!screenshotPayload) { + return params.content + } + + try { + this.throwIfAbortRequested(abortSignal) + const visionModel = await this.resolveScreenshotVisionModel(params.sessionId, abortSignal) + this.throwIfAbortRequested(abortSignal) + + if (!visionModel) { + return 'Screenshot captured, but automatic English analysis is unavailable because neither the current session model nor the agent vision model can analyze images.' + } + + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { + type: 'text', + text: this.buildScreenshotAnalysisPrompt() + }, + { + type: 'image_url', + image_url: { + url: screenshotPayload.dataUrl, + detail: 'auto' + } + } + ] + } + ] + + const modelConfig = this.configPresenter.getModelConfig( + visionModel.modelId, + visionModel.providerId + ) + const response = await this.llmProviderPresenter.generateCompletionStandalone( + visionModel.providerId, + messages, + visionModel.modelId, + modelConfig?.temperature ?? 0.2, + Math.min(modelConfig?.maxTokens ?? 900, 900), + abortSignal ? { signal: abortSignal } : undefined + ) + this.throwIfAbortRequested(abortSignal) + const normalized = response.trim() + if (!normalized) { + return 'Screenshot captured, but automatic English analysis returned no usable description.' + } + return normalized + } catch (error) { + if (this.isAbortError(error)) { + return 'Screenshot captured, but automatic English analysis was canceled.' + } + + const message = error instanceof Error ? error.message : String(error) + console.warn('[DeepChatAgent] Failed to normalize screenshot tool output:', { + sessionId: params.sessionId, + toolCallId: params.toolCallId, + error: message + }) + return `Screenshot captured, but automatic English analysis failed: ${message}` + } + } + + private extractScreenshotToolPayload( + toolName: string, + toolArgs: string, + content: MCPToolResponse['content'] + ): { dataUrl: string } | null { + if (toolName !== 'cdp_send' || typeof content !== 'string') { + return null + } + + const parsedArgs = this.parseJsonRecord(toolArgs) + if (!parsedArgs || parsedArgs.method !== 'Page.captureScreenshot') { + return null + } + + const parsedContent = this.parseJsonRecord(content) + const rawData = typeof parsedContent?.data === 'string' ? parsedContent.data.trim() : '' + if (!rawData) { + return null + } + + const screenshotParams = this.normalizeJsonRecord(parsedArgs.params) + const mimeType = this.resolveScreenshotMimeType(screenshotParams?.format) + const dataUrl = rawData.startsWith('data:image/') + ? rawData + : `data:${mimeType};base64,${rawData}` + + return { dataUrl } + } + + private normalizeJsonRecord(value: unknown): Record | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + + if (typeof value !== 'string' || !value.trim()) { + return null + } + + return this.parseJsonRecord(value) + } + + private parseJsonRecord(value: string): Record | null { + try { + const parsed = JSON.parse(value) as unknown + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record + } + } catch {} + + return null + } + + private resolveScreenshotMimeType(format: unknown): string { + if (format === 'jpeg') { + return 'image/jpeg' + } + if (format === 'webp') { + return 'image/webp' + } + return 'image/png' + } + + private async resolveScreenshotVisionModel( + sessionId: string, + abortSignal?: AbortSignal + ): Promise<{ providerId: string; modelId: string } | null> { + this.throwIfAbortRequested(abortSignal) + const state = this.runtimeState.get(sessionId) + const dbSession = this.sessionStore.get(sessionId) + const agentId = this.getSessionAgentId(sessionId) ?? 'deepchat' + const resolved = await resolveSessionVisionTarget({ + providerId: state?.providerId ?? dbSession?.provider_id, + modelId: state?.modelId ?? dbSession?.model_id, + agentId, + configPresenter: this.configPresenter, + signal: abortSignal, + logLabel: `screenshot:${sessionId}` + }) + this.throwIfAbortRequested(abortSignal) + + if (!resolved) { + return null + } + + if (resolved.source === 'agent-vision-model') { + const agentSupportsVision = + (await this.configPresenter.agentSupportsCapability?.(agentId, 'vision')) === true + this.throwIfAbortRequested(abortSignal) + if (!agentSupportsVision) { + return null + } + } + + return { + providerId: resolved.providerId, + modelId: resolved.modelId + } + } + + private buildScreenshotAnalysisPrompt(): string { + return [ + 'Analyze this browser screenshot and respond in English only.', + 'Describe only what is clearly visible.', + 'Include the page type or layout, the most important visible text, interactive controls, status indicators, warnings, errors, and any detail that matters for the next browser action.', + 'Do not speculate about hidden or unreadable content.', + 'Return detailed plain text in a single paragraph.' + ].join('\n') + } + private toolContentToText(content: MCPToolResponse['content']): string { if (typeof content === 'string') { return content diff --git a/src/main/presenter/deepchatAgentPresenter/types.ts b/src/main/presenter/deepchatAgentPresenter/types.ts index 785ae76e1..df53d0936 100644 --- a/src/main/presenter/deepchatAgentPresenter/types.ts +++ b/src/main/presenter/deepchatAgentPresenter/types.ts @@ -6,7 +6,7 @@ import type { } from '@shared/types/agent-interface' import type { LLMCoreStreamEvent } from '@shared/types/core/llm-events' import type { ChatMessage } from '@shared/types/core/chat-message' -import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { MCPToolDefinition, MCPToolResponse } from '@shared/types/core/mcp' import type { ModelConfig } from '@shared/presenter' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import type { DeepChatMessageStore } from './messageStore' @@ -76,6 +76,14 @@ export interface ProcessHooks { reasoningContentLength: number toolCallCount: number }) => void + normalizeToolResult?: (tool: { + sessionId: string + toolCallId: string + toolName: string + toolArgs: string + content: MCPToolResponse['content'] + isError: boolean + }) => Promise } export interface PendingToolInteraction { diff --git a/src/main/presenter/deeplinkPresenter/index.ts b/src/main/presenter/deeplinkPresenter/index.ts index 40fe04541..abc34f1f9 100644 --- a/src/main/presenter/deeplinkPresenter/index.ts +++ b/src/main/presenter/deeplinkPresenter/index.ts @@ -2,8 +2,22 @@ import { app, BrowserWindow } from 'electron' import { presenter } from '@/presenter' import { IDeeplinkPresenter, MCPServerConfig } from '@shared/presenter' import path from 'path' -import { DEEPLINK_EVENTS, MCP_EVENTS, WINDOW_EVENTS } from '@/events' +import { + NOTIFICATION_EVENTS, + SETTINGS_EVENTS, + DEEPLINK_EVENTS, + MCP_EVENTS, + WINDOW_EVENTS +} from '@/events' import { eventBus, SendTarget } from '@/eventbus' +import { consumeStartupDeepLink } from '@/lib/startupDeepLink' +import { + PROVIDER_INSTALL_VERSION, + isProviderInstallCustomType, + maskApiKey, + type ProviderInstallDeeplinkPayload, + type ProviderInstallPreview +} from '@shared/providerDeeplink' interface MCPInstallConfig { mcpServers: Record< @@ -35,10 +49,9 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { private pendingMcpInstallUrl: string | null = null init(): void { - // 检查启动时的命令行参数是否包含deeplink URL(冷启动情况) - const startupDeepLinkUrl = this.checkStartupDeepLink() + const startupDeepLinkUrl = consumeStartupDeepLink() if (startupDeepLinkUrl) { - console.log('Found startup deeplink URL:', startupDeepLinkUrl) + console.log('Found startup deeplink URL:', this.redactDeepLinkUrlForLog(startupDeepLinkUrl)) this.startupUrl = startupDeepLinkUrl } @@ -53,25 +66,12 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { app.setAsDefaultProtocolClient('deepchat') } - // 处理 macOS 上协议被调用的情况 - app.on('open-url', (event, url) => { - event.preventDefault() - console.log('open-url event received:', url) - if (!app.isReady()) { - console.log('App not ready yet, saving URL:', url) - this.startupUrl = url - } else { - console.log('App is ready, checking URL:', url) - this.processDeepLink(url) - } - }) - // 监听窗口内容加载完成事件 eventBus.once(WINDOW_EVENTS.FIRST_CONTENT_LOADED, () => { console.log('Window content loaded. Processing DeepLink if exists.') if (this.startupUrl) { - console.log('Processing startup URL:', this.startupUrl) - this.processDeepLink(this.startupUrl) + console.log('Processing startup URL:', this.redactDeepLinkUrlForLog(this.startupUrl)) + void this.handleDeepLink(this.startupUrl) this.startupUrl = null } }) @@ -80,130 +80,55 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { eventBus.on(MCP_EVENTS.INITIALIZED, () => { console.log('MCP initialized. Processing pending MCP install if exists.') if (this.pendingMcpInstallUrl) { - console.log('Processing pending MCP install URL:', this.pendingMcpInstallUrl) - this.handleDeepLink(this.pendingMcpInstallUrl) + console.log( + 'Processing pending MCP install URL:', + this.redactDeepLinkUrlForLog(this.pendingMcpInstallUrl) + ) + void this.handleDeepLink(this.pendingMcpInstallUrl) this.pendingMcpInstallUrl = null } }) - - // 处理 Windows 上协议被调用的情况 - const gotTheLock = app.requestSingleInstanceLock() - if (!gotTheLock) { - app.quit() // Exit trigger: Second instance - } else { - app.on('second-instance', (_event, commandLine) => { - // 用户尝试运行第二个实例,我们应该聚焦到我们的窗口 - if (presenter.windowPresenter.mainWindow) { - if (presenter.windowPresenter.mainWindow.isMinimized()) { - presenter.windowPresenter.mainWindow.restore() - } - presenter.windowPresenter.mainWindow.show() - presenter.windowPresenter.mainWindow.focus() - } - if (process.platform === 'win32') { - // 在 Windows 上,命令行参数包含协议 URL - const deepLinkUrl = commandLine.find((arg) => arg.startsWith('deepchat://')) - if (deepLinkUrl) { - if (!app.isReady()) { - console.log('Windows: App not ready yet, saving URL:', deepLinkUrl) - this.startupUrl = deepLinkUrl - } else { - console.log('Windows: App is ready, checking URL:', deepLinkUrl) - this.processDeepLink(deepLinkUrl) - } - } - } - }) - } - } - - // 新增:处理DeepLink的方法,根据URL类型和系统状态决定如何处理 - private processDeepLink(url: string): void { - console.log('processDeepLink called with URL:', url) - try { - const urlObj = new URL(url) - const command = urlObj.hostname - const subCommand = urlObj.pathname.slice(1) - - console.log('Parsed deeplink - command:', command, 'subCommand:', subCommand) - - // 如果是MCP安装命令,需要等待MCP初始化完成 - if (command === 'mcp' && subCommand === 'install') { - console.log('MCP install deeplink detected') - if (!presenter.mcpPresenter.isReady()) { - console.log('MCP not ready yet, saving MCP install URL for later') - this.pendingMcpInstallUrl = url - return - } else { - console.log('MCP is ready, processing MCP install immediately') - } - } - - // 其他类型的DeepLink或MCP已初始化完成,直接处理 - this.handleDeepLink(url) - } catch (error) { - console.error('Error processing DeepLink:', error) - } - } - - /** - * 检查启动时的deeplink URL - * 用于处理冷启动时传递的deeplink - */ - private checkStartupDeepLink(): string | null { - console.log('Checking for startup deeplink...') - - // 首先检查环境变量(在main.ts中设置的) - const envDeepLink = process.env.STARTUP_DEEPLINK - if (envDeepLink) { - console.log('Found deeplink in startup environment variable:', envDeepLink) - // 清理环境变量,避免重复处理 - delete process.env.STARTUP_DEEPLINK - return envDeepLink - } - - // 检查命令行参数 - 尝试多种deeplink格式 - const deepLinkArg = process.argv.find((arg) => { - return arg.startsWith('deepchat://') || arg.includes('deepchat://') || arg.match(/^deepchat:/) - }) - - if (deepLinkArg) { - console.log('Found deeplink in command line arguments:', deepLinkArg) - return deepLinkArg - } - - // 检查所有命令行参数 - console.log('All command line arguments:', process.argv) - - console.log('No startup deeplink found') - return null } async handleDeepLink(url: string): Promise { - console.log('Received DeepLink:', url) - try { const urlObj = new URL(url) + console.log('Received DeepLink:', this.redactDeepLinkUrlForLog(url)) if (urlObj.protocol !== 'deepchat:') { console.error('Unsupported protocol:', urlObj.protocol) return } - // 从 hostname 获取命令 - const command = urlObj.hostname + const rawPath = [urlObj.hostname, urlObj.pathname.replace(/^\/+/, '')] + .filter((segment) => segment.length > 0) + .join('/') + const [command = '', subCommand = ''] = rawPath.split('/') + + console.log('Parsed deeplink - command:', command, 'subCommand:', subCommand) + + if (command === 'mcp' && subCommand === 'install' && !presenter.mcpPresenter.isReady()) { + console.log('MCP not ready yet, saving MCP install URL for later') + this.pendingMcpInstallUrl = url + return + } // 处理不同的命令 if (command === 'start') { await this.handleStart(urlObj.searchParams) } else if (command === 'mcp') { // 处理 mcp/install 命令 - const subCommand = urlObj.pathname.slice(1) // 移除开头的斜杠 if (subCommand === 'install') { await this.handleMcpInstall(urlObj.searchParams) } else { console.warn('Unknown MCP subcommand:', subCommand) } + } else if (command === 'provider') { + if (subCommand === 'install') { + await this.handleProviderInstall(urlObj.searchParams) + } else { + console.warn('Unknown provider subcommand:', subCommand) + } } else { console.warn('Unknown DeepLink command:', command) } @@ -213,7 +138,7 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } async handleStart(params: URLSearchParams): Promise { - console.log('Processing start command, parameters:', Object.fromEntries(params.entries())) + console.log('Processing start command, parameters:', this.redactSearchParamsForLog(params)) let msg = params.get('msg') if (!msg) { @@ -257,17 +182,14 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { console.log('systemPrompt:', systemPrompt) console.log('autoSend:', autoSend, '(disabled for security)') - const focusedWindow = presenter.windowPresenter.getFocusedWindow() - if (focusedWindow) { - focusedWindow.show() - focusedWindow.focus() - } else { - presenter.windowPresenter.show() + const targetWindow = await this.resolveChatWindow() + if (!targetWindow) { + console.error('Failed to resolve chat window for start deeplink') + return } - const windowId = focusedWindow?.id || 1 - await this.ensureChatWindowReady(windowId) - eventBus.sendToRenderer(DEEPLINK_EVENTS.START, SendTarget.DEFAULT_WINDOW, { + await this.ensureChatWindowReady(targetWindow.id) + presenter.windowPresenter.sendToWindow(targetWindow.id, DEEPLINK_EVENTS.START, { msg, modelId, systemPrompt, @@ -276,6 +198,38 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { }) } + private async resolveChatWindow(): Promise { + const appWindows = presenter.windowPresenter.getAllWindows() + const focusedWindow = presenter.windowPresenter.getFocusedWindow() + const focusedChatWindow = + focusedWindow && appWindows.some((window) => window.id === focusedWindow.id) + ? focusedWindow + : null + + let targetWindow: BrowserWindow | null | undefined = focusedChatWindow ?? appWindows[0] + + if (!targetWindow) { + const windowId = await presenter.windowPresenter.createAppWindow({ + initialRoute: 'chat' + }) + if (windowId == null) { + return null + } + targetWindow = BrowserWindow.fromId(windowId) ?? null + } + + if (!targetWindow || targetWindow.isDestroyed()) { + return null + } + + if (targetWindow.isMinimized()) { + targetWindow.restore() + } + targetWindow.show() + targetWindow.focus() + return targetWindow + } + /** * Ensure the active chat window is ready to receive the deeplink payload. * @param windowId 窗口ID @@ -298,7 +252,10 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } async handleMcpInstall(params: URLSearchParams): Promise { - console.log('Processing mcp/install command, parameters:', Object.fromEntries(params.entries())) + console.log( + 'Processing mcp/install command, parameters:', + this.redactSearchParamsForLog(params) + ) // 获取 JSON 数据 const jsonBase64 = params.get('code') @@ -314,7 +271,7 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { const jsonString = Buffer.from(jsonBase64, 'base64').toString('utf-8') const mcpConfig = JSON.parse(jsonString) as MCPInstallConfig - console.log('Parsed MCP config:', mcpConfig) + console.log('Parsed MCP config:', this.redactValueForLog(mcpConfig)) // 检查 MCP 配置是否有效 if (!mcpConfig || !mcpConfig.mcpServers) { @@ -322,12 +279,6 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { return } - // 检查应用程序是否已经完全启动(有窗口存在) - const allWindows = presenter.windowPresenter.getAllWindows() - const hasWindows = allWindows.length > 0 - - console.log('Window check - hasWindows:', hasWindows, 'windowCount:', allWindows.length) - // Prepare complete MCP configuration for all servers const completeMcpConfig: { mcpServers: Record } = { mcpServers: {} } @@ -426,104 +377,261 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { // 添加服务器配置到完整配置中 console.log( `Preparing to install MCP server: ${serverName} (type: ${determinedType})`, - finalConfig + this.redactValueForLog(finalConfig) ) completeMcpConfig.mcpServers[serverName] = finalConfig } - if (hasWindows) { - // 应用程序已启动,使用现有逻辑创建 Settings 窗口 - const settingsWindowId = await presenter.windowPresenter.createSettingsWindow() - if (!settingsWindowId) { - console.error('Failed to open Settings window for MCP install deeplink') - return - } + if (Object.keys(completeMcpConfig.mcpServers).length === 0) { + console.error('No valid MCP servers found in deeplink payload') + return + } - // Store the complete MCP configuration in localStorage of the Settings window - const settingsWindow = BrowserWindow.fromId(settingsWindowId) - if (settingsWindow && !settingsWindow.isDestroyed()) { - try { - await settingsWindow.webContents.executeJavaScript(` - localStorage.setItem('pending-mcp-install', '${JSON.stringify(completeMcpConfig).replace(/'/g, "\\'")}'); - `) - console.log('Complete MCP configuration stored in Settings window localStorage') - } catch (error) { - console.error('Failed to store MCP configuration in localStorage:', error) - } - } - } else { - console.log('App not fully started yet, saving MCP config for first app window') - await this.saveMcpConfigToAppWindow(completeMcpConfig) + const settingsWindowId = await presenter.windowPresenter.createSettingsWindow() + if (!settingsWindowId) { + console.error('Failed to open Settings window for MCP install deeplink') + return } + presenter.windowPresenter.sendToWindow(settingsWindowId, DEEPLINK_EVENTS.MCP_INSTALL, { + mcpConfig: JSON.stringify(completeMcpConfig) + }) + console.log('All MCP servers processing completed') } catch (error) { console.error('Error parsing or processing MCP configuration:', error) } } - /** - * Store MCP config in the first available app window localStorage. - * @param mcpConfig MCP 配置对象 - */ - private async saveMcpConfigToAppWindow(mcpConfig: { - mcpServers: Record - }): Promise { + async handleProviderInstall(params: URLSearchParams): Promise { + console.log( + 'Processing provider/install command, parameters:', + this.redactSearchParamsForLog(params) + ) + try { - const appWindow = await this.waitForFirstAppWindow() - if (!appWindow) { - console.error('No app window available to store MCP configuration') + const preview = this.parseProviderInstallParams(params) + const settingsWindowId = await presenter.windowPresenter.createSettingsWindow() + if (!settingsWindowId) { + this.notifyProviderImportError('Failed to open settings window for provider deeplink.') return } - if (appWindow.webContents.isLoading()) { - await new Promise((resolve) => { - appWindow.webContents.once('dom-ready', () => resolve()) - }) + presenter.windowPresenter.setPendingSettingsProviderInstall(preview) + presenter.windowPresenter.sendToWindow(settingsWindowId, SETTINGS_EVENTS.NAVIGATE, { + routeName: 'settings-provider' + }) + presenter.windowPresenter.sendToWindow(settingsWindowId, SETTINGS_EVENTS.PROVIDER_INSTALL) + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid provider deeplink.' + console.error('Error parsing provider install deeplink:', error) + this.notifyProviderImportError(message) + } + } + + private parseProviderInstallParams(params: URLSearchParams): ProviderInstallPreview { + const version = params.get('v') + if (version !== PROVIDER_INSTALL_VERSION) { + throw new Error(`Unsupported provider deeplink version: ${version || 'missing'}`) + } + + const rawData = params.get('data') + if (!rawData) { + throw new Error("Missing 'data' parameter") + } + + const payload = this.parseProviderInstallPayload(rawData) + + if ('id' in payload) { + const id = this.sanitizeStringParameter(payload.id) + const baseUrl = this.sanitizeProviderInstallField(payload.baseUrl, 'baseUrl') + const apiKey = this.sanitizeProviderInstallField(payload.apiKey, 'apiKey') + if (!id) { + throw new Error('Provider id is required.') + } + if (id === 'acp') { + throw new Error('ACP provider deeplinks are not supported.') } - await appWindow.webContents.executeJavaScript(` - localStorage.setItem('pending-mcp-install', '${JSON.stringify(mcpConfig).replace(/'/g, "\\'")}'); - `) - console.log('MCP configuration stored in app window localStorage for cold start') - } catch (error) { - console.error('Failed to store MCP configuration in app window localStorage:', error) + const provider = presenter.configPresenter.getProviderById(id) + if (!provider) { + throw new Error(`Unknown provider id: ${id}`) + } + + return { + kind: 'builtin', + id, + baseUrl, + apiKey, + maskedApiKey: maskApiKey(apiKey), + iconModelId: id, + willOverwrite: true + } + } + + const type = this.sanitizeStringParameter(payload.type) + const name = this.sanitizeStringParameter(payload.name) + const baseUrl = this.sanitizeProviderInstallField(payload.baseUrl, 'baseUrl') + const apiKey = this.sanitizeProviderInstallField(payload.apiKey, 'apiKey') + if (!name) { + throw new Error('Provider name is required for custom provider imports.') + } + if (!type) { + throw new Error('Provider type is required for custom provider imports.') + } + if (type === 'acp') { + throw new Error('ACP provider deeplinks are not supported.') + } + if (!isProviderInstallCustomType(type)) { + throw new Error(`Unsupported provider type: ${type}`) + } + + return { + kind: 'custom', + name, + type, + baseUrl, + apiKey, + maskedApiKey: maskApiKey(apiKey), + iconModelId: type } } - /** - * Wait for the first app window to become available. - * @returns Promise - */ - private async waitForFirstAppWindow(): Promise { - return new Promise((resolve) => { - // 先检查是否已经有窗口 - const existingWindows = presenter.windowPresenter.getAllWindows() - if (existingWindows.length > 0) { - resolve(existingWindows[0]) - return + private parseProviderInstallPayload(rawData: string): ProviderInstallDeeplinkPayload { + const sanitizedBase64 = rawData.replace(/\s+/g, '') + if (!sanitizedBase64) { + throw new Error('Provider deeplink data is empty.') + } + + let jsonString = '' + try { + const buffer = Buffer.from(sanitizedBase64, 'base64') + const normalizedOutput = buffer.toString('base64') + if (sanitizedBase64 !== normalizedOutput) { + throw new Error('Invalid base64 payload.') } + jsonString = buffer.toString('utf8') + } catch (error) { + throw new Error( + error instanceof Error ? error.message : 'Failed to decode provider deeplink payload.' + ) + } - // 监听窗口创建事件 - const checkForWindow = () => { - const windows = presenter.windowPresenter.getAllWindows() - if (windows.length > 0) { - eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) - resolve(windows[0]) - } + let parsed: unknown + try { + parsed = JSON.parse(jsonString) + } catch { + throw new Error('Provider deeplink payload is not valid JSON.') + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Provider deeplink payload must be an object.') + } + + const payload = parsed as Partial & Record + const hasId = typeof payload.id === 'string' + const hasType = typeof payload.type === 'string' + + if (hasId === hasType) { + throw new Error("Provider deeplink payload must include either 'id' or 'type'.") + } + + if (typeof payload.baseUrl !== 'string') { + throw new Error("Provider deeplink payload must include a string 'baseUrl'.") + } + if (typeof payload.apiKey !== 'string') { + throw new Error("Provider deeplink payload must include a string 'apiKey'.") + } + + if (hasId) { + return { + id: payload.id as string, + baseUrl: payload.baseUrl, + apiKey: payload.apiKey } + } - eventBus.on(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) + if (typeof payload.name !== 'string') { + throw new Error("Custom provider deeplink payload must include a string 'name'.") + } - // 设置超时,避免无限等待 - setTimeout(() => { - eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) - console.warn('Timeout waiting for app window creation') - resolve(null) - }, 10000) // 10秒超时 + return { + name: payload.name, + type: payload.type as string, + baseUrl: payload.baseUrl, + apiKey: payload.apiKey + } + } + + private sanitizeProviderInstallField(value: string, field: string): string { + const sanitized = this.sanitizeStringParameter(value) + if (value.trim().length > 0 && sanitized.length === 0) { + throw new Error(`Provider deeplink field '${field}' is invalid.`) + } + return sanitized + } + + private notifyProviderImportError(message: string): void { + eventBus.sendToRenderer(NOTIFICATION_EVENTS.SHOW_ERROR, SendTarget.ALL_WINDOWS, { + id: `provider-deeplink-${Date.now()}`, + title: 'Provider Deeplink', + message, + type: 'error' }) } + private redactDeepLinkUrlForLog(url: string): string { + try { + const parsedUrl = new URL(url) + const sensitiveKeys = [...parsedUrl.searchParams.keys()].filter((key) => + this.isSensitiveLogKey(key) + ) + + sensitiveKeys.forEach((key) => { + parsedUrl.searchParams.set(key, '[REDACTED]') + }) + + return parsedUrl.toString() + } catch { + return url.replace( + /([?&](?:apiKey|api_key|token|password|data|code)=)[^&]*/gi, + '$1[REDACTED]' + ) + } + } + + private redactSearchParamsForLog(params: URLSearchParams): Record { + return this.redactValueForLog(Object.fromEntries(params.entries())) as Record + } + + private redactValueForLog(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.redactValueForLog(item)) + } + + if (!value || typeof value !== 'object') { + return value + } + + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + this.isSensitiveLogKey(key) ? '[REDACTED]' : this.redactValueForLog(nestedValue) + ]) + ) + } + + private isSensitiveLogKey(key: string): boolean { + const normalizedKey = key.replace(/[^a-z0-9]/gi, '').toLowerCase() + return ( + normalizedKey.includes('apikey') || + normalizedKey.includes('token') || + normalizedKey.includes('password') || + normalizedKey === 'data' || + normalizedKey === 'code' + ) + } + /** * 净化消息内容,防止恶意输入 * @param content 原始消息内容 diff --git a/src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts b/src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts index b98617b70..9255e7325 100644 --- a/src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts +++ b/src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts @@ -10,8 +10,6 @@ import { } from './layout' import windowStateManager from 'electron-window-state' -const FLOATING_WIDGET_WINDOW_OPACITY = 1 - export class FloatingButtonWindow { private window: BrowserWindow | null = null private config: FloatingButtonConfig @@ -83,7 +81,7 @@ export class FloatingButtonWindow { this.windowState.manage(this.window) this.window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) this.window.setAlwaysOnTop(this.config.alwaysOnTop, 'floating') - this.window.setOpacity(FLOATING_WIDGET_WINDOW_OPACITY) + this.window.setOpacity(1) this.setBounds(initialBounds) if (isDev) { @@ -135,7 +133,7 @@ export class FloatingButtonWindow { return } - this.window.setOpacity(FLOATING_WIDGET_WINDOW_OPACITY) + this.window.setOpacity(1) if (config.alwaysOnTop !== undefined) { this.window.setAlwaysOnTop(this.config.alwaysOnTop, 'floating') @@ -176,6 +174,14 @@ export class FloatingButtonWindow { this.state.bounds = { ...bounds } } + public setOpacity(opacity: number): void { + if (!this.window || this.window.isDestroyed()) { + return + } + + this.window.setOpacity(opacity) + } + public getDockSide(): FloatingWidgetDockSide { return this.dockSide } diff --git a/src/main/presenter/floatingButtonPresenter/index.ts b/src/main/presenter/floatingButtonPresenter/index.ts index 7132f9ed2..f22aec505 100644 --- a/src/main/presenter/floatingButtonPresenter/index.ts +++ b/src/main/presenter/floatingButtonPresenter/index.ts @@ -2,6 +2,7 @@ import { FloatingButtonWindow } from './FloatingButtonWindow' import { FloatingButtonConfig, FloatingButtonState, DEFAULT_FLOATING_BUTTON_CONFIG } from './types' import { buildFloatingWidgetSnapshot, + getPeekedCollapsedBounds, getWidgetSizeForSnapshot, repositionWidgetForResize, snapWidgetBoundsToEdge, @@ -23,6 +24,9 @@ const EMPTY_SNAPSHOT: FloatingWidgetSnapshot = { const WIDGET_LAYOUT_ANIMATION_DURATION_MS = 360 const WIDGET_LAYOUT_ANIMATION_INTERVAL_MS = 16 +const COLLAPSE_REVEAL_LOCK_MS = WIDGET_LAYOUT_ANIMATION_DURATION_MS + 120 +const COLLAPSED_WIDGET_INACTIVE_OPACITY = 0.5 +const ACTIVE_WIDGET_OPACITY = 1 type DragRuntimeState = { startX: number @@ -39,7 +43,10 @@ export class FloatingButtonPresenter { private configPresenter: IConfigPresenter private snapshot: FloatingWidgetSnapshot = { ...EMPTY_SNAPSHOT } private layoutAnimationTimer: ReturnType | null = null + private collapseRevealTimer: ReturnType | null = null private isDragging = false + private isHovered = false + private collapseRevealLock = false private pendingLayoutSync = false constructor(configPresenter: IConfigPresenter) { @@ -80,6 +87,8 @@ export class FloatingButtonPresenter { this.config.enabled = false this.snapshot = { ...EMPTY_SNAPSHOT } this.isDragging = false + this.isHovered = false + this.clearCollapseRevealLock() this.pendingLayoutSync = false this.stopLayoutAnimation() @@ -88,6 +97,7 @@ export class FloatingButtonPresenter { ipcMain.removeHandler(FLOATING_BUTTON_EVENTS.THEME_REQUEST) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.CLICKED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.RIGHT_CLICKED) + ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.HOVER_STATE_CHANGED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.TOGGLE_EXPANDED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.SET_EXPANDED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.OPEN_SESSION) @@ -111,10 +121,10 @@ export class FloatingButtonPresenter { this.config.enabled = true if (this.floatingWindow) { - this.floatingWindow.show() await this.refreshWidgetState() this.refreshLanguage() await this.refreshTheme() + this.floatingWindow.show() return } @@ -190,10 +200,10 @@ export class FloatingButtonPresenter { await this.floatingWindow.create() } - this.floatingWindow.show() await this.refreshWidgetState() this.refreshLanguage() await this.refreshTheme() + this.floatingWindow.show() } private registerIpcHandlers(): void { @@ -202,6 +212,7 @@ export class FloatingButtonPresenter { ipcMain.removeHandler(FLOATING_BUTTON_EVENTS.THEME_REQUEST) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.CLICKED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.RIGHT_CLICKED) + ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.HOVER_STATE_CHANGED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.TOGGLE_EXPANDED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.SET_EXPANDED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.OPEN_SESSION) @@ -234,6 +245,10 @@ export class FloatingButtonPresenter { this.showContextMenu() }) + ipcMain.on(FLOATING_BUTTON_EVENTS.HOVER_STATE_CHANGED, (_event, hovering: boolean) => { + this.setHovering(Boolean(hovering)) + }) + ipcMain.on(FLOATING_BUTTON_EVENTS.TOGGLE_EXPANDED, () => { this.toggleExpanded() }) @@ -257,9 +272,11 @@ export class FloatingButtonPresenter { } this.stopLayoutAnimation() + this.clearCollapseRevealLock() + this.isDragging = true const stableBounds = this.getSnapshotBounds(bounds) this.floatingWindow.setBounds(stableBounds) - this.isDragging = true + this.floatingWindow.setOpacity(this.resolveWindowOpacity()) dragState = { startX: x, @@ -313,7 +330,12 @@ export class FloatingButtonPresenter { this.floatingWindow.setBounds(snapped) this.isDragging = false dragState = null + this.floatingWindow.setOpacity(this.resolveWindowOpacity()) + const hadPendingLayoutSync = this.pendingLayoutSync this.flushPendingLayoutSync() + if (!hadPendingLayoutSync) { + this.applyWindowLayout() + } }) } @@ -322,10 +344,20 @@ export class FloatingButtonPresenter { return } + const wasExpanded = this.snapshot.expanded + if (expanded) { + this.clearCollapseRevealLock() + } + this.snapshot = { ...this.snapshot, expanded } + + if (wasExpanded && !expanded) { + this.engageCollapseRevealLock() + } + this.applyWindowLayout(true) this.pushSnapshotToRenderer() } @@ -334,6 +366,20 @@ export class FloatingButtonPresenter { this.setExpanded(!this.snapshot.expanded) } + private setHovering(hovering: boolean): void { + if (this.isHovered === hovering) { + return + } + + this.isHovered = hovering + + if (!this.snapshot.expanded && this.collapseRevealLock) { + return + } + + this.applyWindowLayout(true) + } + private applyWindowLayout(animate = false): void { if (!this.floatingWindow?.exists()) { return @@ -350,6 +396,7 @@ export class FloatingButtonPresenter { } const nextBounds = this.getSnapshotBounds(bounds) + this.floatingWindow.setOpacity(this.resolveWindowOpacity()) if (!animate || this.areBoundsEqual(bounds, nextBounds)) { this.stopLayoutAnimation() @@ -411,6 +458,29 @@ export class FloatingButtonPresenter { } } + private clearCollapseRevealLock(): void { + this.collapseRevealLock = false + + if (this.collapseRevealTimer) { + clearTimeout(this.collapseRevealTimer) + this.collapseRevealTimer = null + } + } + + private engageCollapseRevealLock(): void { + this.collapseRevealLock = true + + if (this.collapseRevealTimer) { + clearTimeout(this.collapseRevealTimer) + } + + this.collapseRevealTimer = setTimeout(() => { + this.collapseRevealTimer = null + this.collapseRevealLock = false + this.applyWindowLayout(true) + }, COLLAPSE_REVEAL_LOCK_MS) + } + private flushPendingLayoutSync(): void { if (!this.pendingLayoutSync) { return @@ -426,12 +496,32 @@ export class FloatingButtonPresenter { } const currentDisplay = screen.getDisplayMatching(bounds) - return repositionWidgetForResize( + const resizedBounds = repositionWidgetForResize( bounds, getWidgetSizeForSnapshot(this.snapshot), currentDisplay.workArea, this.floatingWindow.getDockSide() ) + + if (!this.snapshot.expanded && !this.shouldRevealCollapsedWidget()) { + return getPeekedCollapsedBounds( + resizedBounds, + currentDisplay.workArea, + this.floatingWindow.getDockSide() + ) + } + + return resizedBounds + } + + private shouldRevealCollapsedWidget(): boolean { + return this.snapshot.expanded || this.isHovered || this.isDragging || this.collapseRevealLock + } + + private resolveWindowOpacity(): number { + return this.shouldRevealCollapsedWidget() + ? ACTIVE_WIDGET_OPACITY + : COLLAPSED_WIDGET_INACTIVE_OPACITY } private easeInOutCubic(progress: number): number { diff --git a/src/main/presenter/floatingButtonPresenter/layout.ts b/src/main/presenter/floatingButtonPresenter/layout.ts index b8a9ea358..f6fe2a99b 100644 --- a/src/main/presenter/floatingButtonPresenter/layout.ts +++ b/src/main/presenter/floatingButtonPresenter/layout.ts @@ -15,8 +15,8 @@ export interface WidgetRect { } export const FLOATING_WIDGET_LAYOUT = { - collapsedIdle: { width: 64, height: 64 }, - collapsedBusy: { width: 64, height: 64 }, + collapsedIdle: { width: 50, height: 50 }, + collapsedBusy: { width: 50, height: 50 }, expandedWidth: 388, expandedMinHeight: 168, expandedMaxHeight: 392, @@ -147,6 +147,25 @@ export function repositionWidgetForResize( } } +export function getPeekedCollapsedBounds( + bounds: WidgetRect, + workArea: WidgetRect, + dockSide: FloatingWidgetDockSide +): WidgetRect { + const hiddenWidth = Math.round(bounds.width / 2) + const x = + dockSide === 'left' + ? workArea.x - hiddenWidth + : workArea.x + workArea.width - bounds.width + hiddenWidth + + return { + x: Math.round(x), + y: clampWidgetY(bounds.y, bounds.height, workArea), + width: bounds.width, + height: bounds.height + } +} + export function snapWidgetBoundsToEdge( bounds: WidgetRect, workArea: WidgetRect diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index c1075734b..185eb9ccb 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -116,6 +116,15 @@ export class Presenter implements IPresenter { ]) static readonly REMOTE_CONTROL_METHODS = new Set([ + 'getChannelSettings', + 'saveChannelSettings', + 'getChannelStatus', + 'getChannelBindings', + 'removeChannelBinding', + 'getChannelPairingSnapshot', + 'createChannelPairCode', + 'clearChannelPairCode', + 'clearChannelBindings', 'getTelegramSettings', 'saveTelegramSettings', 'getTelegramStatus', @@ -250,6 +259,18 @@ export class Presenter implements IPresenter { return null }, + resolveConversationSessionInfo: async (conversationId) => { + const session = await this.newAgentPresenter?.getSession(conversationId) + if (!session) { + return null + } + + return { + agentId: session.agentId, + providerId: session.providerId, + modelId: session.modelId + } + }, getSkillPresenter: () => this.skillPresenter, getYoBrowserToolHandler: () => this.yoBrowserPresenter.toolHandler, getFilePresenter: () => ({ @@ -353,20 +374,7 @@ export class Presenter implements IPresenter { this.configPresenter.setHooksNotificationsConfig(config), testTelegramHookNotification: () => this.configPresenter.testTelegramNotification() }) - this.#remoteControlBridge = { - getTelegramSettings: () => this.#remoteControlPresenter.getTelegramSettings(), - saveTelegramSettings: (input) => this.#remoteControlPresenter.saveTelegramSettings(input), - getTelegramStatus: () => this.#remoteControlPresenter.getTelegramStatus(), - getTelegramBindings: () => this.#remoteControlPresenter.getTelegramBindings(), - removeTelegramBinding: (endpointKey) => - this.#remoteControlPresenter.removeTelegramBinding(endpointKey), - getTelegramPairingSnapshot: () => this.#remoteControlPresenter.getTelegramPairingSnapshot(), - createTelegramPairCode: () => this.#remoteControlPresenter.createTelegramPairCode(), - clearTelegramPairCode: () => this.#remoteControlPresenter.clearTelegramPairCode(), - clearTelegramBindings: () => this.#remoteControlPresenter.clearTelegramBindings(), - testTelegramHookNotification: () => - this.#remoteControlPresenter.testTelegramHookNotification() - } + this.#remoteControlBridge = this.#remoteControlPresenter // Update hooksNotifications with actual dependencies now that newAgentPresenter is ready this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { @@ -528,7 +536,7 @@ export class Presenter implements IPresenter { } const handler = this.#remoteControlBridge[method] as (...args: unknown[]) => unknown - return await handler(...payloads) + return await Reflect.apply(handler, this.#remoteControlBridge, payloads) } // 从配置中同步自定义模型到 LLMProviderPresenter diff --git a/src/main/presenter/llmProviderPresenter/index.ts b/src/main/presenter/llmProviderPresenter/index.ts index d5caa1eaf..8cd0ca95c 100644 --- a/src/main/presenter/llmProviderPresenter/index.ts +++ b/src/main/presenter/llmProviderPresenter/index.ts @@ -33,6 +33,16 @@ import { AcpSessionPersistence } from './acp' import { AcpProvider } from './providers/acpProvider' import type { ProviderMcpRuntimePort } from './runtimePorts' +const createAbortError = (): Error => { + if (typeof DOMException !== 'undefined') { + return new DOMException('Aborted', 'AbortError') + } + + const error = new Error('Aborted') + error.name = 'AbortError' + return error +} + export class LLMProviderPresenter implements ILlmProviderPresenter { private currentProviderId: string | null = null private readonly activeStreams: Map = new Map() @@ -258,16 +268,37 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { messages: ChatMessage[], modelId: string, temperature?: number, - maxTokens?: number + maxTokens?: number, + options?: { signal?: AbortSignal } ): Promise { const provider = this.getProviderInstance(providerId) let response = '' + const signal = options?.signal + + if (signal?.aborted) { + throw createAbortError() + } + + const completionPromise = provider.completions(messages, modelId, temperature, maxTokens) + const abortPromise = + signal && + new Promise((_, reject) => { + const onAbort = () => reject(createAbortError()) + signal.addEventListener('abort', onAbort, { once: true }) + completionPromise.finally(() => signal.removeEventListener('abort', onAbort)) + }) + try { - const llmResponse = await provider.completions(messages, modelId, temperature, maxTokens) + const llmResponse = await (abortPromise + ? Promise.race([completionPromise, abortPromise]) + : completionPromise) response = llmResponse.content return response } catch (error) { + if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { + throw error + } console.error('Stream error:', error) return '' } diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts index 9a02e88fb..62272a788 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts @@ -2,21 +2,19 @@ import { ArtifactsServer } from './artifactsServer' // FileSystemServer has been removed - filesystem capabilities are now provided via Agent tools import { BochaSearchServer } from './bochaSearchServer' import { BraveSearchServer } from './braveSearchServer' -import { ImageServer } from './imageServer' import { DifyKnowledgeServer } from './difyKnowledgeServer' import { RagflowKnowledgeServer } from './ragflowKnowledgeServer' import { FastGptKnowledgeServer } from './fastGptKnowledgeServer' import { DeepResearchServer } from './deepResearchServer' import { AutoPromptingServer } from './autoPromptingServer' import { ConversationSearchServer } from './conversationSearchServer' -import { MeetingServer } from './meetingServer' import { BuiltinKnowledgeServer } from './builtinKnowledgeServer' import { BuiltinKnowledgeConfig } from '@shared/presenter' import { AppleServer } from './appleServer' export function getInMemoryServer( serverName: string, - args: string[], + _args: string[], env?: Record ) { switch (serverName) { @@ -29,8 +27,6 @@ export function getInMemoryServer( return new BraveSearchServer(env) case 'deepResearch': return new DeepResearchServer(env) - case 'imageServer': - return new ImageServer(args[0] || undefined, args[1] || undefined) case 'difyKnowledge': return new DifyKnowledgeServer( env as { @@ -79,8 +75,6 @@ export function getInMemoryServer( return new AutoPromptingServer() case 'deepchat-inmemory/conversation-search-server': return new ConversationSearchServer() - case 'deepchat-inmemory/meeting-server': - return new MeetingServer() case 'deepchat/apple-server': // 只在 macOS 上创建 AppleServer if (process.platform !== 'darwin') { diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts deleted file mode 100644 index 370acb6eb..000000000 --- a/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' -import fs from 'fs/promises' -import path from 'path' -import { z } from 'zod' -import { zodToJsonSchema } from 'zod-to-json-schema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' -import { presenter } from '@/presenter' -import { ChatMessage, ChatMessageContent } from '@shared/presenter' -// import { GenerateCompletionOptions } from '@/presenter/llmProviderPresenter' // Assuming this path and type exist - using any for now - -// --- Zod Schemas for Tool Arguments --- - -const ReadImageBase64ArgsSchema = z.object({ - path: z.string().describe('Path to the image file.') -}) - -const UploadImageArgsSchema = z.object({ - path: z.string().describe('Path to the image file to upload.') -}) - -const ReadMultipleImagesBase64ArgsSchema = z.object({ - paths: z.array(z.string()).describe('List of paths to the image files.') -}) - -const UploadMultipleImagesArgsSchema = z.object({ - paths: z.array(z.string()).describe('List of paths to the image files to upload.') -}) - -const QueryImageWithPromptArgsSchema = z.object({ - path: z.string().describe('Path to the image file to query.'), - prompt: z - .string() - .describe('The prompt to use when querying the image with the multimodal model.') -}) - -const DescribeImageArgsSchema = z.object({ - path: z.string().describe('Path to the image file to do simple describe.') -}) - -const OcrImageArgsSchema = z.object({ - path: z.string().describe('Path to the image file for OCR text extraction.') -}) - -// --- Image Server Implementation --- - -export class ImageServer { - private server: Server - private provider: string - private model: string - - constructor(provider?: string, model?: string) { - const defaultVisionModel = presenter.configPresenter.getDefaultVisionModel() - this.provider = provider || defaultVisionModel?.providerId || 'openai' - this.model = model || defaultVisionModel?.modelId || 'gpt-4o' - this.server = new Server( - { - name: 'image-processing-server', - version: '0.1.0' - }, - { - capabilities: { - tools: {} - } - } - ) - this.setupRequestHandlers() - } - - // No specific initialization needed for now, but can be added for upload service config - // public async initialize(): Promise { - // // Initialization logic, e.g., configure upload service client - // } - - private getEffectiveModel(): { provider: string; model: string } { - if (this.provider && this.model) { - return { provider: this.provider, model: this.model } - } - - const defaultVisionModel = presenter.configPresenter.getDefaultVisionModel() - if (defaultVisionModel?.providerId && defaultVisionModel?.modelId) { - return { provider: defaultVisionModel.providerId, model: defaultVisionModel.modelId } - } - - throw new Error( - 'No vision model configured. Please set a default vision model in Settings > Common > Default Model.' - ) - } - - public startServer(transport: Transport): void { - this.server.connect(transport) - } - - // --- Placeholder for Image Upload Logic --- - private async uploadImageToService(filePath: string, fileBuffer: Buffer): Promise { - // TODO: Implement actual image upload logic here - // This might involve using a library like 'axios' or a specific SDK - // for services like Imgur, AWS S3, Cloudinary, etc. - console.log(`Uploading ${filePath} (size: ${fileBuffer.length} bytes)...`) - // Replace with actual upload call - await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate network delay - const fakeUrl = `https://fake-upload-service.com/uploads/${path.basename(filePath)}_${Date.now()}` - console.log(`Upload complete: ${fakeUrl}`) - return fakeUrl - } - - // --- Placeholder for Multimodal Model Interaction --- - private async queryImageWithModel( - filePath: string, - fileBuffer: Buffer, - prompt: string - ): Promise { - const { provider, model } = this.getEffectiveModel() - // TODO: Implement actual API call to a multimodal model (e.g., GPT-4o, Gemini) - console.log( - `Querying ${filePath} (size: ${fileBuffer.length} bytes) using ${provider}/${model} with prompt: "${prompt}"...` - ) - - // Construct the messages array for the multimodal model - const base64Image = fileBuffer.toString('base64') - // TODO: Dynamically determine mime type if possible, otherwise assume common type like jpeg - const dataUrl = `data:image/jpeg;base64,${base64Image}` - - const messages: ChatMessage[] = [ - { - role: 'user', - content: [ - { type: 'text', text: prompt }, // Use the provided prompt - { - type: 'image_url', - image_url: { url: dataUrl } - } - ] as ChatMessageContent[] // Type assertion might be needed depending on ChatMessageContent definition - } - ] - - const modelConfig = presenter.configPresenter.getModelConfig(model, provider) - - try { - const response = await presenter.llmproviderPresenter.generateCompletionStandalone( - provider, - messages, - model, - modelConfig?.temperature ?? 0.6, - modelConfig?.maxTokens || 1000 - ) - console.log(`Model response received: ${response}`) - return response ?? 'No response generated.' // Handle potential null/undefined response - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`Error querying image: ${errorMessage}`) - // Re-throw or return an error message - throw new Error(`Failed to query image: ${errorMessage}`) - // Or return `Error generating response: ${errorMessage}`; - } - } - - private async ocrImageWithModel(filePath: string, fileBuffer: Buffer): Promise { - const { provider, model } = this.getEffectiveModel() - // TODO: Implement actual API call to an OCR service or a multimodal model capable of OCR - console.log( - `Requesting OCR for ${filePath} (size: ${fileBuffer.length} bytes) using ${provider}/${model}...` - ) - - // Construct the messages array for the multimodal model - const base64Image = fileBuffer.toString('base64') - // TODO: Dynamically determine mime type if possible - const dataUrl = `data:image/jpeg;base64,${base64Image}` - - const messages: ChatMessage[] = [ - { - role: 'user', - content: [ - { type: 'text', text: 'Perform OCR on this image and return the extracted text.' }, - { - type: 'image_url', - image_url: { url: dataUrl } - } - ] as ChatMessageContent[] // Type assertion - } - ] - - console.log(messages) - - const modelConfig = presenter.configPresenter.getModelConfig(model, provider) - - try { - const ocrText = await presenter.llmproviderPresenter.generateCompletionStandalone( - provider, - messages, - model, - modelConfig?.temperature ?? 0.6, - modelConfig?.maxTokens || 1000 - ) - console.log(`OCR text received: ${ocrText}`) - return ocrText ?? 'No text extracted.' // Handle potential null/undefined response - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`Error performing OCR: ${errorMessage}`) - // Re-throw or return an error message - throw new Error(`Failed to perform OCR: ${errorMessage}`) - // Or return `Error performing OCR: ${errorMessage}`; - } - } - - // --- Request Handlers --- - - private setupRequestHandlers(): void { - // List Tools Handler - this.server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: 'read_image_base64', - description: - 'Reads an image file from the specified path and returns its base64 encoded content.', - inputSchema: zodToJsonSchema(ReadImageBase64ArgsSchema), - annotations: { - title: 'Read Image Base64', - readOnlyHint: true - } - }, - { - name: 'upload_image', - description: - 'Uploads an image file from the specified path to a hosting service and returns the public URL.', - inputSchema: zodToJsonSchema(UploadImageArgsSchema), - annotations: { - title: 'Upload Image', - destructiveHint: false, - openWorldHint: true - } - }, - { - name: 'read_multiple_images_base64', - description: - 'Reads multiple image files from the specified paths and returns their base64 encoded content.', - inputSchema: zodToJsonSchema(ReadMultipleImagesBase64ArgsSchema), - annotations: { - title: 'Read Multiple Images Base64', - readOnlyHint: true - } - }, - { - name: 'upload_multiple_images', - description: - 'Uploads multiple image files from the specified paths to a hosting service and returns their public URLs.', - inputSchema: zodToJsonSchema(UploadMultipleImagesArgsSchema), - annotations: { - title: 'Upload Multiple Images', - destructiveHint: false, - openWorldHint: true - } - }, - { - name: 'describe_image', - description: - 'Uses a multimodal model to simply describe the image at the specified path.', - inputSchema: zodToJsonSchema(DescribeImageArgsSchema), - annotations: { - title: 'Describe Image', - readOnlyHint: true, - openWorldHint: true - } - }, - { - name: 'query_image_with_prompt', - description: - 'Uses a multimodal model to answer a query (prompt) about the image at the specified path.', - inputSchema: zodToJsonSchema(QueryImageWithPromptArgsSchema), - annotations: { - title: 'Query Image with Prompt', - readOnlyHint: true, - openWorldHint: true - } - }, - { - name: 'ocr_image', - description: - 'Performs Optical Character Recognition (OCR) on the image at the specified path and returns the extracted text.', - inputSchema: zodToJsonSchema(OcrImageArgsSchema), - annotations: { - title: 'OCR Image', - readOnlyHint: true, - openWorldHint: true - } - } - ] - } - }) - - // Call Tool Handler - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - try { - const { name, arguments: args } = request.params - - switch (name) { - case 'read_image_base64': { - const parsed = ReadImageBase64ArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary (similar to FileSystemServer) - const filePath = parsed.data.path - const fileBuffer = await fs.readFile(filePath) - const base64Content = fileBuffer.toString('base64') - // Determine mime type (optional but good practice) - // const mimeType = lookup(filePath) || 'application/octet-stream'; - // const dataUri = `data:${mimeType};base64,${base64Content}`; - return { - content: [{ type: 'text', text: base64Content }] // Or return dataUri - } - } - - case 'upload_image': { - const parsed = UploadImageArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary - const filePath = parsed.data.path - const fileBuffer = await fs.readFile(filePath) - const imageUrl = await this.uploadImageToService(filePath, fileBuffer) - return { - content: [{ type: 'text', text: imageUrl }] - } - } - - case 'read_multiple_images_base64': { - const parsed = ReadMultipleImagesBase64ArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - const results = await Promise.allSettled( - parsed.data.paths.map(async (filePath: string) => { - try { - // TODO: Implement path validation if necessary - const fileBuffer = await fs.readFile(filePath) - return { - path: filePath, - base64: fileBuffer.toString('base64'), - status: 'fulfilled' - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - // Ensure the structure includes path and error for rejected promises - return Promise.reject({ path: filePath, error: errorMessage }) - } - }) - ) - - // Format output: [{path: string, base64?: string, error?: string}] - const formattedResults = results.map((result) => { - if (result.status === 'fulfilled') { - return { path: result.value.path, base64: result.value.base64 } - } else { - // Access reason directly as it contains the rejected structure - return { path: result.reason.path, error: result.reason.error } - } - }) - - return { - content: [{ type: 'text', text: JSON.stringify(formattedResults, null, 2) }] - } - } - - case 'upload_multiple_images': { - const parsed = UploadMultipleImagesArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - - const results = await Promise.allSettled( - parsed.data.paths.map(async (filePath: string) => { - try { - // TODO: Implement path validation if necessary - const fileBuffer = await fs.readFile(filePath) - const url = await this.uploadImageToService(filePath, fileBuffer) - return { path: filePath, url: url, status: 'fulfilled' } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - // Ensure the structure includes path and error for rejected promises - return Promise.reject({ path: filePath, error: errorMessage }) - } - }) - ) - - // Format output: [{path: string, url?: string, error?: string}] - const formattedResults = results.map((result) => { - if (result.status === 'fulfilled') { - return { path: result.value.path, url: result.value.url } - } else { - // Access reason directly as it contains the rejected structure - return { path: result.reason.path, error: result.reason.error } - } - }) - - return { - content: [{ type: 'text', text: JSON.stringify(formattedResults, null, 2) }] - } - } - - case 'describe_image': { - const parsed = DescribeImageArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary - const filePath = parsed.data.path - const fileBuffer = await fs.readFile(filePath) - const description = await this.queryImageWithModel( - filePath, - fileBuffer, - 'Describe this image.' - ) - return { - content: [{ type: 'text', text: description }] - } - } - - case 'query_image_with_prompt': { - const parsed = QueryImageWithPromptArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary - const filePath = parsed.data.path - const prompt = parsed.data.prompt // Get the prompt - const fileBuffer = await fs.readFile(filePath) - // Call the renamed function with the prompt - const response = await this.queryImageWithModel(filePath, fileBuffer, prompt) - return { - content: [{ type: 'text', text: response }] - } - } - - case 'ocr_image': { - const parsed = OcrImageArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary - const filePath = parsed.data.path - const fileBuffer = await fs.readFile(filePath) - const ocrText = await this.ocrImageWithModel(filePath, fileBuffer) - return { - content: [{ type: 'text', text: ocrText }] - } - } - - default: - throw new Error(`Unknown tool: ${name}`) - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - // Consider logging the error server-side - console.error(`Error processing tool call: ${errorMessage}`) - // Ensure the error response structure matches expected format - return { - content: [{ type: 'text', text: `Error: ${errorMessage}` }], - isError: true // Indicate this is an error response - } - } - }) - } -} - -// --- Usage Example (similar to FileSystemServer) --- -// import { WebSocketServerTransport } from '@modelcontextprotocol/sdk/transport/node'; -// -// const imageServer = new ImageServer('your-llm-provider', 'your-multimodal-model'); -// // await imageServer.initialize(); // If initialization is added -// -// // Example using WebSocket transport -// const transport = new WebSocketServerTransport({ port: 8081 }); // Choose a different port -// imageServer.startServer(transport); -// console.log('ImageServer started on port 8081'); - -// You would need a client to connect to this server and call the tools. diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts new file mode 100644 index 000000000..44c7476e3 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts @@ -0,0 +1,160 @@ +import * as Lark from '@larksuiteoapi/node-sdk' +import type { EventHandles } from '@larksuiteoapi/node-sdk' +import type { FeishuTransportTarget } from '../types' + +const FEISHU_OUTBOUND_TEXT_LIMIT = 8_000 + +export type FeishuRawMessageEvent = Parameters< + NonNullable +>[0] + +export interface FeishuBotIdentity { + openId: string + name?: string +} + +const createTextPayload = (text: string): string => + JSON.stringify({ + text + }) + +const chunkFeishuText = (text: string): string[] => { + const normalized = text.trim() || '(No text output)' + if (normalized.length <= FEISHU_OUTBOUND_TEXT_LIMIT) { + return [normalized] + } + + const chunks: string[] = [] + let remaining = normalized + + while (remaining.length > FEISHU_OUTBOUND_TEXT_LIMIT) { + const window = remaining.slice(0, FEISHU_OUTBOUND_TEXT_LIMIT) + const splitIndex = Math.max(window.lastIndexOf('\n\n'), window.lastIndexOf('\n')) + const nextIndex = + splitIndex > Math.floor(FEISHU_OUTBOUND_TEXT_LIMIT * 0.55) + ? splitIndex + : FEISHU_OUTBOUND_TEXT_LIMIT + chunks.push(remaining.slice(0, nextIndex).trim()) + remaining = remaining.slice(nextIndex).trim() + } + + if (remaining) { + chunks.push(remaining) + } + + return chunks +} + +export class FeishuClient { + private readonly sdk: Lark.Client + private wsClient: Lark.WSClient | null = null + + constructor( + private readonly credentials: { + appId: string + appSecret: string + verificationToken: string + encryptKey: string + } + ) { + this.sdk = new Lark.Client({ + appId: credentials.appId, + appSecret: credentials.appSecret, + appType: Lark.AppType.SelfBuild + }) + } + + async probeBot(): Promise { + const response = await (this.sdk as any).request({ + method: 'GET', + url: '/open-apis/bot/v3/info', + data: {} + }) + + if (response?.code !== 0) { + throw new Error(response?.msg?.trim() || 'Failed to fetch Feishu bot info.') + } + + const bot = response?.bot || response?.data?.bot + const openId = bot?.open_id?.trim() + if (!openId) { + throw new Error('Feishu bot open_id is missing from bot/v3/info response.') + } + + return { + openId, + name: bot?.bot_name?.trim() || undefined + } + } + + async startMessageStream(params: { + onMessage: (event: FeishuRawMessageEvent) => Promise + }): Promise { + this.stop() + + const dispatcherOptions: { + encryptKey?: string + verificationToken?: string + } = {} + + if (this.credentials.encryptKey.trim()) { + dispatcherOptions.encryptKey = this.credentials.encryptKey + } + + if (this.credentials.verificationToken.trim()) { + dispatcherOptions.verificationToken = this.credentials.verificationToken + } + + const dispatcher = new Lark.EventDispatcher(dispatcherOptions) + + dispatcher.register({ + 'im.message.receive_v1': async (event: FeishuRawMessageEvent) => { + await params.onMessage(event) + } + }) + + this.wsClient = new Lark.WSClient({ + appId: this.credentials.appId, + appSecret: this.credentials.appSecret, + loggerLevel: Lark.LoggerLevel.info + }) + + await this.wsClient.start({ + eventDispatcher: dispatcher + }) + } + + stop(): void { + this.wsClient?.close({ force: true }) + this.wsClient = null + } + + async sendText(target: FeishuTransportTarget, text: string): Promise { + for (const chunk of chunkFeishuText(text)) { + if (target.replyToMessageId) { + await this.sdk.im.message.reply({ + path: { + message_id: target.replyToMessageId + }, + data: { + content: createTextPayload(chunk), + msg_type: 'text', + reply_in_thread: Boolean(target.threadId) + } + }) + continue + } + + await this.sdk.im.message.create({ + params: { + receive_id_type: 'chat_id' + }, + data: { + receive_id: target.chatId, + msg_type: 'text', + content: createTextPayload(chunk) + } + }) + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts new file mode 100644 index 000000000..fb05da665 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts @@ -0,0 +1,69 @@ +import type { TelegramCommandPayload } from '../types' +import type { FeishuRawMessageEvent } from './feishuClient' +import type { FeishuInboundMessage } from '../types' + +const FEISHU_COMMAND_REGEX = /^\/([a-zA-Z0-9_]+)(?:\s+([\s\S]*))?$/ +const FEISHU_LEADING_AT_TAG_REGEX = /^(?:\s*]*>.*?<\/at>\s*)+/i +const FEISHU_LEADING_AT_TEXT_REGEX = /^(?:\s*@[\w.-]+\s*)+/i + +const parseTextContent = (content: string): string => { + try { + const parsed = JSON.parse(content) as { text?: string } + if (typeof parsed?.text === 'string') { + return parsed.text.trim() + } + } catch { + // Fall through to raw content. + } + + return content.trim() +} + +const stripLeadingMentions = (text: string): string => + text.replace(FEISHU_LEADING_AT_TAG_REGEX, '').replace(FEISHU_LEADING_AT_TEXT_REGEX, '').trim() + +const parseCommand = (text: string): TelegramCommandPayload | null => { + const match = FEISHU_COMMAND_REGEX.exec(text) + if (!match) { + return null + } + + return { + name: match[1].toLowerCase(), + args: match[2]?.trim() ?? '' + } +} + +export class FeishuParser { + parseEvent(event: FeishuRawMessageEvent, botOpenId?: string): FeishuInboundMessage | null { + const rawText = parseTextContent(event.message?.content ?? '') + if (!rawText) { + return null + } + + const mentions = event.message?.mentions ?? [] + const mentionedBot = Boolean( + botOpenId && + mentions.some((mention) => mention.id?.open_id && mention.id.open_id === botOpenId) + ) + + const normalizedText = stripLeadingMentions(rawText) + if (!normalizedText) { + return null + } + + return { + kind: 'message', + eventId: event.event_id?.trim() || event.uuid?.trim() || event.message.message_id, + chatId: event.message.chat_id, + threadId: event.message.thread_id || event.message.root_id || null, + messageId: event.message.message_id, + chatType: event.message.chat_type === 'p2p' ? 'p2p' : 'group', + senderOpenId: event.sender?.sender_id?.open_id?.trim() || null, + text: normalizedText, + command: parseCommand(normalizedText), + mentionedBot, + mentions + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts new file mode 100644 index 000000000..1f01d2362 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts @@ -0,0 +1,368 @@ +import { + FEISHU_CONVERSATION_POLL_TIMEOUT_MS, + FEISHU_INBOUND_DEDUP_LIMIT, + FEISHU_INBOUND_DEDUP_TTL_MS, + TELEGRAM_STREAM_POLL_INTERVAL_MS, + buildFeishuEndpointKey, + type FeishuInboundMessage, + type FeishuRuntimeStatusSnapshot, + type FeishuTransportTarget +} from '../types' +import { FeishuCommandRouter } from '../services/feishuCommandRouter' +import type { RemoteConversationExecution } from '../services/remoteConversationRunner' +import { FeishuClient, type FeishuBotIdentity } from './feishuClient' +import { FeishuParser } from './feishuParser' + +const sleep = async (ms: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +const FEISHU_INTERNAL_ERROR_REPLY = 'An internal error occurred while processing your request.' + +type FeishuRuntimeDeps = { + client: FeishuClient + parser: FeishuParser + router: FeishuCommandRouter + logger?: { + error: (...params: unknown[]) => void + } + onStatusChange?: (snapshot: FeishuRuntimeStatusSnapshot) => void + onFatalError?: (message: string) => void +} + +type FeishuProcessedInboundEntry = { + receivedAt: number + eventId: string | null +} + +export class FeishuRuntime { + private runId = 0 + private started = false + private stopRequested = false + private statusSnapshot: FeishuRuntimeStatusSnapshot = { + state: 'stopped', + lastError: null, + botUser: null + } + private readonly processedInboundByMessage = new Map() + private readonly processedEventToMessage = new Map() + private readonly endpointOperations = new Map>() + + constructor(private readonly deps: FeishuRuntimeDeps) {} + + async start(): Promise { + if (this.started) { + return + } + + const runId = ++this.runId + this.started = true + this.stopRequested = false + this.setStatus({ + state: 'starting', + lastError: null + }) + + try { + const botUser = await this.deps.client.probeBot() + if (!this.isCurrentRun(runId)) { + return + } + + this.setBotUser(botUser) + await this.deps.client.startMessageStream({ + onMessage: async (event) => { + try { + this.acceptRawMessage(event, runId) + } catch (error) { + console.warn('[FeishuRuntime] Failed to enqueue event:', error) + } + } + }) + if (!this.isCurrentRun(runId)) { + return + } + + this.setStatus({ + state: 'running', + lastError: null + }) + } catch (error) { + if (!this.isCurrentRun(runId)) { + return + } + + this.started = false + this.setStatus({ + state: 'error', + lastError: error instanceof Error ? error.message : String(error) + }) + throw error + } + } + + async stop(): Promise { + this.stopRequested = true + this.started = false + this.runId += 1 + this.deps.client.stop() + this.endpointOperations.clear() + this.processedInboundByMessage.clear() + this.processedEventToMessage.clear() + this.setStatus({ + state: 'stopped' + }) + } + + getStatusSnapshot(): FeishuRuntimeStatusSnapshot { + return { ...this.statusSnapshot } + } + + private isCurrentRun(runId: number): boolean { + return this.runId === runId && this.started && !this.stopRequested + } + + private acceptRawMessage(event: Parameters[0], runId: number): void { + if (!this.isCurrentRun(runId)) { + return + } + + const parsed = this.deps.parser.parseEvent(event, this.statusSnapshot.botUser?.openId) + if (!parsed) { + return + } + + const duplicateReason = this.rememberInboundMessage(parsed) + if (duplicateReason) { + console.info('[FeishuRuntime] Dropped duplicate inbound message.', { + reason: duplicateReason, + chatId: parsed.chatId, + threadId: parsed.threadId, + messageId: parsed.messageId, + eventId: parsed.eventId + }) + return + } + + const endpointKey = buildFeishuEndpointKey(parsed.chatId, parsed.threadId) + if (parsed.command?.name === 'stop') { + void this.processInboundMessage(parsed, runId) + return + } + + this.enqueueEndpointOperation(endpointKey, runId, async () => { + await this.processInboundMessage(parsed, runId) + }) + } + + private rememberInboundMessage(message: FeishuInboundMessage): 'eventId' | 'messageId' | null { + const now = Date.now() + this.pruneProcessedInbound(now) + + const messageKey = this.buildMessageDedupKey(message) + if (this.processedInboundByMessage.has(messageKey)) { + return 'messageId' + } + + const normalizedEventId = message.eventId.trim() + if (normalizedEventId && this.processedEventToMessage.has(normalizedEventId)) { + return 'eventId' + } + + this.processedInboundByMessage.set(messageKey, { + receivedAt: now, + eventId: normalizedEventId || null + }) + if (normalizedEventId) { + this.processedEventToMessage.set(normalizedEventId, messageKey) + } + + while (this.processedInboundByMessage.size > FEISHU_INBOUND_DEDUP_LIMIT) { + const oldestKey = this.processedInboundByMessage.keys().next().value + if (!oldestKey) { + break + } + this.deleteProcessedInbound(oldestKey) + } + + return null + } + + private buildMessageDedupKey(message: FeishuInboundMessage): string { + return `${message.chatId}:${message.messageId}` + } + + private pruneProcessedInbound(now: number): void { + for (const [messageKey, entry] of this.processedInboundByMessage.entries()) { + if (now - entry.receivedAt <= FEISHU_INBOUND_DEDUP_TTL_MS) { + break + } + this.deleteProcessedInbound(messageKey) + } + } + + private deleteProcessedInbound(messageKey: string): void { + const entry = this.processedInboundByMessage.get(messageKey) + if (!entry) { + return + } + + this.processedInboundByMessage.delete(messageKey) + if (entry.eventId) { + this.processedEventToMessage.delete(entry.eventId) + } + } + + private enqueueEndpointOperation( + endpointKey: string, + runId: number, + operation: () => Promise + ): void { + const previous = this.endpointOperations.get(endpointKey) ?? Promise.resolve() + const next = previous + .catch(() => undefined) + .then(async () => { + if (!this.isCurrentRun(runId)) { + return + } + + await operation() + }) + .finally(() => { + if (this.endpointOperations.get(endpointKey) === next) { + this.endpointOperations.delete(endpointKey) + } + }) + + this.endpointOperations.set(endpointKey, next) + } + + private async processInboundMessage(parsed: FeishuInboundMessage, runId: number): Promise { + if (!this.isCurrentRun(runId)) { + return + } + + const target: FeishuTransportTarget = { + chatId: parsed.chatId, + threadId: parsed.threadId, + replyToMessageId: parsed.messageId + } + + try { + const routed = await this.deps.router.handleMessage(parsed) + if (!this.isCurrentRun(runId)) { + return + } + + for (const reply of routed.replies) { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText(target, reply) + } + + if (routed.conversation) { + await this.deliverConversation(target, routed.conversation, runId) + } + } catch (error) { + const diagnostics = { + runId, + target, + chatId: parsed.chatId, + threadId: parsed.threadId, + messageId: parsed.messageId, + eventId: parsed.eventId + } + + console.warn('[FeishuRuntime] Failed to handle event:', { + ...diagnostics, + error + }) + if (this.deps.logger?.error) { + this.deps.logger.error(error, diagnostics) + } else { + console.error('[FeishuRuntime] Failed to handle event:', error, diagnostics) + } + + if (!this.isCurrentRun(runId)) { + return + } + + try { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText(target, FEISHU_INTERNAL_ERROR_REPLY) + } catch (sendError) { + console.warn('[FeishuRuntime] Failed to send error reply:', { + chatId: parsed.chatId, + threadId: parsed.threadId, + messageId: parsed.messageId, + eventId: parsed.eventId, + error: sendError + }) + } + } + } + + private async deliverConversation( + target: FeishuTransportTarget, + execution: RemoteConversationExecution, + runId: number + ): Promise { + const startedAt = Date.now() + + while (this.isCurrentRun(runId)) { + const snapshot = await execution.getSnapshot() + if (!this.isCurrentRun(runId)) { + return + } + + if (snapshot.completed) { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText(target, snapshot.text) + return + } + + if (Date.now() - startedAt >= FEISHU_CONVERSATION_POLL_TIMEOUT_MS) { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText( + target, + 'The current conversation timed out before finishing. Please try again.' + ) + return + } + + await sleep(TELEGRAM_STREAM_POLL_INTERVAL_MS) + } + } + + private setBotUser(botUser: FeishuBotIdentity): void { + this.setStatus({ + botUser: { + openId: botUser.openId, + name: botUser.name + } + }) + } + + private setStatus( + patch: Partial & { + state?: FeishuRuntimeStatusSnapshot['state'] + } + ): void { + this.statusSnapshot = { + ...this.statusSnapshot, + ...patch + } + this.deps.onStatusChange?.(this.getStatusSnapshot()) + + if (patch.state === 'error' && patch.lastError) { + this.deps.onFatalError?.(patch.lastError) + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts index f9cea6de0..9512d6cee 100644 --- a/src/main/presenter/remoteControlPresenter/index.ts +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -1,5 +1,12 @@ import type { HookTestResult, TelegramNotificationsConfig } from '@shared/hooksNotifications' import type { + FeishuPairingSnapshot, + FeishuRemoteSettings, + FeishuRemoteStatus, + RemoteBindingSummary, + RemoteChannel, + RemoteChannelSettings, + RemoteChannelStatus, TelegramPairingSnapshot, TelegramRemoteBindingSummary, TelegramRemoteSettings, @@ -8,20 +15,35 @@ import type { import { TELEGRAM_REMOTE_COMMANDS, TELEGRAM_REMOTE_DEFAULT_AGENT_ID, + buildBindingSummary, + normalizeFeishuSettingsInput, normalizeTelegramSettingsInput, parseTelegramEndpointKey, + type FeishuRuntimeStatusSnapshot, type TelegramPollerStatusSnapshot } from './types' import type { RemoteControlPresenterDeps } from './interface' +import logger from '@shared/logger' import { RemoteBindingStore } from './services/remoteBindingStore' +import { FeishuAuthGuard } from './services/feishuAuthGuard' +import { FeishuCommandRouter } from './services/feishuCommandRouter' import { RemoteAuthGuard } from './services/remoteAuthGuard' import { RemoteConversationRunner } from './services/remoteConversationRunner' import { RemoteCommandRouter } from './services/remoteCommandRouter' +import { FeishuClient } from './feishu/feishuClient' +import { FeishuParser } from './feishu/feishuParser' +import { FeishuRuntime } from './feishu/feishuRuntime' import { TelegramClient } from './telegram/telegramClient' import { TelegramParser } from './telegram/telegramParser' import { TelegramPoller } from './telegram/telegramPoller' -const DEFAULT_POLLER_STATUS: TelegramPollerStatusSnapshot = { +const DEFAULT_TELEGRAM_POLLER_STATUS: TelegramPollerStatusSnapshot = { + state: 'stopped', + lastError: null, + botUser: null +} + +const DEFAULT_FEISHU_RUNTIME_STATUS: FeishuRuntimeStatusSnapshot = { state: 'stopped', lastError: null, botUser: null @@ -30,8 +52,11 @@ const DEFAULT_POLLER_STATUS: TelegramPollerStatusSnapshot = { export class RemoteControlPresenter { private readonly bindingStore: RemoteBindingStore private telegramPoller: TelegramPoller | null = null - private telegramPollerStatus: TelegramPollerStatusSnapshot = { ...DEFAULT_POLLER_STATUS } + private telegramPollerStatus: TelegramPollerStatusSnapshot = { ...DEFAULT_TELEGRAM_POLLER_STATUS } private activeBotToken: string | null = null + private feishuRuntime: FeishuRuntime | null = null + private feishuRuntimeStatus: FeishuRuntimeStatusSnapshot = { ...DEFAULT_FEISHU_RUNTIME_STATUS } + private activeFeishuRuntimeKey: string | null = null private runtimeOperation: Promise = Promise.resolve() constructor(private readonly deps: RemoteControlPresenterDeps) { @@ -40,13 +65,13 @@ export class RemoteControlPresenter { async initialize(): Promise { await this.enqueueRuntimeOperation(async () => { - await this.rebuildTelegramRuntime() + await Promise.all([this.rebuildTelegramRuntime(), this.rebuildFeishuRuntime()]) }) } async destroy(): Promise { await this.enqueueRuntimeOperation(async () => { - await this.stopTelegramRuntime() + await Promise.all([this.stopTelegramRuntime(), this.stopFeishuRuntime()]) }) } @@ -68,9 +93,112 @@ export class RemoteControlPresenter { } } + buildFeishuSettingsSnapshot(): FeishuRemoteSettings { + const remoteConfig = this.bindingStore.getFeishuConfig() + return { + appId: remoteConfig.appId, + appSecret: remoteConfig.appSecret, + verificationToken: remoteConfig.verificationToken, + encryptKey: remoteConfig.encryptKey, + remoteEnabled: remoteConfig.enabled, + defaultAgentId: remoteConfig.defaultAgentId, + pairedUserOpenIds: [...remoteConfig.pairedUserOpenIds] + } + } + + async getChannelSettings(channel: 'telegram'): Promise + async getChannelSettings(channel: 'feishu'): Promise + async getChannelSettings(channel: RemoteChannel): Promise + async getChannelSettings(channel: RemoteChannel): Promise { + if (channel === 'telegram') { + return await this.getTelegramSettings() + } + + return await this.getFeishuSettings() + } + + async saveChannelSettings( + channel: 'telegram', + input: TelegramRemoteSettings + ): Promise + async saveChannelSettings( + channel: 'feishu', + input: FeishuRemoteSettings + ): Promise + async saveChannelSettings( + channel: RemoteChannel, + input: RemoteChannelSettings + ): Promise + async saveChannelSettings( + channel: RemoteChannel, + input: RemoteChannelSettings + ): Promise { + if (channel === 'telegram') { + return await this.saveTelegramSettings(input as TelegramRemoteSettings) + } + + return await this.saveFeishuSettings(input as FeishuRemoteSettings) + } + + async getChannelStatus(channel: 'telegram'): Promise + async getChannelStatus(channel: 'feishu'): Promise + async getChannelStatus(channel: RemoteChannel): Promise + async getChannelStatus(channel: RemoteChannel): Promise { + if (channel === 'telegram') { + return await this.getTelegramStatus() + } + + return await this.getFeishuStatus() + } + + async getChannelBindings(channel: RemoteChannel): Promise { + return this.bindingStore + .listBindings(channel) + .map(({ endpointKey, binding }) => buildBindingSummary(endpointKey, binding)) + .filter((binding): binding is RemoteBindingSummary => binding !== null) + .sort((left, right) => right.updatedAt - left.updatedAt) + } + + async removeChannelBinding(channel: RemoteChannel, endpointKey: string): Promise { + if (!endpointKey.startsWith(`${channel}:`)) { + return + } + + this.bindingStore.clearBinding(endpointKey) + } + + async getChannelPairingSnapshot(channel: 'telegram'): Promise + async getChannelPairingSnapshot(channel: 'feishu'): Promise + async getChannelPairingSnapshot( + channel: RemoteChannel + ): Promise + async getChannelPairingSnapshot( + channel: RemoteChannel + ): Promise { + if (channel === 'telegram') { + return this.bindingStore.getTelegramPairingSnapshot() + } + + return this.bindingStore.getFeishuPairingSnapshot() + } + + async createChannelPairCode( + channel: RemoteChannel + ): Promise<{ code: string; expiresAt: number }> { + return this.bindingStore.createPairCode(channel) + } + + async clearChannelPairCode(channel: RemoteChannel): Promise { + this.bindingStore.clearPairCode(channel) + } + + async clearChannelBindings(channel: RemoteChannel): Promise { + return this.bindingStore.clearBindings(channel) + } + async getTelegramSettings(): Promise { const snapshot = this.buildTelegramSettingsSnapshot() - const defaultAgentId = await this.sanitizeDefaultAgentId(snapshot.defaultAgentId) + const defaultAgentId = await this.sanitizeDefaultAgentId('telegram', snapshot.defaultAgentId) return { ...snapshot, defaultAgentId @@ -79,7 +207,7 @@ export class RemoteControlPresenter { async saveTelegramSettings(input: TelegramRemoteSettings): Promise { const normalized = normalizeTelegramSettingsInput(input) - const defaultAgentId = await this.sanitizeDefaultAgentId(normalized.defaultAgentId) + const defaultAgentId = await this.sanitizeDefaultAgentId('telegram', normalized.defaultAgentId) const currentHooksConfig = this.deps.getHooksNotificationsConfig() const currentRemoteConfig = this.bindingStore.getTelegramConfig() const currentBotToken = currentHooksConfig.telegram.botToken.trim() @@ -97,7 +225,7 @@ export class RemoteControlPresenter { enabled: normalized.remoteEnabled, allowlist: normalized.allowedUserIds, defaultAgentId, - streamMode: 'draft', + streamMode: currentRemoteConfig.streamMode, lastFatalError: shouldClearFatalError ? null : config.lastFatalError, pairing: config.pairing })) @@ -111,13 +239,14 @@ export class RemoteControlPresenter { async getTelegramStatus(): Promise { const remoteConfig = this.bindingStore.getTelegramConfig() const hooksConfig = this.deps.getHooksNotificationsConfig().telegram - const runtimeStatus = this.getEffectivePollerStatus( + const runtimeStatus = this.getEffectiveTelegramStatus( hooksConfig.botToken, remoteConfig.enabled, remoteConfig.lastFatalError ) return { + channel: 'telegram', enabled: remoteConfig.enabled, state: runtimeStatus.state, pollOffset: remoteConfig.pollOffset, @@ -130,7 +259,7 @@ export class RemoteControlPresenter { async getTelegramBindings(): Promise { return this.bindingStore - .listBindings() + .listBindings('telegram') .map(({ endpointKey, binding }) => { const endpoint = parseTelegramEndpointKey(endpointKey) if (!endpoint) { @@ -150,23 +279,82 @@ export class RemoteControlPresenter { } async removeTelegramBinding(endpointKey: string): Promise { - this.bindingStore.clearBinding(endpointKey) + await this.removeChannelBinding('telegram', endpointKey) } async getTelegramPairingSnapshot(): Promise { - return this.bindingStore.getPairingSnapshot() + return this.bindingStore.getTelegramPairingSnapshot() } async createTelegramPairCode(): Promise<{ code: string; expiresAt: number }> { - return this.bindingStore.createPairCode() + return await this.createChannelPairCode('telegram') } async clearTelegramPairCode(): Promise { - this.bindingStore.clearPairCode() + await this.clearChannelPairCode('telegram') } async clearTelegramBindings(): Promise { - return this.bindingStore.clearBindings() + return await this.clearChannelBindings('telegram') + } + + async getFeishuSettings(): Promise { + const snapshot = this.buildFeishuSettingsSnapshot() + const defaultAgentId = await this.sanitizeDefaultAgentId('feishu', snapshot.defaultAgentId) + return { + ...snapshot, + defaultAgentId + } + } + + async saveFeishuSettings(input: FeishuRemoteSettings): Promise { + const normalized = normalizeFeishuSettingsInput(input) + const defaultAgentId = await this.sanitizeDefaultAgentId('feishu', normalized.defaultAgentId) + const currentRemoteConfig = this.bindingStore.getFeishuConfig() + const shouldClearFatalError = + currentRemoteConfig.enabled !== normalized.remoteEnabled || + currentRemoteConfig.appId !== normalized.appId || + currentRemoteConfig.appSecret !== normalized.appSecret || + currentRemoteConfig.verificationToken !== normalized.verificationToken || + currentRemoteConfig.encryptKey !== normalized.encryptKey + + this.bindingStore.updateFeishuConfig((config) => ({ + ...config, + appId: normalized.appId, + appSecret: normalized.appSecret, + verificationToken: normalized.verificationToken, + encryptKey: normalized.encryptKey, + enabled: normalized.remoteEnabled, + defaultAgentId, + pairedUserOpenIds: normalized.pairedUserOpenIds, + lastFatalError: shouldClearFatalError ? null : config.lastFatalError, + pairing: config.pairing + })) + + await this.enqueueRuntimeOperation(async () => { + await this.rebuildFeishuRuntime() + }) + return await this.getFeishuSettings() + } + + async getFeishuStatus(): Promise { + const remoteConfig = this.bindingStore.getFeishuConfig() + const runtimeStatus = this.getEffectiveFeishuStatus( + remoteConfig.enabled, + remoteConfig.lastFatalError, + remoteConfig.appId, + remoteConfig.appSecret + ) + + return { + channel: 'feishu', + enabled: remoteConfig.enabled, + state: runtimeStatus.state, + bindingCount: Object.keys(remoteConfig.bindings).length, + pairedUserCount: remoteConfig.pairedUserOpenIds.length, + lastError: runtimeStatus.lastError, + botUser: runtimeStatus.botUser + } } async testTelegramHookNotification(): Promise { @@ -227,23 +415,12 @@ export class RemoteControlPresenter { await this.registerTelegramCommands(client) const authGuard = new RemoteAuthGuard(this.bindingStore) - const runner = new RemoteConversationRunner( - { - configPresenter: this.deps.configPresenter, - newAgentPresenter: this.deps.newAgentPresenter, - deepchatAgentPresenter: this.deps.deepchatAgentPresenter, - windowPresenter: this.deps.windowPresenter, - tabPresenter: this.deps.tabPresenter, - resolveDefaultAgentId: async () => - await this.sanitizeDefaultAgentId(this.bindingStore.getDefaultAgentId()) - }, - this.bindingStore - ) + const runner = this.createConversationRunner('telegram') const router = new RemoteCommandRouter({ authGuard, runner, bindingStore: this.bindingStore, - getPollerStatus: () => this.getEffectivePollerStatus(botToken, true, null) + getPollerStatus: () => this.getEffectiveTelegramStatus(botToken, true, null) }) this.telegramPoller = new TelegramPoller({ @@ -273,6 +450,84 @@ export class RemoteControlPresenter { } } + private async rebuildFeishuRuntime(): Promise { + const settings = this.buildFeishuSettingsSnapshot() + const runtimeKey = this.buildFeishuRuntimeKey(settings) + + if (!settings.remoteEnabled) { + await this.stopFeishuRuntime() + this.feishuRuntimeStatus = { + state: 'disabled', + lastError: null, + botUser: null + } + return + } + + if (!settings.appId.trim() || !settings.appSecret.trim()) { + await this.stopFeishuRuntime() + this.feishuRuntimeStatus = { + state: 'error', + lastError: 'App ID and App Secret are required.', + botUser: null + } + return + } + + if (this.feishuRuntime && this.activeFeishuRuntimeKey === runtimeKey) { + return + } + + await this.stopFeishuRuntime() + this.activeFeishuRuntimeKey = runtimeKey + this.feishuRuntimeStatus = { + state: 'starting', + lastError: null, + botUser: null + } + + const client = new FeishuClient({ + appId: settings.appId, + appSecret: settings.appSecret, + verificationToken: settings.verificationToken, + encryptKey: settings.encryptKey + }) + const runner = this.createConversationRunner('feishu') + const router = new FeishuCommandRouter({ + authGuard: new FeishuAuthGuard(this.bindingStore), + runner, + bindingStore: this.bindingStore, + getRuntimeStatus: () => + this.getEffectiveFeishuStatus(true, null, settings.appId, settings.appSecret) + }) + + this.feishuRuntime = new FeishuRuntime({ + client, + parser: new FeishuParser(), + router, + logger, + onStatusChange: (snapshot) => { + this.feishuRuntimeStatus = snapshot + }, + onFatalError: (message) => { + void this.enqueueRuntimeOperation(async () => { + await this.disableFeishuRuntimeForFatalError(runtimeKey, message) + }) + } + }) + + try { + await this.feishuRuntime.start() + } catch (error) { + this.feishuRuntimeStatus = { + state: 'error', + lastError: error instanceof Error ? error.message : String(error), + botUser: null + } + await this.stopFeishuRuntime() + } + } + private async stopTelegramRuntime(): Promise { const poller = this.telegramPoller this.telegramPoller = null @@ -285,7 +540,19 @@ export class RemoteControlPresenter { await poller.stop() } - private getEffectivePollerStatus( + private async stopFeishuRuntime(): Promise { + const runtime = this.feishuRuntime + this.feishuRuntime = null + this.activeFeishuRuntimeKey = null + + if (!runtime) { + return + } + + await runtime.stop() + } + + private getEffectiveTelegramStatus( botToken: string, remoteEnabled: boolean, lastFatalError: string | null @@ -317,6 +584,39 @@ export class RemoteControlPresenter { return { ...this.telegramPollerStatus } } + private getEffectiveFeishuStatus( + remoteEnabled: boolean, + lastFatalError: string | null, + appId: string, + appSecret: string + ): FeishuRuntimeStatusSnapshot { + if (!remoteEnabled) { + if (lastFatalError) { + return { + state: 'error', + lastError: lastFatalError, + botUser: null + } + } + + return { + state: 'disabled', + lastError: null, + botUser: null + } + } + + if (!appId.trim() || !appSecret.trim()) { + return { + state: 'error', + lastError: 'App ID and App Secret are required.', + botUser: null + } + } + + return { ...this.feishuRuntimeStatus } + } + private async disableTelegramRuntimeForFatalError( botToken: string, errorMessage: string @@ -342,13 +642,82 @@ export class RemoteControlPresenter { } } + private async disableFeishuRuntimeForFatalError( + runtimeKey: string, + errorMessage: string + ): Promise { + const currentRemoteConfig = this.bindingStore.getFeishuConfig() + if ( + !currentRemoteConfig.enabled || + this.buildFeishuRuntimeKey({ + appId: currentRemoteConfig.appId, + appSecret: currentRemoteConfig.appSecret, + verificationToken: currentRemoteConfig.verificationToken, + encryptKey: currentRemoteConfig.encryptKey + }) !== runtimeKey + ) { + return + } + + this.bindingStore.updateFeishuConfig((config) => ({ + ...config, + enabled: false, + lastFatalError: errorMessage + })) + + await this.stopFeishuRuntime() + this.feishuRuntimeStatus = { + state: 'error', + lastError: errorMessage, + botUser: null + } + } + + private createConversationRunner(channel: RemoteChannel): RemoteConversationRunner { + return new RemoteConversationRunner( + { + configPresenter: this.deps.configPresenter, + newAgentPresenter: this.deps.newAgentPresenter, + deepchatAgentPresenter: this.deps.deepchatAgentPresenter, + windowPresenter: this.deps.windowPresenter, + tabPresenter: this.deps.tabPresenter, + resolveDefaultAgentId: async () => + await this.sanitizeDefaultAgentId(channel, this.getDefaultAgentId(channel)) + }, + this.bindingStore + ) + } + + private getDefaultAgentId(channel: RemoteChannel): string { + return channel === 'telegram' + ? this.bindingStore.getTelegramDefaultAgentId() + : this.bindingStore.getFeishuDefaultAgentId() + } + + private buildFeishuRuntimeKey(settings: { + appId: string + appSecret: string + verificationToken: string + encryptKey: string + }): string { + return [ + settings.appId.trim(), + settings.appSecret.trim(), + settings.verificationToken.trim(), + settings.encryptKey.trim() + ].join('::') + } + private enqueueRuntimeOperation(operation: () => Promise): Promise { const nextOperation = this.runtimeOperation.then(operation, operation) this.runtimeOperation = nextOperation.catch(() => {}) return nextOperation } - private async sanitizeDefaultAgentId(candidate: string | null | undefined): Promise { + private async sanitizeDefaultAgentId( + channel: RemoteChannel, + candidate: string | null | undefined + ): Promise { const normalizedCandidate = candidate?.trim() || TELEGRAM_REMOTE_DEFAULT_AGENT_ID const agents = await this.deps.configPresenter.listAgents() const enabledDeepChatAgents = agents.filter( @@ -361,8 +730,15 @@ export class RemoteControlPresenter { ? TELEGRAM_REMOTE_DEFAULT_AGENT_ID : enabledDeepChatAgents[0]?.id || TELEGRAM_REMOTE_DEFAULT_AGENT_ID - if (this.bindingStore.getDefaultAgentId() !== nextDefaultAgentId) { - this.bindingStore.updateTelegramConfig((config) => ({ + if (channel === 'telegram') { + if (this.bindingStore.getTelegramDefaultAgentId() !== nextDefaultAgentId) { + this.bindingStore.updateTelegramConfig((config) => ({ + ...config, + defaultAgentId: nextDefaultAgentId + })) + } + } else if (this.bindingStore.getFeishuDefaultAgentId() !== nextDefaultAgentId) { + this.bindingStore.updateFeishuConfig((config) => ({ ...config, defaultAgentId: nextDefaultAgentId })) diff --git a/src/main/presenter/remoteControlPresenter/interface.ts b/src/main/presenter/remoteControlPresenter/interface.ts index b955446c6..cdcd74b01 100644 --- a/src/main/presenter/remoteControlPresenter/interface.ts +++ b/src/main/presenter/remoteControlPresenter/interface.ts @@ -1,5 +1,6 @@ import type { HookTestResult, HooksNotificationsSettings } from '@shared/hooksNotifications' import type { + FeishuRemoteSettings, IConfigPresenter, INewAgentPresenter, IRemoteControlPresenter, @@ -28,4 +29,5 @@ export interface RemoteRuntimeLifecycle { export interface RemoteControlPresenterLike extends IRemoteControlPresenter, RemoteRuntimeLifecycle { buildTelegramSettingsSnapshot(): TelegramRemoteSettings + buildFeishuSettingsSnapshot(): FeishuRemoteSettings } diff --git a/src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts b/src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts new file mode 100644 index 000000000..66f523c07 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts @@ -0,0 +1,83 @@ +import { REMOTE_PAIR_CODE_MAX_FAILURES, type FeishuInboundMessage } from '../types' +import { RemoteBindingStore } from './remoteBindingStore' + +export type FeishuAuthResult = + | { + ok: true + userOpenId: string + } + | { + ok: false + message: string + silent?: boolean + } + +export class FeishuAuthGuard { + constructor(private readonly bindingStore: RemoteBindingStore) {} + + ensureAuthorized(message: FeishuInboundMessage): FeishuAuthResult { + if (message.chatType !== 'p2p' && !message.mentionedBot) { + return { + ok: false, + message: '', + silent: true + } + } + + if (!message.senderOpenId) { + return { + ok: false, + message: 'Unable to verify your Feishu account.' + } + } + + if (this.bindingStore.isFeishuPairedUser(message.senderOpenId)) { + return { + ok: true, + userOpenId: message.senderOpenId + } + } + + return { + ok: false, + message: + 'This Feishu account is not paired. Open Remote settings and use the current /pair code.' + } + } + + pair(message: FeishuInboundMessage, rawCode: string): string { + if (message.chatType !== 'p2p') { + return 'Pairing is only available in a private chat with the Feishu bot.' + } + + if (!message.senderOpenId) { + return 'Unable to verify your Feishu account for pairing.' + } + + const normalizedCode = rawCode.trim() + if (!/^\d{6}$/.test(normalizedCode)) { + return 'Usage: /pair <6-digit-code>' + } + + const pairing = this.bindingStore.getFeishuPairingState() + if (!pairing.code || !pairing.expiresAt || pairing.expiresAt <= Date.now()) { + this.bindingStore.clearPairCode('feishu') + return 'Pairing code is missing or expired. Generate a new code from DeepChat Remote settings.' + } + + if (pairing.code !== normalizedCode) { + const result = this.bindingStore.recordPairCodeFailure( + 'feishu', + REMOTE_PAIR_CODE_MAX_FAILURES + ) + if (result.exhausted) { + return 'Too many invalid pairing attempts. The current pairing code has expired. Generate a new code from DeepChat Remote settings.' + } + return 'Pairing code is invalid.' + } + + this.bindingStore.addFeishuPairedUser(message.senderOpenId) + this.bindingStore.clearPairCode('feishu') + return `Pairing complete. Feishu user ${message.senderOpenId} is now authorized.` + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts new file mode 100644 index 000000000..614d983f9 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts @@ -0,0 +1,295 @@ +import type { SessionWithState } from '@shared/types/agent-interface' +import { + FEISHU_REMOTE_COMMANDS, + buildFeishuBindingMeta, + buildFeishuEndpointKey, + type FeishuInboundMessage, + type FeishuRuntimeStatusSnapshot, + type TelegramModelProviderOption +} from '../types' +import type { RemoteConversationExecution } from './remoteConversationRunner' +import { FeishuAuthGuard } from './feishuAuthGuard' +import { RemoteBindingStore } from './remoteBindingStore' +import { RemoteConversationRunner } from './remoteConversationRunner' + +export interface FeishuCommandRouteResult { + replies: string[] + conversation?: RemoteConversationExecution +} + +type FeishuCommandRouterDeps = { + authGuard: FeishuAuthGuard + runner: RemoteConversationRunner + bindingStore: RemoteBindingStore + getRuntimeStatus: () => FeishuRuntimeStatusSnapshot +} + +export class FeishuCommandRouter { + constructor(private readonly deps: FeishuCommandRouterDeps) {} + + async handleMessage(message: FeishuInboundMessage): Promise { + const endpointKey = buildFeishuEndpointKey(message.chatId, message.threadId) + const bindingMeta = buildFeishuBindingMeta({ + chatId: message.chatId, + threadId: message.threadId, + chatType: message.chatType + }) + const command = message.command?.name + + if (command === 'start') { + const auth = this.deps.authGuard.ensureAuthorized(message) + if (!auth.ok && auth.silent) { + return { + replies: [] + } + } + + return { + replies: [this.formatStartMessage(auth.ok)] + } + } + + if (command === 'help') { + if (message.chatType !== 'p2p' && !message.mentionedBot) { + return { + replies: [] + } + } + + return { + replies: [this.formatHelpMessage()] + } + } + + if (command === 'pair') { + return { + replies: [this.deps.authGuard.pair(message, message.command?.args ?? '')] + } + } + + const auth = this.deps.authGuard.ensureAuthorized(message) + if (!auth.ok) { + return { + replies: auth.silent ? [] : [auth.message] + } + } + + try { + switch (command) { + case 'new': { + const title = message.command?.args?.trim() + const session = await this.deps.runner.createNewSession(endpointKey, title, bindingMeta) + return { + replies: [`Started a new session: ${this.formatSessionLabel(session)}`] + } + } + + case 'sessions': { + const sessions = await this.deps.runner.listSessions(endpointKey) + if (sessions.length === 0) { + return { + replies: ['No DeepChat sessions were found.'] + } + } + + return { + replies: [ + [ + 'Recent sessions:', + ...sessions.map((session, index) => this.formatSessionLine(session, index + 1)) + ].join('\n') + ] + } + } + + case 'use': { + const rawIndex = message.command?.args?.trim() + const index = Number.parseInt(rawIndex ?? '', 10) + if (!Number.isInteger(index) || index <= 0) { + return { + replies: ['Usage: /use '] + } + } + + const session = await this.deps.runner.useSessionByIndex( + endpointKey, + index - 1, + bindingMeta + ) + return { + replies: [`Now using: ${this.formatSessionLabel(session)}`] + } + } + + case 'stop': { + const stopped = await this.deps.runner.stop(endpointKey) + return { + replies: [ + stopped ? 'Stopped the active generation.' : 'There is no active generation to stop.' + ] + } + } + + case 'open': { + const openResult = await this.deps.runner.open(endpointKey) + return { + replies: [ + openResult.status === 'ok' + ? `Opened on desktop: ${this.formatSessionLabel(openResult.session)}` + : openResult.status === 'windowNotFound' + ? 'Could not find a DeepChat desktop window. Open DeepChat and try /open again.' + : 'No bound session. Send a message, /new, or /use first.' + ] + } + } + + case 'model': + return await this.handleModelCommand(message, endpointKey) + + case 'status': { + const runtime = this.deps.getRuntimeStatus() + const status = await this.deps.runner.getStatus(endpointKey) + const defaultAgentId = await this.deps.runner.getDefaultAgentId() + const feishuConfig = this.deps.bindingStore.getFeishuConfig() + return { + replies: [ + [ + 'DeepChat Feishu Remote', + `Runtime: ${runtime.state}`, + `Default agent: ${defaultAgentId}`, + `Current session: ${status.session ? this.formatSessionLabel(status.session) : 'none'}`, + `Current agent: ${status.session?.agentId ?? 'none'}`, + `Current model: ${status.session?.modelId ?? 'none'}`, + `Generating: ${status.isGenerating ? 'yes' : 'no'}`, + `Paired users: ${feishuConfig.pairedUserOpenIds.length}`, + `Bindings: ${Object.keys(feishuConfig.bindings).length}`, + `Last error: ${runtime.lastError ?? 'none'}` + ].join('\n') + ] + } + } + + default: + break + } + + return { + replies: [], + conversation: await this.deps.runner.sendText(endpointKey, message.text, bindingMeta) + } + } catch (error) { + return { + replies: [error instanceof Error ? error.message : String(error)] + } + } + } + + private async handleModelCommand( + message: FeishuInboundMessage, + endpointKey: string + ): Promise { + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session) { + return { + replies: ['No bound session. Send a message, /new, or /use first.'] + } + } + + const providers = await this.deps.runner.listAvailableModelProviders() + if (providers.length === 0) { + return { + replies: ['No enabled providers or models are available.'] + } + } + + const rawArgs = message.command?.args?.trim() ?? '' + if (!rawArgs) { + return { + replies: [this.formatModelOverview(session, providers)] + } + } + + const [providerId, ...modelParts] = rawArgs.split(/\s+/) + const modelId = modelParts.join(' ').trim() + if (!providerId || !modelId) { + return { + replies: ['Usage: /model '] + } + } + + const provider = providers.find((item) => item.providerId === providerId) + const model = provider?.models.find((item) => item.modelId === modelId) + if (!provider || !model) { + return { + replies: [ + `Model "${providerId} ${modelId}" is not enabled.\n\n${this.formatModelOverview(session, providers)}` + ] + } + } + + const updatedSession = await this.deps.runner.setSessionModel( + endpointKey, + provider.providerId, + model.modelId + ) + + return { + replies: [ + [ + 'Model updated.', + `Session: ${this.formatSessionLabel(updatedSession)}`, + `Provider: ${provider.providerName}`, + `Model: ${model.modelName}` + ].join('\n') + ] + } + } + + private formatModelOverview( + session: SessionWithState, + providers: TelegramModelProviderOption[] + ): string { + return [ + `Session: ${this.formatSessionLabel(session)}`, + `Current model: ${session.modelId ?? 'none'}`, + 'Usage: /model ', + '', + 'Available models:', + ...providers.flatMap((provider) => [ + `${provider.providerName} (${provider.providerId})`, + ...provider.models.map( + (model) => `- ${model.modelName} (${provider.providerId} ${model.modelId})` + ) + ]) + ].join('\n') + } + + private formatStartMessage(isAuthorized: boolean): string { + if (isAuthorized) { + return [ + 'DeepChat Feishu Remote is ready.', + 'Send any message to continue the bound session, or /help for commands.' + ].join('\n') + } + + return [ + 'DeepChat Feishu Remote is online.', + 'Pair first from a direct message with /pair before using group control.' + ].join('\n') + } + + private formatHelpMessage(): string { + return [ + 'DeepChat Feishu Remote commands:', + ...FEISHU_REMOTE_COMMANDS.map((item) => `/${item.command} - ${item.description}`) + ].join('\n') + } + + private formatSessionLabel(session: Pick): string { + return `${session.title} [${session.id}]` + } + + private formatSessionLine(session: SessionWithState, index: number): string { + return `${index}. ${session.title} [${session.id}]` + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts b/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts index 428e6fdc9..e7c171344 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts @@ -1,4 +1,8 @@ -import type { TelegramInboundEvent, TelegramInboundMessage } from '../types' +import { + REMOTE_PAIR_CODE_MAX_FAILURES, + type TelegramInboundEvent, + type TelegramInboundMessage +} from '../types' import { RemoteBindingStore } from './remoteBindingStore' export type RemoteAuthResult = @@ -54,19 +58,26 @@ export class RemoteAuthGuard { return 'Usage: /pair <6-digit-code>' } - const pairing = this.bindingStore.getPairingState() + const pairing = this.bindingStore.getTelegramPairingState() if (!pairing.code || !pairing.expiresAt || pairing.expiresAt <= Date.now()) { - this.bindingStore.clearPairCode() + this.bindingStore.clearPairCode('telegram') return 'Pairing code is missing or expired. Generate a new code from DeepChat Remote settings.' } if (pairing.code !== normalizedCode) { + const result = this.bindingStore.recordPairCodeFailure( + 'telegram', + REMOTE_PAIR_CODE_MAX_FAILURES + ) + if (result.exhausted) { + return 'Too many invalid pairing attempts. The current pairing code has expired. Generate a new code from DeepChat Remote settings.' + } return 'Pairing code is invalid.' } const userId = message.fromId as number this.bindingStore.addAllowedUser(userId) - this.bindingStore.clearPairCode() + this.bindingStore.clearPairCode('telegram') return `Pairing complete. Telegram user ${userId} is now authorized.` } diff --git a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts index d22f5678d..d4c8ada4d 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts @@ -1,14 +1,18 @@ -import type { IConfigPresenter } from '@shared/presenter' +import type { IConfigPresenter, RemoteChannel } from '@shared/presenter' import { REMOTE_CONTROL_SETTING_KEY, TELEGRAM_MODEL_MENU_TTL_MS, - buildTelegramEndpointKey, normalizeRemoteControlConfig, createPairCode, createTelegramCallbackToken, + buildFeishuPairingSnapshot, + buildTelegramEndpointKey, buildTelegramPairingSnapshot, + type FeishuPairingState, + type FeishuRemoteRuntimeConfig, type RemoteControlConfig, - type TelegramEndpointBinding, + type RemoteEndpointBinding, + type RemoteEndpointBindingMeta, type TelegramInboundEvent, type TelegramModelMenuState, type TelegramPairingState, @@ -28,8 +32,20 @@ export class RemoteBindingStore { ) } + getChannelConfig(channel: 'telegram'): TelegramRemoteRuntimeConfig + getChannelConfig(channel: 'feishu'): FeishuRemoteRuntimeConfig + getChannelConfig(channel: RemoteChannel): TelegramRemoteRuntimeConfig | FeishuRemoteRuntimeConfig + getChannelConfig(channel: RemoteChannel) { + const config = this.getConfig() + return config[channel] + } + getTelegramConfig(): TelegramRemoteRuntimeConfig { - return this.getConfig().telegram + return this.getChannelConfig('telegram') + } + + getFeishuConfig(): FeishuRemoteRuntimeConfig { + return this.getChannelConfig('feishu') } updateTelegramConfig( @@ -44,25 +60,55 @@ export class RemoteBindingStore { return next.telegram } + updateFeishuConfig( + updater: (config: FeishuRemoteRuntimeConfig) => FeishuRemoteRuntimeConfig + ): FeishuRemoteRuntimeConfig { + const current = this.getConfig() + const next = normalizeRemoteControlConfig({ + ...current, + feishu: updater(current.feishu) + }) + this.configPresenter.setSetting(REMOTE_CONTROL_SETTING_KEY, next) + return next.feishu + } + getEndpointKey( target: { chatId: number; messageThreadId?: number } | TelegramInboundEvent ): string { return buildTelegramEndpointKey(target.chatId, target.messageThreadId ?? 0) } - getBinding(endpointKey: string): TelegramEndpointBinding | null { - return this.getTelegramConfig().bindings[endpointKey] ?? null + getBinding(endpointKey: string): RemoteEndpointBinding | null { + const channel = this.resolveChannelFromEndpointKey(endpointKey) + if (!channel) { + return null + } + + return this.getChannelBindings(channel)[endpointKey] ?? null } - setBinding(endpointKey: string, sessionId: string): void { - this.updateTelegramConfig((config) => ({ - ...config, - bindings: { - ...config.bindings, - [endpointKey]: { - sessionId, - updatedAt: Date.now() - } + setBinding(endpointKey: string, sessionId: string, meta?: RemoteEndpointBindingMeta): void { + const resolvedChannel = this.resolveChannelFromEndpointKey(endpointKey) + if (!resolvedChannel) { + return + } + + this.updateBindings(resolvedChannel, (bindings) => ({ + ...bindings, + [endpointKey]: { + sessionId, + updatedAt: Date.now(), + meta: meta + ? { + ...meta, + channel: resolvedChannel + } + : bindings[endpointKey]?.meta + ? { + ...bindings[endpointKey].meta, + channel: resolvedChannel + } + : undefined } })) this.activeEvents.delete(endpointKey) @@ -70,43 +116,75 @@ export class RemoteBindingStore { } clearBinding(endpointKey: string): void { - this.updateTelegramConfig((config) => { - const bindings = { ...config.bindings } - delete bindings[endpointKey] - return { - ...config, - bindings - } + const channel = this.resolveChannelFromEndpointKey(endpointKey) + if (!channel) { + return + } + + this.updateBindings(channel, (bindings) => { + const nextBindings = { ...bindings } + delete nextBindings[endpointKey] + return nextBindings }) - this.activeEvents.delete(endpointKey) - this.sessionSnapshots.delete(endpointKey) - this.clearModelMenuStatesForEndpoint(endpointKey) + this.clearTransientStateForEndpoint(endpointKey) } - listBindings(): Array<{ + listBindings(channel?: RemoteChannel): Array<{ endpointKey: string - binding: TelegramEndpointBinding + binding: RemoteEndpointBinding }> { - return Object.entries(this.getTelegramConfig().bindings).map(([endpointKey, binding]) => ({ - endpointKey, - binding - })) + const configs = + channel === undefined + ? (['telegram', 'feishu'] as const).map( + (key) => [key, this.getChannelBindings(key)] as const + ) + : ([[channel, this.getChannelBindings(channel)]] as const) + + return configs.flatMap((entry) => { + const bindings = entry[1] + return Object.entries(bindings).map(([endpointKey, binding]) => ({ + endpointKey, + binding + })) + }) } - clearBindings(): number { - const count = Object.keys(this.getTelegramConfig().bindings).length - this.updateTelegramConfig((config) => ({ - ...config, - bindings: {} - })) - this.activeEvents.clear() - this.sessionSnapshots.clear() - this.modelMenuStates.clear() - return count + clearBindings(channel?: RemoteChannel): number { + const entries = this.listBindings(channel) + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: {} + })) + } else if (channel === 'feishu') { + this.updateFeishuConfig((config) => ({ + ...config, + bindings: {} + })) + } else { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: {} + })) + this.updateFeishuConfig((config) => ({ + ...config, + bindings: {} + })) + } + + for (const { endpointKey } of entries) { + this.clearTransientStateForEndpoint(endpointKey) + } + + if (channel === undefined) { + this.modelMenuStates.clear() + } + + return entries.length } - countBindings(): number { - return Object.keys(this.getTelegramConfig().bindings).length + countBindings(channel?: RemoteChannel): number { + return this.listBindings(channel).length } getPollOffset(): number { @@ -124,10 +202,18 @@ export class RemoteBindingStore { return this.getTelegramConfig().allowlist } - getDefaultAgentId(): string { + getTelegramDefaultAgentId(): string { return this.getTelegramConfig().defaultAgentId } + getDefaultAgentId(): string { + return this.getTelegramDefaultAgentId() + } + + getFeishuDefaultAgentId(): string { + return this.getFeishuConfig().defaultAgentId + } + isAllowedUser(userId: number | null | undefined): boolean { if (!userId) { return false @@ -144,33 +230,153 @@ export class RemoteBindingStore { })) } - getPairingState(): TelegramPairingState { + getFeishuPairedUserOpenIds(): string[] { + return this.getFeishuConfig().pairedUserOpenIds + } + + isFeishuPairedUser(openId: string | null | undefined): boolean { + if (!openId) { + return false + } + return this.getFeishuPairedUserOpenIds().includes(openId.trim()) + } + + addFeishuPairedUser(openId: string): void { + const normalized = openId.trim() + if (!normalized) { + return + } + + this.updateFeishuConfig((config) => ({ + ...config, + pairedUserOpenIds: Array.from(new Set([...config.pairedUserOpenIds, normalized])).sort( + (left, right) => left.localeCompare(right) + ) + })) + } + + getTelegramPairingState(): TelegramPairingState { return this.getTelegramConfig().pairing } - getPairingSnapshot() { + getPairingState(): TelegramPairingState { + return this.getTelegramPairingState() + } + + getFeishuPairingState(): FeishuPairingState { + return this.getFeishuConfig().pairing + } + + getTelegramPairingSnapshot() { return buildTelegramPairingSnapshot(this.getTelegramConfig()) } - createPairCode(): { code: string; expiresAt: number } { + getFeishuPairingSnapshot() { + return buildFeishuPairingSnapshot(this.getFeishuConfig()) + } + + createPairCode(channel: RemoteChannel = 'telegram'): { code: string; expiresAt: number } { const pairing = createPairCode() - this.updateTelegramConfig((config) => ({ - ...config, - pairing - })) - return pairing + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + pairing + })) + } else { + this.updateFeishuConfig((config) => ({ + ...config, + pairing + })) + } + return { + code: pairing.code!, + expiresAt: pairing.expiresAt! + } } - clearPairCode(): void { - this.updateTelegramConfig((config) => ({ + clearPairCode(channel: RemoteChannel = 'telegram'): void { + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + pairing: { + code: null, + expiresAt: null, + failedAttempts: 0 + } + })) + return + } + + this.updateFeishuConfig((config) => ({ ...config, pairing: { code: null, - expiresAt: null + expiresAt: null, + failedAttempts: 0 } })) } + recordPairCodeFailure( + channel: RemoteChannel, + maxAttempts: number + ): { attempts: number; exhausted: boolean } { + let result = { + attempts: 0, + exhausted: false + } + + if (channel === 'telegram') { + this.updateTelegramConfig((config) => { + const attempts = config.pairing.failedAttempts + 1 + const exhausted = attempts >= maxAttempts + result = { + attempts, + exhausted + } + + return { + ...config, + pairing: exhausted + ? { + code: null, + expiresAt: null, + failedAttempts: 0 + } + : { + ...config.pairing, + failedAttempts: attempts + } + } + }) + } else { + this.updateFeishuConfig((config) => { + const attempts = config.pairing.failedAttempts + 1 + const exhausted = attempts >= maxAttempts + result = { + attempts, + exhausted + } + + return { + ...config, + pairing: exhausted + ? { + code: null, + expiresAt: null, + failedAttempts: 0 + } + : { + ...config.pairing, + failedAttempts: attempts + } + } + }) + } + + return result + } + rememberActiveEvent(endpointKey: string, eventId: string): void { this.activeEvents.set(endpointKey, eventId) } @@ -236,6 +442,47 @@ export class RemoteBindingStore { this.modelMenuStates.delete(token) } + private getChannelBindings(channel: RemoteChannel): Record { + const config = this.getChannelConfig(channel) + return config.bindings + } + + private updateBindings( + channel: RemoteChannel, + updater: ( + bindings: Record + ) => Record + ): void { + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: updater(config.bindings) + })) + return + } + + this.updateFeishuConfig((config) => ({ + ...config, + bindings: updater(config.bindings) + })) + } + + private resolveChannelFromEndpointKey(endpointKey: string): RemoteChannel | null { + if (endpointKey.startsWith('telegram:')) { + return 'telegram' + } + if (endpointKey.startsWith('feishu:')) { + return 'feishu' + } + return null + } + + private clearTransientStateForEndpoint(endpointKey: string): void { + this.activeEvents.delete(endpointKey) + this.sessionSnapshots.delete(endpointKey) + this.clearModelMenuStatesForEndpoint(endpointKey) + } + private clearExpiredModelMenuStates(): void { const now = Date.now() for (const [token, state] of this.modelMenuStates.entries()) { diff --git a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts index 1f17ac06b..1d424c51e 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts @@ -10,6 +10,7 @@ import type { } from '../types' import { TELEGRAM_MODEL_MENU_TTL_MS, + TELEGRAM_REMOTE_COMMANDS, buildModelMenuBackCallbackData, buildModelMenuCancelCallbackData, buildModelMenuChoiceCallbackData, @@ -130,6 +131,19 @@ export class RemoteCommandRouter { } } + case 'open': { + const openResult = await this.deps.runner.open(endpointKey) + return { + replies: [ + openResult.status === 'ok' + ? `Opened on desktop: ${this.formatSessionLabel(openResult.session)}` + : openResult.status === 'windowNotFound' + ? 'Could not find a DeepChat desktop window. Open DeepChat and try /open again.' + : 'No bound session. Send a message, /new, or /use first.' + ] + } + } + case 'model': { const session = await this.deps.runner.getCurrentSession(endpointKey) if (!session) { @@ -426,15 +440,15 @@ export class RemoteCommandRouter { private formatHelpMessage(): string { return [ 'Commands:', - '/start', - '/help', - '/pair ', - '/new [title]', - '/sessions', - '/use ', - '/stop', - '/status', - '/model', + ...TELEGRAM_REMOTE_COMMANDS.map((item) => + item.command === 'pair' + ? '/pair - Authorize this Telegram account' + : item.command === 'new' + ? '/new [title] - Start a new DeepChat session' + : item.command === 'use' + ? '/use - Bind a listed session' + : `/${item.command} - ${item.description}` + ), 'Plain text sends to the current bound session.' ].join('\n') } diff --git a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts index c5e23b94a..1a1ae6b8a 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts @@ -10,11 +10,12 @@ import type { DeepChatAgentPresenter } from '../../deepchatAgentPresenter' import { TELEGRAM_RECENT_SESSION_LIMIT, TELEGRAM_STREAM_POLL_INTERVAL_MS, + type RemoteEndpointBindingMeta, type TelegramModelProviderOption } from '../types' import { buildTelegramFinalText, - extractTelegramStreamText, + extractTelegramDraftText, safeParseAssistantBlocks } from '../telegram/telegramOutbound' import { RemoteBindingStore } from './remoteBindingStore' @@ -41,6 +42,18 @@ export interface RemoteRunnerStatus { isGenerating: boolean } +export type RemoteOpenSessionResult = + | { + status: 'noSession' + } + | { + status: 'windowNotFound' + } + | { + status: 'ok' + session: SessionWithState + } + type RemoteConversationRunnerDeps = { configPresenter: IConfigPresenter newAgentPresenter: INewAgentPresenter @@ -60,13 +73,21 @@ export class RemoteConversationRunner { private readonly bindingStore: RemoteBindingStore ) {} - async createNewSession(endpointKey: string, title?: string): Promise { + async createNewSession( + endpointKey: string, + title?: string, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { const agentId = await this.deps.resolveDefaultAgentId() const session = await this.deps.newAgentPresenter.createDetachedSession({ title: title?.trim() || 'New Chat', agentId }) - this.bindingStore.setBinding(endpointKey, session.id) + if (bindingMeta) { + this.bindingStore.setBinding(endpointKey, session.id, bindingMeta) + } else { + this.bindingStore.setBinding(endpointKey, session.id) + } return session } @@ -85,13 +106,16 @@ export class RemoteConversationRunner { return session } - async ensureBoundSession(endpointKey: string): Promise { + async ensureBoundSession( + endpointKey: string, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { const existing = await this.getCurrentSession(endpointKey) if (existing) { return existing } - return await this.createNewSession(endpointKey) + return await this.createNewSession(endpointKey, undefined, bindingMeta) } async listSessions(endpointKey: string): Promise { @@ -109,7 +133,11 @@ export class RemoteConversationRunner { return sorted } - async useSessionByIndex(endpointKey: string, index: number): Promise { + async useSessionByIndex( + endpointKey: string, + index: number, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { const snapshot = this.bindingStore.getSessionSnapshot(endpointKey) if (snapshot.length === 0) { throw new Error('Run /sessions first before using /use.') @@ -125,7 +153,11 @@ export class RemoteConversationRunner { throw new Error('Selected session no longer exists.') } - this.bindingStore.setBinding(endpointKey, session.id) + if (bindingMeta) { + this.bindingStore.setBinding(endpointKey, session.id, bindingMeta) + } else { + this.bindingStore.setBinding(endpointKey, session.id) + } return session } @@ -161,8 +193,12 @@ export class RemoteConversationRunner { return await this.deps.newAgentPresenter.setSessionModel(session.id, providerId, modelId) } - async sendText(endpointKey: string, text: string): Promise { - const session = await this.ensureBoundSession(endpointKey) + async sendText( + endpointKey: string, + text: string, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { + const session = await this.ensureBoundSession(endpointKey, bindingMeta) const beforeMessages = await this.deps.newAgentPresenter.getMessages(session.id) const lastOrderSeq = beforeMessages.at(-1)?.orderSeq ?? 0 const previousActiveEventId = @@ -214,20 +250,27 @@ export class RemoteConversationRunner { return stopped } - async open(endpointKey: string): Promise { + async open(endpointKey: string): Promise { const session = await this.getCurrentSession(endpointKey) if (!session) { - return null + return { + status: 'noSession' + } } const window = await this.resolveChatWindow() if (!window || window.isDestroyed()) { - return null + return { + status: 'windowNotFound' + } } await this.deps.newAgentPresenter.activateSession(window.webContents.id, session.id) this.deps.windowPresenter.show(window.id, true) - return session + return { + status: 'ok', + session + } } async getStatus(endpointKey: string): Promise { @@ -315,7 +358,7 @@ export class RemoteConversationRunner { return { messageId: trackedMessage.id, - text: completed ? buildTelegramFinalText(blocks) : extractTelegramStreamText(blocks), + text: completed ? buildTelegramFinalText(blocks) : extractTelegramDraftText(blocks), completed } } diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts index a68da993d..3ef44ed3d 100644 --- a/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts @@ -44,22 +44,39 @@ export const blocksRequireDesktopConfirmation = (blocks: AssistantMessageBlock[] block.extra?.needsUserAction !== false ) -export const extractTelegramStreamText = (blocks: AssistantMessageBlock[]): string => { - const preferred = blocks - .filter((block) => block.type === 'content' && typeof block.content === 'string') +const collectText = ( + blocks: AssistantMessageBlock[], + predicate: (block: AssistantMessageBlock) => boolean +): string => + blocks + .filter(predicate) .map((block) => block.content?.trim() ?? '') .filter(Boolean) + .join('\n\n') + .trim() + +export const extractTelegramDraftText = (blocks: AssistantMessageBlock[]): string => + collectText(blocks, (block) => block.type === 'content' && typeof block.content === 'string') + +export const shouldSendTelegramDraft = (blocks: AssistantMessageBlock[]): boolean => + Boolean(extractTelegramDraftText(blocks)) + +export const extractTelegramStreamText = (blocks: AssistantMessageBlock[]): string => { + const preferred = extractTelegramDraftText(blocks) - if (preferred.length > 0) { - return preferred.join('\n\n').trim() + if (preferred) { + return preferred } - return blocks - .filter((block) => block.type !== 'tool_call' && typeof block.content === 'string') - .map((block) => block.content?.trim() ?? '') - .filter(Boolean) - .join('\n\n') - .trim() + return collectText( + blocks, + (block) => + typeof block.content === 'string' && + (block.type === 'content' || + (block.type === 'action' && + (block.action_type === 'tool_call_permission' || + block.action_type === 'question_request'))) + ) } export const buildTelegramFinalText = (blocks: AssistantMessageBlock[]): string => { diff --git a/src/main/presenter/remoteControlPresenter/types.ts b/src/main/presenter/remoteControlPresenter/types.ts index 0ed146f28..3d2046211 100644 --- a/src/main/presenter/remoteControlPresenter/types.ts +++ b/src/main/presenter/remoteControlPresenter/types.ts @@ -1,9 +1,15 @@ import { z } from 'zod' import type { HookEventName } from '@shared/hooksNotifications' import type { + FeishuPairingSnapshot, + FeishuRemoteSettings, + FeishuRemoteStatus, + RemoteBindingKind, + RemoteBindingSummary, + RemoteChannel, + RemoteRuntimeState, TelegramPairingSnapshot, TelegramRemoteBindingSummary, - TelegramRemoteRuntimeState, TelegramRemoteSettings, TelegramRemoteStatus, TelegramStreamMode @@ -14,6 +20,11 @@ export const TELEGRAM_REMOTE_POLL_LIMIT = 20 export const TELEGRAM_REMOTE_POLL_TIMEOUT_SEC = 30 export const TELEGRAM_OUTBOUND_TEXT_LIMIT = 4096 export const TELEGRAM_PAIR_CODE_TTL_MS = 10 * 60 * 1000 +export const FEISHU_PAIR_CODE_TTL_MS = TELEGRAM_PAIR_CODE_TTL_MS +export const REMOTE_PAIR_CODE_MAX_FAILURES = 5 +export const FEISHU_INBOUND_DEDUP_TTL_MS = 30 * 60 * 1000 +export const FEISHU_INBOUND_DEDUP_LIMIT = 2048 +export const FEISHU_CONVERSATION_POLL_TIMEOUT_MS = 5 * 60 * 1000 export const TELEGRAM_TYPING_DELAY_MS = 800 export const TELEGRAM_STREAM_POLL_INTERVAL_MS = 450 export const TELEGRAM_STREAM_START_TIMEOUT_MS = 8_000 @@ -21,6 +32,7 @@ export const TELEGRAM_PRIVATE_THREAD_DEFAULT = 0 export const TELEGRAM_RECENT_SESSION_LIMIT = 10 export const TELEGRAM_MODEL_MENU_TTL_MS = 10 * 60 * 1000 export const TELEGRAM_REMOTE_DEFAULT_AGENT_ID = 'deepchat' +export const FEISHU_REMOTE_DEFAULT_AGENT_ID = TELEGRAM_REMOTE_DEFAULT_AGENT_ID export const TELEGRAM_REMOTE_REACTION_EMOJI = '🤯' export const TELEGRAM_REMOTE_COMMANDS = [ { @@ -51,6 +63,10 @@ export const TELEGRAM_REMOTE_COMMANDS = [ command: 'stop', description: 'Stop the active generation' }, + { + command: 'open', + description: 'Open the current session on desktop' + }, { command: 'model', description: 'Switch provider and model' @@ -61,16 +77,72 @@ export const TELEGRAM_REMOTE_COMMANDS = [ } ] as const -export type TelegramEndpointBinding = { +export const FEISHU_REMOTE_COMMANDS = [ + { + command: 'start', + description: 'Show remote control status' + }, + { + command: 'help', + description: 'Show available commands' + }, + { + command: 'pair', + description: 'Authorize this Feishu account' + }, + { + command: 'new', + description: 'Start a new DeepChat session' + }, + { + command: 'sessions', + description: 'List recent sessions' + }, + { + command: 'use', + description: 'Bind a listed session' + }, + { + command: 'stop', + description: 'Stop the active generation' + }, + { + command: 'open', + description: 'Open the current session on desktop' + }, + { + command: 'model', + description: 'View or switch the current model' + }, + { + command: 'status', + description: 'Show runtime and session status' + } +] as const + +export interface RemoteEndpointBindingMeta { + channel: RemoteChannel + kind: RemoteBindingKind + chatId: string + threadId: string | null +} + +export type RemoteEndpointBinding = { sessionId: string updatedAt: number + meta?: RemoteEndpointBindingMeta } +export type TelegramEndpointBinding = RemoteEndpointBinding + export type TelegramPairingState = { code: string | null expiresAt: number | null + failedAttempts: number } +export type FeishuPairingState = TelegramPairingState + export type TelegramCommandPayload = { name: string args: string @@ -87,8 +159,22 @@ export interface TelegramRemoteRuntimeConfig { bindings: Record } +export interface FeishuRemoteRuntimeConfig { + appId: string + appSecret: string + verificationToken: string + encryptKey: string + enabled: boolean + defaultAgentId: string + pairedUserOpenIds: string[] + lastFatalError: string | null + pairing: FeishuPairingState + bindings: Record +} + export interface RemoteControlConfig { telegram: TelegramRemoteRuntimeConfig + feishu: FeishuRemoteRuntimeConfig } interface TelegramInboundBase { @@ -114,6 +200,28 @@ export interface TelegramInboundCallbackQuery extends TelegramInboundBase { export type TelegramInboundEvent = TelegramInboundMessage | TelegramInboundCallbackQuery +export interface FeishuRawMention { + key: string + id?: { + open_id?: string + } + name?: string +} + +export interface FeishuInboundMessage { + kind: 'message' + eventId: string + chatId: string + threadId: string | null + messageId: string + chatType: 'p2p' | 'group' + senderOpenId: string | null + text: string + command: TelegramCommandPayload | null + mentionedBot: boolean + mentions: FeishuRawMention[] +} + export interface TelegramInlineKeyboardButton { text: string callback_data: string @@ -177,6 +285,8 @@ export type TelegramModelMenuCallback = } const TELEGRAM_MODEL_MENU_CALLBACK_PREFIX = 'model' +const TELEGRAM_ENDPOINT_KEY_REGEX = /^telegram:(-?\d+):(-?\d+)$/ +const FEISHU_ENDPOINT_KEY_REGEX = /^feishu:([^:]+):([^:]+)$/ export const createTelegramCallbackToken = (): string => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` @@ -251,16 +361,28 @@ export const parseModelMenuCallbackData = (data: string): TelegramModelMenuCallb } export interface TelegramPollerStatusSnapshot { - state: TelegramRemoteRuntimeState + state: RemoteRuntimeState lastError: string | null botUser: TelegramRemoteStatus['botUser'] } +export interface FeishuRuntimeStatusSnapshot { + state: RemoteRuntimeState + lastError: string | null + botUser: FeishuRemoteStatus['botUser'] +} + export interface TelegramTransportTarget { chatId: number messageThreadId: number } +export interface FeishuTransportTarget { + chatId: string + threadId: string | null + replyToMessageId?: string | null +} + export interface TelegramRemoteHookSettingsInput { enabled: boolean chatId: string @@ -278,23 +400,51 @@ export const createDefaultRemoteControlConfig = (): RemoteControlConfig => ({ lastFatalError: null, pairing: { code: null, - expiresAt: null + expiresAt: null, + failedAttempts: 0 + }, + bindings: {} + }, + feishu: { + appId: '', + appSecret: '', + verificationToken: '', + encryptKey: '', + enabled: false, + defaultAgentId: FEISHU_REMOTE_DEFAULT_AGENT_ID, + pairedUserOpenIds: [], + lastFatalError: null, + pairing: { + code: null, + expiresAt: null, + failedAttempts: 0 }, bindings: {} } }) -const TelegramEndpointBindingSchema = z +const RemoteEndpointBindingMetaSchema = z + .object({ + channel: z.enum(['telegram', 'feishu']).optional(), + kind: z.enum(['dm', 'group', 'topic']).optional(), + chatId: z.string().optional(), + threadId: z.string().nullable().optional() + }) + .strip() + +const RemoteEndpointBindingSchema = z .object({ sessionId: z.string().min(1), - updatedAt: z.number().int().nonnegative().optional() + updatedAt: z.number().int().nonnegative().optional(), + meta: RemoteEndpointBindingMetaSchema.optional() }) .strip() -const TelegramPairingStateSchema = z +const PairingStateSchema = z .object({ code: z.string().nullable().optional(), - expiresAt: z.number().int().nonnegative().nullable().optional() + expiresAt: z.number().int().nonnegative().nullable().optional(), + failedAttempts: z.number().int().nonnegative().optional() }) .strip() @@ -306,17 +456,97 @@ const TelegramRemoteRuntimeConfigSchema = z streamMode: z.enum(['draft', 'final']).optional(), pollOffset: z.number().int().nonnegative().optional(), lastFatalError: z.string().nullable().optional(), - pairing: TelegramPairingStateSchema.optional(), + pairing: PairingStateSchema.optional(), + bindings: z.record(z.string(), z.unknown()).optional() + }) + .strip() + +const FeishuRemoteRuntimeConfigSchema = z + .object({ + appId: z.string().optional(), + appSecret: z.string().optional(), + verificationToken: z.string().optional(), + encryptKey: z.string().optional(), + enabled: z.boolean().optional(), + defaultAgentId: z.string().optional(), + pairedUserOpenIds: z.array(z.string()).optional(), + lastFatalError: z.string().nullable().optional(), + pairing: PairingStateSchema.optional(), bindings: z.record(z.string(), z.unknown()).optional() }) .strip() const RemoteControlConfigSchema = z .object({ - telegram: TelegramRemoteRuntimeConfigSchema.optional() + telegram: TelegramRemoteRuntimeConfigSchema.optional(), + feishu: FeishuRemoteRuntimeConfigSchema.optional() }) .strip() +type LegacyTelegramRemoteConfig = z.infer +type LegacyFeishuRemoteConfig = z.infer + +const hasOwn = (value: Record, key: string): boolean => + Object.prototype.hasOwnProperty.call(value, key) + +const hasAnyOwn = (value: Record, keys: string[]): boolean => + keys.some((key) => hasOwn(value, key)) + +const hasBindingPrefix = (value: Record, prefix: string): boolean => { + const bindings = value.bindings + if (!bindings || typeof bindings !== 'object' || Array.isArray(bindings)) { + return false + } + + return Object.keys(bindings as Record).some((key) => key.startsWith(prefix)) +} + +const extractLegacyTelegramConfig = (input: unknown): LegacyTelegramRemoteConfig | null => { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null + } + + const record = input as Record + if ( + !hasAnyOwn(record, ['allowlist', 'streamMode', 'pollOffset', 'lastFatalError']) && + !hasBindingPrefix(record, 'telegram:') + ) { + return null + } + + const parsed = TelegramRemoteRuntimeConfigSchema.safeParse(record) + return parsed.success ? parsed.data : null +} + +const extractLegacyFeishuConfig = (input: unknown): LegacyFeishuRemoteConfig | null => { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null + } + + const record = input as Record + if ( + !hasAnyOwn(record, [ + 'appId', + 'appSecret', + 'verificationToken', + 'encryptKey', + 'pairedUserOpenIds', + 'lastFatalError' + ]) && + !hasBindingPrefix(record, 'feishu:') + ) { + return null + } + + const parsed = FeishuRemoteRuntimeConfigSchema.safeParse(record) + return parsed.success ? parsed.data : null +} + +const normalizeStringList = (input: Array | undefined): string[] => + Array.from( + new Set((input ?? []).map((value) => String(value ?? '').trim()).filter(Boolean)) + ).sort((left, right) => left.localeCompare(right)) + export const normalizeTelegramUserIds = (input: Array | undefined): number[] => { const normalized = new Set() for (const value of input ?? []) { @@ -333,17 +563,38 @@ export const normalizeTelegramUserIds = (input: Array | undefin return Array.from(normalized).sort((left, right) => left - right) } -export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfig => { - const defaults = createDefaultRemoteControlConfig() - const parsed = RemoteControlConfigSchema.safeParse(input) - if (!parsed.success) { - return defaults +export const normalizeFeishuOpenIds = (input: Array | undefined): string[] => + normalizeStringList(input) + +const normalizeBindingMeta = ( + endpointKey: string, + meta: unknown, + fallbackChannel: RemoteChannel +): RemoteEndpointBindingMeta | undefined => { + const parsed = RemoteEndpointBindingMetaSchema.safeParse(meta) + if (parsed.success && parsed.data.channel && parsed.data.kind && parsed.data.chatId) { + return { + channel: parsed.data.channel, + kind: parsed.data.kind, + chatId: parsed.data.chatId, + threadId: parsed.data.threadId ?? null + } + } + + if (fallbackChannel === 'telegram') { + return deriveTelegramBindingMeta(endpointKey) ?? undefined } - const telegram = parsed.data.telegram ?? {} - const bindings: Record = {} - for (const [endpointKey, binding] of Object.entries(telegram.bindings ?? {})) { - const parsedBinding = TelegramEndpointBindingSchema.safeParse(binding) + return deriveFeishuBindingMeta(endpointKey) ?? undefined +} + +const normalizeBindings = ( + rawBindings: Record | undefined, + channel: RemoteChannel +): Record => { + const bindings: Record = {} + for (const [endpointKey, binding] of Object.entries(rawBindings ?? {})) { + const parsedBinding = RemoteEndpointBindingSchema.safeParse(binding) if (!parsedBinding.success) { continue } @@ -355,15 +606,28 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi bindings[endpointKey] = { sessionId: normalizedSessionId, - updatedAt: parsedBinding.data.updatedAt ?? Date.now() + updatedAt: parsedBinding.data.updatedAt ?? Date.now(), + meta: normalizeBindingMeta(endpointKey, parsedBinding.data.meta, channel) } } + return bindings +} + +export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfig => { + const defaults = createDefaultRemoteControlConfig() + const parsed = RemoteControlConfigSchema.safeParse(input) + if (!parsed.success) { + return defaults + } + + const telegram = parsed.data.telegram ?? extractLegacyTelegramConfig(input) ?? {} + const feishu = parsed.data.feishu ?? extractLegacyFeishuConfig(input) ?? {} return { telegram: { enabled: Boolean(telegram.enabled), allowlist: normalizeTelegramUserIds(telegram.allowlist), - streamMode: 'draft', + streamMode: telegram.streamMode === 'final' ? 'final' : defaults.telegram.streamMode, defaultAgentId: telegram.defaultAgentId?.trim() || defaults.telegram.defaultAgentId, pollOffset: typeof telegram.pollOffset === 'number' && telegram.pollOffset >= 0 @@ -373,9 +637,33 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi pairing: { code: telegram.pairing?.code?.trim() || null, expiresAt: - typeof telegram.pairing?.expiresAt === 'number' ? telegram.pairing.expiresAt : null + typeof telegram.pairing?.expiresAt === 'number' ? telegram.pairing.expiresAt : null, + failedAttempts: + typeof telegram.pairing?.failedAttempts === 'number' && + telegram.pairing.failedAttempts >= 0 + ? Math.trunc(telegram.pairing.failedAttempts) + : 0 }, - bindings + bindings: normalizeBindings(telegram.bindings, 'telegram') + }, + feishu: { + appId: feishu.appId?.trim() || '', + appSecret: feishu.appSecret?.trim() || '', + verificationToken: feishu.verificationToken?.trim() || '', + encryptKey: feishu.encryptKey?.trim() || '', + enabled: Boolean(feishu.enabled), + defaultAgentId: feishu.defaultAgentId?.trim() || defaults.feishu.defaultAgentId, + pairedUserOpenIds: normalizeFeishuOpenIds(feishu.pairedUserOpenIds), + lastFatalError: feishu.lastFatalError?.trim() || null, + pairing: { + code: feishu.pairing?.code?.trim() || null, + expiresAt: typeof feishu.pairing?.expiresAt === 'number' ? feishu.pairing.expiresAt : null, + failedAttempts: + typeof feishu.pairing?.failedAttempts === 'number' && feishu.pairing.failedAttempts >= 0 + ? Math.trunc(feishu.pairing.failedAttempts) + : 0 + }, + bindings: normalizeBindings(feishu.bindings, 'feishu') } } } @@ -386,7 +674,7 @@ export const buildTelegramEndpointKey = (chatId: number, messageThreadId: number export const parseTelegramEndpointKey = ( endpointKey: string ): Pick | null => { - const match = /^telegram:(-?\d+):(-?\d+)$/.exec(endpointKey.trim()) + const match = TELEGRAM_ENDPOINT_KEY_REGEX.exec(endpointKey.trim()) if (!match) { return null } @@ -397,11 +685,101 @@ export const parseTelegramEndpointKey = ( } } -export const createPairCode = (): { code: string; expiresAt: number } => { - const code = `${Math.floor(100000 + Math.random() * 900000)}` +export const buildTelegramBindingMeta = ( + chatId: number, + messageThreadId: number +): RemoteEndpointBindingMeta => { + const normalizedThreadId = messageThreadId || TELEGRAM_PRIVATE_THREAD_DEFAULT + const isTopic = normalizedThreadId > 0 + const isGroup = chatId < 0 return { - code, - expiresAt: Date.now() + TELEGRAM_PAIR_CODE_TTL_MS + channel: 'telegram', + kind: isTopic ? 'topic' : isGroup ? 'group' : 'dm', + chatId: String(chatId), + threadId: isTopic ? String(normalizedThreadId) : null + } +} + +export const deriveTelegramBindingMeta = ( + endpointKey: string +): RemoteEndpointBindingMeta | null => { + const endpoint = parseTelegramEndpointKey(endpointKey) + if (!endpoint) { + return null + } + + return buildTelegramBindingMeta(endpoint.chatId, endpoint.messageThreadId) +} + +export const buildFeishuEndpointKey = (chatId: string, threadId?: string | null): string => + `feishu:${chatId}:${threadId?.trim() || 'root'}` + +export const parseFeishuEndpointKey = ( + endpointKey: string +): Pick | null => { + const match = FEISHU_ENDPOINT_KEY_REGEX.exec(endpointKey.trim()) + if (!match) { + return null + } + + return { + chatId: match[1], + threadId: match[2] === 'root' ? null : match[2] + } +} + +export const buildFeishuBindingMeta = (params: { + chatId: string + threadId?: string | null + chatType: 'p2p' | 'group' +}): RemoteEndpointBindingMeta => ({ + channel: 'feishu', + kind: params.chatType === 'p2p' ? 'dm' : params.threadId ? 'topic' : 'group', + chatId: params.chatId.trim(), + threadId: params.threadId?.trim() || null +}) + +export const deriveFeishuBindingMeta = (endpointKey: string): RemoteEndpointBindingMeta | null => { + const endpoint = parseFeishuEndpointKey(endpointKey) + if (!endpoint) { + return null + } + + return { + channel: 'feishu', + kind: endpoint.threadId ? 'topic' : 'group', + chatId: endpoint.chatId, + threadId: endpoint.threadId + } +} + +export const buildBindingSummary = ( + endpointKey: string, + binding: RemoteEndpointBinding +): RemoteBindingSummary | null => { + const meta = + binding.meta ?? deriveTelegramBindingMeta(endpointKey) ?? deriveFeishuBindingMeta(endpointKey) + + if (!meta) { + return null + } + + return { + channel: meta.channel, + endpointKey, + sessionId: binding.sessionId, + chatId: meta.chatId, + threadId: meta.threadId, + kind: meta.kind, + updatedAt: binding.updatedAt + } +} + +export const createPairCode = (ttlMs: number = TELEGRAM_PAIR_CODE_TTL_MS): TelegramPairingState => { + return { + code: `${Math.floor(100000 + Math.random() * 900000)}`, + failedAttempts: 0, + expiresAt: Date.now() + ttlMs } } @@ -420,6 +798,18 @@ export const normalizeTelegramSettingsInput = ( } }) +export const normalizeFeishuSettingsInput = ( + input: FeishuRemoteSettings +): FeishuRemoteSettings => ({ + appId: input.appId?.trim() ?? '', + appSecret: input.appSecret?.trim() ?? '', + verificationToken: input.verificationToken?.trim() ?? '', + encryptKey: input.encryptKey?.trim() ?? '', + remoteEnabled: Boolean(input.remoteEnabled), + defaultAgentId: input.defaultAgentId?.trim() || FEISHU_REMOTE_DEFAULT_AGENT_ID, + pairedUserOpenIds: normalizeFeishuOpenIds(input.pairedUserOpenIds) +}) + export const buildTelegramPairingSnapshot = ( settings: TelegramRemoteRuntimeConfig ): TelegramPairingSnapshot => ({ @@ -427,3 +817,11 @@ export const buildTelegramPairingSnapshot = ( pairCodeExpiresAt: settings.pairing.expiresAt, allowedUserIds: [...settings.allowlist] }) + +export const buildFeishuPairingSnapshot = ( + settings: FeishuRemoteRuntimeConfig +): FeishuPairingSnapshot => ({ + pairCode: settings.pairing.code, + pairCodeExpiresAt: settings.pairing.expiresAt, + pairedUserOpenIds: [...settings.pairedUserOpenIds] +}) diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 06583badb..8426c987f 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -20,6 +20,7 @@ import { } from './chatSettingsTools' import type { AgentToolRuntimePort } from '../runtimePorts' import { YO_BROWSER_TOOL_NAMES } from '../../browser/YoBrowserToolDefinitions' +import { resolveSessionVisionTarget } from '../../vision/sessionVisionResolver' // Consider moving to a shared handlers location in future refactoring import { @@ -433,7 +434,7 @@ export class AgentToolManager { function: { name: 'read', description: - "Read the contents of a file. Supports pagination via offset/limit for large files (auto-truncated at 4500 chars if not specified). When invoked from a skill context with relative paths, provide base_directory as the skill's root directory.", + "Read the contents of a file. Supports pagination via offset/limit for large files (auto-truncated at 4500 chars if not specified). For image files, returns an English description of visible content instead of raw pixels. When invoked from a skill context with relative paths, provide base_directory as the skill's root directory.", parameters: zodToJsonSchema(schemas.read) as { type: string properties: Record @@ -721,7 +722,7 @@ export class AgentToolManager { if (this.isImageMimeType(mimeType)) { return { - content: await this.readImageWithVisionFallback(validPath, mimeType) + content: await this.readImageWithVisionFallback(validPath, mimeType, conversationId) } } @@ -1063,13 +1064,28 @@ export class AgentToolManager { return lines.join('\n') } - private async readImageWithVisionFallback(filePath: string, mimeType: string): Promise { + private async readImageWithVisionFallback( + filePath: string, + mimeType: string, + conversationId?: string + ): Promise { const fileBuffer = await fs.promises.readFile(filePath) const metadata = this.buildImageMetadataBlock(filePath, mimeType, fileBuffer.length) - const defaultVisionModel = this.configPresenter.getDefaultVisionModel?.() + let visionTarget: Awaited> + + try { + visionTarget = await this.resolveVisionTargetForConversation(conversationId) + } catch (error) { + logger.warn('[AgentToolManager] Failed to resolve vision target for image read:', { + conversationId, + filePath, + error + }) + throw error + } - if (!defaultVisionModel?.providerId || !defaultVisionModel?.modelId) { - return `${metadata}\n\nNo defaultVisionModel configured, downgraded to metadata.` + if (!visionTarget) { + return `${metadata}\n\nImage analysis unavailable because neither the current session model nor the agent vision model can analyze images.` } try { @@ -1080,12 +1096,7 @@ export class AgentToolManager { content: [ { type: 'text', - text: [ - 'Analyze this image and return exactly two sections.', - 'Section 1 title: OCR', - 'Section 2 title: Summary', - 'Keep OCR as faithful extracted text and Summary concise.' - ].join('\n') + text: this.buildImageAnalysisPrompt() }, { type: 'image_url', @@ -1096,28 +1107,61 @@ export class AgentToolManager { ] const modelConfig = this.configPresenter.getModelConfig( - defaultVisionModel.modelId, - defaultVisionModel.providerId + visionTarget.modelId, + visionTarget.providerId ) const response = await this.getLlmProviderPresenter().generateCompletionStandalone( - defaultVisionModel.providerId, + visionTarget.providerId, messages, - defaultVisionModel.modelId, + visionTarget.modelId, modelConfig?.temperature ?? 0.2, modelConfig?.maxTokens ?? 1200 ) const normalized = (response || '').trim() if (!normalized) { - return `${metadata}\n\nOCR:\n\nSummary:\nNo result returned by vision model.` + return `${metadata}\n\nImage analysis returned no usable description.` } - return normalized.startsWith('OCR:') ? normalized : `OCR:\n\nSummary:\n${normalized}` + return normalized } catch (error) { const message = error instanceof Error ? error.message : String(error) return `${metadata}\n\nVision analysis failed, downgraded to metadata.\nerror: ${message}` } } + private async resolveVisionTargetForConversation(conversationId?: string) { + if (!conversationId) { + return null + } + + try { + const sessionInfo = await this.runtimePort.resolveConversationSessionInfo(conversationId) + return await resolveSessionVisionTarget({ + providerId: sessionInfo?.providerId, + modelId: sessionInfo?.modelId, + agentId: sessionInfo?.agentId, + configPresenter: this.configPresenter, + logLabel: `read:${conversationId}` + }) + } catch (error) { + if (this.isConversationNotFoundError(error)) { + return null + } + + throw error + } + } + + private buildImageAnalysisPrompt(): string { + return [ + 'Analyze this image and respond in English only.', + 'Describe only what is clearly visible.', + 'Include the main subject, scene or layout, any legible text, UI elements if present, status indicators, warnings, errors, and any detail that matters for understanding the image.', + 'Do not speculate about hidden or unreadable content.', + 'Return detailed plain text in a single paragraph.' + ].join('\n') + } + private assertWritePermission( toolName: string, args: Record, diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 9d29d63eb..fc03ca324 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -377,6 +377,11 @@ export class ToolPresenter implements IToolPresenter { 'Use `background: true` when you know a command should detach immediately; otherwise a foreground `exec` may yield a running `sessionId` after `yieldMs`.' ) } + if (toolNames.has('read')) { + lines.push( + 'When `read` targets an image file, it returns an English description of the visible content and any legible text.' + ) + } if (toolNames.has('exec') && toolNames.has('read') && toolNames.has('edit')) { lines.push( 'Recommended file task flow: `exec` for discovery/search -> `read` -> `edit`/`write`.' @@ -461,10 +466,13 @@ export class ToolPresenter implements IToolPresenter { lines.push('- Use `get_browser_status` to inspect the current session browser state.') } if (toolNames.has('load_url')) { - lines.push('- Use `load_url` to lazily create the session browser and navigate to a page.') + lines.push('- Prefer `load_url` to create the session browser and handle navigation.') } if (toolNames.has('cdp_send')) { - lines.push('- Use `cdp_send` for DOM inspection, scripted interaction, and screenshots.') + lines.push( + '- Use `cdp_send` for DOM inspection, scripted interaction, screenshots, and low-level CDP commands.' + ) + lines.push('- Avoid using `cdp_send` `Page.navigate` for normal navigation unless needed.') } return lines.join('\n') diff --git a/src/main/presenter/toolPresenter/runtimePorts.ts b/src/main/presenter/toolPresenter/runtimePorts.ts index 899540811..806b436c2 100644 --- a/src/main/presenter/toolPresenter/runtimePorts.ts +++ b/src/main/presenter/toolPresenter/runtimePorts.ts @@ -6,8 +6,15 @@ import type { } from '@shared/presenter' import type { ISkillPresenter } from '@shared/types/skill' +export interface ConversationSessionInfo { + agentId: string + providerId: string + modelId: string +} + export interface AgentToolRuntimePort { resolveConversationWorkdir(conversationId: string): Promise + resolveConversationSessionInfo(conversationId: string): Promise getSkillPresenter(): ISkillPresenter getYoBrowserToolHandler(): IYoBrowserPresenter['toolHandler'] getFilePresenter(): Pick diff --git a/src/main/presenter/vision/sessionVisionResolver.ts b/src/main/presenter/vision/sessionVisionResolver.ts new file mode 100644 index 000000000..3dd075b3e --- /dev/null +++ b/src/main/presenter/vision/sessionVisionResolver.ts @@ -0,0 +1,91 @@ +import type { IConfigPresenter } from '@shared/presenter' + +export type SessionVisionTarget = { + providerId: string + modelId: string + source: 'session-model' | 'agent-vision-model' +} + +type SessionVisionResolverParams = { + providerId?: string | null + modelId?: string | null + agentId?: string | null + signal?: AbortSignal + configPresenter: Pick< + IConfigPresenter, + 'getModelConfig' | 'resolveDeepChatAgentConfig' | 'isKnownModel' + > + logLabel?: string +} + +const createAbortError = (): Error => { + if (typeof DOMException !== 'undefined') { + return new DOMException('Aborted', 'AbortError') + } + + const error = new Error('Aborted') + error.name = 'AbortError' + return error +} + +const throwIfAbortRequested = (signal?: AbortSignal): void => { + if (signal?.aborted) { + throw createAbortError() + } +} + +export async function resolveSessionVisionTarget( + params: SessionVisionResolverParams +): Promise { + throwIfAbortRequested(params.signal) + const sessionProviderId = params.providerId?.trim() + const sessionModelId = params.modelId?.trim() + const sessionModelConfig = + sessionProviderId && sessionModelId + ? params.configPresenter.getModelConfig(sessionModelId, sessionProviderId) + : null + + if ( + sessionProviderId && + sessionModelId && + params.configPresenter.isKnownModel?.(sessionProviderId, sessionModelId) === true && + sessionModelConfig?.vision + ) { + return { + providerId: sessionProviderId, + modelId: sessionModelId, + source: 'session-model' + } + } + + const agentId = params.agentId?.trim() + if (!agentId) { + return null + } + + try { + throwIfAbortRequested(params.signal) + const agentConfig = await params.configPresenter.resolveDeepChatAgentConfig(agentId) + throwIfAbortRequested(params.signal) + const providerId = agentConfig.visionModel?.providerId?.trim() + const modelId = agentConfig.visionModel?.modelId?.trim() + if (providerId && modelId) { + return { + providerId, + modelId, + source: 'agent-vision-model' + } + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + console.warn('[Vision] Failed to resolve agent vision model:', { + agentId, + context: params.logLabel ?? 'unknown', + error + }) + } + + return null +} diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 94532ad1d..efc53e5c0 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -15,6 +15,7 @@ import { IConfigPresenter, IWindowPresenter } from '@shared/presenter' // Window import { eventBus } from '@/eventbus' // Event bus import { CONFIG_EVENTS, + DEEPLINK_EVENTS, SETTINGS_EVENTS, SHORTCUT_EVENTS, SYSTEM_EVENTS, @@ -25,6 +26,7 @@ import windowStateManager from 'electron-window-state' // Window state manager // TrayPresenter is globally managed in main/index.ts, this Presenter is not responsible for its lifecycle import { TabPresenter } from '../tabPresenter' // TabPresenter type import { FloatingChatWindow } from './FloatingChatWindow' // Floating chat window +import type { ProviderInstallPreview } from '@shared/providerDeeplink' type PendingSettingsMessage = { channel: string @@ -49,6 +51,7 @@ export class WindowPresenter implements IWindowPresenter { private settingsWindow: BrowserWindow | null = null private settingsWindowReady = false private pendingSettingsMessages: PendingSettingsMessage[] = [] + private pendingSettingsProviderInstalls: ProviderInstallPreview[] = [] constructor(configPresenter: IConfigPresenter) { this.windows = new Map() @@ -864,7 +867,9 @@ export class WindowPresenter implements IWindowPresenter { console.log( `Loading packaged main renderer file: ${join(__dirname, '../renderer/index.html')}` ) - appWindow.loadFile(join(__dirname, '../renderer/index.html'), { hash: '/chat' }) + appWindow.loadFile(join(__dirname, '../renderer/index.html'), { + hash: '/chat' + }) } // DevTools 不再自动打开,需要手动通过菜单或快捷键打开 @@ -1201,8 +1206,8 @@ export class WindowPresenter implements IWindowPresenter { // Initialize window state manager to remember position and size const settingsWindowState = windowStateManager({ file: 'settings-window-state.json', - defaultWidth: 900, - defaultHeight: 600 + defaultWidth: 1300, + defaultHeight: 800 }) // Create Settings Window with state persistence @@ -1328,6 +1333,19 @@ export class WindowPresenter implements IWindowPresenter { return null } + public setPendingSettingsProviderInstall(preview: ProviderInstallPreview): void { + this.pendingSettingsProviderInstalls.push(this.clonePendingSettingsProviderInstall(preview)) + } + + public consumePendingSettingsProviderInstall(): ProviderInstallPreview | null { + const preview = this.pendingSettingsProviderInstalls.shift() + if (!preview) { + return null + } + + return this.clonePendingSettingsProviderInstall(preview) + } + /** * Close Settings Window if it exists */ @@ -1348,7 +1366,10 @@ export class WindowPresenter implements IWindowPresenter { } private shouldQueueSettingsMessage(channel: string): boolean { - return channel.startsWith('settings:') && !this.settingsWindowReady + return ( + !this.settingsWindowReady && + (channel.startsWith('settings:') || channel === DEEPLINK_EVENTS.MCP_INSTALL) + ) } private handleSettingsWindowReady(senderWebContentsId: number): void { @@ -1391,9 +1412,23 @@ export class WindowPresenter implements IWindowPresenter { this.settingsWindowReady = false if (clearQueue) { this.pendingSettingsMessages = [] + this.clearPendingSettingsProviderInstalls() } } + private clonePendingSettingsProviderInstall( + preview: ProviderInstallPreview + ): ProviderInstallPreview { + return { ...preview } + } + + private clearPendingSettingsProviderInstalls(): void { + this.pendingSettingsProviderInstalls.forEach((preview) => { + preview.apiKey = '' + }) + this.pendingSettingsProviderInstalls = [] + } + public isApplicationQuitting(): boolean { return this.isQuitting } diff --git a/src/preload/floating-preload.ts b/src/preload/floating-preload.ts index f418acac4..a643f8e0b 100644 --- a/src/preload/floating-preload.ts +++ b/src/preload/floating-preload.ts @@ -5,6 +5,7 @@ import type { FloatingWidgetSnapshot } from '@shared/types/floating-widget' const FLOATING_BUTTON_EVENTS = { CLICKED: 'floating-button:clicked', RIGHT_CLICKED: 'floating-button:right-clicked', + HOVER_STATE_CHANGED: 'floating-button:hover-state-changed', SNAPSHOT_REQUEST: 'floating-button:snapshot-request', SNAPSHOT_UPDATED: 'floating-button:snapshot-updated', LANGUAGE_REQUEST: 'floating-button:language-request', @@ -58,6 +59,10 @@ const floatingButtonAPI = { ipcRenderer.send(FLOATING_BUTTON_EVENTS.SET_EXPANDED, expanded) }, + setHovering: (hovering: boolean) => { + ipcRenderer.send(FLOATING_BUTTON_EVENTS.HOVER_STATE_CHANGED, hovering) + }, + openSession: (sessionId: string) => { ipcRenderer.send(FLOATING_BUTTON_EVENTS.OPEN_SESSION, sessionId) }, diff --git a/src/renderer/floating/FloatingButton.vue b/src/renderer/floating/FloatingButton.vue index 22a292192..daa23cdf4 100644 --- a/src/renderer/floating/FloatingButton.vue +++ b/src/renderer/floating/FloatingButton.vue @@ -25,16 +25,21 @@ const props = defineProps<{ const DRAG_DELAY = 180 const DRAG_THRESHOLD = 4 +const CLOSE_MOTION_SETTLE_MS = 240 const { t } = useI18n() const isDragging = ref(false) +const isHovering = ref(false) +const isClosing = ref(false) const snapshot = ref({ expanded: false, activeCount: 0, sessions: [] }) +let closingTimer: number | null = null + const dragState = ref({ isDragging: false, isMouseDown: false, @@ -61,11 +66,39 @@ const clearDragTimer = () => { } } +const clearClosingTimer = () => { + if (closingTimer) { + clearTimeout(closingTimer) + closingTimer = null + } +} + +const syncCloseMotionState = (nextExpanded: boolean) => { + if (nextExpanded) { + clearClosingTimer() + isClosing.value = false + return + } + + if (!snapshot.value.expanded) { + return + } + + clearClosingTimer() + isClosing.value = true + closingTimer = window.setTimeout(() => { + isClosing.value = false + closingTimer = null + }, CLOSE_MOTION_SETTLE_MS) +} + const handleSnapshotUpdate = (nextSnapshot: FloatingWidgetSnapshot) => { + syncCloseMotionState(nextSnapshot.expanded) snapshot.value = nextSnapshot } const setExpanded = (expanded: boolean) => { + syncCloseMotionState(expanded) snapshot.value = { ...snapshot.value, expanded @@ -77,12 +110,33 @@ const toggleExpanded = () => { setExpanded(!snapshot.value.expanded) } +const setHovering = (hovering: boolean) => { + if (isHovering.value === hovering) { + return + } + + isHovering.value = hovering + window.floatingButtonAPI.setHovering(hovering) +} + const startDragging = () => { dragState.value.isDragging = true isDragging.value = true window.floatingButtonAPI.onDragStart(dragState.value.startScreenX, dragState.value.startScreenY) } +const handleMouseEnter = () => { + setHovering(true) +} + +const handleMouseLeave = () => { + if (dragState.value.isDragging) { + return + } + + setHovering(false) +} + const handleMouseDown = (event: MouseEvent) => { if (event.button !== 0) { return @@ -187,6 +241,8 @@ onMounted(async () => { onUnmounted(() => { clearDragTimer() + clearClosingTimer() + setHovering(false) document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) window.removeEventListener('blur', handleWindowBlur) @@ -198,6 +254,7 @@ onUnmounted(() => {
{ snapshot.expanded ? 'cursor-grab' : 'cursor-pointer', isDragging ? 'cursor-grabbing' : '' ]" + @mouseenter="handleMouseEnter" + @mouseleave="handleMouseLeave" @mousedown="handleMouseDown" @contextmenu="handleRightClick" > -
+
+
@@ -52,7 +61,7 @@ diff --git a/src/renderer/settings/components/AcpSettings.vue b/src/renderer/settings/components/AcpSettings.vue index b90851bb4..80a11e9fe 100644 --- a/src/renderer/settings/components/AcpSettings.vue +++ b/src/renderer/settings/components/AcpSettings.vue @@ -75,14 +75,9 @@ {{ t('settings.acp.installedSectionDescription') }}

-
- - {{ t('settings.acp.installedCount', { count: installedRegistryAgents.length }) }} - - -
+ + {{ t('settings.acp.installedCount', { count: installedRegistryAgents.length }) }} +
{{ t('settings.acp.installedEmptyDescription') }}

-
diff --git a/src/renderer/settings/components/NotificationsHooksSettings.vue b/src/renderer/settings/components/NotificationsHooksSettings.vue index 75536c750..3a9423f16 100644 --- a/src/renderer/settings/components/NotificationsHooksSettings.vue +++ b/src/renderer/settings/components/NotificationsHooksSettings.vue @@ -468,7 +468,7 @@ const confirmoAvailable = computed(() => confirmoStatus.value?.available ?? fals const eventNames = HOOK_EVENT_NAMES -const buildEventRecord = (value: T) => +const buildEventRecord = (value: T) => Object.fromEntries(eventNames.map((name) => [name, value])) as Record const commandTesting = ref>(buildEventRecord(false)) diff --git a/src/renderer/settings/components/ProviderDeeplinkImportDialog.vue b/src/renderer/settings/components/ProviderDeeplinkImportDialog.vue new file mode 100644 index 000000000..988d4575d --- /dev/null +++ b/src/renderer/settings/components/ProviderDeeplinkImportDialog.vue @@ -0,0 +1,108 @@ + + + diff --git a/src/renderer/settings/components/RemoteSettings.vue b/src/renderer/settings/components/RemoteSettings.vue index 73d606dd6..62ac6c261 100644 --- a/src/renderer/settings/components/RemoteSettings.vue +++ b/src/renderer/settings/components/RemoteSettings.vue @@ -4,14 +4,17 @@
{{ t('common.loading') }}
-
+
{{ t('common.error.requestFailed') }}
@@ -296,9 +546,19 @@
- {{ t('settings.remote.remoteControl.pairDialogTitle') }} + + {{ + t('settings.remote.remoteControl.pairDialogTitle', { + channel: pairDialogChannel ? t(`settings.remote.${pairDialogChannel}.title`) : '' + }) + }} + - {{ t('settings.remote.remoteControl.pairDialogDescription') }} + {{ + t('settings.remote.remoteControl.pairDialogDescription', { + channel: pairDialogChannel ? t(`settings.remote.${pairDialogChannel}.title`) : '' + }) + }} @@ -321,7 +581,11 @@
- {{ t('settings.remote.remoteControl.pairDialogInstruction') }} + {{ + pairDialogChannel === 'feishu' + ? t('settings.remote.remoteControl.pairDialogInstructionFeishu') + : t('settings.remote.remoteControl.pairDialogInstructionTelegram') + }}
/pair {{ pairDialogCode || '------' }} @@ -342,9 +606,23 @@
- {{ t('settings.remote.remoteControl.bindingsDialogTitle') }} + + {{ + t('settings.remote.remoteControl.bindingsDialogTitle', { + channel: bindingsDialogChannel + ? t(`settings.remote.${bindingsDialogChannel}.title`) + : '' + }) + }} + - {{ t('settings.remote.remoteControl.bindingsDialogDescription') }} + {{ + t('settings.remote.remoteControl.bindingsDialogDescription', { + channel: bindingsDialogChannel + ? t(`settings.remote.${bindingsDialogChannel}.title`) + : '' + }) + }} @@ -367,9 +645,20 @@ class="flex items-center justify-between gap-3 rounded-lg border p-3" >
-
{{ binding.sessionId }}
+
+
{{ binding.sessionId }}
+ + {{ t(`settings.remote.bindingKinds.${binding.kind}`) }} + +
- telegram:{{ binding.chatId }}:{{ binding.messageThreadId }} + {{ binding.channel }}:{{ binding.chatId + }}{{ binding.threadId ? `:${binding.threadId}` : '' }}
- {{ remoteControlTooltip }} + + {{ remoteControlTooltip }} + @@ -140,88 +142,105 @@
- -
-
- -
+ +
+ +

{{ t('chat.sidebar.emptyTitle') }}

+

+ {{ t('chat.sidebar.emptyDescription') }} +

+
- -
- -

{{ t('chat.sidebar.emptyTitle') }}

-

- {{ t('chat.sidebar.emptyDescription') }} -

+ +
+
+ + + +
+ +
+
- @@ -512,7 +776,39 @@ onUnmounted(() => { -webkit-app-region: drag; } +.session-list { + overflow-anchor: none; +} + button { -webkit-app-region: no-drag; } + +:global(.sidebar-pin-flight) { + transform: translateZ(0); + backface-visibility: hidden; +} + +.sidebar-group-collapse-enter-active, +.sidebar-group-collapse-leave-active { + overflow: hidden; + transition: + max-height 180ms ease, + opacity 160ms ease, + transform 180ms ease; +} + +.sidebar-group-collapse-enter-from, +.sidebar-group-collapse-leave-to { + max-height: 0; + opacity: 0; + transform: translateY(-4px); +} + +.sidebar-group-collapse-enter-to, +.sidebar-group-collapse-leave-from { + max-height: 720px; + opacity: 1; + transform: translateY(0); +} diff --git a/src/renderer/src/components/WindowSideBarSessionItem.vue b/src/renderer/src/components/WindowSideBarSessionItem.vue index fe98d12bc..3579519d0 100644 --- a/src/renderer/src/components/WindowSideBarSessionItem.vue +++ b/src/renderer/src/components/WindowSideBarSessionItem.vue @@ -1,88 +1,434 @@ - - + + diff --git a/src/renderer/src/components/icons/ModelIcon.vue b/src/renderer/src/components/icons/ModelIcon.vue index 80e03e70a..e0ee3e5f1 100644 --- a/src/renderer/src/components/icons/ModelIcon.vue +++ b/src/renderer/src/components/icons/ModelIcon.vue @@ -72,6 +72,7 @@ import burncloudColorIcon from '@/assets/llm-icons/burncloud-color.svg?url' import xiaomiColorIcon from '@/assets/llm-icons/xiaomi.png?url' import o3fanColorIcon from '@/assets/llm-icons/o3-fan.png?url' import voiceAiColorIcon from '@/assets/llm-icons/voiceai.svg?url' +import novitaAiIcon from '@/assets/llm-icons/novitaai.svg?url' // 导入所有图标 const icons = { @@ -157,6 +158,9 @@ const icons = { burncloud: burncloudColorIcon, xiaomi: xiaomiColorIcon, voiceai: voiceAiColorIcon, + novita: novitaAiIcon, + novitaai: novitaAiIcon, + 'novita.ai': novitaAiIcon, default: defaultIcon } @@ -244,7 +248,8 @@ const monoIconUrls = new Set([ lmstudioColorIcon, _302aiIcon, awsBedrockIcon, - voiceAiColorIcon + voiceAiColorIcon, + novitaAiIcon ]) const invert = computed(() => { diff --git a/src/renderer/src/components/mcp-config/mcpServerForm.vue b/src/renderer/src/components/mcp-config/mcpServerForm.vue index 8b1ca56bb..8c5d32923 100644 --- a/src/renderer/src/components/mcp-config/mcpServerForm.vue +++ b/src/renderer/src/components/mcp-config/mcpServerForm.vue @@ -18,16 +18,12 @@ import { EmojiPicker } from '@/components/emoji-picker' import { useToast } from '@/components/use-toast' import { Icon } from '@iconify/vue' import { X } from 'lucide-vue-next' -import ModelIcon from '@/components/icons/ModelIcon.vue' -import { useModelStore } from '@/stores/modelStore' import { usePresenter } from '@/composables/usePresenter' import { nanoid } from 'nanoid' const { t } = useI18n() const { toast } = useToast() -const modelStore = useModelStore() const devicePresenter = usePresenter('devicePresenter') -const configPresenter = usePresenter('configPresenter') const props = defineProps<{ serverName?: string initialConfig?: MCPServerConfig @@ -57,14 +53,8 @@ const customHeadersFocused = ref(false) const customHeadersDisplayValue = ref('') const npmRegistry = ref(props.initialConfig?.customNpmRegistry || '') -// imageServer 展示用(只读,来源于 defaultVisionModel) -const selectedImageModelName = ref('') -const selectedImageModelProvider = ref('') - // 判断是否是inmemory类型 const isInMemoryType = computed(() => type.value === 'inmemory') -// 判断是否是imageServer -const isImageServer = computed(() => isInMemoryType.value && name.value === 'imageServer') // 判断是否是buildInFileSystem const isBuildInFileSystem = computed( () => isInMemoryType.value && name.value === 'buildInFileSystem' @@ -80,32 +70,6 @@ const formatJsonHeaders = (headers: Record): string => { .map(([key, value]) => `${key}=${value}`) .join('\n') } -const refreshImageServerDefaultModelDisplay = async (): Promise => { - if (!isImageServer.value) { - selectedImageModelName.value = '' - selectedImageModelProvider.value = '' - return - } - - const defaultVisionModel = (await configPresenter.getSetting('defaultVisionModel')) as - | { providerId: string; modelId: string } - | undefined - if (!defaultVisionModel?.providerId || !defaultVisionModel?.modelId) { - selectedImageModelName.value = '' - selectedImageModelProvider.value = '' - return - } - - selectedImageModelProvider.value = defaultVisionModel.providerId - const providerEntry = modelStore.enabledModels.find( - (entry) => entry.providerId === defaultVisionModel.providerId - ) - const resolvedModel = providerEntry?.models.find( - (model) => model.id === defaultVisionModel.modelId - ) - selectedImageModelName.value = - resolvedModel?.name || `${defaultVisionModel.providerId}/${defaultVisionModel.modelId}` -} // 获取内置服务器的本地化名称和描述 const getLocalizedName = computed(() => { @@ -144,11 +108,9 @@ const jsonConfig = ref('') const showBaseUrl = computed(() => isRemoteType.value) // 添加计算属性来控制命令相关字段的显示 const showCommandFields = computed(() => type.value === 'stdio') -// 控制参数输入框的显示 (stdio 或 非imageServer且非buildInFileSystem的inmemory) +// 控制参数输入框的显示 (stdio 或 非buildInFileSystem的inmemory) const showArgsInput = computed( - () => - showCommandFields.value || - (isInMemoryType.value && !isImageServer.value && !isBuildInFileSystem.value) + () => showCommandFields.value || (isInMemoryType.value && !isBuildInFileSystem.value) ) // 控制文件夹选择界面的显示 (仅针对 buildInFileSystem) @@ -253,11 +215,11 @@ const isNameValid = computed(() => name.value.trim().length > 0) const isCommandValid = computed(() => { // 对于SSE类型,命令不是必需的 if (isRemoteType.value) return true - // 对于STDIO 或 inmemory 类型,命令是必需的 (排除内置 server) - if (type.value === 'stdio' || (isInMemoryType.value && !isImageServer.value)) { + // 对于STDIO 或 inmemory 类型,命令是必需的 + if (type.value === 'stdio' || isInMemoryType.value) { return command.value.trim().length > 0 } - return true // 其他情况(如 imageServer)默认有效 + return true }) const isEnvValid = computed(() => { try { @@ -473,11 +435,9 @@ const handleSubmit = (): void => { } } else { // STDIO 或 inmemory 类型的服务器 - const normalizedArgs = isImageServer.value - ? [] - : isBuildInFileSystem.value - ? foldersList.value.filter((folder) => folder.trim().length > 0) - : argsRows.value.map((row) => row.value.trim()).filter((value) => value.length > 0) + const normalizedArgs = isBuildInFileSystem.value + ? foldersList.value.filter((folder) => folder.trim().length > 0) + : argsRows.value.map((row) => row.value.trim()).filter((value) => value.length > 0) serverConfig = { ...baseConfig, command: command.value.trim(), @@ -592,15 +552,6 @@ watch( { immediate: true } ) -// imageServer 仅展示默认视觉模型,不再通过 args 配置 -watch( - [() => name.value, () => type.value, () => modelStore.enabledModels], - () => { - void refreshImageServerDefaultModelDisplay() - }, - { immediate: true, deep: true } -) - // Watch for initial config changes (primarily for edit mode) watch( () => props.initialConfig, @@ -793,25 +744,6 @@ HTTP-Referer=deepchatai.cn` />
- -
- -
- - {{ - selectedImageModelName || t('settings.mcp.serverForm.imageModel') - }} -
-
-