From 75166716109ea1d95ee8533bdf29a56b7524a59c Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 5 Mar 2026 16:27:33 +0800 Subject: [PATCH] feat: release v0.80.0 with gateway tunnel system and agent improvements Major Features: - Gateway tunnel system with Discord/Feishu/Telegram support - Agent request tool policy management - Aster session recovery mechanism - Channel log viewer UI with filtering Improvements: - Refactor MCP database migration and config hot-reload - Optimize request tool policy service - Add comprehensive tests for new features Fixes: - Fix test compilation errors (Config gateway field, type inference) - Fix doctest import paths - Fix flaky antigravity token validation test - Fix CI build issues Dependencies: - Update pnpm-lock.yaml and Cargo.lock Co-Authored-By: Claude Opus 4.6 (1M context) --- RELEASE_NOTES.md | 66 +- .../16.gateway-tunnel-webhook.md | 173 ++ docs/plugins/antigravity-provider.md | 30 +- package.json | 8 +- pnpm-lock.yaml | 2316 +++++++++++---- src-tauri/Cargo.lock | 171 +- src-tauri/Cargo.toml | 8 +- src-tauri/crates/agent/Cargo.toml | 2 +- src-tauri/crates/agent/src/lib.rs | 6 + .../crates/agent/src/request_tool_policy.rs | 731 +++++ .../agent/tests/real_codex_tool_events.rs | 2 +- src-tauri/crates/core/src/config/mod.rs | 35 +- src-tauri/crates/core/src/config/types.rs | 986 ++++++- src-tauri/crates/core/src/database/dao/mcp.rs | 106 +- .../crates/core/src/database/migration.rs | 55 + .../crates/core/src/database/migration_v3.rs | 2 +- src-tauri/crates/core/src/database/mod.rs | 12 + src-tauri/crates/core/src/logger.rs | 10 + src-tauri/crates/gateway/Cargo.toml | 27 + src-tauri/crates/gateway/src/discord.rs | 2092 ++++++++++++++ src-tauri/crates/gateway/src/feishu.rs | 2400 ++++++++++++++++ src-tauri/crates/gateway/src/lib.rs | 8 + src-tauri/crates/gateway/src/telegram.rs | 2474 +++++++++++++++++ src-tauri/crates/gateway/src/tunnel.rs | 682 +++++ .../providers/src/providers/antigravity.rs | 61 +- src-tauri/crates/scheduler/Cargo.toml | 2 + src-tauri/crates/scheduler/src/executor.rs | 174 +- .../src/connections/connection_router.rs | 4 +- .../terminal/src/connections/local_pty.rs | 37 +- .../terminal/src/integration/osc_parser.rs | 4 +- src-tauri/crates/terminal/src/pty_session.rs | 39 +- .../websocket/src/handlers/rpc_handler.rs | 101 +- src-tauri/crates/websocket/src/protocol.rs | 2 + src-tauri/src/app/bootstrap.rs | 8 + src-tauri/src/app/commands/config.rs | 16 +- src-tauri/src/app/runner.rs | 125 + src-tauri/src/commands/aster_agent_cmd.rs | 145 +- src-tauri/src/commands/gateway_channel_cmd.rs | 204 ++ src-tauri/src/commands/gateway_tunnel_cmd.rs | 572 ++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/config/observer/mod.rs | 3 +- src-tauri/src/config/tests.rs | 3 + .../request_tool_policy_prompt_service.rs | 572 +--- src-tauri/tauri.conf.json | 2 +- .../chat/hooks/useAsterAgentChat.test.tsx | 170 +- .../agent/chat/hooks/useAsterAgentChat.ts | 427 ++- .../system/channels/ChannelLogTailPanel.tsx | 259 ++ .../channels/channel-log-filter.test.ts | 58 + .../system/channels/channel-log-filter.ts | 42 + .../settings-v2/system/channels/index.tsx | 1924 ++++++++++++- .../settings-v2/system/developer/index.tsx | 6 +- src/hooks/useTauri.ts | 478 ++++ src/lib/api/channels.ts | 166 ++ src/lib/asterSessionRecovery.test.ts | 54 + src/lib/asterSessionRecovery.ts | 59 + src/stores/agentStore.ts | 37 + 56 files changed, 16520 insertions(+), 1638 deletions(-) create mode 100644 docs/content/02.user-guide/16.gateway-tunnel-webhook.md create mode 100644 src-tauri/crates/agent/src/request_tool_policy.rs create mode 100644 src-tauri/crates/gateway/Cargo.toml create mode 100644 src-tauri/crates/gateway/src/discord.rs create mode 100644 src-tauri/crates/gateway/src/feishu.rs create mode 100644 src-tauri/crates/gateway/src/lib.rs create mode 100644 src-tauri/crates/gateway/src/telegram.rs create mode 100644 src-tauri/crates/gateway/src/tunnel.rs create mode 100644 src-tauri/src/commands/gateway_channel_cmd.rs create mode 100644 src-tauri/src/commands/gateway_tunnel_cmd.rs create mode 100644 src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx create mode 100644 src/components/settings-v2/system/channels/channel-log-filter.test.ts create mode 100644 src/components/settings-v2/system/channels/channel-log-filter.ts create mode 100644 src/lib/asterSessionRecovery.test.ts create mode 100644 src/lib/asterSessionRecovery.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 46130ecd1..0f11bceaa 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,44 +1,36 @@ -# ProxyCast v0.79.0 Release Notes +## ProxyCast v0.80.0 -## 🎯 主要功能 +### ✨ 新功能 +- 新增 Gateway 网关模块,支持隧道和频道管理 (gateway crate + gateway_tunnel_cmd + gateway_channel_cmd) +- 新增 Agent 工具调用策略管理 (request_tool_policy.rs) +- 新增 Aster 会话恢复机制 (asterSessionRecovery) +- 新增频道日志尾部面板和日志过滤功能 (ChannelLogTailPanel + channel-log-filter) +- 新增 Gateway 隧道 Webhook 使用文档 +- Agent 聊天增强:扩展 useAsterAgentChat hooks 功能 +- 新增 useTauri hooks 扩展,提供更多 Tauri 桥接能力 +- 新增频道 API 接口 (channels.ts) +- 数据库新增 v3 迁移支持 -### Agent 与工具调用增强 -- 新增 `request_tool_policy_prompt_service`,统一注入工具请求策略提示词 -- 增强 Aster Agent 聊天链路,补齐工具调用偏好与策略处理 -- 新增真实联网回归测试(Web Search policy / preflight) +### 🐛 修复 +- 修复 CI 构建中 app-version 模块的 shebang 解析问题 +- 修复 CI release 流程中 tauri action 的版本兼容性 +- 修复 CI release 的 projectPath 配置 -### 内容创作工作流升级 -- 新增活动日志能力(`ActivityLog` 组件、Hook、工具函数及测试) -- 新增社媒模板体系(行业分析、产品发布、技术分享、热点话题、视觉内容) -- 新增掘金与知乎平台适配 -- 改进海报工作流与文档画布类型系统 +### 🔧 优化与重构 +- 重构 request_tool_policy_prompt_service,精简代码 +- 优化 scheduler executor 执行逻辑 +- 优化 websocket RPC handler,扩展协议支持 +- 优化 terminal PTY 会话和本地连接管理 +- 扩展 core 配置类型,新增 GatewayConfig +- 增强 agent store 状态管理 +- 优化 aster agent 命令处理 +- 改进 config observer 和 bootstrap 流程 -### 聊天与工作台体验优化 -- 优化 Agent Chat 输入区、空状态、工具入口与状态流转 -- 改进 Unified Chat 与通用聊天面板交互 -- 调整 Workbench 主内容区/右侧栏结构和导航逻辑 - -## 🔧 工程改进 -- 新增应用版本一致性脚本: - - `scripts/app-version.mjs` - - `scripts/check-app-version-consistency.mjs` -- 版本统一升级到 `0.79.0`(`package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`) -- 同步更新 Rust 锁文件与多模块实现 - -## 🐛 修复 -- 修复版本解析相关测试用例(`appVersion`) -- 修复部分 Agent/Provider/MCP/Terminal 场景下的兼容与稳定性问题 -- 改进 Live Sync 与命令处理链路的健壮性 - -## 📊 统计 -- 89 个文件修改 -- +6642 行新增代码 -- -859 行删除代码 - -## 🔗 依赖更新 -- Aster 依赖保持 `v0.16.0` -- 同步更新 `Cargo.lock` +### 📦 其他 +- 更新依赖版本 (pnpm-lock.yaml, Cargo.lock) +- 新增 scheduler 依赖 +- 开发者设置页面微调 --- -**完整变更**: v0.78.0...v0.79.0 +**完整变更**: v0.79.0...v0.80.0 diff --git a/docs/content/02.user-guide/16.gateway-tunnel-webhook.md b/docs/content/02.user-guide/16.gateway-tunnel-webhook.md new file mode 100644 index 000000000..3169fada0 --- /dev/null +++ b/docs/content/02.user-guide/16.gateway-tunnel-webhook.md @@ -0,0 +1,173 @@ +--- +title: Gateway 公共隧道与飞书 Webhook +description: 在桌面端通过 Gateway 公共隧道配置 Cloudflare Tunnel,并完成飞书事件回调接入 +navigation: + icon: i-heroicons-globe-alt +--- + +# Gateway 公共隧道与飞书 Webhook + +本文用于桌面端场景:本机没有公网 IP,通过 Gateway 公共隧道暴露 webhook 地址,给飞书事件订阅使用。 + +## 适用版本 + +- ProxyCast 已支持“渠道管理 > Gateway 公共隧道”配置卡片 +- 飞书渠道支持 `webhook` 模式 + +## 前置条件 + +1. 已安装 `cloudflared`,并确保命令可执行: + +```bash +cloudflared --version +``` + +2. 已准备 Cloudflare 账号和域名(若使用固定域名) +3. 飞书应用已创建,拿到 `App ID` 与 `App Secret` + +> 桌面端已支持在「渠道管理 > Gateway 公共隧道」中直接点击: +> `检测 cloudflared`、`一键安装 cloudflared`(支持 macOS / Windows / Ubuntu 等常见 Linux 发行版)。 + +## 步骤 1:配置 Gateway 公共隧道 + +进入:`设置 > 渠道管理`,在顶部找到 `Gateway 公共隧道` 卡片。 + +推荐配置: + +- `启用隧道`: 开启 +- `Provider`: `cloudflare` +- `模式`: `managed` +- `本地 Host`: `127.0.0.1`(或 `0.0.0.0`) +- `本地 Port`: `3000` + +Cloudflare 相关字段(二选一): + +1. `Run Token`(推荐) +2. `Tunnel ID + Credentials File` + +如果你希望应用内创建隧道,可填写: + +- `Tunnel Name` +- `DNS Name`(例如 `bot.example.com`) + +然后点击 `创建隧道`。 + +## 步骤 2:同步飞书回调地址 + +在同一个卡片中: + +1. 填 `同步回调账号 ID(Feishu)`,单账号一般为 `default` +2. 点击 `同步飞书回调 URL` + +该动作会自动: + +- 计算并返回完整 webhook URL +- 将飞书渠道切换为 `webhook` 模式 +- 回填飞书 `webhook_host/webhook_port/webhook_path` + +## 步骤 3:启动隧道并验证状态 + +在 Gateway 公共隧道卡片依次点击: + +1. `探测` +2. `启动` +3. `查询状态` + +正常时状态里应看到: + +- `running: true` +- `public_base_url` 不为空 +- `last_error` 为空 + +## 步骤 4:配置飞书后台事件订阅 + +进入飞书开发者后台,事件订阅选择“发送至开发者服务器”。 + +将上一步返回的 `webhook_url` 填入请求地址,并保存。 + +如果你启用了飞书验证或加密,请确保以下字段一致: + +- `Verification Token` +- `Encrypt Key` + +## 步骤 5:启动飞书 Gateway 并联调 + +在 `渠道管理 > 飞书` 中: + +1. 确认 `连接模式 = webhook` +2. 点击飞书调试工具中的 `重启` 或 `启动` +3. 在飞书给机器人发消息,观察是否收到回复 + +## 常见问题 + +### 1) 飞书后台提示“未检测到应用连接信息” + +常见原因: + +- 隧道未启动 +- `public_base_url` 为空或不可访问 +- 飞书仍在 `websocket` 模式(当前版本不建议) + +处理: + +1. 在 Gateway 卡片点击 `查询状态` +2. 确认 `running: true` +3. 重新执行 `同步飞书回调 URL` + +### 2) 启动隧道失败 + +常见原因: + +- `cloudflared` 不在 PATH +- `Run Token` / `Tunnel ID` / `Credentials File` 缺失或无效 + +处理: + +1. 先点 `探测` 看具体错误 +2. 优先改为使用 `Run Token` +3. 如果提示未安装 cloudflared,可先点 `检测 cloudflared`,再点 `一键安装 cloudflared` + +### 3) 飞书保存回调 URL 失败 + +常见原因: + +- URL 不可公网访问 +- Token/加密配置不一致 + +处理: + +1. 确认 URL 为 `https://` 且可访问 +2. 对齐飞书后台与 ProxyCast 的 Token/Encrypt Key + +## 运维建议 + +1. 生产场景优先使用固定域名,不建议临时域名 +2. 将 `Run Token` 存储在受控环境,避免明文传播 +3. 隧道可用后再启动渠道 Gateway,减少“启动成功但回调不可达”的误判 + +## 进程常驻策略(Tauri + Cloudflared) + +桌面端建议按环境选择: + +1. `managed`(应用内托管,默认) + 由 ProxyCast 在启动后自动拉起 tunnel,并定时检查进程状态;异常退出会自动重拉。 +2. `external`(系统级常驻,生产更稳) + 由系统服务管理 `cloudflared`,ProxyCast 仅做状态诊断与 webhook 同步。 + +### 何时用 external + +- 需要长期稳定在线(飞书后台持续探活) +- 本机网络波动较大,应用重启不希望影响 tunnel +- 需要把 tunnel 运维与桌面应用生命周期解耦 + +### 常见连通性检查 + +```bash +curl -i "https://<你的域名>/feishu/default" +cloudflared tunnel info "" +``` + +期望结果: + +- `curl` 返回 2xx/4xx(只要不是 Cloudflare 1033/530,说明已到达你的应用入口) +- `tunnel info` 显示有 active connectors diff --git a/docs/plugins/antigravity-provider.md b/docs/plugins/antigravity-provider.md index 4f60e4fa6..d338d64f6 100644 --- a/docs/plugins/antigravity-provider.md +++ b/docs/plugins/antigravity-provider.md @@ -265,10 +265,22 @@ pub enum OutputProtocol { ```rust /// OAuth 2.0 配置(Google) -pub const OAUTH_CLIENT_ID: &str = - "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; -pub const OAUTH_CLIENT_SECRET: &str = - "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"; +/// 必须通过环境变量配置: +/// - ANTIGRAVITY_OAUTH_CLIENT_ID +/// - ANTIGRAVITY_OAUTH_CLIENT_SECRET +/// +/// 获取方式: +/// 1. 使用 Antigravity CLI 的默认凭据 +/// 2. 从 Google Cloud Console 创建 OAuth 2.0 客户端 +fn oauth_client_id() -> String { + std::env::var("ANTIGRAVITY_OAUTH_CLIENT_ID") + .expect("ANTIGRAVITY_OAUTH_CLIENT_ID environment variable must be set") +} + +fn oauth_client_secret() -> String { + std::env::var("ANTIGRAVITY_OAUTH_CLIENT_SECRET") + .expect("ANTIGRAVITY_OAUTH_CLIENT_SECRET environment variable must be set") +} pub const OAUTH_SCOPES: &[&str] = &[ "https://www.googleapis.com/auth/cloud-platform", @@ -321,11 +333,13 @@ impl AntigravityProvider { let refresh_token = credential.refresh_token.as_ref() .ok_or(Error::MissingRefreshToken)?; + let client_id = oauth_client_id(); + let client_secret = oauth_client_secret(); let response = self.http_client .post(OAUTH_TOKEN_URL) .form(&[ - ("client_id", OAUTH_CLIENT_ID), - ("client_secret", OAUTH_CLIENT_SECRET), + ("client_id", client_id.as_str()), + ("client_secret", client_secret.as_str()), ("refresh_token", refresh_token.as_str()), ("grant_type", "refresh_token"), ]) @@ -1217,6 +1231,10 @@ pnpm test | `ANTIGRAVITY_ENVIRONMENT` | API 环境 | `daily` | | `ANTIGRAVITY_DEBUG` | 调试模式 | `false` | | `ANTIGRAVITY_TIMEOUT_MS` | 请求超时 | `120000` | +| `ANTIGRAVITY_OAUTH_CLIENT_ID` | OAuth Client ID(必需) | 无,必须设置 | +| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | OAuth Client Secret(必需) | 无,必须设置 | + +**注意**:OAuth 凭据可以从 Antigravity CLI 获取或从 Google Cloud Console 创建。 ### B. 参考链接 diff --git a/package.json b/package.json index e558b8e98..ac885d10a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.79.0", + "version": "0.80.0", "type": "module", "repository": { "type": "git", @@ -54,10 +54,10 @@ "@sentry/browser": "^8.55.0", "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "2.10.1", - "@tauri-apps/plugin-deep-link": "2.4.6", + "@tauri-apps/plugin-deep-link": "2.4.7", "@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-global-shortcut": "2.3.1", - "@tauri-apps/plugin-shell": "2.3.4", + "@tauri-apps/plugin-shell": "2.3.5", "@tiptap/core": "^3.19.0", "@tiptap/extension-highlight": "^3.19.0", "@tiptap/extension-image": "^3.19.0", @@ -119,7 +119,7 @@ "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@eslint/js": "^9.15.0", "@fast-check/vitest": "^0.2.4", - "@tauri-apps/cli": "^2.0.0", + "@tauri-apps/cli": "^2.10.1", "@types/babel__standalone": "^7.1.9", "@types/fabric": "^5.3.11", "@types/node": "^22.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f54b81db2..645540c03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@babel/standalone': + specifier: ^7.29.0 + version: 7.29.1 '@fabianlars/tauri-plugin-oauth': specifier: ^2 version: 2.0.0 @@ -56,21 +59,69 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.3 version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@sentry/browser': + specifier: ^8.55.0 + version: 8.55.0 + '@tanstack/react-virtual': + specifier: ^3.13.18 + version: 3.13.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tauri-apps/api': - specifier: ^2.9.1 - version: 2.9.1 + specifier: 2.10.1 + version: 2.10.1 '@tauri-apps/plugin-deep-link': - specifier: ^2.4.5 - version: 2.4.5 + specifier: 2.4.7 + version: 2.4.7 '@tauri-apps/plugin-dialog': - specifier: ^2.4.2 - version: 2.4.2 + specifier: 2.6.0 + version: 2.6.0 '@tauri-apps/plugin-global-shortcut': - specifier: ^2 + specifier: 2.3.1 version: 2.3.1 '@tauri-apps/plugin-shell': - specifier: ^2.0.0 - version: 2.3.3 + specifier: 2.3.5 + version: 2.3.5 + '@tiptap/core': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/extension-highlight': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-image': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-placeholder': + specifier: ^3.18.0 + version: 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-table': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-table-cell': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-table-header': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-table-row': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-task-item': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-task-list': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/pm': + specifier: ^3.18.0 + version: 3.20.0 + '@tiptap/react': + specifier: ^3.18.0 + version: 3.20.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tiptap/starter-kit': + specifier: ^3.18.0 + version: 3.20.0 + '@tiptap/suggestion': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) '@tonejs/midi': specifier: ^2.0.28 version: 2.0.28 @@ -80,6 +131,9 @@ importers: '@types/styled-components': specifier: ^5.1.36 version: 5.1.36 + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -104,6 +158,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -113,6 +170,9 @@ importers: fabric: specifier: ^5.5.2 version: 5.5.2 + hast-util-to-html: + specifier: ^9.0.5 + version: 9.0.5 i18next: specifier: ^25.7.3 version: 25.7.4(typescript@5.9.3) @@ -125,6 +185,12 @@ importers: lucide-react: specifier: ^0.460.0 version: 0.460.0(react@18.3.1) + marked: + specifier: ^17.0.1 + version: 17.0.3 + mermaid: + specifier: ^11.12.2 + version: 11.12.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -146,6 +212,9 @@ importers: react-syntax-highlighter: specifier: ^16.1.0 version: 16.1.0(react@18.3.1) + refractor: + specifier: ^5.0.0 + version: 5.0.0 rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -167,9 +236,18 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.0 + tippy.js: + specifier: ^6.3.7 + version: 6.3.7 tone: specifier: ^15.1.22 version: 15.1.22 + turndown: + specifier: ^7.2.2 + version: 7.2.2 + zustand: + specifier: ^5.0.10 + version: 5.0.11(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: '@babel/plugin-transform-react-jsx-source': specifier: ^7.27.1 @@ -179,10 +257,13 @@ importers: version: 9.39.2 '@fast-check/vitest': specifier: ^0.2.4 - version: 0.2.4(vitest@4.0.16(@types/node@22.19.3)(jiti@1.21.7)(jsdom@27.4.0(canvas@2.11.2))(tsx@4.21.0)) + version: 0.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(jsdom@22.1.0(canvas@2.11.2))) '@tauri-apps/cli': - specifier: ^2.0.0 - version: 2.9.6 + specifier: ^2.10.1 + version: 2.10.1 + '@types/babel__standalone': + specifier: ^7.1.9 + version: 7.1.9 '@types/fabric': specifier: ^5.3.11 version: 5.3.11 @@ -229,8 +310,8 @@ importers: specifier: ^9.1.7 version: 9.1.7 jsdom: - specifier: ^27.3.0 - version: 27.4.0(canvas@2.11.2) + specifier: ^22.1.0 + version: 22.1.0(canvas@2.11.2) postcss: specifier: ^8.4.47 version: 8.5.6 @@ -247,32 +328,23 @@ importers: specifier: ^5.6.3 version: 5.9.3 vite: - specifier: ^5.4.10 + specifier: ^5.4.21 version: 5.4.21(@types/node@22.19.3) vite-plugin-svgr: specifier: ^4.5.0 version: 4.5.0(rollup@4.54.0)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.3)) vitest: - specifier: ^4.0.16 - version: 4.0.16(@types/node@22.19.3)(jiti@1.21.7)(jsdom@27.4.0(canvas@2.11.2))(tsx@4.21.0) + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(jsdom@22.1.0(canvas@2.11.2)) packages: - '@acemir/cssom@0.9.30': - resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@asamuzakjp/css-color@4.1.1': - resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} - - '@asamuzakjp/dom-selector@6.7.6': - resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} - - '@asamuzakjp/nwsapi@2.3.9': - resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} @@ -349,6 +421,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/standalone@7.29.1': + resolution: {integrity: sha512-z42abD0C6fiHfgLyCWw8PYv6FCJ0IGVtSCxXk/NPykWO5LNIEGfdLDJ3HdYqlPcAhwtQ3oKH1PvNj2JGpTxQKg==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -361,37 +437,23 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@csstools/color-helpers@5.1.0': - resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} - engines: {node: '>=18'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} - '@csstools/css-calc@2.1.4': - resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} - '@csstools/css-color-parser@3.1.0': - resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} - '@csstools/css-parser-algorithms@3.0.5': - resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-tokenizer': ^3.0.4 + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} - '@csstools/css-syntax-patches-for-csstree@1.0.22': - resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} - engines: {node: '>=18'} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} - '@csstools/css-tokenizer@3.0.4': - resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} - engines: {node: '>=18'} + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} '@emotion/is-prop-valid@1.2.2': resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==} @@ -734,15 +796,6 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@exodus/bytes@1.7.0': - resolution: {integrity: sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - '@exodus/crypto': ^1.0.0-rc.4 - peerDependenciesMeta: - '@exodus/crypto': - optional: true - '@fabianlars/tauri-plugin-oauth@2.0.0': resolution: {integrity: sha512-I1s08ZXrsFuYfNWusAcpLyiCfr5TCvaBrRuKfTG+XQrcaqnAcwjdWH0U5J9QWuMDLwCUMnVxdobtMJzPR8raxQ==} @@ -788,6 +841,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -808,6 +867,12 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true + '@mermaid-js/parser@1.0.0': + resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -824,6 +889,9 @@ packages: resolution: {integrity: sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==} engines: {node: '>=12'} + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1280,6 +1348,9 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1413,8 +1484,29 @@ packages: cpu: [x64] os: [win32] - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@sentry-internal/browser-utils@8.55.0': + resolution: {integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==} + engines: {node: '>=14.18'} + + '@sentry-internal/feedback@8.55.0': + resolution: {integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==} + engines: {node: '>=14.18'} + + '@sentry-internal/replay-canvas@8.55.0': + resolution: {integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==} + engines: {node: '>=14.18'} + + '@sentry-internal/replay@8.55.0': + resolution: {integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==} + engines: {node: '>=14.18'} + + '@sentry/browser@8.55.0': + resolution: {integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==} + engines: {node: '>=14.18'} + + '@sentry/core@8.55.0': + resolution: {integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==} + engines: {node: '>=14.18'} '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} @@ -1484,96 +1576,306 @@ packages: peerDependencies: '@svgr/core': '*' - '@tauri-apps/api@2.9.1': - resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==} + '@tanstack/react-virtual@3.13.19': + resolution: {integrity: sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tauri-apps/cli-darwin-arm64@2.9.6': - resolution: {integrity: sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==} + '@tanstack/virtual-core@3.13.19': + resolution: {integrity: sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==} + + '@tauri-apps/api@2.10.1': + resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + + '@tauri-apps/cli-darwin-arm64@2.10.1': + resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.9.6': - resolution: {integrity: sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==} + '@tauri-apps/cli-darwin-x64@2.10.1': + resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.9.6': - resolution: {integrity: sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': + resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.9.6': - resolution: {integrity: sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==} + '@tauri-apps/cli-linux-arm64-gnu@2.10.1': + resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.9.6': - resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==} + '@tauri-apps/cli-linux-arm64-musl@2.10.1': + resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': - resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==} + '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': + resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.9.6': - resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==} + '@tauri-apps/cli-linux-x64-gnu@2.10.1': + resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.9.6': - resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==} + '@tauri-apps/cli-linux-x64-musl@2.10.1': + resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.9.6': - resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==} + '@tauri-apps/cli-win32-arm64-msvc@2.10.1': + resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.9.6': - resolution: {integrity: sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==} + '@tauri-apps/cli-win32-ia32-msvc@2.10.1': + resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.9.6': - resolution: {integrity: sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==} + '@tauri-apps/cli-win32-x64-msvc@2.10.1': + resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.9.6': - resolution: {integrity: sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==} + '@tauri-apps/cli@2.10.1': + resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==} engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-deep-link@2.4.5': - resolution: {integrity: sha512-Zf2RTj1D9IQQ45/jqW8XTKvql24HqlPjcpv0mV/O2jHQkNe11HOTZBVj6IK37qs+MWV7xZzcmazx/QVZnhAwaQ==} + '@tauri-apps/plugin-deep-link@2.4.7': + resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==} - '@tauri-apps/plugin-dialog@2.4.2': - resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==} + '@tauri-apps/plugin-dialog@2.6.0': + resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} '@tauri-apps/plugin-global-shortcut@2.3.1': resolution: {integrity: sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==} - '@tauri-apps/plugin-shell@2.3.3': - resolution: {integrity: sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ==} + '@tauri-apps/plugin-shell@2.3.5': + resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} + + '@tiptap/core@3.20.0': + resolution: {integrity: sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==} + peerDependencies: + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-blockquote@3.20.0': + resolution: {integrity: sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-bold@3.20.0': + resolution: {integrity: sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-bubble-menu@3.20.0': + resolution: {integrity: sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-bullet-list@3.20.0': + resolution: {integrity: sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-code-block@3.20.0': + resolution: {integrity: sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-code@3.20.0': + resolution: {integrity: sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-document@3.20.0': + resolution: {integrity: sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-dropcursor@3.20.0': + resolution: {integrity: sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==} + peerDependencies: + '@tiptap/extensions': ^3.20.0 + + '@tiptap/extension-floating-menu@3.20.0': + resolution: {integrity: sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-gapcursor@3.20.0': + resolution: {integrity: sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==} + peerDependencies: + '@tiptap/extensions': ^3.20.0 + + '@tiptap/extension-hard-break@3.20.0': + resolution: {integrity: sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-heading@3.20.0': + resolution: {integrity: sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-highlight@3.20.0': + resolution: {integrity: sha512-ANL1wFz0s1ScNR4uBfO0s6Sz+qqGp2u6ynrCVk6TCT3d10CQ+gD1gSDTrVRC3CtlMKtHHH4fYrFAq284+J0gKA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-horizontal-rule@3.20.0': + resolution: {integrity: sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-image@3.20.0': + resolution: {integrity: sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-italic@3.20.0': + resolution: {integrity: sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-link@3.20.0': + resolution: {integrity: sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-list-item@3.20.0': + resolution: {integrity: sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-list-keymap@3.20.0': + resolution: {integrity: sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-list@3.20.0': + resolution: {integrity: sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-ordered-list@3.20.0': + resolution: {integrity: sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-paragraph@3.20.0': + resolution: {integrity: sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-placeholder@3.20.0': + resolution: {integrity: sha512-ZhYD3L5m16ydSe2z8vqz+RdtAG/iOQaFHHedFct70tKRoLqi2ajF5kgpemu8DwpaRTcyiCN4G99J/+MqehKNjQ==} + peerDependencies: + '@tiptap/extensions': ^3.20.0 + + '@tiptap/extension-strike@3.20.0': + resolution: {integrity: sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-table-cell@3.20.0': + resolution: {integrity: sha512-9Dg4zda3UWwtpBwSG7b9BeQy5oT27a/yEIBeARuxe19bloMLZgqpPRtnSrOK0OAITtVnjA+NZdKPcVLRMS2E8A==} + peerDependencies: + '@tiptap/extension-table': ^3.20.0 + + '@tiptap/extension-table-header@3.20.0': + resolution: {integrity: sha512-2tVHHlihpeHO/gh2uU46gAX3NTGdKR+yDmfLlO2l0QAvx2TXNfNzX2pOM4MmyostW5Ko9TCWV4x0D9h3IQDhPw==} + peerDependencies: + '@tiptap/extension-table': ^3.20.0 + + '@tiptap/extension-table-row@3.20.0': + resolution: {integrity: sha512-clkfQahkYW/U48QBh1rPZv3AWWSC9AqGKp2DLTH/SGIorM/NwI0jpPtBETMlvowyQu0ivlH9B896smEph+Do2A==} + peerDependencies: + '@tiptap/extension-table': ^3.20.0 + + '@tiptap/extension-table@3.20.0': + resolution: {integrity: sha512-vaaMtQ2KnSSr8WVwgWf7NYNzPwrHx/6T0ekA5CxV8qNUEpXIaLXa5+tE7tJHWEdNR2KY3gUJ46D3lfOkxyFrBQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-task-item@3.20.0': + resolution: {integrity: sha512-UArl5ffRNKyiOGJYNtjF50+A0qgYF2DXIEFGyMgJsbp1KYQpDj+UrezWvnGOHZfX64eBWDIO6Bum5jS7o32dYQ==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-task-list@3.20.0': + resolution: {integrity: sha512-Ab2hfun+5xMG6kQhRjL7s16zTe3XaQRXe3/deGo9hnZrAzTQpWVwDN7H36BX8MemrWnAw3PiuwBH5iOrwNRGjA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-text@3.20.0': + resolution: {integrity: sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-underline@3.20.0': + resolution: {integrity: sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extensions@3.20.0': + resolution: {integrity: sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/pm@3.20.0': + resolution: {integrity: sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==} + + '@tiptap/react@3.20.0': + resolution: {integrity: sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.20.0': + resolution: {integrity: sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==} + + '@tiptap/suggestion@3.20.0': + resolution: {integrity: sha512-OA9Fe+1Q/Ex0ivTcpRcVFiLnNsVdIBmiEoctt/gu4H2ayCYmZ906veioXNdc1m/3MtVVUIuEnvwwsrOZXlfDEw==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 '@tonejs/midi@2.0.28': resolution: {integrity: sha512-RII6YpInPsOZ5t3Si/20QKpNqB1lZ2OCFJSOzJxz38YdY/3zqDr3uaml4JuCWkdixuPqP1/TBnXzhQ39csyoVg==} @@ -1588,6 +1890,9 @@ packages: '@types/babel__generator@7.27.0': resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + '@types/babel__standalone@7.1.9': + resolution: {integrity: sha512-IcCNPLqpevUD7UpV8QB0uwQPOyoOKACFf0YtYWRHcmxcakaje4Q7dbG2+jMqxw/I8Zk0NHvEps66WwS7z/UaaA==} + '@types/babel__template@7.4.4': resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} @@ -1597,6 +1902,99 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1612,6 +2010,9 @@ packages: '@types/fabric@5.3.11': resolution: {integrity: sha512-dvicyfS0rDW2KGnwXGz+t+ckMGZUtHzhp8XvaM99R1TsYVsIenf96PbEWCNMHknR6a3/lyYwh1APxxm+DqRkpw==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1626,15 +2027,24 @@ packages: '@types/katex@0.16.7': resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} '@types/lodash@4.17.23': resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1664,12 +2074,21 @@ packages: '@types/stylis@4.2.5': resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.51.0': resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1738,34 +2157,34 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/expect@4.0.16': - resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/mocker@4.0.16': - resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.16': - resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/runner@4.0.16': - resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/snapshot@4.0.16': - resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} - '@vitest/spy@4.0.16': - resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/utils@4.0.16': - resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} @@ -1818,10 +2237,6 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1889,9 +2304,6 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true - bidi-js@1.0.3: - resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1917,6 +2329,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1946,8 +2362,8 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} chalk@4.1.2: @@ -1966,6 +2382,18 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1981,6 +2409,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2014,6 +2448,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} @@ -2024,6 +2461,12 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cosmiconfig@8.3.6: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} @@ -2033,6 +2476,9 @@ packages: typescript: optional: true + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2044,10 +2490,6 @@ packages: css-to-react-native@3.2.0: resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2063,9 +2505,9 @@ packages: resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} engines: {node: '>=8'} - cssstyle@5.3.6: - resolution: {integrity: sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==} - engines: {node: '>=20'} + cssstyle@3.0.0: + resolution: {integrity: sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==} + engines: {node: '>=14'} csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -2073,6 +2515,23 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -2142,6 +2601,9 @@ packages: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + d3-path@3.1.0: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} @@ -2158,6 +2620,9 @@ packages: resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} engines: {node: '>=12'} + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + d3-scale-chromatic@3.1.0: resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} engines: {node: '>=12'} @@ -2170,6 +2635,9 @@ packages: resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} engines: {node: '>=12'} + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -2200,13 +2668,16 @@ packages: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} + dagre-d3-es@7.0.13: + resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + data-urls@3.0.2: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} - data-urls@6.0.0: - resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} - engines: {node: '>=20'} + data-urls@4.0.0: + resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==} + engines: {node: '>=14'} date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -2233,6 +2704,10 @@ packages: resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==} engines: {node: '>=8'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2271,6 +2746,9 @@ packages: engines: {node: '>=12'} deprecated: Use your platform's native DOMException instead + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -2426,6 +2904,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2544,6 +3026,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2584,6 +3069,9 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -2612,10 +3100,6 @@ packages: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} - html-encoding-sniffer@6.0.0: - resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -2629,18 +3113,10 @@ packages: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -2684,6 +3160,9 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -2768,6 +3247,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2781,11 +3263,11 @@ packages: canvas: optional: true - jsdom@27.4.0: - resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + jsdom@22.1.0: + resolution: {integrity: sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==} + engines: {node: '>=16'} peerDependencies: - canvas: ^3.0.0 + canvas: ^2.5.0 peerDependenciesMeta: canvas: optional: true @@ -2819,6 +3301,19 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + langium@4.2.1: + resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2830,6 +3325,12 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2837,6 +3338,9 @@ packages: lodash-es@4.17.22: resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2847,16 +3351,15 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2872,9 +3375,23 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + marked@17.0.3: + resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2927,13 +3444,16 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.12.3: + resolution: {integrity: sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -3064,6 +3584,9 @@ packages: engines: {node: '>=10'} hasBin: true + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3120,9 +3643,6 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3130,6 +3650,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3138,6 +3661,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3155,8 +3681,8 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.0: - resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} @@ -3180,6 +3706,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3199,6 +3729,15 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -3266,9 +3805,71 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-changeset@2.4.0: + resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==} + + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.0: + resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} + + prosemirror-menu@1.3.0: + resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + + prosemirror-transform@1.11.0: + resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} + + prosemirror-view@1.41.6: + resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==} + psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3414,10 +4015,6 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -3450,6 +4047,15 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rrweb-cssom@0.6.0: + resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3558,6 +4164,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -3574,6 +4183,9 @@ packages: stylis@4.3.2: resolution: {integrity: sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==} + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -3618,6 +4230,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -3626,16 +4241,20 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tldts-core@7.0.19: - resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} - tldts@7.0.19: - resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} - hasBin: true + tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -3648,10 +4267,6 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} - tough-cookie@6.0.0: - resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} - engines: {node: '>=16'} - tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -3659,9 +4274,9 @@ packages: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} - tr46@6.0.0: - resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} - engines: {node: '>=20'} + tr46@4.1.1: + resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} + engines: {node: '>=14'} trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -3675,6 +4290,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -3689,6 +4308,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + turndown@7.2.2: + resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3698,6 +4320,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3769,6 +4397,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -3778,6 +4410,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-svgr@4.5.0: resolution: {integrity: sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==} peerDependencies: @@ -3814,72 +4451,26 @@ packages: terser: optional: true - vite@7.3.0: - resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.0.16: - resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.16 - '@vitest/browser-preview': 4.0.16 - '@vitest/browser-webdriverio': 4.0.16 - '@vitest/ui': 4.0.16 + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@opentelemetry/api': + '@types/debug': optional: true '@types/node': optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': + '@vitest/browser': optional: true '@vitest/ui': optional: true @@ -3892,17 +4483,40 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-hr-time@1.0.2: resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} deprecated: Use your platform's native performance.now() and performance.timeOrigin. + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@3.0.0: resolution: {integrity: sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==} engines: {node: '>=12'} - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -3914,10 +4528,6 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} - webidl-conversions@8.0.0: - resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} - engines: {node: '>=20'} - whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} @@ -3927,10 +4537,6 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - whatwg-url@10.0.0: resolution: {integrity: sha512-CLxxCmdUby142H5FZzn4D8ikO1cmypvXVQktsgosNy4a4BHrDHeciBBGZhb0bNoR5/MltoCatso+vFjjGx8t0w==} engines: {node: '>=12'} @@ -3939,9 +4545,9 @@ packages: resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} engines: {node: '>=12'} - whatwg-url@15.1.0: - resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} - engines: {node: '>=20'} + whatwg-url@12.0.1: + resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==} + engines: {node: '>=14'} whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -3982,10 +4588,6 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -3999,32 +4601,35 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.11: + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@acemir/cssom@0.9.30': {} - '@alloc/quick-lru@5.2.0': {} - '@asamuzakjp/css-color@4.1.1': + '@antfu/install-pkg@1.1.0': dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 11.2.4 - - '@asamuzakjp/dom-selector@6.7.6': - dependencies: - '@asamuzakjp/nwsapi': 2.3.9 - bidi-js: 1.0.3 - css-tree: 3.1.0 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.4 - - '@asamuzakjp/nwsapi@2.3.9': {} + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 '@babel/code-frame@7.27.1': dependencies: @@ -4117,6 +4722,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/standalone@7.29.1': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -4140,27 +4747,24 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@csstools/color-helpers@5.1.0': {} + '@braintree/sanitize-url@7.1.2': {} - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + '@chevrotain/cst-dts-gen@11.1.2': dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + '@chevrotain/gast@11.1.2': dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-tokenizer': 3.0.4 + '@chevrotain/regexp-to-ast@11.1.2': {} - '@csstools/css-syntax-patches-for-csstree@1.0.22': {} + '@chevrotain/types@11.1.2': {} - '@csstools/css-tokenizer@3.0.4': {} + '@chevrotain/utils@11.1.2': {} '@emotion/is-prop-valid@1.2.2': dependencies: @@ -4363,16 +4967,14 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.7.0': {} - '@fabianlars/tauri-plugin-oauth@2.0.0': dependencies: - '@tauri-apps/api': 2.9.1 + '@tauri-apps/api': 2.10.1 - '@fast-check/vitest@0.2.4(vitest@4.0.16(@types/node@22.19.3)(jiti@1.21.7)(jsdom@27.4.0(canvas@2.11.2))(tsx@4.21.0))': + '@fast-check/vitest@0.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(jsdom@22.1.0(canvas@2.11.2)))': dependencies: fast-check: 4.5.3 - vitest: 4.0.16(@types/node@22.19.3)(jiti@1.21.7)(jsdom@27.4.0(canvas@2.11.2))(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(jsdom@22.1.0(canvas@2.11.2)) '@floating-ui/core@1.7.3': dependencies: @@ -4410,6 +5012,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4445,6 +5055,12 @@ snapshots: - supports-color optional: true + '@mermaid-js/parser@1.0.0': + dependencies: + langium: 4.2.1 + + '@mixmark-io/domino@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4463,6 +5079,8 @@ snapshots: interval-tree-1d: 1.0.4 isoformat: 0.2.1 + '@popperjs/core@2.11.8': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -4939,6 +5557,8 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@remirror/core-constants@3.0.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.3.0(rollup@4.54.0)': @@ -5015,7 +5635,33 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.54.0': optional: true - '@standard-schema/spec@1.1.0': {} + '@sentry-internal/browser-utils@8.55.0': + dependencies: + '@sentry/core': 8.55.0 + + '@sentry-internal/feedback@8.55.0': + dependencies: + '@sentry/core': 8.55.0 + + '@sentry-internal/replay-canvas@8.55.0': + dependencies: + '@sentry-internal/replay': 8.55.0 + '@sentry/core': 8.55.0 + + '@sentry-internal/replay@8.55.0': + dependencies: + '@sentry-internal/browser-utils': 8.55.0 + '@sentry/core': 8.55.0 + + '@sentry/browser@8.55.0': + dependencies: + '@sentry-internal/browser-utils': 8.55.0 + '@sentry-internal/feedback': 8.55.0 + '@sentry-internal/replay': 8.55.0 + '@sentry-internal/replay-canvas': 8.55.0 + '@sentry/core': 8.55.0 + + '@sentry/core@8.55.0': {} '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)': dependencies: @@ -5087,78 +5733,304 @@ snapshots: transitivePeerDependencies: - supports-color - '@tauri-apps/api@2.9.1': {} + '@tanstack/react-virtual@3.13.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.19 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - '@tauri-apps/cli-darwin-arm64@2.9.6': + '@tanstack/virtual-core@3.13.19': {} + + '@tauri-apps/api@2.10.1': {} + + '@tauri-apps/cli-darwin-arm64@2.10.1': optional: true - '@tauri-apps/cli-darwin-x64@2.9.6': + '@tauri-apps/cli-darwin-x64@2.10.1': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.9.6': + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.9.6': + '@tauri-apps/cli-linux-arm64-gnu@2.10.1': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.9.6': + '@tauri-apps/cli-linux-arm64-musl@2.10.1': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': + '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.9.6': + '@tauri-apps/cli-linux-x64-gnu@2.10.1': optional: true - '@tauri-apps/cli-linux-x64-musl@2.9.6': + '@tauri-apps/cli-linux-x64-musl@2.10.1': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.9.6': + '@tauri-apps/cli-win32-arm64-msvc@2.10.1': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.9.6': + '@tauri-apps/cli-win32-ia32-msvc@2.10.1': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.9.6': + '@tauri-apps/cli-win32-x64-msvc@2.10.1': optional: true - '@tauri-apps/cli@2.9.6': + '@tauri-apps/cli@2.10.1': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.9.6 - '@tauri-apps/cli-darwin-x64': 2.9.6 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.9.6 - '@tauri-apps/cli-linux-arm64-gnu': 2.9.6 - '@tauri-apps/cli-linux-arm64-musl': 2.9.6 - '@tauri-apps/cli-linux-riscv64-gnu': 2.9.6 - '@tauri-apps/cli-linux-x64-gnu': 2.9.6 - '@tauri-apps/cli-linux-x64-musl': 2.9.6 - '@tauri-apps/cli-win32-arm64-msvc': 2.9.6 - '@tauri-apps/cli-win32-ia32-msvc': 2.9.6 - '@tauri-apps/cli-win32-x64-msvc': 2.9.6 + '@tauri-apps/cli-darwin-arm64': 2.10.1 + '@tauri-apps/cli-darwin-x64': 2.10.1 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1 + '@tauri-apps/cli-linux-arm64-gnu': 2.10.1 + '@tauri-apps/cli-linux-arm64-musl': 2.10.1 + '@tauri-apps/cli-linux-riscv64-gnu': 2.10.1 + '@tauri-apps/cli-linux-x64-gnu': 2.10.1 + '@tauri-apps/cli-linux-x64-musl': 2.10.1 + '@tauri-apps/cli-win32-arm64-msvc': 2.10.1 + '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 + '@tauri-apps/cli-win32-x64-msvc': 2.10.1 - '@tauri-apps/plugin-deep-link@2.4.5': + '@tauri-apps/plugin-deep-link@2.4.7': dependencies: - '@tauri-apps/api': 2.9.1 + '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-dialog@2.4.2': + '@tauri-apps/plugin-dialog@2.6.0': dependencies: - '@tauri-apps/api': 2.9.1 + '@tauri-apps/api': 2.10.1 '@tauri-apps/plugin-global-shortcut@2.3.1': dependencies: - '@tauri-apps/api': 2.9.1 + '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-shell@2.3.3': + '@tauri-apps/plugin-shell@2.3.5': dependencies: - '@tauri-apps/api': 2.9.1 + '@tauri-apps/api': 2.10.1 + + '@tiptap/core@3.20.0(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/pm': 3.20.0 + + '@tiptap/extension-blockquote@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-bold@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-bubble-menu@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + optional: true + + '@tiptap/extension-bullet-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-code-block@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/extension-code@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-document@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-dropcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-floating-menu@3.20.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + optional: true + + '@tiptap/extension-gapcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-hard-break@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-heading@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-highlight@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-horizontal-rule@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/extension-image@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-italic@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-link@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-list-keymap@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/extension-ordered-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-paragraph@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-placeholder@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-strike@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-table-cell@3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-table': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-table-header@3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-table': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-table-row@3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-table': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/extension-task-item@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-task-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-text@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-underline@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/pm@3.20.0': + dependencies: + prosemirror-changeset: 2.4.0 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.4 + prosemirror-menu: 1.3.0 + prosemirror-model: 1.25.4 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6) + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + '@tiptap/react@3.20.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-floating-menu': 3.20.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.20.0': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/extension-blockquote': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-bold': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-bullet-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-code': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-code-block': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-document': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-dropcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-gapcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-hard-break': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-heading': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-horizontal-rule': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-italic': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-link': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-list-item': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-list-keymap': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-ordered-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-paragraph': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-strike': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-text': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-underline': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/suggestion@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 '@tonejs/midi@2.0.28': dependencies: array-flatten: 3.0.0 midi-file: 1.2.4 - '@tootallnate/once@2.0.0': - optional: true + '@tootallnate/once@2.0.0': {} '@types/babel__core@7.20.5': dependencies: @@ -5172,6 +6044,15 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/babel__standalone@7.1.9': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__core': 7.20.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + '@types/babel__template@7.4.4': dependencies: '@babel/parser': 7.28.5 @@ -5186,6 +6067,123 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -5200,6 +6198,8 @@ snapshots: '@types/fabric@5.3.11': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -5213,16 +6213,25 @@ snapshots: '@types/katex@0.16.7': {} + '@types/linkify-it@5.0.0': {} + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.23 '@types/lodash@4.17.23': {} + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/ms@2.1.0': {} '@types/node@22.19.3': @@ -5254,10 +6263,17 @@ snapshots: '@types/stylis@4.2.5': {} + '@types/trusted-types@2.0.7': + optional: true + + '@types/turndown@5.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5363,44 +6379,47 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/expect@4.0.16': + '@vitest/expect@3.2.4': dependencies: - '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 - chai: 6.2.2 - tinyrainbow: 3.0.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 - '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@22.19.3)(jiti@1.21.7)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.3))': dependencies: - '@vitest/spy': 4.0.16 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@22.19.3)(jiti@1.21.7)(tsx@4.21.0) + vite: 5.4.21(@types/node@22.19.3) - '@vitest/pretty-format@4.0.16': + '@vitest/pretty-format@3.2.4': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 2.0.0 - '@vitest/runner@4.0.16': + '@vitest/runner@3.2.4': dependencies: - '@vitest/utils': 4.0.16 + '@vitest/utils': 3.2.4 pathe: 2.0.3 + strip-literal: 3.1.0 - '@vitest/snapshot@4.0.16': + '@vitest/snapshot@3.2.4': dependencies: - '@vitest/pretty-format': 4.0.16 + '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.16': {} - - '@vitest/utils@4.0.16': + '@vitest/spy@3.2.4': dependencies: - '@vitest/pretty-format': 4.0.16 - tinyrainbow: 3.0.3 + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 '@xterm/addon-fit@0.11.0': {} @@ -5414,8 +6433,7 @@ snapshots: '@xterm/xterm@6.0.0': {} - abab@2.0.6: - optional: true + abab@2.0.6: {} abbrev@1.1.1: optional: true @@ -5443,9 +6461,6 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - optional: true - - agent-base@7.1.4: {} ajv@6.12.6: dependencies: @@ -5489,8 +6504,7 @@ snapshots: assertion-error@2.0.1: {} - asynckit@0.4.0: - optional: true + asynckit@0.4.0: {} automation-events@7.1.14: dependencies: @@ -5512,10 +6526,6 @@ snapshots: baseline-browser-mapping@2.9.11: {} - bidi-js@1.0.3: - dependencies: - require-from-string: 2.0.2 - binary-extensions@2.3.0: {} binary-search-bounds@2.0.5: {} @@ -5544,11 +6554,12 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - optional: true callsites@3.1.0: {} @@ -5572,7 +6583,13 @@ snapshots: ccount@2.0.1: {} - chai@6.2.2: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 chalk@4.1.2: dependencies: @@ -5587,6 +6604,22 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + + chevrotain-allstar@0.3.1(chevrotain@11.1.2): + dependencies: + chevrotain: 11.1.2 + lodash-es: 4.17.23 + + chevrotain@11.1.2: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 + lodash-es: 4.17.23 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -5608,6 +6641,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5620,7 +6665,6 @@ snapshots: combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 - optional: true comma-separated-tokens@2.0.3: {} @@ -5632,6 +6676,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + console-control-strings@1.1.0: optional: true @@ -5639,6 +6685,14 @@ snapshots: cookie@1.1.1: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 @@ -5648,6 +6702,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crelt@1.0.6: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5662,11 +6718,6 @@ snapshots: css-color-keywords: 1.0.0 postcss-value-parser: 4.2.0 - css-tree@3.1.0: - dependencies: - mdn-data: 2.12.2 - source-map-js: 1.2.1 - cssesc@3.0.0: {} cssom@0.3.8: @@ -5680,17 +6731,30 @@ snapshots: cssom: 0.3.8 optional: true - cssstyle@5.3.6: + cssstyle@3.0.0: dependencies: - '@asamuzakjp/css-color': 4.1.1 - '@csstools/css-syntax-patches-for-csstree': 1.0.22 - css-tree: 3.1.0 - lru-cache: 11.2.4 + rrweb-cssom: 0.6.0 csstype@3.1.3: {} csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -5756,6 +6820,8 @@ snapshots: dependencies: d3-color: 3.1.0 + d3-path@1.0.9: {} + d3-path@3.1.0: {} d3-polygon@3.0.1: {} @@ -5764,6 +6830,11 @@ snapshots: d3-random@3.0.1: {} + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + d3-scale-chromatic@3.1.0: dependencies: d3-color: 3.1.0 @@ -5779,6 +6850,10 @@ snapshots: d3-selection@3.0.0: {} + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -5843,6 +6918,11 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 + dagre-d3-es@7.0.13: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + data-urls@3.0.2: dependencies: abab: 2.0.6 @@ -5850,10 +6930,11 @@ snapshots: whatwg-url: 11.0.0 optional: true - data-urls@6.0.0: + data-urls@4.0.0: dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 15.1.0 + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 date-fns@4.1.0: {} @@ -5874,14 +6955,15 @@ snapshots: mimic-response: 2.1.0 optional: true + deep-eql@5.0.2: {} + deep-is@0.1.4: {} delaunator@5.0.1: dependencies: robust-predicates: 3.0.2 - delayed-stream@1.0.0: - optional: true + delayed-stream@1.0.0: {} delegates@1.0.0: optional: true @@ -5904,7 +6986,10 @@ snapshots: domexception@4.0.0: dependencies: webidl-conversions: 7.0.0 - optional: true + + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 dot-case@3.0.4: dependencies: @@ -5916,7 +7001,6 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 - optional: true electron-to-chromium@1.5.267: {} @@ -5931,18 +7015,15 @@ snapshots: dependencies: is-arrayish: 0.2.1 - es-define-property@1.0.1: - optional: true + es-define-property@1.0.1: {} - es-errors@1.3.0: - optional: true + es-errors@1.3.0: {} es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 - optional: true es-set-tostringtag@2.1.0: dependencies: @@ -5950,7 +7031,6 @@ snapshots: get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 hasown: 2.0.2 - optional: true esbuild@0.21.5: optionalDependencies: @@ -6129,6 +7209,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6180,7 +7262,6 @@ snapshots: es-set-tostringtag: 2.1.0 hasown: 2.0.2 mime-types: 2.1.35 - optional: true format@0.2.2: {} @@ -6226,7 +7307,6 @@ snapshots: has-symbols: 1.1.0 hasown: 2.0.2 math-intrinsics: 1.1.0 - optional: true get-nonce@1.0.1: {} @@ -6234,7 +7314,6 @@ snapshots: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - optional: true get-tsconfig@4.13.0: dependencies: @@ -6262,18 +7341,17 @@ snapshots: globals@15.15.0: {} - gopd@1.2.0: - optional: true + gopd@1.2.0: {} + + hachure-fill@0.5.2: {} has-flag@4.0.0: {} - has-symbols@1.1.0: - optional: true + has-symbols@1.1.0: {} has-tostringtag@1.0.2: dependencies: has-symbols: 1.1.0 - optional: true has-unicode@2.0.1: optional: true @@ -6339,6 +7417,20 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -6399,13 +7491,6 @@ snapshots: html-encoding-sniffer@3.0.0: dependencies: whatwg-encoding: 2.0.0 - optional: true - - html-encoding-sniffer@6.0.0: - dependencies: - '@exodus/bytes': 1.7.0 - transitivePeerDependencies: - - '@exodus/crypto' html-parse-stringify@3.0.1: dependencies: @@ -6422,14 +7507,6 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - optional: true - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color https-proxy-agent@5.0.1: dependencies: @@ -6437,14 +7514,6 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - optional: true - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color husky@9.1.7: {} @@ -6480,6 +7549,8 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + internmap@2.0.3: {} interval-tree-1d@1.0.4: @@ -6537,6 +7608,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -6578,32 +7651,34 @@ snapshots: - utf-8-validate optional: true - jsdom@27.4.0(canvas@2.11.2): + jsdom@22.1.0(canvas@2.11.2): dependencies: - '@acemir/cssom': 0.9.30 - '@asamuzakjp/dom-selector': 6.7.6 - '@exodus/bytes': 1.7.0 - cssstyle: 5.3.6 - data-urls: 6.0.0 + abab: 2.0.6 + cssstyle: 3.0.0 + data-urls: 4.0.0 decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + domexception: 4.0.0 + form-data: 4.0.5 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 is-potential-custom-element-name: 1.0.1 - parse5: 8.0.0 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.6.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 6.0.0 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 8.0.0 - whatwg-mimetype: 4.0.0 - whatwg-url: 15.1.0 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 ws: 8.18.3 - xml-name-validator: 5.0.0 + xml-name-validator: 4.0.0 optionalDependencies: canvas: 2.11.2 transitivePeerDependencies: - - '@exodus/crypto' - bufferutil - supports-color - utf-8-validate @@ -6628,6 +7703,20 @@ snapshots: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + + langium@4.2.1: + dependencies: + chevrotain: 11.1.2 + chevrotain-allstar: 0.3.1(chevrotain@11.1.2) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -6637,12 +7726,20 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + linkifyjs@4.3.2: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash-es@4.17.22: {} + lodash-es@4.17.23: {} + lodash.merge@4.6.2: {} longest-streak@3.1.0: {} @@ -6651,6 +7748,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -6660,8 +7759,6 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 - lru-cache@11.2.4: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -6679,10 +7776,22 @@ snapshots: semver: 6.3.1 optional: true + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} - math-intrinsics@1.1.0: - optional: true + marked@16.4.2: {} + + marked@17.0.3: {} + + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: dependencies: @@ -6849,10 +7958,33 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - mdn-data@2.12.2: {} + mdurl@2.0.0: {} merge2@1.4.1: {} + mermaid@11.12.3: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 1.0.0 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.13 + dayjs: 1.11.19 + dompurify: 3.3.1 + katex: 0.16.27 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.2.0 @@ -7061,13 +8193,11 @@ snapshots: midi-file@1.2.4: {} - mime-db@1.52.0: - optional: true + mime-db@1.52.0: {} mime-types@2.1.35: dependencies: mime-db: 1.52.0 - optional: true mimic-response@2.1.0: optional: true @@ -7097,6 +8227,13 @@ snapshots: mkdirp@1.0.4: optional: true + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + ms@2.1.3: {} mz@2.7.0: @@ -7139,15 +8276,12 @@ snapshots: set-blocking: 2.0.0 optional: true - nwsapi@2.2.23: - optional: true + nwsapi@2.2.23: {} object-assign@4.1.1: {} object-hash@3.0.0: {} - obug@2.1.1: {} - once@1.4.0: dependencies: wrappy: 1.0.2 @@ -7162,6 +8296,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + orderedmap@2.1.1: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -7170,6 +8306,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-manager-detector@1.6.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7198,9 +8336,7 @@ snapshots: dependencies: entities: 6.0.1 - parse5@8.0.0: - dependencies: - entities: 6.0.1 + path-data-parser@0.1.0: {} path-exists@4.0.0: {} @@ -7215,6 +8351,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -7225,6 +8363,19 @@ snapshots: pirates@4.0.7: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss-import@15.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -7277,17 +8428,120 @@ snapshots: property-information@7.1.0: {} + prosemirror-changeset@2.4.0: + dependencies: + prosemirror-transform: 1.11.0 + + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.4 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-gapcursor@1.4.0: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-markdown@1.13.4: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.1 + prosemirror-model: 1.25.4 + + prosemirror-menu@1.3.0: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.5.0 + prosemirror-state: 1.4.4 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + + prosemirror-transform@1.11.0: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.6: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + psl@1.15.0: dependencies: punycode: 2.3.1 - optional: true + + punycode.js@2.3.1: {} punycode@2.3.1: {} pure-rand@7.0.1: {} - querystringify@2.2.0: - optional: true + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -7471,10 +8725,7 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - require-from-string@2.0.2: {} - - requires-port@1.0.0: - optional: true + requires-port@1.0.0: {} resolve-from@4.0.0: {} @@ -7523,6 +8774,17 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.54.0 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rrweb-cssom@0.6.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -7630,6 +8892,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -7654,6 +8920,8 @@ snapshots: stylis@4.3.2: {} + stylis@4.3.6: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -7726,6 +8994,8 @@ snapshots: tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -7733,13 +9003,15 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinyrainbow@3.0.3: {} + tinypool@1.1.1: {} - tldts-core@7.0.19: {} + tinyrainbow@2.0.0: {} - tldts@7.0.19: + tinyspy@4.0.4: {} + + tippy.js@6.3.7: dependencies: - tldts-core: 7.0.19 + '@popperjs/core': 2.11.8 to-regex-range@5.0.1: dependencies: @@ -7756,11 +9028,6 @@ snapshots: punycode: 2.3.1 universalify: 0.2.0 url-parse: 1.5.10 - optional: true - - tough-cookie@6.0.0: - dependencies: - tldts: 7.0.19 tr46@0.0.3: optional: true @@ -7770,7 +9037,7 @@ snapshots: punycode: 2.3.1 optional: true - tr46@6.0.0: + tr46@4.1.1: dependencies: punycode: 2.3.1 @@ -7782,6 +9049,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-dedent@2.2.0: {} + ts-interface-checker@0.1.13: {} tslib@2.6.2: {} @@ -7795,12 +9064,20 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + turndown@7.2.2: + dependencies: + '@mixmark-io/domino': 2.2.0 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 typescript@5.9.3: {} + uc.micro@2.1.0: {} + + ufo@1.6.3: {} + undici-types@6.21.0: {} unified@11.0.5: @@ -7846,8 +9123,7 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - universalify@0.2.0: - optional: true + universalify@0.2.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: @@ -7863,7 +9139,6 @@ snapshots: dependencies: querystringify: 2.2.0 requires-port: 1.0.0 - optional: true use-callback-ref@1.3.3(@types/react@18.3.27)(react@18.3.1): dependencies: @@ -7886,6 +9161,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -7901,6 +9178,24 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@22.19.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.21(@types/node@22.19.3) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-plugin-svgr@4.5.0(rollup@4.54.0)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.3)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.54.0) @@ -7921,47 +9216,36 @@ snapshots: '@types/node': 22.19.3 fsevents: 2.3.3 - vite@7.3.0(@types/node@22.19.3)(jiti@1.21.7)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(jsdom@22.1.0(canvas@2.11.2)): dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.54.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.19.3 - fsevents: 2.3.3 - jiti: 1.21.7 - tsx: 4.21.0 - - vitest@4.0.16(@types/node@22.19.3)(jiti@1.21.7)(jsdom@27.4.0(canvas@2.11.2))(tsx@4.21.0): - dependencies: - '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@22.19.3)(jiti@1.21.7)(tsx@4.21.0)) - '@vitest/pretty-format': 4.0.16 - '@vitest/runner': 4.0.16 - '@vitest/snapshot': 4.0.16 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 - es-module-lexer: 1.7.0 + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.3)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.0.2 + tinyexec: 0.3.2 tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@22.19.3)(jiti@1.21.7)(tsx@4.21.0) + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 5.4.21(@types/node@22.19.3) + vite-node: 3.2.4(@types/node@22.19.3) why-is-node-running: 2.3.0 optionalDependencies: + '@types/debug': 4.1.12 '@types/node': 22.19.3 - jsdom: 27.4.0(canvas@2.11.2) + jsdom: 22.1.0(canvas@2.11.2) transitivePeerDependencies: - - jiti - less - lightningcss - msw @@ -7969,45 +9253,56 @@ snapshots: - sass-embedded - stylus - sugarss + - supports-color - terser - - tsx - - yaml void-elements@3.1.0: {} + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + w3c-hr-time@1.0.2: dependencies: browser-process-hrtime: 1.0.0 optional: true + w3c-keyname@2.2.8: {} + w3c-xmlserializer@3.0.0: dependencies: xml-name-validator: 4.0.0 optional: true - w3c-xmlserializer@5.0.0: + w3c-xmlserializer@4.0.0: dependencies: - xml-name-validator: 5.0.0 + xml-name-validator: 4.0.0 web-namespaces@2.0.1: {} webidl-conversions@3.0.1: optional: true - webidl-conversions@7.0.0: - optional: true - - webidl-conversions@8.0.0: {} + webidl-conversions@7.0.0: {} whatwg-encoding@2.0.0: dependencies: iconv-lite: 0.6.3 - optional: true - whatwg-mimetype@3.0.0: - optional: true - - whatwg-mimetype@4.0.0: {} + whatwg-mimetype@3.0.0: {} whatwg-url@10.0.0: dependencies: @@ -8021,10 +9316,10 @@ snapshots: webidl-conversions: 7.0.0 optional: true - whatwg-url@15.1.0: + whatwg-url@12.0.1: dependencies: - tr46: 6.0.0 - webidl-conversions: 8.0.0 + tr46: 4.1.1 + webidl-conversions: 7.0.0 whatwg-url@5.0.0: dependencies: @@ -8053,10 +9348,7 @@ snapshots: ws@8.18.3: {} - xml-name-validator@4.0.0: - optional: true - - xml-name-validator@5.0.0: {} + xml-name-validator@4.0.0: {} xmlchars@2.2.0: {} @@ -8067,4 +9359,10 @@ snapshots: yocto-queue@0.1.0: {} + zustand@5.0.11(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): + optionalDependencies: + '@types/react': 18.3.27 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + zwitch@2.0.4: {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0f2a35232..be1389d0d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -306,7 +306,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -317,7 +317,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2399,7 +2399,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -2415,7 +2415,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.3", "smallvec", ] @@ -2773,7 +2773,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3087,7 +3087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5546,7 +5546,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5679,7 +5679,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", "syn 2.0.114", @@ -5773,17 +5773,10 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0", "block2 0.6.2", - "libc", "objc2 0.6.3", - "objc2-cloud-kit", - "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image 0.3.2", - "objc2-core-text", - "objc2-core-video", "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", ] [[package]] @@ -5815,7 +5808,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.10.0", "objc2 0.6.3", "objc2-foundation 0.3.2", ] @@ -5888,19 +5880,6 @@ dependencies = [ "objc2-core-graphics", ] -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -5952,16 +5931,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2 0.6.3", - "objc2-core-foundation", -] - [[package]] name = "objc2-metal" version = "0.2.2" @@ -5999,17 +5968,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -6053,8 +6011,6 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "objc2-javascript-core", - "objc2-security", ] [[package]] @@ -6321,7 +6277,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -6456,9 +6412,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", - "proc-macro-hack", ] [[package]] @@ -6467,7 +6421,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ + "phf_macros 0.10.0", "phf_shared 0.10.0", + "proc-macro-hack", ] [[package]] @@ -6571,12 +6527,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -6988,7 +6944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -6996,7 +6952,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.79.0" +version = "0.80.0" dependencies = [ "anyhow", "arboard", @@ -7037,6 +6993,7 @@ dependencies = [ "proxycast-core", "proxycast-credential", "proxycast-embedding", + "proxycast-gateway", "proxycast-infra", "proxycast-mcp", "proxycast-memory", @@ -7097,7 +7054,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.79.0" +version = "0.80.0" dependencies = [ "aster-core", "async-trait", @@ -7122,7 +7079,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.79.0" +version = "0.80.0" dependencies = [ "async-trait", "parking_lot", @@ -7138,7 +7095,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.79.0" +version = "0.80.0" dependencies = [ "aster-models", "async-trait", @@ -7178,7 +7135,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.79.0" +version = "0.80.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -7211,9 +7168,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "proxycast-gateway" +version = "0.80.0" +dependencies = [ + "axum 0.7.9", + "chrono", + "futures", + "proptest", + "proxycast-core", + "proxycast-websocket", + "reqwest 0.12.28", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-tungstenite 0.24.0", + "tokio-util", + "urlencoding", + "uuid", +] + [[package]] name = "proxycast-infra" -version = "0.79.0" +version = "0.80.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7233,7 +7211,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.79.0" +version = "0.80.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7265,7 +7243,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.79.0" +version = "0.80.0" dependencies = [ "async-trait", "parking_lot", @@ -7284,7 +7262,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.79.0" +version = "0.80.0" dependencies = [ "anyhow", "async-stream", @@ -7321,8 +7299,10 @@ name = "proxycast-scheduler" version = "0.61.0" dependencies = [ "anyhow", + "aster-core", "async-trait", "chrono", + "futures", "proxycast-agent", "proxycast-core", "rusqlite", @@ -7336,7 +7316,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.79.0" +version = "0.80.0" dependencies = [ "aster-core", "async-stream", @@ -7381,7 +7361,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.79.0" +version = "0.80.0" dependencies = [ "axum 0.7.9", "futures", @@ -7396,7 +7376,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.79.0" +version = "0.80.0" dependencies = [ "anyhow", "aster-core", @@ -7437,7 +7417,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.79.0" +version = "0.80.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7453,7 +7433,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.79.0" +version = "0.80.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7480,7 +7460,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.79.0" +version = "0.80.0" dependencies = [ "axum 0.7.9", "chrono", @@ -8188,7 +8168,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8959,7 +8939,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 4.0.0", + "dirs 6.0.0", ] [[package]] @@ -9672,9 +9652,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.2" +version = "2.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" dependencies = [ "anyhow", "bytes", @@ -9724,9 +9704,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.5" +version = "2.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", @@ -9746,9 +9726,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" dependencies = [ "base64 0.22.1", "brotli", @@ -9773,9 +9753,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -9787,9 +9767,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" dependencies = [ "anyhow", "glob", @@ -9930,9 +9910,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" dependencies = [ "cookie", "dpi", @@ -9955,9 +9935,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" dependencies = [ "gtk", "http 1.4.0", @@ -9965,7 +9945,6 @@ dependencies = [ "log", "objc2 0.6.3", "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -9982,9 +9961,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" dependencies = [ "anyhow", "brotli", @@ -10039,7 +10018,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11422,7 +11401,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a2363282..41d89c5ae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.79.0" +version = "0.80.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -28,6 +28,7 @@ proxycast-agent = { path = "crates/agent" } proxycast-scheduler = { path = "crates/scheduler" } proxycast-memory = { path = "crates/memory" } proxycast-embedding = { path = "crates/embedding" } +proxycast-gateway = { path = "crates/gateway" } voice-core = { path = "crates/voice-core" } # 序列化 @@ -135,7 +136,7 @@ rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-ch # Tauri -tauri = { version = "2.5", features = ["tray-icon", "image-png", "unstable", "macos-private-api"] } +tauri = { version = "2.10.3", features = ["tray-icon", "image-png", "unstable", "macos-private-api"] } tauri-build = { version = "2", features = [] } tauri-plugin-shell = "2.3" tauri-plugin-autostart = "2.3" @@ -190,7 +191,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.79.0" +version = "0.80.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -219,6 +220,7 @@ proxycast-server-utils.workspace = true proxycast-server.workspace = true proxycast-skills.workspace = true proxycast-mcp.workspace = true +proxycast-gateway.workspace = true proxycast-agent.workspace = true proxycast-scheduler.workspace = true proxycast-memory.workspace = true diff --git a/src-tauri/crates/agent/Cargo.toml b/src-tauri/crates/agent/Cargo.toml index 483813ef8..bc5265209 100644 --- a/src-tauri/crates/agent/Cargo.toml +++ b/src-tauri/crates/agent/Cargo.toml @@ -16,6 +16,7 @@ serde.workspace = true serde_json.workspace = true tokio.workspace = true tokio-util.workspace = true +futures.workspace = true async-trait.workspace = true tracing.workspace = true chrono.workspace = true @@ -26,4 +27,3 @@ regex.workspace = true [dev-dependencies] tempfile.workspace = true -futures.workspace = true diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index 31e1b4932..017e9dc97 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -12,6 +12,7 @@ pub mod hooks; pub mod lsp_bridge; pub mod mcp_bridge; pub mod prompt; +pub mod request_tool_policy; pub mod session_store; pub mod shell_security; pub mod subagent_scheduler; @@ -31,6 +32,11 @@ pub use credential_bridge::{ pub use event_converter::{convert_agent_event, convert_to_tauri_message, TauriAgentEvent}; pub use lsp_bridge::create_lsp_callback; pub use prompt::SystemPromptBuilder; +pub use request_tool_policy::{ + execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy, + resolve_request_tool_policy, stream_reply_with_policy, ReplyAttemptError, RequestToolPolicy, + StreamReplyExecution, WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER, +}; pub use session_store::{ create_session_sync, get_session_sync, list_sessions_sync, SessionDetail, SessionInfo, }; diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs new file mode 100644 index 000000000..aa17d4ebf --- /dev/null +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -0,0 +1,731 @@ +//! 请求级工具策略与统一回复执行链 +//! +//! 该模块沉淀“请求级工具策略(例如联网搜索)”与统一流式执行逻辑, +//! 供 aster_agent_cmd、scheduler、gateway 等入口复用同一条执行主链。 + +use crate::event_converter::{convert_agent_event, TauriAgentEvent, TauriToolResult}; +use aster::agents::{Agent, AgentEvent}; +use aster::conversation::message::Message; +use aster::tools::ToolContext; +use futures::StreamExt; +use std::collections::HashMap; +use std::path::Path; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +pub const REQUEST_TOOL_POLICY_MARKER: &str = "【请求级工具策略】"; + +const DEFAULT_REQUIRED_TOOLS: &[&str] = &["WebSearch"]; +const DEFAULT_ALLOWED_TOOLS: &[&str] = &["WebSearch", "WebFetch"]; +const WEB_SEARCH_REQUIRED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_REQUIRED_TOOLS"; +const WEB_SEARCH_ALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_ALLOWED_TOOLS"; +const WEB_SEARCH_DISALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_DISALLOWED_TOOLS"; +const WEB_SEARCH_PREFLIGHT_ENABLED_ENV: &str = "PROXYCAST_WEB_SEARCH_PREFLIGHT_ENABLED"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestToolPolicy { + /// 本次请求是否开启联网搜索策略 + pub effective_web_search: bool, + /// 必须至少成功一次的工具(默认 WebSearch) + pub required_tools: Vec, + /// 允许的联网工具集合(默认 WebSearch/WebFetch) + pub allowed_tools: Vec, + /// 禁止工具集合(可配置) + pub disallowed_tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolAttemptRecord { + pub tool_id: String, + pub tool_name: String, + pub success: Option, + pub error: Option, +} + +#[derive(Debug, Default)] +pub struct WebSearchExecutionTracker { + ordered_tool_ids: Vec, + attempts_by_id: HashMap, +} + +impl WebSearchExecutionTracker { + pub fn record_tool_start( + &mut self, + policy: &RequestToolPolicy, + tool_id: &str, + tool_name: &str, + ) { + if !policy.effective_web_search || tool_id.trim().is_empty() || tool_name.trim().is_empty() + { + return; + } + + if !self.attempts_by_id.contains_key(tool_id) { + self.ordered_tool_ids.push(tool_id.to_string()); + self.attempts_by_id.insert( + tool_id.to_string(), + ToolAttemptRecord { + tool_id: tool_id.to_string(), + tool_name: tool_name.to_string(), + success: None, + error: None, + }, + ); + } + } + + pub fn record_tool_end( + &mut self, + policy: &RequestToolPolicy, + tool_id: &str, + success: bool, + error: Option<&str>, + ) { + if !policy.effective_web_search || tool_id.trim().is_empty() { + return; + } + if let Some(record) = self.attempts_by_id.get_mut(tool_id) { + record.success = Some(success); + record.error = error + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()); + } + } + + pub fn validate_web_search_requirement( + &self, + policy: &RequestToolPolicy, + ) -> Result<(), String> { + if !policy.effective_web_search { + return Ok(()); + } + + let disallowed_attempts: Vec<&ToolAttemptRecord> = self + .ordered_tool_ids + .iter() + .filter_map(|tool_id| self.attempts_by_id.get(tool_id)) + .filter(|record| matches_tool_list(&record.tool_name, &policy.disallowed_tools)) + .collect(); + if !disallowed_attempts.is_empty() { + let disallowed_names = disallowed_attempts + .iter() + .map(|record| record.tool_name.clone()) + .collect::>() + .join(", "); + return Err(format!( + "联网搜索策略阻止了禁止工具调用: {}。\n尝试记录: {}", + disallowed_names, + self.format_attempts() + )); + } + + let required_attempts: Vec<&ToolAttemptRecord> = self + .ordered_tool_ids + .iter() + .filter_map(|tool_id| self.attempts_by_id.get(tool_id)) + .filter(|record| policy.matches_any_required_tool(&record.tool_name)) + .collect(); + + if required_attempts.is_empty() { + return Err(format!( + "联网搜索已开启,但未检测到必需工具调用。必须先调用 {} 至少一次后再给出最终答复。\n尝试记录: {}", + policy.required_tools.join(", "), + self.format_attempts() + )); + } + + if required_attempts + .iter() + .any(|record| record.success.unwrap_or(false)) + { + return Ok(()); + } + + Err(format!( + "联网搜索已开启,但必需工具调用全部失败,无法给出符合约束的最终答复。\n失败原因与尝试记录: {}", + self.format_attempts() + )) + } + + pub fn format_attempts(&self) -> String { + if self.ordered_tool_ids.is_empty() { + return "无工具调用".to_string(); + } + + self.ordered_tool_ids + .iter() + .filter_map(|tool_id| self.attempts_by_id.get(tool_id)) + .map(|record| { + let status = match record.success { + Some(true) => "success".to_string(), + Some(false) => { + format!("failed({})", record.error.as_deref().unwrap_or("unknown")) + } + None => "pending".to_string(), + }; + format!("{}#{}:{}", record.tool_name, record.tool_id, status) + }) + .collect::>() + .join("; ") + } +} + +#[derive(Debug, Clone)] +pub struct PreflightToolExecution { + pub events: Vec, +} + +impl PreflightToolExecution { + fn none() -> Self { + Self { events: Vec::new() } + } +} + +#[derive(Debug, Clone)] +pub struct ReplyAttemptError { + pub message: String, + pub emitted_any: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct StreamReplyExecution { + pub text_output: String, + pub event_errors: Vec, + pub emitted_any: bool, + pub attempts_summary: String, +} + +impl RequestToolPolicy { + pub fn matches_any_required_tool(&self, tool_name: &str) -> bool { + matches_tool_list(tool_name, &self.required_tools) + } + + pub fn matches_any_allowed_tool(&self, tool_name: &str) -> bool { + matches_tool_list(tool_name, &self.allowed_tools) + } +} + +/// 解析请求级工具策略 +/// +/// 规则: +/// - `effective_web_search = request_web_search.unwrap_or(mode_default)` +/// - 白/黑名单支持环境变量覆盖: +/// - `PROXYCAST_WEB_SEARCH_REQUIRED_TOOLS` +/// - `PROXYCAST_WEB_SEARCH_ALLOWED_TOOLS` +/// - `PROXYCAST_WEB_SEARCH_DISALLOWED_TOOLS` +pub fn resolve_request_tool_policy( + request_web_search: Option, + mode_default: bool, +) -> RequestToolPolicy { + let effective_web_search = request_web_search.unwrap_or(mode_default); + let required_tools = parse_tool_list_env(WEB_SEARCH_REQUIRED_TOOLS_ENV, DEFAULT_REQUIRED_TOOLS); + let mut allowed_tools = + parse_tool_list_env(WEB_SEARCH_ALLOWED_TOOLS_ENV, DEFAULT_ALLOWED_TOOLS); + let disallowed_tools = parse_tool_list_env(WEB_SEARCH_DISALLOWED_TOOLS_ENV, &[]); + + for required in &required_tools { + if !allowed_tools + .iter() + .any(|candidate| is_same_tool(candidate, required)) + { + allowed_tools.push(required.clone()); + } + } + + RequestToolPolicy { + effective_web_search, + required_tools, + allowed_tools, + disallowed_tools, + } +} + +/// 合并请求级工具策略到系统提示词 +/// +/// - `effective_web_search=false`:保持原始 system prompt 不变 +/// - 已包含 marker 时:不重复追加 +pub fn merge_system_prompt_with_request_tool_policy( + base_prompt: Option, + policy: &RequestToolPolicy, +) -> Option { + if !policy.effective_web_search { + return base_prompt; + } + + let disallowed_line = if policy.disallowed_tools.is_empty() { + "无".to_string() + } else { + policy.disallowed_tools.join(", ") + }; + + let policy_prompt = format!( + "{REQUEST_TOOL_POLICY_MARKER}\n\ +- 用户在本次请求中已开启“联网搜索”开关。\n\ +- 必须先调用 {} 至少一次(必要时再调用 WebFetch),再输出最终答复。\n\ +- 若工具调用失败,必须返回失败原因与尝试记录;不要在未完成必需工具调用前直接给最终结论。\n\ +- 允许工具: {}\n\ +- 禁止工具: {}", + policy.required_tools.join(", "), + policy.allowed_tools.join(", "), + disallowed_line + ); + + match base_prompt { + Some(base) => { + if base.contains(REQUEST_TOOL_POLICY_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(policy_prompt) + } else { + Some(format!("{base}\n\n{policy_prompt}")) + } + } + None => Some(policy_prompt), + } +} + +fn parse_tool_list_env(key: &str, default_values: &[&str]) -> Vec { + let from_env = std::env::var(key) + .ok() + .map(|raw| parse_tool_list(&raw)) + .filter(|tools| !tools.is_empty()); + + let values = + from_env.unwrap_or_else(|| default_values.iter().map(|item| item.to_string()).collect()); + dedup_tools(values) +} + +fn parse_tool_list(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| item.to_string()) + .collect() +} + +fn dedup_tools(values: Vec) -> Vec { + let mut result: Vec = Vec::new(); + for value in values { + if !result.iter().any(|existing| is_same_tool(existing, &value)) { + result.push(value); + } + } + result +} + +fn matches_tool_list(tool_name: &str, list: &[String]) -> bool { + list.iter() + .any(|candidate| is_same_tool(tool_name, candidate)) +} + +fn is_same_tool(a: &str, b: &str) -> bool { + let normalized_a = normalize_tool_name(a); + let normalized_b = normalize_tool_name(b); + if normalized_a.is_empty() || normalized_b.is_empty() { + return false; + } + normalized_a == normalized_b + || normalized_a.contains(&normalized_b) + || normalized_b.contains(&normalized_a) +} + +fn normalize_tool_name(value: &str) -> String { + value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(|ch| ch.to_lowercase()) + .collect::() +} + +fn extract_inline_agent_provider_error(message: &Message) -> Option { + let text = message.as_concat_text(); + let text = text.trim(); + if text.is_empty() { + return None; + } + if !text.contains("Ran into this error:") { + return None; + } + if !text.contains("Please retry if you think this is a transient or recoverable error.") { + return None; + } + + let after_prefix = text.split_once("Ran into this error:")?.1.trim(); + let detail = after_prefix + .split_once("\n\nPlease retry if you think this is a transient or recoverable error.") + .map(|(left, _)| left.trim()) + .unwrap_or(after_prefix) + .trim_end_matches('.'); + + if detail.is_empty() { + return Some("Agent provider execution failed".to_string()); + } + + Some(format!("Agent provider execution failed: {detail}")) +} + +/// 当开启联网搜索时,在正式回复前执行一次 WebSearch 预调用。 +/// +/// 目标: +/// - 通过执行层保证至少一次 WebSearch 调用(而非仅依赖提示词) +/// - 统一生成 tool_start/tool_end 事件,供前端落地 +/// - 若预调用失败,返回失败原因并由上层中断本次回答 +pub async fn execute_web_search_preflight_if_needed( + agent: &Agent, + session_id: &str, + message_text: &str, + working_directory: Option<&Path>, + cancel_token: Option, + policy: &RequestToolPolicy, + tracker: &mut WebSearchExecutionTracker, +) -> Result { + if !policy.effective_web_search || !is_web_search_preflight_enabled() { + return Ok(PreflightToolExecution::none()); + } + + let registry_arc = agent.tool_registry().clone(); + let registry = registry_arc.read().await; + let available_tools = registry.get_definitions(); + let preflight_tool = available_tools + .iter() + .find(|definition| { + policy.matches_any_required_tool(&definition.name) + && normalize_tool_name(&definition.name).contains("websearch") + }) + .ok_or_else(|| { + format!( + "联网搜索已开启,但未找到可执行的必需工具定义。required_tools={}, available_tools={}", + policy.required_tools.join(", "), + available_tools + .iter() + .map(|definition| definition.name.clone()) + .collect::>() + .join(", ") + ) + })?; + + let query = derive_preflight_query(message_text); + let params = serde_json::json!({ "query": query }); + let arguments = serde_json::to_string(¶ms).ok(); + let tool_id = format!("preflight-websearch-{}", Uuid::new_v4()); + tracker.record_tool_start(policy, &tool_id, &preflight_tool.name); + + let mut context = ToolContext::new( + working_directory + .map(Path::to_path_buf) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_default(), + ) + .with_session_id(session_id.to_string()); + if let Some(token) = cancel_token { + context = context.with_cancellation_token(token); + } + + let mut events = vec![TauriAgentEvent::ToolStart { + tool_name: preflight_tool.name.clone(), + tool_id: tool_id.clone(), + arguments, + }]; + + let result = registry + .execute(&preflight_tool.name, params, &context, None) + .await + .map_err(|error| format!("执行 WebSearch 预调用失败: {}", error.to_string())); + + match result { + Ok(tool_result) => { + tracker.record_tool_end( + policy, + &tool_id, + tool_result.success, + tool_result.error.as_deref(), + ); + let event = TauriAgentEvent::ToolEnd { + tool_id, + result: TauriToolResult { + success: tool_result.success, + output: tool_result.output.unwrap_or_default(), + error: tool_result.error, + images: None, + }, + }; + events.push(event); + + if events + .last() + .and_then(|event| match event { + TauriAgentEvent::ToolEnd { result, .. } => Some(result.success), + _ => None, + }) + .unwrap_or(false) + { + Ok(PreflightToolExecution { events }) + } else { + let failure = events.last().and_then(|event| match event { + TauriAgentEvent::ToolEnd { result, .. } => result.error.clone(), + _ => None, + }); + Err(format!( + "联网搜索预调用失败: {}", + failure.unwrap_or_else(|| "unknown".to_string()) + )) + } + } + Err(error) => { + tracker.record_tool_end(policy, &tool_id, false, Some(error.as_str())); + events.push(TauriAgentEvent::ToolEnd { + tool_id, + result: TauriToolResult { + success: false, + output: String::new(), + error: Some(error.clone()), + images: None, + }, + }); + Err(error) + } + } +} + +/// 统一流式执行器:执行 preflight + reply 流,并复用统一的策略校验。 +pub async fn stream_reply_with_policy( + agent: &Agent, + message_text: &str, + working_directory: Option<&Path>, + session_config: aster::agents::SessionConfig, + cancel_token: Option, + request_tool_policy: &RequestToolPolicy, + mut on_event: F, +) -> Result +where + F: FnMut(&TauriAgentEvent), +{ + let mut web_search_tracker = WebSearchExecutionTracker::default(); + let preflight = execute_web_search_preflight_if_needed( + agent, + &session_config.id, + message_text, + working_directory, + cancel_token.clone(), + request_tool_policy, + &mut web_search_tracker, + ) + .await; + match preflight { + Ok(preflight_execution) => { + for event in preflight_execution.events { + on_event(&event); + } + } + Err(error) => { + return Err(ReplyAttemptError { + message: format!( + "{error}\n尝试记录: {}", + web_search_tracker.format_attempts() + ), + emitted_any: false, + }); + } + } + + let user_message = Message::user().with_text(message_text); + let mut stream = agent + .reply(user_message, session_config, cancel_token) + .await + .map_err(|e| ReplyAttemptError { + message: format!("Agent error: {e}"), + emitted_any: false, + })?; + + let mut emitted_any = false; + let mut text_chunks: Vec = Vec::new(); + let mut event_errors: Vec = Vec::new(); + + while let Some(event_result) = stream.next().await { + match event_result { + Ok(agent_event) => { + emitted_any = true; + let inline_provider_error = match &agent_event { + AgentEvent::Message(message) => extract_inline_agent_provider_error(message), + _ => None, + }; + let tauri_events = convert_agent_event(agent_event); + for tauri_event in tauri_events { + match &tauri_event { + TauriAgentEvent::TextDelta { text } => { + if !text.is_empty() { + text_chunks.push(text.clone()); + } + } + TauriAgentEvent::Error { message } => { + if !message.trim().is_empty() { + event_errors.push(message.clone()); + } + } + TauriAgentEvent::ToolStart { + tool_name, tool_id, .. + } => web_search_tracker.record_tool_start( + request_tool_policy, + tool_id, + tool_name, + ), + TauriAgentEvent::ToolEnd { tool_id, result } => { + web_search_tracker.record_tool_end( + request_tool_policy, + tool_id, + result.success, + result.error.as_deref(), + ); + } + _ => {} + } + on_event(&tauri_event); + } + if let Some(message) = inline_provider_error { + return Err(ReplyAttemptError { + message, + emitted_any: true, + }); + } + } + Err(e) => { + return Err(ReplyAttemptError { + message: format!("Stream error: {e}"), + emitted_any, + }); + } + } + } + + if let Err(validation_error) = + web_search_tracker.validate_web_search_requirement(request_tool_policy) + { + return Err(ReplyAttemptError { + message: validation_error, + emitted_any, + }); + } + + Ok(StreamReplyExecution { + text_output: text_chunks.join(""), + event_errors, + emitted_any, + attempts_summary: web_search_tracker.format_attempts(), + }) +} + +fn is_web_search_preflight_enabled() -> bool { + match std::env::var(WEB_SEARCH_PREFLIGHT_ENABLED_ENV) { + Ok(raw) => match raw.trim().to_ascii_lowercase().as_str() { + "0" | "false" | "no" | "off" => false, + _ => true, + }, + Err(_) => true, + } +} + +fn derive_preflight_query(message_text: &str) -> String { + let trimmed = message_text.trim(); + if trimmed.chars().count() >= 2 { + return trimmed.to_string(); + } + if trimmed.is_empty() { + return "最新信息".to_string(); + } + + // 兜底补齐最短长度,避免触发 WebSearch.query minLength 校验失败 + let mut fallback = trimmed.to_string(); + while fallback.chars().count() < 2 { + fallback.push_str(" 信息"); + } + fallback +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_effective_web_search_with_request_override() { + let policy = resolve_request_tool_policy(Some(false), true); + assert!(!policy.effective_web_search); + + let policy = resolve_request_tool_policy(Some(true), false); + assert!(policy.effective_web_search); + } + + #[test] + fn resolves_effective_web_search_with_mode_default() { + let policy = resolve_request_tool_policy(None, true); + assert!(policy.effective_web_search); + + let policy = resolve_request_tool_policy(None, false); + assert!(!policy.effective_web_search); + } + + #[test] + fn keeps_original_prompt_when_disabled() { + let base = Some("base".to_string()); + let policy = resolve_request_tool_policy(Some(false), false); + assert_eq!( + merge_system_prompt_with_request_tool_policy(base.clone(), &policy), + base + ); + } + + #[test] + fn appends_policy_prompt_when_enabled() { + let policy = resolve_request_tool_policy(Some(true), false); + let merged = + merge_system_prompt_with_request_tool_policy(Some("base".to_string()), &policy) + .expect("merged prompt should exist"); + assert!(merged.contains(REQUEST_TOOL_POLICY_MARKER)); + assert!(merged.contains("必须先调用")); + assert!(merged.contains("WebSearch")); + } + + #[test] + fn no_duplicate_when_marker_exists() { + let base = Some(format!("{REQUEST_TOOL_POLICY_MARKER}\nexists")); + let policy = resolve_request_tool_policy(Some(true), false); + assert_eq!( + merge_system_prompt_with_request_tool_policy(base.clone(), &policy), + base + ); + } + + #[test] + fn tracker_requires_websearch_when_enabled() { + let policy = resolve_request_tool_policy(Some(true), false); + let mut tracker = WebSearchExecutionTracker::default(); + tracker.record_tool_start(&policy, "tool-1", "WebFetch"); + tracker.record_tool_end(&policy, "tool-1", true, None); + let err = tracker + .validate_web_search_requirement(&policy) + .expect_err("missing web search should fail"); + assert!(err.contains("未检测到必需工具调用")); + } + + #[test] + fn tracker_accepts_successful_websearch() { + let policy = resolve_request_tool_policy(Some(true), false); + let mut tracker = WebSearchExecutionTracker::default(); + tracker.record_tool_start(&policy, "tool-1", "WebSearch"); + tracker.record_tool_end(&policy, "tool-1", true, None); + assert!(tracker.validate_web_search_requirement(&policy).is_ok()); + } + + #[test] + fn tracker_reports_failure_record() { + let policy = resolve_request_tool_policy(Some(true), false); + let mut tracker = WebSearchExecutionTracker::default(); + tracker.record_tool_start(&policy, "tool-1", "WebSearch"); + tracker.record_tool_end(&policy, "tool-1", false, Some("network timeout")); + let err = tracker + .validate_web_search_requirement(&policy) + .expect_err("failed required tool should fail"); + assert!(err.contains("network timeout")); + assert!(err.contains("尝试记录")); + } +} diff --git a/src-tauri/crates/agent/tests/real_codex_tool_events.rs b/src-tauri/crates/agent/tests/real_codex_tool_events.rs index 941a62843..9bad487ac 100644 --- a/src-tauri/crates/agent/tests/real_codex_tool_events.rs +++ b/src-tauri/crates/agent/tests/real_codex_tool_events.rs @@ -65,7 +65,7 @@ fn resolve_codex_provider_and_model( }) .map(|item| { let model = resolve_model_name(explicit_model, &item.provider.custom_models)?; - Ok((item.provider.id, model)) + Ok::<_, String>((item.provider.id, model)) }) .transpose()? .ok_or_else(|| "未找到启用且含可用 Key 的 Codex Provider".to_string()) diff --git a/src-tauri/crates/core/src/config/mod.rs b/src-tauri/crates/core/src/config/mod.rs index 67fce4cee..660ba5af1 100644 --- a/src-tauri/crates/core/src/config/mod.rs +++ b/src-tauri/crates/core/src/config/mod.rs @@ -22,19 +22,26 @@ pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, AsrCredentialEntry, AsrProviderType, AssistantConfig, AssistantProfile, BaiduConfig, ChannelsConfig, - ChatAppearanceConfig, Config, ContentCreatorConfig, ConversationSettings, CrashReportingConfig, - CredentialEntry, CredentialPoolConfig, CustomProviderConfig, DeliveryConfig, - EndpointProvidersConfig, ExperimentalFeatures, GeminiApiKeyEntry, HeartbeatExecutionMode, - HeartbeatSecurityConfig, HeartbeatSettings, HintRouteSettingsEntry, HintRouterSettings, - ImageGenConfig, InjectionRuleConfig, InjectionSettings, LoggingConfig, MemoryAutoConfig, - MemoryConfig, MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, ModelInfo, - ModelsConfig, MultiSearchConfig, MultiSearchEngineEntryConfig, NativeAgentConfig, - NavigationConfig, OpenAIAsrConfig, PairingSettings, ProviderConfig, ProviderModelsConfig, - ProvidersConfig, QuotaExceededConfig, RateLimitSettings, RemoteManagementConfig, - ResponseCacheSettings, RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine, - ServerConfig, TaskSchedule, TlsConfig, ToolCallingConfig, UpdateCheckConfig, UserProfile, - VertexApiKeyEntry, VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, - VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, - WhisperLocalConfig, WhisperModelSize, WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY, + ChatAppearanceConfig, CloudflareTunnelConfig, Config, ContentCreatorConfig, + ConversationSettings, CrashReportingConfig, CredentialEntry, CredentialPoolConfig, + CustomProviderConfig, DeliveryConfig, DiscordAccountConfig, DiscordActionsConfig, + DiscordAgentComponentsConfig, DiscordAutoPresenceConfig, DiscordBotConfig, + DiscordChannelConfig, DiscordExecApprovalsConfig, DiscordGuildConfig, DiscordIntentsConfig, + DiscordThreadBindingsConfig, DiscordUiComponentsConfig, DiscordUiConfig, + DiscordVoiceAutoJoinConfig, DiscordVoiceConfig, EndpointProvidersConfig, ExperimentalFeatures, + FeishuAccountConfig, FeishuBotConfig, FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig, + GeminiApiKeyEntry, HeartbeatExecutionMode, HeartbeatSecurityConfig, HeartbeatSettings, + HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig, InjectionRuleConfig, + InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig, MemoryProfileConfig, + MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig, MultiSearchConfig, + MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, + PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, + RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig, + ScreenshotChatConfig, SearchEngine, ServerConfig, TaskSchedule, TelegramAccountConfig, + TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig, ToolCallingConfig, + UpdateCheckConfig, UserProfile, VertexApiKeyEntry, VertexModelAlias, VoiceConfig, + VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, + WebSearchConfig, WebSearchProvider, WhisperLocalConfig, WhisperModelSize, + WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY, }; pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService}; diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 17f115208..21a2a3b6d 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -447,6 +447,9 @@ pub struct Config { /// 心跳引擎配置 #[serde(default)] pub heartbeat: HeartbeatSettings, + /// Gateway 全局配置(隧道/公网接入) + #[serde(default)] + pub gateway: GatewayConfig, /// 渠道配置(Telegram / Discord / 飞书 Bot) #[serde(default)] pub channels: ChannelsConfig, @@ -1974,6 +1977,7 @@ impl Default for Config { hint_router: HintRouterSettings::default(), pairing: PairingSettings::default(), heartbeat: HeartbeatSettings::default(), + gateway: GatewayConfig::default(), channels: ChannelsConfig::default(), } } @@ -3130,6 +3134,97 @@ pub struct PairingSettings { pub enabled: bool, } +// ============ Gateway 配置类型 ============ + +/// Gateway 全局配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct GatewayConfig { + #[serde(default)] + pub tunnel: GatewayTunnelConfig, +} + +/// Gateway 隧道配置(用于 webhook 公网回调) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GatewayTunnelConfig { + /// 是否启用隧道能力 + #[serde(default)] + pub enabled: bool, + /// provider:cloudflare | ngrok | none + #[serde(default = "default_gateway_tunnel_provider")] + pub provider: String, + /// 模式:managed | external + #[serde(default = "default_gateway_tunnel_mode")] + pub mode: String, + /// 隧道二进制路径(为空则走 PATH) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary_path: Option, + /// 本地回源 host + #[serde(default = "default_gateway_tunnel_local_host")] + pub local_host: String, + /// 本地回源 port + #[serde(default = "default_gateway_tunnel_local_port")] + pub local_port: u16, + /// 固定公网基础地址(例如 https://bot.example.com) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_base_url: Option, + #[serde(default)] + pub cloudflare: CloudflareTunnelConfig, +} + +impl Default for GatewayTunnelConfig { + fn default() -> Self { + Self { + enabled: false, + provider: default_gateway_tunnel_provider(), + mode: default_gateway_tunnel_mode(), + binary_path: None, + local_host: default_gateway_tunnel_local_host(), + local_port: default_gateway_tunnel_local_port(), + public_base_url: None, + cloudflare: CloudflareTunnelConfig::default(), + } + } +} + +/// Cloudflare Tunnel 配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct CloudflareTunnelConfig { + /// Cloudflare account_id(预留给 API 模式) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, + /// tunnel 名称(cloudflared tunnel create 时使用) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tunnel_name: Option, + /// tunnel UUID + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tunnel_id: Option, + /// cloudflared 运行 token(推荐) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_token: Option, + /// tunnel credentials 文件路径 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credentials_file: Option, + /// 绑定域名(例如 bot.example.com) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dns_name: Option, +} + +fn default_gateway_tunnel_provider() -> String { + "cloudflare".to_string() +} + +fn default_gateway_tunnel_mode() -> String { + "managed".to_string() +} + +fn default_gateway_tunnel_local_host() -> String { + "127.0.0.1".to_string() +} + +fn default_gateway_tunnel_local_port() -> u16 { + 3000 +} + // ============ 渠道配置类型(Telegram / Discord / 飞书 Bot) ============ /// 渠道配置 @@ -3144,33 +3239,732 @@ pub struct ChannelsConfig { } /// Telegram Bot 配置 -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TelegramBotConfig { #[serde(default)] pub enabled: bool, + /// 兼容旧配置:单账号 Token #[serde(default)] pub bot_token: String, + /// 兼容旧配置:允许用户 ID(迁移时可映射到 allow_from) #[serde(default)] pub allowed_user_ids: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, + /// 默认账号 ID(多账号启用时) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_account: Option, + /// 多账号配置 + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub accounts: HashMap, + /// DM 策略:pairing | allowlist | open | disabled + #[serde(default = "default_telegram_dm_policy")] + pub dm_policy: String, + /// DM 允许列表(支持 "*") + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_from: Vec, + /// 群组策略:allowlist | open | disabled + #[serde(default = "default_telegram_group_policy")] + pub group_policy: String, + /// 群组发送者允许列表(为空时回退 allow_from) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub group_allow_from: Vec, + /// 群组配置(key 为 chat_id 或 "*") + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub groups: HashMap, + /// 流式模式:off | partial | block | progress(兼容 legacy bool) + #[serde( + default = "default_telegram_streaming_mode", + deserialize_with = "deserialize_telegram_streaming_mode" + )] + pub streaming: String, + /// 回复模式:off | first | all + #[serde(default = "default_telegram_reply_to_mode")] + pub reply_to_mode: String, +} + +impl Default for TelegramBotConfig { + fn default() -> Self { + Self { + enabled: false, + bot_token: String::new(), + allowed_user_ids: Vec::new(), + default_model: None, + default_account: None, + accounts: HashMap::new(), + dm_policy: default_telegram_dm_policy(), + allow_from: Vec::new(), + group_policy: default_telegram_group_policy(), + group_allow_from: Vec::new(), + groups: HashMap::new(), + streaming: default_telegram_streaming_mode(), + reply_to_mode: default_telegram_reply_to_mode(), + } + } +} + +/// Telegram 多账号配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TelegramAccountConfig { + #[serde(default = "default_telegram_account_enabled")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bot_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_file: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dm_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_from: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub group_allow_from: Vec, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub groups: HashMap, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_telegram_streaming_mode" + )] + pub streaming: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_to_mode: Option, +} + +impl Default for TelegramAccountConfig { + fn default() -> Self { + Self { + enabled: true, + name: None, + bot_token: None, + token_file: None, + default_model: None, + dm_policy: None, + allow_from: Vec::new(), + group_policy: None, + group_allow_from: Vec::new(), + groups: HashMap::new(), + streaming: None, + reply_to_mode: None, + } + } +} + +fn default_telegram_account_enabled() -> bool { + true +} + +/// Telegram 群组配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct TelegramGroupConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub require_mention: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_policy: Option, + #[serde( + default = "default_feishu_allow_from", + skip_serializing_if = "Vec::is_empty" + )] + pub allow_from: Vec, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub topics: HashMap, +} + +/// Telegram 话题配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct TelegramTopicConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub require_mention: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_from: Vec, +} + +fn default_telegram_dm_policy() -> String { + "pairing".to_string() +} + +fn default_telegram_group_policy() -> String { + "allowlist".to_string() +} + +fn default_telegram_streaming_mode() -> String { + "partial".to_string() +} + +fn default_telegram_reply_to_mode() -> String { + "off".to_string() +} + +fn normalize_telegram_streaming_mode(mode: &str) -> String { + match mode.trim().to_ascii_lowercase().as_str() { + "off" => "off".to_string(), + "block" => "block".to_string(), + "progress" => "partial".to_string(), + "partial" => "partial".to_string(), + _ => default_telegram_streaming_mode(), + } +} + +fn deserialize_telegram_streaming_mode<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + let mode = match value { + serde_json::Value::String(s) => normalize_telegram_streaming_mode(&s), + serde_json::Value::Bool(false) => "off".to_string(), + serde_json::Value::Bool(true) => "partial".to_string(), + serde_json::Value::Null => default_telegram_streaming_mode(), + _ => default_telegram_streaming_mode(), + }; + Ok(mode) +} + +fn deserialize_optional_telegram_streaming_mode<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let mode = match value { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(s)) => Some(normalize_telegram_streaming_mode(&s)), + Some(serde_json::Value::Bool(false)) => Some("off".to_string()), + Some(serde_json::Value::Bool(true)) => Some("partial".to_string()), + Some(_) => Some(default_telegram_streaming_mode()), + }; + Ok(mode) } /// Discord Bot 配置 -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct DiscordBotConfig { #[serde(default)] pub enabled: bool, + /// 兼容旧配置:单账号 Token #[serde(default)] pub bot_token: String, + /// 兼容旧配置:允许服务器 ID 列表 #[serde(default)] pub allowed_server_ids: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, + /// 默认账号 ID(多账号启用时) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_account: Option, + /// 多账号配置 + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub accounts: HashMap, + /// DM 策略:pairing | allowlist | open | disabled + #[serde(default = "default_discord_dm_policy")] + pub dm_policy: String, + /// DM 允许列表(支持 "*") + #[serde( + default = "default_discord_allow_from", + skip_serializing_if = "Vec::is_empty" + )] + pub allow_from: Vec, + /// 群组策略:allowlist | open | disabled + #[serde(default = "default_discord_group_policy")] + pub group_policy: String, + /// 群组发送者允许列表(为空时回退 allow_from) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub group_allow_from: Vec, + /// 群组配置(key 为 guild_id 或 "*") + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub groups: HashMap, + /// 流式模式:off | partial | block | progress(兼容 legacy bool) + #[serde( + default = "default_discord_streaming_mode", + deserialize_with = "deserialize_discord_streaming_mode" + )] + pub streaming: String, + /// 回复模式:off | first | all + #[serde(default = "default_discord_reply_to_mode")] + pub reply_to_mode: String, + /// Message 内容意图与特权意图 + #[serde(default)] + pub intents: DiscordIntentsConfig, + /// 细粒度动作开关 + #[serde(default)] + pub actions: DiscordActionsConfig, + /// 线程绑定配置 + #[serde(default)] + pub thread_bindings: DiscordThreadBindingsConfig, + /// 自动在线状态(Presence) + #[serde(default)] + pub auto_presence: DiscordAutoPresenceConfig, + /// 语音配置 + #[serde(default)] + pub voice: DiscordVoiceConfig, + /// 交互组件配置 + #[serde(default)] + pub agent_components: DiscordAgentComponentsConfig, + /// UI 配置 + #[serde(default)] + pub ui: DiscordUiConfig, + /// 执行审批配置 + #[serde(default)] + pub exec_approvals: DiscordExecApprovalsConfig, + /// 回复前缀(可选) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_prefix: Option, + /// ACK reaction(可选) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ack_reaction: Option, +} + +impl Default for DiscordBotConfig { + fn default() -> Self { + Self { + enabled: false, + bot_token: String::new(), + allowed_server_ids: Vec::new(), + default_model: None, + default_account: None, + accounts: HashMap::new(), + dm_policy: default_discord_dm_policy(), + allow_from: default_discord_allow_from(), + group_policy: default_discord_group_policy(), + group_allow_from: Vec::new(), + groups: HashMap::new(), + streaming: default_discord_streaming_mode(), + reply_to_mode: default_discord_reply_to_mode(), + intents: DiscordIntentsConfig::default(), + actions: DiscordActionsConfig::default(), + thread_bindings: DiscordThreadBindingsConfig::default(), + auto_presence: DiscordAutoPresenceConfig::default(), + voice: DiscordVoiceConfig::default(), + agent_components: DiscordAgentComponentsConfig::default(), + ui: DiscordUiConfig::default(), + exec_approvals: DiscordExecApprovalsConfig::default(), + response_prefix: None, + ack_reaction: None, + } + } +} + +/// Discord 多账号配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DiscordAccountConfig { + #[serde(default = "default_discord_account_enabled")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bot_token: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_server_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dm_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_from: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub group_allow_from: Vec, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub groups: HashMap, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_discord_streaming_mode" + )] + pub streaming: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_to_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intents: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_bindings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_presence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub voice: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_components: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec_approvals: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ack_reaction: Option, +} + +impl Default for DiscordAccountConfig { + fn default() -> Self { + Self { + enabled: true, + name: None, + bot_token: None, + allowed_server_ids: Vec::new(), + default_model: None, + dm_policy: None, + allow_from: Vec::new(), + group_policy: None, + group_allow_from: Vec::new(), + groups: HashMap::new(), + streaming: None, + reply_to_mode: None, + intents: None, + actions: None, + thread_bindings: None, + auto_presence: None, + voice: None, + agent_components: None, + ui: None, + exec_approvals: None, + response_prefix: None, + ack_reaction: None, + } + } +} + +fn default_discord_account_enabled() -> bool { + true +} + +/// Discord 群组配置(按 guild) +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct DiscordGuildConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub require_mention: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_from: Vec, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub channels: HashMap, +} + +/// Discord 频道配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct DiscordChannelConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub require_mention: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_from: Vec, +} + +/// Discord Intents 配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DiscordIntentsConfig { + #[serde(default = "default_discord_message_content_intent")] + pub message_content: bool, + #[serde(default)] + pub guild_members: bool, + #[serde(default)] + pub presence: bool, +} + +impl Default for DiscordIntentsConfig { + fn default() -> Self { + Self { + message_content: default_discord_message_content_intent(), + guild_members: false, + presence: false, + } + } +} + +fn default_discord_message_content_intent() -> bool { + true +} + +/// Discord Action 开关 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DiscordActionsConfig { + #[serde(default = "default_true")] + pub reactions: bool, + #[serde(default = "default_true")] + pub messages: bool, + #[serde(default = "default_true")] + pub threads: bool, + #[serde(default = "default_true")] + pub moderation: bool, + #[serde(default = "default_true")] + pub presence: bool, +} + +impl Default for DiscordActionsConfig { + fn default() -> Self { + Self { + reactions: true, + messages: true, + threads: true, + moderation: true, + presence: true, + } + } +} + +fn default_true() -> bool { + true +} + +/// Discord 线程绑定配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DiscordThreadBindingsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_discord_thread_idle_hours")] + pub idle_hours: u32, + #[serde(default)] + pub max_age_hours: u32, + #[serde(default)] + pub spawn_subagent_sessions: bool, + #[serde(default)] + pub spawn_acp_sessions: bool, +} + +impl Default for DiscordThreadBindingsConfig { + fn default() -> Self { + Self { + enabled: false, + idle_hours: default_discord_thread_idle_hours(), + max_age_hours: 0, + spawn_subagent_sessions: false, + spawn_acp_sessions: false, + } + } +} + +fn default_discord_thread_idle_hours() -> u32 { + 24 +} + +/// Discord Auto Presence 配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DiscordAutoPresenceConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_discord_presence_interval_ms")] + pub interval_ms: u64, + #[serde(default = "default_discord_presence_min_update_interval_ms")] + pub min_update_interval_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub healthy_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub degraded_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exhausted_text: Option, +} + +impl Default for DiscordAutoPresenceConfig { + fn default() -> Self { + Self { + enabled: false, + interval_ms: default_discord_presence_interval_ms(), + min_update_interval_ms: default_discord_presence_min_update_interval_ms(), + healthy_text: None, + degraded_text: None, + exhausted_text: None, + } + } +} + +fn default_discord_presence_interval_ms() -> u64 { + 30_000 +} + +fn default_discord_presence_min_update_interval_ms() -> u64 { + 15_000 +} + +/// Discord 语音自动加入 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct DiscordVoiceAutoJoinConfig { + pub guild_id: String, + pub channel_id: String, +} + +/// Discord 语音配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DiscordVoiceConfig { + #[serde(default = "default_discord_voice_enabled")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub auto_join: Vec, + #[serde(default = "default_discord_voice_dave_encryption")] + pub dave_encryption: bool, + #[serde(default = "default_discord_voice_decryption_failure_tolerance")] + pub decryption_failure_tolerance: u32, +} + +impl Default for DiscordVoiceConfig { + fn default() -> Self { + Self { + enabled: default_discord_voice_enabled(), + auto_join: Vec::new(), + dave_encryption: default_discord_voice_dave_encryption(), + decryption_failure_tolerance: default_discord_voice_decryption_failure_tolerance(), + } + } +} + +fn default_discord_voice_enabled() -> bool { + true +} + +fn default_discord_voice_dave_encryption() -> bool { + true +} + +fn default_discord_voice_decryption_failure_tolerance() -> u32 { + 24 +} + +/// Discord Agent Components 配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DiscordAgentComponentsConfig { + #[serde(default = "default_true")] + pub enabled: bool, +} + +impl Default for DiscordAgentComponentsConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +/// Discord UI 组件配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct DiscordUiComponentsConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accent_color: Option, +} + +/// Discord UI 配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct DiscordUiConfig { + #[serde(default)] + pub components: DiscordUiComponentsConfig, +} + +/// Discord 执行审批配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DiscordExecApprovalsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub approvers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_filter: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub session_filter: Vec, + #[serde(default)] + pub cleanup_after_resolve: bool, + #[serde(default = "default_discord_exec_approval_target")] + pub target: String, +} + +impl Default for DiscordExecApprovalsConfig { + fn default() -> Self { + Self { + enabled: false, + approvers: Vec::new(), + agent_filter: Vec::new(), + session_filter: Vec::new(), + cleanup_after_resolve: false, + target: default_discord_exec_approval_target(), + } + } +} + +fn default_discord_exec_approval_target() -> String { + "dm".to_string() +} + +fn default_discord_dm_policy() -> String { + "pairing".to_string() +} + +fn default_discord_allow_from() -> Vec { + Vec::new() +} + +fn default_discord_group_policy() -> String { + "allowlist".to_string() +} + +fn default_discord_streaming_mode() -> String { + "partial".to_string() +} + +fn default_discord_reply_to_mode() -> String { + "off".to_string() +} + +fn normalize_discord_streaming_mode(mode: &str) -> String { + match mode.trim().to_ascii_lowercase().as_str() { + "off" => "off".to_string(), + "block" => "block".to_string(), + "progress" => "partial".to_string(), + "partial" => "partial".to_string(), + _ => default_discord_streaming_mode(), + } +} + +fn deserialize_discord_streaming_mode<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + let mode = match value { + serde_json::Value::String(s) => normalize_discord_streaming_mode(&s), + serde_json::Value::Bool(false) => "off".to_string(), + serde_json::Value::Bool(true) => "partial".to_string(), + serde_json::Value::Null => default_discord_streaming_mode(), + _ => default_discord_streaming_mode(), + }; + Ok(mode) +} + +fn deserialize_optional_discord_streaming_mode<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let mode = match value { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(s)) => Some(normalize_discord_streaming_mode(&s)), + Some(serde_json::Value::Bool(false)) => Some("off".to_string()), + Some(serde_json::Value::Bool(true)) => Some("partial".to_string()), + Some(_) => Some(default_discord_streaming_mode()), + }; + Ok(mode) } /// 飞书 Bot 配置 -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FeishuBotConfig { #[serde(default)] pub enabled: bool, @@ -3184,4 +3978,190 @@ pub struct FeishuBotConfig { pub encrypt_key: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, + /// 默认账号 ID(多账号启用时) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_account: Option, + /// 多账号配置 + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub accounts: HashMap, + /// API 域名:feishu | lark + #[serde(default = "default_feishu_domain")] + pub domain: String, + /// 连接模式:websocket | webhook + #[serde(default = "default_feishu_connection_mode")] + pub connection_mode: String, + /// webhook 监听地址 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhook_host: Option, + /// webhook 监听端口 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhook_port: Option, + /// webhook 路由路径 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhook_path: Option, + /// DM 策略:pairing | allowlist | open | disabled + #[serde(default = "default_feishu_dm_policy")] + pub dm_policy: String, + /// DM 允许列表(支持 "*") + #[serde( + default = "default_feishu_allow_from", + skip_serializing_if = "Vec::is_empty" + )] + pub allow_from: Vec, + /// 群组策略:allowlist | open | disabled + #[serde(default = "default_feishu_group_policy")] + pub group_policy: String, + /// 群组发送者允许列表(为空时回退 allow_from) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub group_allow_from: Vec, + /// 群组配置(key 为 chat_id 或 "*") + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub groups: HashMap, + /// 流式模式:off | partial | block + #[serde(default = "default_feishu_streaming_mode")] + pub streaming: String, + /// 回复模式:off | first | all + #[serde(default = "default_feishu_reply_to_mode")] + pub reply_to_mode: String, +} + +impl Default for FeishuBotConfig { + fn default() -> Self { + Self { + enabled: false, + app_id: String::new(), + app_secret: String::new(), + verification_token: None, + encrypt_key: None, + default_model: None, + default_account: None, + accounts: HashMap::new(), + domain: default_feishu_domain(), + connection_mode: default_feishu_connection_mode(), + webhook_host: None, + webhook_port: None, + webhook_path: None, + dm_policy: default_feishu_dm_policy(), + allow_from: default_feishu_allow_from(), + group_policy: default_feishu_group_policy(), + group_allow_from: Vec::new(), + groups: HashMap::new(), + streaming: default_feishu_streaming_mode(), + reply_to_mode: default_feishu_reply_to_mode(), + } + } +} + +/// 飞书多账号配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FeishuAccountConfig { + #[serde(default = "default_feishu_account_enabled")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_secret: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verification_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encrypt_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connection_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhook_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhook_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhook_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dm_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_from: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub group_allow_from: Vec, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub groups: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub streaming: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_to_mode: Option, +} + +impl Default for FeishuAccountConfig { + fn default() -> Self { + Self { + enabled: true, + name: None, + app_id: None, + app_secret: None, + verification_token: None, + encrypt_key: None, + default_model: None, + domain: None, + connection_mode: None, + webhook_host: None, + webhook_port: None, + webhook_path: None, + dm_policy: None, + allow_from: Vec::new(), + group_policy: None, + group_allow_from: Vec::new(), + groups: HashMap::new(), + streaming: None, + reply_to_mode: None, + } + } +} + +fn default_feishu_account_enabled() -> bool { + true +} + +/// 飞书群组配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct FeishuGroupConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub require_mention: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_from: Vec, +} + +fn default_feishu_domain() -> String { + "feishu".to_string() +} + +fn default_feishu_connection_mode() -> String { + "websocket".to_string() +} + +fn default_feishu_dm_policy() -> String { + "open".to_string() +} + +fn default_feishu_allow_from() -> Vec { + vec!["*".to_string()] +} + +fn default_feishu_group_policy() -> String { + "allowlist".to_string() +} + +fn default_feishu_streaming_mode() -> String { + "partial".to_string() +} + +fn default_feishu_reply_to_mode() -> String { + "off".to_string() } diff --git a/src-tauri/crates/core/src/database/dao/mcp.rs b/src-tauri/crates/core/src/database/dao/mcp.rs index 798ea7b00..fe55ac933 100644 --- a/src-tauri/crates/core/src/database/dao/mcp.rs +++ b/src-tauri/crates/core/src/database/dao/mcp.rs @@ -1,6 +1,7 @@ use crate::models::McpServer; +use rusqlite::types::Value as SqlValue; use rusqlite::{params, Connection}; -use serde_json::Value; +use serde_json::Value as JsonValue; pub struct McpDao; @@ -14,7 +15,9 @@ impl McpDao { let servers = stmt.query_map([], |row| { let config_str: String = row.get(2)?; - let server_config: Value = serde_json::from_str(&config_str).unwrap_or(Value::Null); + let server_config: JsonValue = + serde_json::from_str(&config_str).unwrap_or(JsonValue::Null); + let created_at_raw: Option = row.get(8)?; Ok(McpServer { id: row.get(0)?, @@ -25,7 +28,7 @@ impl McpDao { enabled_claude: row.get::<_, i32>(5)? == 1, enabled_codex: row.get::<_, i32>(6)? == 1, enabled_gemini: row.get::<_, i32>(7)? == 1, - created_at: row.get(8)?, + created_at: created_at_raw.and_then(normalize_created_at), }) })?; @@ -100,3 +103,100 @@ impl McpDao { Ok(()) } } + +fn normalize_created_at(value: SqlValue) -> Option { + match value { + SqlValue::Integer(ts) => Some(ts), + SqlValue::Real(ts) => Some(ts as i64), + SqlValue::Text(text) => parse_timestamp_text(&text), + SqlValue::Null | SqlValue::Blob(_) => None, + } +} + +fn parse_timestamp_text(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + + if let Ok(ts) = trimmed.parse::() { + return Some(ts); + } + + chrono::DateTime::parse_from_rfc3339(trimmed) + .ok() + .map(|dt| dt.timestamp()) +} + +#[cfg(test)] +mod tests { + use super::McpDao; + use rusqlite::Connection; + use serde_json::json; + + fn setup_conn() -> Connection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + conn.execute( + "CREATE TABLE mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_config TEXT NOT NULL, + description TEXT, + enabled_proxycast INTEGER DEFAULT 0, + enabled_claude INTEGER DEFAULT 0, + enabled_codex INTEGER DEFAULT 0, + enabled_gemini INTEGER DEFAULT 0, + created_at + )", + [], + ) + .expect("创建 mcp_servers 表失败"); + conn + } + + #[test] + fn get_all_should_parse_integer_created_at() { + let conn = setup_conn(); + conn.execute( + "INSERT INTO mcp_servers ( + id, name, server_config, description, + enabled_proxycast, enabled_claude, enabled_codex, enabled_gemini, created_at + ) VALUES (?1, ?2, ?3, ?4, 1, 0, 0, 0, ?5)", + rusqlite::params![ + "srv-int", + "int-server", + json!({"command":"npx"}).to_string(), + "desc", + 1_710_000_000_i64 + ], + ) + .expect("插入整数 created_at 数据失败"); + + let servers = McpDao::get_all(&conn).expect("查询服务器失败"); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].created_at, Some(1_710_000_000)); + } + + #[test] + fn get_all_should_parse_rfc3339_created_at() { + let conn = setup_conn(); + conn.execute( + "INSERT INTO mcp_servers ( + id, name, server_config, description, + enabled_proxycast, enabled_claude, enabled_codex, enabled_gemini, created_at + ) VALUES (?1, ?2, ?3, ?4, 1, 0, 0, 0, ?5)", + rusqlite::params![ + "srv-text", + "text-server", + json!({"command":"npx"}).to_string(), + "desc", + "2026-03-04T01:19:53.599Z" + ], + ) + .expect("插入文本 created_at 数据失败"); + + let servers = McpDao::get_all(&conn).expect("查询服务器失败"); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].created_at, Some(1_772_587_193)); + } +} diff --git a/src-tauri/crates/core/src/database/migration.rs b/src-tauri/crates/core/src/database/migration.rs index 2f764995b..23a430be3 100644 --- a/src-tauri/crates/core/src/database/migration.rs +++ b/src-tauri/crates/core/src/database/migration.rs @@ -544,6 +544,61 @@ pub fn migrate_mcp_proxycast_enabled(conn: &Connection) -> Result Ok(updated) } +/// 归一化 mcp_servers.created_at 字段为 INTEGER 时间戳 +/// +/// 历史版本曾写入 RFC3339 文本,导致下游按 i64 读取时出现类型异常。 +/// 迁移策略: +/// - 纯数字文本 -> CAST 为 INTEGER +/// - RFC3339 文本 -> strftime('%s', ...) 转为秒级时间戳 +/// - 其余值保持不变(由 DAO 兼容读取) +pub fn migrate_mcp_created_at_to_integer(conn: &Connection) -> Result { + let migrated: bool = conn + .query_row( + "SELECT value FROM settings WHERE key = 'migrated_mcp_created_at_to_integer'", + [], + |row| row.get::<_, String>(0), + ) + .map(|v| v == "true") + .unwrap_or(false); + + if migrated { + tracing::debug!("[迁移] MCP created_at 类型已归一化,跳过"); + return Ok(0); + } + + let updated_numeric = conn + .execute( + "UPDATE mcp_servers + SET created_at = CAST(TRIM(created_at) AS INTEGER) + WHERE typeof(created_at) = 'text' + AND TRIM(created_at) != '' + AND TRIM(created_at) NOT GLOB '*[^0-9]*'", + [], + ) + .map_err(|e| format!("归一化 MCP created_at 数字文本失败: {e}"))?; + + let updated_rfc3339 = conn + .execute( + "UPDATE mcp_servers + SET created_at = CAST(strftime('%s', created_at) AS INTEGER) + WHERE typeof(created_at) = 'text' + AND strftime('%s', created_at) IS NOT NULL", + [], + ) + .map_err(|e| format!("归一化 MCP created_at RFC3339 文本失败: {e}"))?; + + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('migrated_mcp_created_at_to_integer', 'true')", + [], + ) + .map_err(|e| format!("标记 MCP created_at 归一化完成失败: {e}"))?; + + let total = updated_numeric + updated_rfc3339; + tracing::info!("[迁移] MCP created_at 归一化完成,更新 {} 条记录", total); + + Ok(total) +} + /// 当前模型注册表版本 /// 每次更新模型数据结构或添加新 Provider 时,增加此版本号 const MODEL_REGISTRY_VERSION: &str = "2026.01.16.1"; diff --git a/src-tauri/crates/core/src/database/migration_v3.rs b/src-tauri/crates/core/src/database/migration_v3.rs index 2fb020ac5..6e41d411d 100644 --- a/src-tauri/crates/core/src/database/migration_v3.rs +++ b/src-tauri/crates/core/src/database/migration_v3.rs @@ -90,7 +90,7 @@ pub fn migrate_playwright_mcp_server(conn: &Connection) -> Result Result { // 创建 Playwright MCP Server 配置 let server_id = Uuid::new_v4().to_string(); - let created_at = Utc::now().to_rfc3339(); + let created_at = Utc::now().timestamp(); // 构建服务器配置 let server_config = json!({ diff --git a/src-tauri/crates/core/src/database/mod.rs b/src-tauri/crates/core/src/database/mod.rs index b40cc0356..b0fd5c048 100644 --- a/src-tauri/crates/core/src/database/mod.rs +++ b/src-tauri/crates/core/src/database/mod.rs @@ -109,6 +109,18 @@ pub fn init_database() -> Result { } } + // 归一化历史 MCP created_at 字段(TEXT -> INTEGER) + match migration::migrate_mcp_created_at_to_integer(&conn) { + Ok(count) => { + if count > 0 { + tracing::info!("[数据库] 已归一化 {} 条 MCP created_at 字段", count); + } + } + Err(e) => { + tracing::warn!("[数据库] MCP created_at 归一化失败(非致命): {}", e); + } + } + // 执行统一内容系统迁移(创建默认项目,迁移话题) // _Requirements: 2.1, 2.2, 2.3, 2.4_ match migration_v2::migrate_unified_content_system(&conn) { diff --git a/src-tauri/crates/core/src/logger.rs b/src-tauri/crates/core/src/logger.rs index a180acd0c..1908be37e 100644 --- a/src-tauri/crates/core/src/logger.rs +++ b/src-tauri/crates/core/src/logger.rs @@ -122,6 +122,16 @@ impl LogStore { pub fn clear(&mut self) { self.logs.clear(); + if let Some(ref path) = self.log_file_path { + if let Ok(mut file) = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + { + let _ = file.write_all(b""); + } + } } pub fn get_log_file_path(&self) -> Option { diff --git a/src-tauri/crates/gateway/Cargo.toml b/src-tauri/crates/gateway/Cargo.toml new file mode 100644 index 000000000..45a97cb3d --- /dev/null +++ b/src-tauri/crates/gateway/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "proxycast-gateway" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +proxycast-core.workspace = true +proxycast-websocket.workspace = true + +serde.workspace = true +serde_json.workspace = true + +tokio.workspace = true +tokio-util.workspace = true +futures.workspace = true +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } +reqwest.workspace = true +chrono.workspace = true +uuid.workspace = true +axum.workspace = true +urlencoding.workspace = true + +[dev-dependencies] +proptest.workspace = true +tempfile.workspace = true diff --git a/src-tauri/crates/gateway/src/discord.rs b/src-tauri/crates/gateway/src/discord.rs new file mode 100644 index 000000000..040eafdb5 --- /dev/null +++ b/src-tauri/crates/gateway/src/discord.rs @@ -0,0 +1,2092 @@ +//! Discord Gateway 运行时 +//! +//! 目标:将 Discord 作为标准渠道接入全局 Gateway。 +//! 当前版本支持: +//! - 多账号启动/停止/状态/探测 +//! - Discord Gateway 实时入站消息(MESSAGE_CREATE) +//! - 入站命令与普通文本触发 RPC Agent 执行并回包 +//! - 会话路由、/new 会话旋转、核心命令 + +use chrono::Utc; +use futures::{SinkExt, StreamExt}; +use proxycast_core::config::{ + Config, DiscordAccountConfig, DiscordActionsConfig, DiscordAgentComponentsConfig, + DiscordAutoPresenceConfig, DiscordBotConfig, DiscordExecApprovalsConfig, DiscordGuildConfig, + DiscordIntentsConfig, DiscordThreadBindingsConfig, DiscordUiConfig, DiscordVoiceConfig, +}; +use proxycast_core::database::DbConnection; +use proxycast_core::logger::LogStore; +use proxycast_websocket::handlers::{RpcHandler, RpcHandlerState}; +use proxycast_websocket::protocol::{ + AgentRunResult, AgentStopResult, AgentWaitResult, CronHealthResult, CronListResult, + CronRunResult, GatewayRpcRequest, GatewayRpcResponse, RpcMethod, SessionGetResult, + SessionsListResult, +}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tokio::task::JoinHandle; +use tokio::time::Duration; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::protocol::Message; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; +const DISCORD_MAX_MESSAGE_LEN: usize = 1800; +const RUN_WAIT_TIMEOUT_MS: u64 = 1200; +const RUN_WAIT_MAX_ROUNDS: usize = 180; +const CONFIRMATION_TTL_SECS: i64 = 90; + +type LogState = Arc>; +type SessionRouteState = Arc>>; +type PendingConfirmationState = Arc>>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StreamingMode { + Off, + Partial, + Block, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReplyToMode { + Off, + First, + All, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DiscordGatewayAccountStatus { + pub account_id: String, + pub running: bool, + pub connected: bool, + pub bot_id: Option, + pub bot_username: Option, + pub application_id: Option, + pub message_content_intent: Option, + pub started_at: Option, + pub last_error: Option, + pub last_event_at: Option, + pub last_message_at: Option, + pub last_disconnect: Option, + pub reconnect_attempts: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DiscordGatewayStatus { + pub running_accounts: usize, + pub accounts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordProbeResult { + pub account_id: String, + pub ok: bool, + pub bot_id: Option, + pub username: Option, + pub application_id: Option, + pub message_content_intent: Option, + pub message: String, +} + +pub struct DiscordGatewayState { + inner: Arc>, +} + +struct DiscordGatewayRuntime { + accounts: HashMap, +} + +struct AccountRuntimeHandle { + stop_token: CancellationToken, + task: JoinHandle<()>, + status: Arc>, +} + +impl Default for DiscordGatewayState { + fn default() -> Self { + Self { + inner: Arc::new(RwLock::new(DiscordGatewayRuntime { + accounts: HashMap::new(), + })), + } + } +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +struct ResolvedDiscordAccount { + account_id: String, + bot_token: String, + default_model: Option, + allowed_server_ids: HashSet, + dm_policy: String, + allow_from: HashSet, + group_policy: String, + group_allow_from: HashSet, + groups: HashMap, + streaming: String, + reply_to_mode: String, + intents: DiscordIntentsConfig, + actions: DiscordActionsConfig, + thread_bindings: DiscordThreadBindingsConfig, + auto_presence: DiscordAutoPresenceConfig, + voice: DiscordVoiceConfig, + agent_components: DiscordAgentComponentsConfig, + ui: DiscordUiConfig, + exec_approvals: DiscordExecApprovalsConfig, + response_prefix: Option, + ack_reaction: Option, +} + +#[derive(Debug, Clone)] +struct InboundMessage { + message_id: String, + channel_id: String, + guild_id: Option, + sender_id: String, + text: String, + mentioned_bot: bool, +} + +#[derive(Debug, Clone)] +struct PendingConfirmation { + token: String, + command: DiscordCommand, + expires_at: chrono::DateTime, +} + +#[derive(Debug, Clone)] +enum DiscordCommand { + Run(String), + New(Option), + Status(String), + Stop(String), + CronList, + CronHealth, + CronRun(String), + Sessions, + Session(String), + Confirm(String), + Cancel, + Help, +} + +#[derive(Clone)] +struct DiscordRuntimeContext { + account: ResolvedDiscordAccount, + client: reqwest::Client, + rpc_handler: Arc, + logs: LogState, + session_route_state: SessionRouteState, + status: Arc>, + pending_confirmation: PendingConfirmationState, + reply_to_mode: ReplyToMode, +} + +#[derive(Debug, Deserialize)] +struct DiscordGatewayBotResponse { + url: String, +} + +#[derive(Debug, Deserialize, Clone)] +struct DiscordUser { + id: String, + username: Option, + bot: Option, +} + +#[derive(Debug, Deserialize)] +struct DiscordApplication { + id: Option, + flags: Option, +} + +#[derive(Debug, Deserialize)] +struct DiscordGatewayPayload { + op: u64, + d: serde_json::Value, + s: Option, + t: Option, +} + +#[derive(Debug, Deserialize)] +struct DiscordGatewayHello { + heartbeat_interval: u64, +} + +#[derive(Debug, Deserialize)] +struct DiscordGatewayReady { + user: DiscordUser, + application: Option, +} + +#[derive(Debug, Deserialize)] +struct DiscordMentionUser { + id: String, +} + +#[derive(Debug, Deserialize)] +struct DiscordMessageCreateEvent { + id: String, + channel_id: String, + guild_id: Option, + content: Option, + author: DiscordUser, + mentions: Option>, +} + +pub async fn start_gateway( + state: &DiscordGatewayState, + db: DbConnection, + logs: LogState, + config: Config, + account_filter: Option, + _poll_timeout_secs: Option, +) -> Result { + let state = state.inner.clone(); + let accounts = resolve_discord_accounts(&config.channels.discord, account_filter.as_deref())?; + if accounts.is_empty() { + return Err("没有可启动的 Discord 账号,请检查 channels.discord 配置".to_string()); + } + + for account in accounts { + let existing = { + let runtime = state.read().await; + runtime.accounts.contains_key(&account.account_id) + }; + if existing { + continue; + } + + let status = Arc::new(RwLock::new(DiscordGatewayAccountStatus { + account_id: account.account_id.clone(), + running: true, + connected: false, + bot_id: None, + bot_username: None, + application_id: None, + message_content_intent: None, + started_at: Some(Utc::now().to_rfc3339()), + last_error: None, + last_event_at: None, + last_message_at: None, + last_disconnect: None, + reconnect_attempts: 0, + })); + + let status_for_task = status.clone(); + let stop_token = CancellationToken::new(); + let stop_for_task = stop_token.clone(); + let account_for_task = account.clone(); + let state_for_task = state.clone(); + let db_for_task = db.clone(); + let logs_for_task = logs.clone(); + + let task = tokio::spawn(async move { + run_account_loop( + state_for_task, + status_for_task, + db_for_task, + logs_for_task, + account_for_task, + stop_for_task, + ) + .await; + }); + + let mut runtime = state.write().await; + runtime.accounts.insert( + account.account_id.clone(), + AccountRuntimeHandle { + stop_token, + task, + status, + }, + ); + } + + snapshot_status(state).await +} + +pub async fn stop_gateway( + state: &DiscordGatewayState, + account_filter: Option, +) -> Result { + let state = state.inner.clone(); + let mut handles = Vec::new(); + { + let mut runtime = state.write().await; + if let Some(account_id) = account_filter { + if let Some(handle) = runtime.accounts.remove(&account_id) { + handles.push(handle); + } + } else { + handles = runtime.accounts.drain().map(|(_, handle)| handle).collect(); + } + } + + for handle in handles { + handle.stop_token.cancel(); + if let Err(join_error) = handle.task.await { + eprintln!("[DiscordGateway] 停止任务 join 失败: {}", join_error); + } + } + + snapshot_status(state).await +} + +pub async fn status_gateway(state: &DiscordGatewayState) -> Result { + snapshot_status(state.inner.clone()).await +} + +pub async fn probe_gateway_account( + config: &Config, + account_filter: Option, +) -> Result { + let accounts = resolve_discord_accounts(&config.channels.discord, account_filter.as_deref())?; + let account = accounts + .into_iter() + .next() + .ok_or_else(|| "未找到可用 Discord 账号".to_string())?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .map_err(|e| format!("创建 Discord probe client 失败: {e}"))?; + + let me_result = fetch_current_user(&client, &account.bot_token).await; + let app_result = fetch_application(&client, &account.bot_token).await; + + match me_result { + Ok(me) => { + let (application_id, message_content_intent) = match app_result { + Ok(app) => ( + app.id.clone(), + app.flags.map(resolve_message_content_intent_status), + ), + Err(_) => (None, None), + }; + Ok(DiscordProbeResult { + account_id: account.account_id, + ok: true, + bot_id: Some(me.id), + username: me.username, + application_id, + message_content_intent, + message: "Discord API 探测成功".to_string(), + }) + } + Err(error) => Ok(DiscordProbeResult { + account_id: account.account_id, + ok: false, + bot_id: None, + username: None, + application_id: None, + message_content_intent: None, + message: error, + }), + } +} + +async fn snapshot_status( + state: Arc>, +) -> Result { + let handles = { + let runtime = state.read().await; + runtime + .accounts + .iter() + .map(|(id, handle)| (id.clone(), handle.status.clone())) + .collect::>() + }; + + let mut accounts = Vec::new(); + for (account_id, status) in handles { + let mut item = status.read().await.clone(); + if item.account_id.is_empty() { + item.account_id = account_id; + } + accounts.push(item); + } + accounts.sort_by(|a, b| a.account_id.cmp(&b.account_id)); + + Ok(DiscordGatewayStatus { + running_accounts: accounts.iter().filter(|item| item.running).count(), + accounts, + }) +} + +async fn run_account_loop( + _state: Arc>, + status: Arc>, + db: DbConnection, + logs: LogState, + account: ResolvedDiscordAccount, + stop_token: CancellationToken, +) { + let rpc_handler_state = RpcHandlerState::new(Some(db), None, logs.clone()); + let rpc_handler = Arc::new(RpcHandler::new(rpc_handler_state)); + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + { + Ok(client) => client, + Err(error) => { + let message = format!("创建 Discord client 失败: {error}"); + { + let mut status_guard = status.write().await; + status_guard.running = false; + status_guard.connected = false; + status_guard.last_error = Some(message.clone()); + } + logs.write().await.add( + "error", + &format!( + "[DiscordGateway] account={} 初始化失败: {}", + account.account_id, message + ), + ); + return; + } + }; + + let streaming_mode = parse_streaming_mode(&account.streaming); + let reply_to_mode = parse_reply_to_mode(&account.reply_to_mode); + let session_route_state: SessionRouteState = Arc::new(RwLock::new(HashMap::new())); + let pending_confirmation: PendingConfirmationState = Arc::new(RwLock::new(None)); + let context = DiscordRuntimeContext { + account: account.clone(), + client, + rpc_handler, + logs: logs.clone(), + session_route_state, + status: status.clone(), + pending_confirmation, + reply_to_mode, + }; + let streaming_mode_label = match streaming_mode { + StreamingMode::Off => "off", + StreamingMode::Partial => "partial", + StreamingMode::Block => "block", + }; + logs.write().await.add( + "info", + &format!( + "[DiscordGateway] account={} 运行参数: streaming={} reply_to_mode={}", + account.account_id, streaming_mode_label, account.reply_to_mode + ), + ); + + let mut reconnect_attempts = 0u64; + loop { + if stop_token.is_cancelled() { + break; + } + + { + let mut status_guard = status.write().await; + status_guard.reconnect_attempts = reconnect_attempts; + } + + match run_gateway_loop(&context, stop_token.clone()).await { + Ok(_) => break, + Err(error) => { + reconnect_attempts = reconnect_attempts.saturating_add(1); + { + let mut status_guard = status.write().await; + status_guard.connected = false; + status_guard.last_error = Some(error.clone()); + status_guard.last_disconnect = Some(Utc::now().to_rfc3339()); + status_guard.reconnect_attempts = reconnect_attempts; + } + logs.write().await.add( + "warn", + &format!( + "[DiscordGateway] account={} gateway 循环异常,准备重连: {}", + account.account_id, error + ), + ); + + let backoff_secs = reconnect_attempts.min(10).saturating_mul(2).max(2); + tokio::select! { + _ = stop_token.cancelled() => break, + _ = tokio::time::sleep(Duration::from_secs(backoff_secs)) => {} + } + } + } + } + + { + let mut status_guard = status.write().await; + status_guard.running = false; + status_guard.connected = false; + status_guard.last_disconnect = Some(Utc::now().to_rfc3339()); + } +} + +async fn run_gateway_loop( + context: &DiscordRuntimeContext, + stop_token: CancellationToken, +) -> Result<(), String> { + if !context.account.actions.messages { + return Err("channels.discord.actions.messages=false,消息处理已禁用".to_string()); + } + + let gateway = fetch_gateway_bot_info(&context.client, &context.account.bot_token).await?; + let ws_url = format!("{}?v=10&encoding=json", gateway.url.trim_end_matches('/')); + let (ws_stream, _) = connect_async(ws_url) + .await + .map_err(|e| format!("连接 Discord gateway 失败: {e}"))?; + + let (write, mut read) = ws_stream.split(); + let writer = Arc::new(Mutex::new(write)); + let sequence = Arc::new(RwLock::new(None::)); + + let hello_payload = tokio::time::timeout(Duration::from_secs(15), read.next()) + .await + .map_err(|_| "等待 Discord HELLO 超时".to_string())? + .ok_or_else(|| "Discord gateway 提前关闭".to_string())? + .map_err(|e| format!("读取 Discord HELLO 失败: {e}"))?; + let hello_text = match hello_payload { + Message::Text(text) => text, + other => return Err(format!("Discord HELLO 类型不匹配: {}", other.to_string())), + }; + let hello_envelope: DiscordGatewayPayload = + serde_json::from_str(&hello_text).map_err(|e| format!("解析 Discord HELLO 失败: {e}"))?; + if hello_envelope.op != 10 { + return Err(format!("Discord HELLO op 非法: {}", hello_envelope.op)); + } + let hello_data: DiscordGatewayHello = serde_json::from_value(hello_envelope.d) + .map_err(|e| format!("解析 Discord HELLO 数据失败: {e}"))?; + + send_gateway_json( + &writer, + json!({ + "op": 2, + "d": { + "token": context.account.bot_token, + "intents": resolve_intents_bitmask(&context.account.intents), + "properties": { + "os": std::env::consts::OS, + "browser": "proxycast", + "device": "proxycast" + } + } + }), + ) + .await?; + + let heartbeat_stop = CancellationToken::new(); + let heartbeat_stop_task = heartbeat_stop.clone(); + let heartbeat_writer = writer.clone(); + let heartbeat_seq = sequence.clone(); + let heartbeat_interval = hello_data.heartbeat_interval.max(5000); + let heartbeat_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(heartbeat_interval)); + loop { + tokio::select! { + _ = heartbeat_stop_task.cancelled() => break, + _ = interval.tick() => { + let seq = *heartbeat_seq.read().await; + let payload = json!({ + "op": 1, + "d": seq + }); + if send_gateway_json(&heartbeat_writer, payload).await.is_err() { + break; + } + } + } + } + }); + + let mut bot_id: Option = None; + loop { + tokio::select! { + _ = stop_token.cancelled() => { + break; + } + next = read.next() => { + let message = match next { + Some(Ok(message)) => message, + Some(Err(error)) => { + heartbeat_stop.cancel(); + let _ = heartbeat_task.await; + return Err(format!("Discord gateway 读取失败: {error}")); + } + None => { + heartbeat_stop.cancel(); + let _ = heartbeat_task.await; + return Err("Discord gateway 连接已关闭".to_string()); + } + }; + + match message { + Message::Text(text) => { + let envelope: DiscordGatewayPayload = serde_json::from_str(&text) + .map_err(|e| format!("解析 Discord gateway payload 失败: {e}"))?; + if let Some(seq) = envelope.s { + *sequence.write().await = Some(seq); + } + if envelope.op == 11 { + continue; + } + if envelope.op == 1 { + let seq = *sequence.read().await; + send_gateway_json(&writer, json!({"op":1, "d": seq})).await?; + continue; + } + if envelope.op == 7 { + heartbeat_stop.cancel(); + let _ = heartbeat_task.await; + return Err("Discord gateway 请求重连".to_string()); + } + if envelope.op == 9 { + heartbeat_stop.cancel(); + let _ = heartbeat_task.await; + return Err("Discord gateway 会话失效(Invalid Session)".to_string()); + } + if envelope.op != 0 { + continue; + } + + let event_type = envelope.t.unwrap_or_default(); + if event_type == "READY" { + let ready: DiscordGatewayReady = serde_json::from_value(envelope.d) + .map_err(|e| format!("解析 Discord READY 失败: {e}"))?; + let app_id = ready + .application + .as_ref() + .and_then(|item| item.id.clone()); + let intent_state = ready + .application + .and_then(|item| item.flags) + .map(resolve_message_content_intent_status); + { + let mut status_guard = context.status.write().await; + status_guard.connected = true; + status_guard.bot_id = Some(ready.user.id.clone()); + status_guard.bot_username = ready.user.username.clone(); + status_guard.application_id = app_id; + status_guard.message_content_intent = intent_state; + status_guard.last_error = None; + status_guard.last_event_at = Some(Utc::now().to_rfc3339()); + } + bot_id = Some(ready.user.id); + context.logs.write().await.add( + "info", + &format!( + "[DiscordGateway] account={} READY connected", + context.account.account_id + ), + ); + continue; + } + if event_type == "MESSAGE_CREATE" { + let event: DiscordMessageCreateEvent = serde_json::from_value(envelope.d) + .map_err(|e| format!("解析 Discord MESSAGE_CREATE 失败: {e}"))?; + handle_message_event(context, &event, bot_id.as_deref()).await?; + continue; + } + + { + let mut status_guard = context.status.write().await; + status_guard.last_event_at = Some(Utc::now().to_rfc3339()); + } + } + Message::Close(frame) => { + heartbeat_stop.cancel(); + let _ = heartbeat_task.await; + return Err(format!("Discord gateway 已关闭: {:?}", frame)); + } + Message::Ping(payload) => { + let mut guard = writer.lock().await; + let _ = guard.send(Message::Pong(payload)).await; + } + _ => {} + } + } + } + } + + heartbeat_stop.cancel(); + let _ = heartbeat_task.await; + let mut guard = writer.lock().await; + let _ = guard.send(Message::Close(None)).await; + Ok(()) +} + +async fn send_gateway_json( + writer: &Arc< + Mutex< + futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, + >, + >, + payload: serde_json::Value, +) -> Result<(), String> { + let text = + serde_json::to_string(&payload).map_err(|e| format!("序列化 Discord payload 失败: {e}"))?; + let mut guard = writer.lock().await; + guard + .send(Message::Text(text)) + .await + .map_err(|e| format!("发送 Discord payload 失败: {e}")) +} + +async fn handle_message_event( + context: &DiscordRuntimeContext, + event: &DiscordMessageCreateEvent, + bot_id: Option<&str>, +) -> Result<(), String> { + if event.author.bot.unwrap_or(false) { + return Ok(()); + } + if let Some(my_id) = bot_id { + if event.author.id == my_id { + return Ok(()); + } + } + + let text = event.content.clone().unwrap_or_default().trim().to_string(); + if text.is_empty() { + return Ok(()); + } + + let inbound = InboundMessage { + message_id: event.id.clone(), + channel_id: event.channel_id.clone(), + guild_id: event.guild_id.clone(), + sender_id: event.author.id.clone(), + text: text.clone(), + mentioned_bot: resolve_mentioned_bot(event, bot_id), + }; + authorize_message(&context.account, &inbound)?; + + let reply = if is_slash_command(&inbound.text) { + let command = parse_discord_command(&inbound.text)?; + handle_command(context, &inbound, command).await? + } else { + match handle_plain_text( + &context.account, + context.rpc_handler.as_ref(), + &context.logs, + &context.session_route_state, + &inbound, + text, + None, + ) + .await? + { + PlainTextReply::Message(value) => Some(value), + } + }; + + if let Some(reply_text) = reply { + send_text_chunks( + &context.client, + &context.account, + &inbound.channel_id, + &inbound.message_id, + &reply_text, + context.reply_to_mode, + ) + .await?; + let mut status_guard = context.status.write().await; + status_guard.last_message_at = Some(Utc::now().to_rfc3339()); + status_guard.last_event_at = Some(Utc::now().to_rfc3339()); + } + Ok(()) +} + +fn resolve_mentioned_bot(event: &DiscordMessageCreateEvent, bot_id: Option<&str>) -> bool { + let Some(bot_id) = bot_id else { + return false; + }; + let mention_hit = event + .mentions + .as_ref() + .map(|items| items.iter().any(|item| item.id == bot_id)) + .unwrap_or(false); + if mention_hit { + return true; + } + let content = event.content.as_deref().unwrap_or_default(); + content.contains(&format!("<@{}>", bot_id)) || content.contains(&format!("<@!{}>", bot_id)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PlainTextReply { + Message(String), +} + +fn resolve_intents_bitmask(intents: &DiscordIntentsConfig) -> u64 { + const GUILDS: u64 = 1 << 0; + const GUILD_MEMBERS: u64 = 1 << 1; + const GUILD_PRESENCES: u64 = 1 << 8; + const GUILD_MESSAGES: u64 = 1 << 9; + const GUILD_MESSAGE_REACTIONS: u64 = 1 << 10; + const DIRECT_MESSAGES: u64 = 1 << 12; + const DIRECT_MESSAGE_REACTIONS: u64 = 1 << 13; + const MESSAGE_CONTENT: u64 = 1 << 15; + + let mut bits = GUILDS + | GUILD_MESSAGES + | GUILD_MESSAGE_REACTIONS + | DIRECT_MESSAGES + | DIRECT_MESSAGE_REACTIONS; + if intents.message_content { + bits |= MESSAGE_CONTENT; + } + if intents.guild_members { + bits |= GUILD_MEMBERS; + } + if intents.presence { + bits |= GUILD_PRESENCES; + } + bits +} + +fn resolve_message_content_intent_status(flags: u64) -> String { + const MESSAGE_CONTENT_ENABLED: u64 = 1 << 18; + const MESSAGE_CONTENT_LIMITED: u64 = 1 << 19; + if flags & MESSAGE_CONTENT_ENABLED != 0 { + "enabled".to_string() + } else if flags & MESSAGE_CONTENT_LIMITED != 0 { + "limited".to_string() + } else { + "disabled".to_string() + } +} + +async fn fetch_gateway_bot_info( + client: &reqwest::Client, + bot_token: &str, +) -> Result { + client + .get(format!("{DISCORD_API_BASE}/gateway/bot")) + .header("Authorization", format!("Bot {}", bot_token)) + .send() + .await + .map_err(|e| format!("调用 Discord gateway/bot 失败: {e}"))? + .error_for_status() + .map_err(|e| format!("Discord gateway/bot 返回错误: {e}"))? + .json::() + .await + .map_err(|e| format!("解析 Discord gateway/bot 失败: {e}")) +} + +async fn fetch_current_user( + client: &reqwest::Client, + bot_token: &str, +) -> Result { + client + .get(format!("{DISCORD_API_BASE}/users/@me")) + .header("Authorization", format!("Bot {}", bot_token)) + .send() + .await + .map_err(|e| format!("调用 Discord users/@me 失败: {e}"))? + .error_for_status() + .map_err(|e| format!("Discord users/@me 返回错误: {e}"))? + .json::() + .await + .map_err(|e| format!("解析 Discord users/@me 失败: {e}")) +} + +async fn fetch_application( + client: &reqwest::Client, + bot_token: &str, +) -> Result { + client + .get(format!("{DISCORD_API_BASE}/oauth2/applications/@me")) + .header("Authorization", format!("Bot {}", bot_token)) + .send() + .await + .map_err(|e| format!("调用 Discord oauth2/applications/@me 失败: {e}"))? + .error_for_status() + .map_err(|e| format!("Discord oauth2/applications/@me 返回错误: {e}"))? + .json::() + .await + .map_err(|e| format!("解析 Discord oauth2/applications/@me 失败: {e}")) +} + +fn normalize_allow_entry(raw: &str) -> String { + let mut value = raw + .trim() + .trim_start_matches("discord:") + .trim_start_matches("user:") + .to_ascii_lowercase(); + if value.starts_with("<@") && value.ends_with('>') { + value = value + .trim_start_matches("<@") + .trim_start_matches('!') + .trim_end_matches('>') + .to_string(); + } + value +} + +fn normalize_allow_set(input: &[String]) -> HashSet { + input + .iter() + .map(|item| normalize_allow_entry(item)) + .filter(|item| !item.is_empty()) + .collect::>() +} + +fn normalize_server_set(input: &[String]) -> HashSet { + input + .iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect::>() +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| item.to_string()) +} + +fn normalize_dm_policy(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "allowlist" => "allowlist".to_string(), + "open" => "open".to_string(), + "disabled" => "disabled".to_string(), + _ => "pairing".to_string(), + } +} + +fn normalize_group_policy(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "open" => "open".to_string(), + "disabled" => "disabled".to_string(), + _ => "allowlist".to_string(), + } +} + +fn normalize_streaming_mode(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "off" => "off".to_string(), + "block" => "block".to_string(), + _ => "partial".to_string(), + } +} + +fn normalize_reply_to_mode(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "first" => "first".to_string(), + "all" => "all".to_string(), + _ => "off".to_string(), + } +} + +fn parse_streaming_mode(raw: &str) -> StreamingMode { + match normalize_streaming_mode(raw).as_str() { + "off" => StreamingMode::Off, + "block" => StreamingMode::Block, + _ => StreamingMode::Partial, + } +} + +fn parse_reply_to_mode(raw: &str) -> ReplyToMode { + match normalize_reply_to_mode(raw).as_str() { + "first" => ReplyToMode::First, + "all" => ReplyToMode::All, + _ => ReplyToMode::Off, + } +} + +fn resolve_discord_accounts( + discord: &DiscordBotConfig, + account_filter: Option<&str>, +) -> Result, String> { + if !discord.enabled { + return Ok(Vec::new()); + } + + let mut resolved = Vec::new(); + if discord.accounts.is_empty() { + if discord.bot_token.trim().is_empty() { + return Ok(Vec::new()); + } + resolved.push(ResolvedDiscordAccount { + account_id: "default".to_string(), + bot_token: discord.bot_token.trim().to_string(), + default_model: normalize_optional_text(discord.default_model.as_deref()), + allowed_server_ids: normalize_server_set(&discord.allowed_server_ids), + dm_policy: normalize_dm_policy(&discord.dm_policy), + allow_from: normalize_allow_set(&discord.allow_from), + group_policy: normalize_group_policy(&discord.group_policy), + group_allow_from: normalize_allow_set(&discord.group_allow_from), + groups: discord.groups.clone(), + streaming: normalize_streaming_mode(&discord.streaming), + reply_to_mode: normalize_reply_to_mode(&discord.reply_to_mode), + intents: discord.intents.clone(), + actions: discord.actions.clone(), + thread_bindings: discord.thread_bindings.clone(), + auto_presence: discord.auto_presence.clone(), + voice: discord.voice.clone(), + agent_components: discord.agent_components.clone(), + ui: discord.ui.clone(), + exec_approvals: discord.exec_approvals.clone(), + response_prefix: normalize_optional_text(discord.response_prefix.as_deref()), + ack_reaction: normalize_optional_text(discord.ack_reaction.as_deref()), + }); + } else { + for (account_id, account) in &discord.accounts { + if !account.enabled { + continue; + } + let bot_token = resolve_account_bot_token(discord, account); + if bot_token.is_empty() { + continue; + } + + let allow_from = if account.allow_from.is_empty() { + normalize_allow_set(&discord.allow_from) + } else { + normalize_allow_set(&account.allow_from) + }; + let group_allow_from = if account.group_allow_from.is_empty() { + normalize_allow_set(&discord.group_allow_from) + } else { + normalize_allow_set(&account.group_allow_from) + }; + let groups = if account.groups.is_empty() { + discord.groups.clone() + } else { + account.groups.clone() + }; + let allowed_server_ids = if account.allowed_server_ids.is_empty() { + normalize_server_set(&discord.allowed_server_ids) + } else { + normalize_server_set(&account.allowed_server_ids) + }; + + resolved.push(ResolvedDiscordAccount { + account_id: account_id.to_string(), + bot_token, + default_model: normalize_optional_text( + account + .default_model + .as_deref() + .or(discord.default_model.as_deref()), + ), + allowed_server_ids, + dm_policy: normalize_dm_policy( + account + .dm_policy + .as_deref() + .unwrap_or(discord.dm_policy.as_str()), + ), + allow_from, + group_policy: normalize_group_policy( + account + .group_policy + .as_deref() + .unwrap_or(discord.group_policy.as_str()), + ), + group_allow_from, + groups, + streaming: normalize_streaming_mode( + account + .streaming + .as_deref() + .unwrap_or(discord.streaming.as_str()), + ), + reply_to_mode: normalize_reply_to_mode( + account + .reply_to_mode + .as_deref() + .unwrap_or(discord.reply_to_mode.as_str()), + ), + intents: account + .intents + .clone() + .unwrap_or_else(|| discord.intents.clone()), + actions: account + .actions + .clone() + .unwrap_or_else(|| discord.actions.clone()), + thread_bindings: account + .thread_bindings + .clone() + .unwrap_or_else(|| discord.thread_bindings.clone()), + auto_presence: account + .auto_presence + .clone() + .unwrap_or_else(|| discord.auto_presence.clone()), + voice: account + .voice + .clone() + .unwrap_or_else(|| discord.voice.clone()), + agent_components: account + .agent_components + .clone() + .unwrap_or_else(|| discord.agent_components.clone()), + ui: account.ui.clone().unwrap_or_else(|| discord.ui.clone()), + exec_approvals: account + .exec_approvals + .clone() + .unwrap_or_else(|| discord.exec_approvals.clone()), + response_prefix: normalize_optional_text( + account + .response_prefix + .as_deref() + .or(discord.response_prefix.as_deref()), + ), + ack_reaction: normalize_optional_text( + account + .ack_reaction + .as_deref() + .or(discord.ack_reaction.as_deref()), + ), + }); + } + + if resolved.is_empty() && !discord.bot_token.trim().is_empty() { + resolved.push(ResolvedDiscordAccount { + account_id: "default".to_string(), + bot_token: discord.bot_token.trim().to_string(), + default_model: normalize_optional_text(discord.default_model.as_deref()), + allowed_server_ids: normalize_server_set(&discord.allowed_server_ids), + dm_policy: normalize_dm_policy(&discord.dm_policy), + allow_from: normalize_allow_set(&discord.allow_from), + group_policy: normalize_group_policy(&discord.group_policy), + group_allow_from: normalize_allow_set(&discord.group_allow_from), + groups: discord.groups.clone(), + streaming: normalize_streaming_mode(&discord.streaming), + reply_to_mode: normalize_reply_to_mode(&discord.reply_to_mode), + intents: discord.intents.clone(), + actions: discord.actions.clone(), + thread_bindings: discord.thread_bindings.clone(), + auto_presence: discord.auto_presence.clone(), + voice: discord.voice.clone(), + agent_components: discord.agent_components.clone(), + ui: discord.ui.clone(), + exec_approvals: discord.exec_approvals.clone(), + response_prefix: normalize_optional_text(discord.response_prefix.as_deref()), + ack_reaction: normalize_optional_text(discord.ack_reaction.as_deref()), + }); + } + } + + if let Some(filter) = account_filter { + let selected = resolved + .into_iter() + .filter(|item| item.account_id == filter) + .collect::>(); + if selected.is_empty() { + return Err(format!("未找到 Discord 账号: {}", filter)); + } + return Ok(selected); + } + + Ok(resolved) +} + +fn resolve_account_bot_token(discord: &DiscordBotConfig, account: &DiscordAccountConfig) -> String { + account + .bot_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| discord.bot_token.trim()) + .to_string() +} + +fn build_session_key(account: &ResolvedDiscordAccount, inbound: &InboundMessage) -> String { + if inbound.guild_id.is_none() { + return format!( + "agent:main:discord:{}:direct:{}", + account.account_id, inbound.sender_id + ); + } + format!( + "agent:main:discord:{}:group:{}:{}", + account.account_id, + inbound.guild_id.as_deref().unwrap_or("unknown"), + inbound.channel_id + ) +} + +async fn resolve_active_session_id( + account: &ResolvedDiscordAccount, + inbound: &InboundMessage, + session_route_state: &SessionRouteState, +) -> String { + let scope_key = build_session_key(account, inbound); + let state = session_route_state.read().await; + state + .get(&scope_key) + .cloned() + .unwrap_or_else(|| scope_key.to_string()) +} + +async fn rotate_active_session_id( + account: &ResolvedDiscordAccount, + inbound: &InboundMessage, + session_route_state: &SessionRouteState, +) -> String { + let scope_key = build_session_key(account, inbound); + let suffix = Uuid::new_v4() + .to_string() + .chars() + .take(8) + .collect::(); + let rotated = format!("{scope_key}:new:{suffix}"); + let mut state = session_route_state.write().await; + state.insert(scope_key, rotated.clone()); + rotated +} + +fn authorize_message( + account: &ResolvedDiscordAccount, + inbound: &InboundMessage, +) -> Result<(), String> { + if inbound.guild_id.is_none() { + return authorize_dm(account, &inbound.sender_id); + } + authorize_group(account, inbound) +} + +fn authorize_dm(account: &ResolvedDiscordAccount, sender_id: &str) -> Result<(), String> { + let sender = normalize_allow_entry(sender_id); + if sender.is_empty() { + return Err("无法识别发送者 ID".to_string()); + } + match account.dm_policy.as_str() { + "disabled" => Err("DM 已禁用".to_string()), + "open" => { + if account.allow_from.contains("*") { + Ok(()) + } else { + Err("dmPolicy=open 但 allow_from 缺少 '*'".to_string()) + } + } + "allowlist" => { + if account.allow_from.contains(&sender) || account.allow_from.contains("*") { + Ok(()) + } else { + Err(format!("发送者 {} 不在 DM allow_from", sender)) + } + } + _ => { + if account.allow_from.is_empty() { + Err("dmPolicy=pairing 但未配置 allow_from(当前为 fail-closed)".to_string()) + } else if account.allow_from.contains(&sender) || account.allow_from.contains("*") { + Ok(()) + } else { + Err(format!("发送者 {} 未通过 pairing allow_from", sender)) + } + } + } +} + +fn authorize_group( + account: &ResolvedDiscordAccount, + inbound: &InboundMessage, +) -> Result<(), String> { + let guild_id = inbound + .guild_id + .as_deref() + .ok_or_else(|| "缺少 guild_id".to_string())?; + if !account.allowed_server_ids.is_empty() && !account.allowed_server_ids.contains(guild_id) { + return Err(format!("服务器 {} 不在 allowed_server_ids 中", guild_id)); + } + + let sender = normalize_allow_entry(&inbound.sender_id); + if sender.is_empty() { + return Err("无法识别群组发送者 ID".to_string()); + } + + let group_cfg = account + .groups + .get(guild_id) + .or_else(|| account.groups.get("*")); + let effective_group_policy = group_cfg + .and_then(|group| group.group_policy.as_deref()) + .map(normalize_group_policy) + .unwrap_or_else(|| account.group_policy.clone()); + let group_allowed = match group_cfg { + Some(group) => group.enabled.unwrap_or(true), + None => effective_group_policy == "open", + }; + if !group_allowed { + return Err(format!("服务器 {} 未加入 allowlist", guild_id)); + } + + let channel_cfg = group_cfg + .and_then(|group| group.channels.get(&inbound.channel_id)) + .or_else(|| group_cfg.and_then(|group| group.channels.get("*"))); + let effective_channel_policy = channel_cfg + .and_then(|channel| channel.group_policy.as_deref()) + .map(normalize_group_policy) + .unwrap_or_else(|| effective_group_policy.clone()); + if effective_channel_policy == "disabled" { + return Err("频道消息已禁用".to_string()); + } + + let require_mention = channel_cfg + .and_then(|channel| channel.require_mention) + .or_else(|| group_cfg.and_then(|group| group.require_mention)) + .unwrap_or(true); + if require_mention && !is_slash_command(&inbound.text) && !inbound.mentioned_bot { + return Err("群组消息需要 mention 触发".to_string()); + } + + match effective_channel_policy.as_str() { + "open" => Ok(()), + "disabled" => Err("群组消息已禁用".to_string()), + _ => { + let allow_from = if let Some(channel) = channel_cfg { + if !channel.allow_from.is_empty() { + normalize_allow_set(&channel.allow_from) + } else if let Some(group) = group_cfg { + if !group.allow_from.is_empty() { + normalize_allow_set(&group.allow_from) + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + } + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + } + } else if let Some(group) = group_cfg { + if !group.allow_from.is_empty() { + normalize_allow_set(&group.allow_from) + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + } + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + }; + if allow_from.contains(&sender) || allow_from.contains("*") { + Ok(()) + } else { + Err(format!("发送者 {} 不在群组 allow_from", sender)) + } + } + } +} + +fn is_slash_command(text: &str) -> bool { + text.trim_start().starts_with('/') +} + +async fn handle_plain_text( + account: &ResolvedDiscordAccount, + rpc_handler: &RpcHandler, + logs: &LogState, + session_route_state: &SessionRouteState, + inbound: &InboundMessage, + text: String, + session_id_override: Option, +) -> Result { + let session_id = match session_id_override { + Some(value) => value, + None => resolve_active_session_id(account, inbound, session_route_state).await, + }; + logs.write().await.add( + "info", + &format!( + "[DiscordGateway] account={} 收到文本消息: guild={:?} channel={} sender={} messageId={} session={} model={} web_search=true", + account.account_id, + inbound.guild_id, + inbound.channel_id, + inbound.sender_id, + inbound.message_id, + session_id, + account.default_model.as_deref().unwrap_or("") + ), + ); + + let run_response = rpc_handler + .handle_request(GatewayRpcRequest { + jsonrpc: "2.0".to_string(), + id: Uuid::new_v4().to_string(), + method: RpcMethod::AgentRun, + params: Some(json!({ + "session_id": session_id, + "message": text, + "stream": false, + "model": account.default_model.clone(), + "web_search": true, + })), + }) + .await; + let run_value = run_response + .result + .ok_or_else(|| extract_rpc_error(run_response.error, "agent.run 失败"))?; + let run_result: AgentRunResult = parse_result(run_value)?; + + for round in 0..RUN_WAIT_MAX_ROUNDS { + let wait_response = rpc_handler + .handle_request(GatewayRpcRequest { + jsonrpc: "2.0".to_string(), + id: Uuid::new_v4().to_string(), + method: RpcMethod::AgentWait, + params: Some(json!({ + "run_id": run_result.run_id, + "timeout": RUN_WAIT_TIMEOUT_MS, + })), + }) + .await; + let wait_value = wait_response + .result + .ok_or_else(|| extract_rpc_error(wait_response.error, "agent.wait 失败"))?; + let wait_result: AgentWaitResult = parse_result(wait_value)?; + if wait_result.completed { + let content = wait_result + .content + .unwrap_or_else(|| "任务已完成,但无可展示输出".to_string()); + return Ok(PlainTextReply::Message(content)); + } + if round % 3 == 0 { + let _ = send_typing_indicator(account, inbound.channel_id.as_str()).await; + } + } + + Err("等待任务完成超时,请稍后使用 /status 查询".to_string()) +} + +async fn handle_command( + context: &DiscordRuntimeContext, + inbound: &InboundMessage, + command: DiscordCommand, +) -> Result, String> { + match command { + DiscordCommand::Help => Ok(Some(help_text())), + DiscordCommand::Cancel => { + *context.pending_confirmation.write().await = None; + Ok(Some("🧹 已取消待确认操作".to_string())) + } + DiscordCommand::Confirm(token) => { + let mut pending_guard = context.pending_confirmation.write().await; + let confirmed = take_pending_confirmation(&mut pending_guard, &token)?; + drop(pending_guard); + dispatch_command(context.rpc_handler.as_ref(), confirmed) + .await + .map(Some) + } + cmd if requires_confirmation(&cmd) => { + let mut pending_guard = context.pending_confirmation.write().await; + let token = set_pending_confirmation(&mut pending_guard, cmd.clone()); + Ok(Some(format!( + "⚠️ 检测到危险操作:{}\n请在 {} 秒内发送 /confirm {} 继续,或发送 /cancel 取消。", + danger_command_label(&cmd), + CONFIRMATION_TTL_SECS, + token + ))) + } + DiscordCommand::Run(prompt) => { + match handle_plain_text( + &context.account, + context.rpc_handler.as_ref(), + &context.logs, + &context.session_route_state, + inbound, + prompt, + None, + ) + .await? + { + PlainTextReply::Message(text) => Ok(Some(text)), + } + } + DiscordCommand::New(first_prompt) => { + let new_session_id = + rotate_active_session_id(&context.account, inbound, &context.session_route_state) + .await; + if let Some(prompt) = first_prompt { + match handle_plain_text( + &context.account, + context.rpc_handler.as_ref(), + &context.logs, + &context.session_route_state, + inbound, + prompt, + Some(new_session_id.clone()), + ) + .await? + { + PlainTextReply::Message(text) => Ok(Some(text)), + } + } else { + Ok(Some(format!( + "🆕 已开启新对话\nsession_id: {}\n后续消息会在这个新会话中进行。", + new_session_id + ))) + } + } + cmd => dispatch_command(context.rpc_handler.as_ref(), cmd) + .await + .map(Some), + } +} + +fn parse_discord_command(text: &str) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err(help_text()); + } + let mut parts = trimmed.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or_default().to_ascii_lowercase(); + let rest = parts.next().unwrap_or_default().trim(); + match first.as_str() { + "/run" => { + if rest.is_empty() { + Err("❌ 用法:/run <任务内容>".to_string()) + } else { + Ok(DiscordCommand::Run(rest.to_string())) + } + } + "/new" | "/reset" => { + if rest.is_empty() { + Ok(DiscordCommand::New(None)) + } else { + Ok(DiscordCommand::New(Some(rest.to_string()))) + } + } + "/status" => { + if rest.is_empty() { + Err("❌ 用法:/status ".to_string()) + } else { + Ok(DiscordCommand::Status(rest.to_string())) + } + } + "/stop" => { + if rest.is_empty() { + Err("❌ 用法:/stop ".to_string()) + } else { + Ok(DiscordCommand::Stop(rest.to_string())) + } + } + "/cron_list" => Ok(DiscordCommand::CronList), + "/cron_health" => Ok(DiscordCommand::CronHealth), + "/cron_run" => { + if rest.is_empty() { + Err("❌ 用法:/cron_run ".to_string()) + } else { + Ok(DiscordCommand::CronRun(rest.to_string())) + } + } + "/sessions" => Ok(DiscordCommand::Sessions), + "/session" => { + if rest.is_empty() { + Err("❌ 用法:/session ".to_string()) + } else { + Ok(DiscordCommand::Session(rest.to_string())) + } + } + "/confirm" => { + if rest.is_empty() { + Err("❌ 用法:/confirm ".to_string()) + } else { + Ok(DiscordCommand::Confirm(rest.to_string())) + } + } + "/cancel" => Ok(DiscordCommand::Cancel), + "/help" | "/start" => Ok(DiscordCommand::Help), + _ => Err(help_text()), + } +} + +fn requires_confirmation(command: &DiscordCommand) -> bool { + matches!( + command, + DiscordCommand::Stop(_) | DiscordCommand::CronRun(_) + ) +} + +fn danger_command_label(command: &DiscordCommand) -> &'static str { + match command { + DiscordCommand::Stop(_) => "/stop", + DiscordCommand::CronRun(_) => "/cron_run", + _ => "unknown", + } +} + +fn help_text() -> String { + [ + "🤖 ProxyCast Discord Gateway 命令", + "/run <任务内容> - 启动一个 Agent 任务", + "/new [首条消息] - 开启新对话(/reset 同义)", + "/status - 查看任务状态", + "/stop - 停止任务(需确认)", + "/cron_list - 列出定时任务", + "/cron_health - 查看定时任务健康概览", + "/cron_run - 触发定时任务(需确认)", + "/sessions - 列出会话", + "/session - 查看会话摘要", + "/confirm - 确认危险操作", + "/cancel - 取消待确认操作", + "/help - 查看帮助", + ] + .join("\n") +} + +fn set_pending_confirmation( + pending_confirmation: &mut Option, + command: DiscordCommand, +) -> String { + let token = Uuid::new_v4() + .to_string() + .chars() + .take(8) + .collect::(); + let expires_at = Utc::now() + chrono::Duration::seconds(CONFIRMATION_TTL_SECS); + *pending_confirmation = Some(PendingConfirmation { + token: token.clone(), + command, + expires_at, + }); + token +} + +fn take_pending_confirmation( + pending_confirmation: &mut Option, + token: &str, +) -> Result { + let pending = pending_confirmation + .take() + .ok_or_else(|| "当前没有待确认操作".to_string())?; + if Utc::now() > pending.expires_at { + return Err("确认已过期,请重新发起命令".to_string()); + } + if pending.token != token { + *pending_confirmation = Some(pending); + return Err("确认 token 不匹配".to_string()); + } + if !requires_confirmation(&pending.command) { + return Err("当前命令不需要确认".to_string()); + } + Ok(pending.command) +} + +async fn dispatch_command( + rpc_handler: &RpcHandler, + command: DiscordCommand, +) -> Result { + let request = build_rpc_request(command)?; + let response = rpc_handler.handle_request(request).await; + format_rpc_response(response) +} + +fn build_rpc_request(command: DiscordCommand) -> Result { + let (method, params) = match command { + DiscordCommand::Run(message) => ( + RpcMethod::AgentRun, + Some(json!({ + "message": message, + "stream": false, + "web_search": true + })), + ), + DiscordCommand::Status(run_id) => ( + RpcMethod::AgentWait, + Some(json!({ "run_id": run_id, "timeout": 200 })), + ), + DiscordCommand::Stop(run_id) => (RpcMethod::AgentStop, Some(json!({ "run_id": run_id }))), + DiscordCommand::CronList => (RpcMethod::CronList, None), + DiscordCommand::CronHealth => (RpcMethod::CronHealth, None), + DiscordCommand::CronRun(task_id) => { + (RpcMethod::CronRun, Some(json!({ "task_id": task_id }))) + } + DiscordCommand::Sessions => (RpcMethod::SessionsList, None), + DiscordCommand::Session(session_id) => ( + RpcMethod::SessionsGet, + Some(json!({ "session_id": session_id })), + ), + DiscordCommand::New(_) => return Err("内部错误:new 不应构造 RPC 请求".to_string()), + DiscordCommand::Help => return Err("内部错误:help 不应构造 RPC 请求".to_string()), + DiscordCommand::Confirm(_) => return Err("内部错误:confirm 不应构造 RPC 请求".to_string()), + DiscordCommand::Cancel => return Err("内部错误:cancel 不应构造 RPC 请求".to_string()), + }; + + Ok(GatewayRpcRequest { + jsonrpc: "2.0".to_string(), + id: Uuid::new_v4().to_string(), + method, + params, + }) +} + +enum ResponseHint { + AgentRun, + AgentWait, + AgentStop, + CronList, + CronRun, + CronHealth, + SessionsList, + SessionGet, +} + +fn response_id_hint(value: &serde_json::Value) -> Option { + if value.get("runId").is_some() && value.get("sessionId").is_some() { + return Some(ResponseHint::AgentRun); + } + if value.get("runId").is_some() && value.get("completed").is_some() { + return Some(ResponseHint::AgentWait); + } + if value.get("runId").is_some() && value.get("stopped").is_some() { + return Some(ResponseHint::AgentStop); + } + if value.get("tasks").is_some() { + return Some(ResponseHint::CronList); + } + if value.get("taskId").is_some() && value.get("executionId").is_some() { + return Some(ResponseHint::CronRun); + } + if value.get("totalTasks").is_some() && value.get("cooldownTasks").is_some() { + return Some(ResponseHint::CronHealth); + } + if value.get("sessions").is_some() { + return Some(ResponseHint::SessionsList); + } + if value.get("sessionId").is_some() && value.get("messageCount").is_some() { + return Some(ResponseHint::SessionGet); + } + None +} + +fn format_rpc_response(response: GatewayRpcResponse) -> Result { + if let Some(error) = response.error { + return Err(format!("{} (code={})", error.message, error.code)); + } + let result_value = response + .result + .ok_or_else(|| "RPC 返回缺少 result".to_string())?; + match response_id_hint(&result_value) { + Some(ResponseHint::AgentRun) => { + let payload: AgentRunResult = parse_result(result_value)?; + Ok(format!( + "✅ 已启动\nrun_id: {}\nsession_id: {}\ncompleted: {}", + payload.run_id, payload.session_id, payload.completed + )) + } + Some(ResponseHint::AgentWait) => { + let payload: AgentWaitResult = parse_result(result_value)?; + if payload.completed { + Ok(format!( + "✅ 已完成\nrun_id: {}\n{}", + payload.run_id, + payload.content.unwrap_or_else(|| "无输出内容".to_string()) + )) + } else { + Ok(format!("⏳ 运行中\nrun_id: {}", payload.run_id)) + } + } + Some(ResponseHint::AgentStop) => { + let payload: AgentStopResult = parse_result(result_value)?; + Ok(format!( + "{} run_id: {}", + if payload.stopped { + "🛑 已停止" + } else { + "ℹ️ 未找到活跃任务" + }, + payload.run_id + )) + } + Some(ResponseHint::CronList) => { + let payload: CronListResult = parse_result(result_value)?; + if payload.tasks.is_empty() { + Ok("📭 当前无定时任务".to_string()) + } else { + let lines = payload + .tasks + .iter() + .take(10) + .map(|item| { + format!( + "- {} | {} | enabled={}", + item.task_id, item.name, item.enabled + ) + }) + .collect::>(); + Ok(format!( + "📌 定时任务(前 {} 条)\n{}", + lines.len(), + lines.join("\n") + )) + } + } + Some(ResponseHint::CronRun) => { + let payload: CronRunResult = parse_result(result_value)?; + Ok(format!( + "✅ cron 已触发\ntask_id: {}\nexecution_id: {}", + payload.task_id, payload.execution_id + )) + } + Some(ResponseHint::CronHealth) => { + let payload: CronHealthResult = parse_result(result_value)?; + Ok(format!( + "📊 cron 健康概览\n总任务: {}\n待执行: {}\n运行中: {}\n失败: {}\n冷却中: {}\n悬挂运行: {}\n24h 失败: {}", + payload.total_tasks, + payload.pending_tasks, + payload.running_tasks, + payload.failed_tasks, + payload.cooldown_tasks, + payload.stale_running_tasks, + payload.failed_last_24h, + )) + } + Some(ResponseHint::SessionsList) => { + let payload: SessionsListResult = parse_result(result_value)?; + if payload.sessions.is_empty() { + Ok("📭 当前无会话".to_string()) + } else { + let lines = payload + .sessions + .iter() + .take(10) + .map(|item| { + format!( + "- {} | model={} | msgs={}", + item.session_id, item.model, item.message_count + ) + }) + .collect::>(); + Ok(format!( + "🧵 会话列表(前 {} 条)\n{}", + lines.len(), + lines.join("\n") + )) + } + } + Some(ResponseHint::SessionGet) => { + let payload: SessionGetResult = parse_result(result_value)?; + Ok(format!( + "🧵 会话详情\nsession_id: {}\nmodel: {}\nmessages: {}", + payload.session_id, payload.model, payload.message_count + )) + } + None => Ok(format!("✅ 已处理\n{}", result_value)), + } +} + +fn parse_result(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(|e| format!("解析 RPC 结果失败: {e}")) +} + +fn extract_rpc_error( + error: Option, + fallback: &str, +) -> String { + if let Some(err) = error { + return format!("{} (code={})", err.message, err.code); + } + fallback.to_string() +} + +fn split_message_chunks(text: &str) -> Vec { + let total_chars = text.chars().count(); + if total_chars <= DISCORD_MAX_MESSAGE_LEN { + return vec![text.to_string()]; + } + let chars: Vec = text.chars().collect(); + let mut chunks = Vec::new(); + let mut start = 0usize; + while start < chars.len() { + let end = (start + DISCORD_MAX_MESSAGE_LEN).min(chars.len()); + chunks.push(chars[start..end].iter().collect::()); + start = end; + } + chunks +} + +async fn send_discord_message( + client: &reqwest::Client, + token: &str, + channel_id: &str, + text: &str, + reply_to_message_id: Option<&str>, +) -> Result<(), String> { + let mut payload = json!({ + "content": text, + "allowed_mentions": { + "parse": [] + } + }); + if let Some(reply_id) = reply_to_message_id { + payload["message_reference"] = json!({ + "message_id": reply_id, + "fail_if_not_exists": false + }); + } + client + .post(format!( + "{DISCORD_API_BASE}/channels/{}/messages", + urlencoding::encode(channel_id) + )) + .header("Authorization", format!("Bot {}", token)) + .json(&payload) + .send() + .await + .map_err(|e| format!("发送 Discord 消息失败: {e}"))? + .error_for_status() + .map_err(|e| format!("Discord 发送消息返回错误: {e}"))?; + Ok(()) +} + +async fn send_text_chunks( + client: &reqwest::Client, + account: &ResolvedDiscordAccount, + channel_id: &str, + inbound_message_id: &str, + text: &str, + reply_to_mode: ReplyToMode, +) -> Result<(), String> { + let chunks = split_message_chunks(text); + for (index, chunk) in chunks.iter().enumerate() { + let reply_ref = match reply_to_mode { + ReplyToMode::Off => None, + ReplyToMode::First => { + if index == 0 && !inbound_message_id.trim().is_empty() { + Some(inbound_message_id) + } else { + None + } + } + ReplyToMode::All => { + if inbound_message_id.trim().is_empty() { + None + } else { + Some(inbound_message_id) + } + } + }; + send_discord_message(client, &account.bot_token, channel_id, chunk, reply_ref).await?; + } + Ok(()) +} + +async fn send_typing_indicator( + account: &ResolvedDiscordAccount, + channel_id: &str, +) -> Result<(), String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|e| format!("创建 typing client 失败: {e}"))?; + let _ = client + .post(format!( + "{DISCORD_API_BASE}/channels/{}/typing", + urlencoding::encode(channel_id) + )) + .header("Authorization", format!("Bot {}", account.bot_token)) + .send() + .await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_account( + dm_policy: &str, + allow_from: &[&str], + group_policy: &str, + ) -> ResolvedDiscordAccount { + let mut allow = HashSet::new(); + for item in allow_from { + allow.insert((*item).to_string()); + } + ResolvedDiscordAccount { + account_id: "default".to_string(), + bot_token: "token".to_string(), + default_model: None, + allowed_server_ids: HashSet::new(), + dm_policy: dm_policy.to_string(), + allow_from: allow, + group_policy: group_policy.to_string(), + group_allow_from: HashSet::new(), + groups: HashMap::new(), + streaming: "partial".to_string(), + reply_to_mode: "off".to_string(), + intents: DiscordIntentsConfig::default(), + actions: DiscordActionsConfig::default(), + thread_bindings: DiscordThreadBindingsConfig::default(), + auto_presence: DiscordAutoPresenceConfig::default(), + voice: DiscordVoiceConfig::default(), + agent_components: DiscordAgentComponentsConfig::default(), + ui: DiscordUiConfig::default(), + exec_approvals: DiscordExecApprovalsConfig::default(), + response_prefix: None, + ack_reaction: None, + } + } + + #[test] + fn parse_command_supports_new() { + assert!(matches!( + parse_discord_command("/new").expect("should parse"), + DiscordCommand::New(None) + )); + assert!(matches!( + parse_discord_command("/reset hi").expect("should parse"), + DiscordCommand::New(Some(_)) + )); + } + + #[test] + fn authorize_message_dm_pairing_fail_closed_without_allow_from() { + let account = build_account("pairing", &[], "allowlist"); + let inbound = InboundMessage { + message_id: "m1".to_string(), + channel_id: "c1".to_string(), + guild_id: None, + sender_id: "123".to_string(), + text: "你好".to_string(), + mentioned_bot: false, + }; + let err = authorize_message(&account, &inbound).expect_err("should reject"); + assert!(err.contains("fail-closed")); + } + + #[test] + fn authorize_message_group_requires_mention_by_default() { + let mut account = build_account("open", &["*"], "open"); + account.groups.insert( + "g1".to_string(), + DiscordGuildConfig { + enabled: Some(true), + require_mention: Some(true), + group_policy: Some("open".to_string()), + allow_from: vec!["*".to_string()], + channels: HashMap::new(), + }, + ); + let inbound = InboundMessage { + message_id: "m2".to_string(), + channel_id: "c2".to_string(), + guild_id: Some("g1".to_string()), + sender_id: "123".to_string(), + text: "普通消息".to_string(), + mentioned_bot: false, + }; + let err = authorize_message(&account, &inbound).expect_err("should reject"); + assert!(err.contains("mention")); + } +} diff --git a/src-tauri/crates/gateway/src/feishu.rs b/src-tauri/crates/gateway/src/feishu.rs new file mode 100644 index 000000000..616896bf6 --- /dev/null +++ b/src-tauri/crates/gateway/src/feishu.rs @@ -0,0 +1,2400 @@ +//! Feishu Gateway 运行时 +//! +//! 目标:将 Feishu 作为标准渠道接入全局 Gateway。 +//! 当前版本已支持: +//! - 多账号启动/停止/状态/探测 +//! - Webhook 入站消息 -> RPC agent 执行 -> Feishu 回包 +//! - 会话路由、/new 会话旋转、核心命令 +//! - websocket 模式配置(当前为占位运行,后续替换为真实长连接实现) + +use axum::body::Bytes; +use axum::extract::{OriginalUri, State}; +use axum::http::{Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::any; +use axum::{Json, Router}; +use chrono::Utc; +use proxycast_core::config::{Config, FeishuBotConfig, FeishuGroupConfig}; +use proxycast_core::database::DbConnection; +use proxycast_core::logger::LogStore; +use proxycast_websocket::handlers::{RpcHandler, RpcHandlerState}; +use proxycast_websocket::protocol::{ + AgentRunResult, AgentStopResult, AgentWaitResult, CronHealthResult, CronListResult, + CronRunResult, GatewayRpcRequest, GatewayRpcResponse, RpcMethod, SessionGetResult, + SessionsListResult, +}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const FEISHU_MAX_MESSAGE_LEN: usize = 1800; +const RUN_WAIT_TIMEOUT_MS: u64 = 1200; +const RUN_WAIT_MAX_ROUNDS: usize = 180; +const CONFIRMATION_TTL_SECS: i64 = 90; + +type LogState = Arc>; +type SessionRouteState = Arc>>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StreamingMode { + Off, + Partial, + Block, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReplyToMode { + Off, + First, + All, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PlainTextReply { + Message(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FeishuGatewayAccountStatus { + pub account_id: String, + pub running: bool, + pub connection_mode: String, + pub started_at: Option, + pub last_error: Option, + pub last_event_at: Option, + pub last_message_at: Option, + pub webhook_endpoint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FeishuGatewayStatus { + pub running_accounts: usize, + pub accounts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeishuProbeResult { + pub account_id: String, + pub ok: bool, + pub app_id: Option, + pub message: String, +} + +pub struct FeishuGatewayState { + inner: Arc>, +} + +struct FeishuGatewayRuntime { + accounts: HashMap, +} + +struct AccountRuntimeHandle { + stop_token: CancellationToken, + task: JoinHandle<()>, + status: Arc>, +} + +impl Default for FeishuGatewayState { + fn default() -> Self { + Self { + inner: Arc::new(RwLock::new(FeishuGatewayRuntime { + accounts: HashMap::new(), + })), + } + } +} + +#[derive(Debug, Clone)] +struct ResolvedFeishuAccount { + account_id: String, + app_id: String, + app_secret: String, + verification_token: Option, + encrypt_key: Option, + default_model: Option, + domain: String, + connection_mode: String, + webhook_host: String, + webhook_port: u16, + webhook_path: String, + dm_policy: String, + allow_from: HashSet, + group_policy: String, + group_allow_from: HashSet, + groups: HashMap, + streaming: String, + reply_to_mode: String, +} + +#[derive(Debug, Clone)] +struct InboundMessage { + message_id: String, + chat_id: String, + chat_kind: String, + sender_id: Option, + text: String, + raw_content: Option, +} + +#[derive(Debug, Clone)] +struct ReceiveTarget { + receive_id_type: &'static str, + receive_id: String, +} + +#[derive(Debug, Clone)] +struct PendingConfirmation { + token: String, + command: FeishuCommand, + expires_at: chrono::DateTime, +} + +#[derive(Debug, Clone)] +enum FeishuCommand { + Run(String), + New(Option), + Status(String), + Stop(String), + CronList, + CronHealth, + CronRun(String), + Sessions, + Session(String), + Confirm(String), + Cancel, + Help, +} + +#[derive(Debug, Clone)] +struct TenantTokenCacheEntry { + token: String, + expires_at_epoch: i64, +} + +type TokenCacheState = Arc>>; + +#[derive(Clone)] +struct WebhookContext { + account: ResolvedFeishuAccount, + client: reqwest::Client, + rpc_handler: Arc, + logs: LogState, + session_route_state: SessionRouteState, + token_cache: TokenCacheState, + status: Arc>, + pending_confirmation: Arc>>, + streaming_mode: StreamingMode, + reply_to_mode: ReplyToMode, +} + +pub async fn start_gateway( + state: &FeishuGatewayState, + db: DbConnection, + logs: LogState, + config: Config, + account_filter: Option, + _poll_timeout_secs: Option, +) -> Result { + let state = state.inner.clone(); + let accounts = resolve_feishu_accounts(&config.channels.feishu, account_filter.as_deref())?; + if accounts.is_empty() { + return Err("没有可启动的 Feishu 账号,请检查 channels.feishu 配置".to_string()); + } + + validate_webhook_bind_conflicts(&accounts)?; + + for account in accounts { + let existing = { + let runtime = state.read().await; + runtime.accounts.contains_key(&account.account_id) + }; + if existing { + continue; + } + + let status = Arc::new(RwLock::new(FeishuGatewayAccountStatus { + account_id: account.account_id.clone(), + running: true, + connection_mode: account.connection_mode.clone(), + started_at: Some(Utc::now().to_rfc3339()), + last_error: None, + last_event_at: None, + last_message_at: None, + webhook_endpoint: None, + })); + let status_for_task = status.clone(); + let stop_token = CancellationToken::new(); + let stop_for_task = stop_token.clone(); + let account_for_task = account.clone(); + let state_for_task = state.clone(); + let db_for_task = db.clone(); + let logs_for_task = logs.clone(); + + let task = tokio::spawn(async move { + run_account_loop( + state_for_task, + status_for_task, + db_for_task, + logs_for_task, + account_for_task, + stop_for_task, + ) + .await; + }); + + let mut runtime = state.write().await; + runtime.accounts.insert( + account.account_id.clone(), + AccountRuntimeHandle { + stop_token, + task, + status, + }, + ); + } + + snapshot_status(state).await +} + +pub async fn stop_gateway( + state: &FeishuGatewayState, + account_filter: Option, +) -> Result { + let state = state.inner.clone(); + let mut handles = Vec::new(); + { + let mut runtime = state.write().await; + if let Some(account_id) = account_filter { + if let Some(handle) = runtime.accounts.remove(&account_id) { + handles.push(handle); + } + } else { + handles = runtime.accounts.drain().map(|(_, handle)| handle).collect(); + } + } + + for handle in &handles { + handle.stop_token.cancel(); + } + for handle in handles { + let _ = tokio::time::timeout(std::time::Duration::from_secs(3), handle.task).await; + } + + snapshot_status(state).await +} + +pub async fn status_gateway(state: &FeishuGatewayState) -> Result { + snapshot_status(state.inner.clone()).await +} + +pub async fn probe_gateway_account( + config: &Config, + account_filter: Option, +) -> Result { + let accounts = resolve_feishu_accounts(&config.channels.feishu, account_filter.as_deref())?; + if accounts.is_empty() { + return Err("未找到可用 Feishu 账号".to_string()); + } + let account = if let Some(filter) = account_filter { + accounts + .into_iter() + .find(|item| item.account_id == filter) + .ok_or_else(|| format!("未找到 Feishu 账号: {filter}"))? + } else { + accounts[0].clone() + }; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let cache: TokenCacheState = Arc::new(RwLock::new(None)); + + match get_tenant_access_token(&client, &account, &cache).await { + Ok(_) => Ok(FeishuProbeResult { + account_id: account.account_id, + ok: true, + app_id: Some(account.app_id), + message: "Feishu 凭证可用".to_string(), + }), + Err(error) => Ok(FeishuProbeResult { + account_id: account.account_id, + ok: false, + app_id: Some(account.app_id), + message: error, + }), + } +} + +async fn snapshot_status( + state: Arc>, +) -> Result { + let handles = { + let runtime = state.read().await; + runtime + .accounts + .values() + .map(|handle| handle.status.clone()) + .collect::>() + }; + let mut accounts = Vec::with_capacity(handles.len()); + for status in handles { + accounts.push(status.read().await.clone()); + } + accounts.sort_by(|a, b| a.account_id.cmp(&b.account_id)); + Ok(FeishuGatewayStatus { + running_accounts: accounts.iter().filter(|item| item.running).count(), + accounts, + }) +} + +fn validate_webhook_bind_conflicts(accounts: &[ResolvedFeishuAccount]) -> Result<(), String> { + let mut seen = HashSet::new(); + for account in accounts { + if normalize_connection_mode(&account.connection_mode) != "webhook" { + continue; + } + let key = format!( + "{}:{}:{}", + account.webhook_host, + account.webhook_port, + normalize_webhook_path(&account.webhook_path) + ); + if !seen.insert(key.clone()) { + return Err(format!( + "Feishu webhook 监听冲突(host:port:path 重复): {}", + key + )); + } + } + Ok(()) +} + +async fn run_account_loop( + runtime_state: Arc>, + status: Arc>, + db: DbConnection, + logs: LogState, + account: ResolvedFeishuAccount, + stop_token: CancellationToken, +) { + let streaming_mode = parse_streaming_mode(&account.streaming); + let reply_to_mode = parse_reply_to_mode(&account.reply_to_mode); + + logs.write().await.add( + "info", + &format!( + "[FeishuGateway] 启动账号: account={} mode={} streaming={} replyToMode={}", + account.account_id, account.connection_mode, account.streaming, account.reply_to_mode + ), + ); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + let rpc_state = RpcHandlerState::new(Some(db), None, logs.clone()); + let rpc_handler = Arc::new(RpcHandler::new(rpc_state)); + let session_route_state: SessionRouteState = Arc::new(RwLock::new(HashMap::new())); + let token_cache: TokenCacheState = Arc::new(RwLock::new(None)); + let pending_confirmation: Arc>> = + Arc::new(RwLock::new(None)); + + let run_result = if normalize_connection_mode(&account.connection_mode) == "webhook" { + run_webhook_server( + WebhookContext { + account: account.clone(), + client: client.clone(), + rpc_handler: rpc_handler.clone(), + logs: logs.clone(), + session_route_state: session_route_state.clone(), + token_cache: token_cache.clone(), + status: status.clone(), + pending_confirmation: pending_confirmation.clone(), + streaming_mode, + reply_to_mode, + }, + stop_token.clone(), + ) + .await + } else { + run_websocket_placeholder(&account, &logs, &status, stop_token.clone()).await + }; + + if let Err(error) = run_result { + status.write().await.last_error = Some(error.clone()); + logs.write().await.add( + "warn", + &format!( + "[FeishuGateway] account={} 运行失败: {}", + account.account_id, error + ), + ); + } + + { + let mut s = status.write().await; + s.running = false; + } + { + let mut runtime = runtime_state.write().await; + runtime.accounts.remove(&account.account_id); + } + logs.write().await.add( + "info", + &format!("[FeishuGateway] 账号已停止: account={}", account.account_id), + ); +} + +async fn run_websocket_placeholder( + account: &ResolvedFeishuAccount, + logs: &LogState, + status: &Arc>, + stop_token: CancellationToken, +) -> Result<(), String> { + let message = "websocket 模式占位已启动(后续将替换为 Feishu 真实长连接实现)"; + status.write().await.last_error = Some(message.to_string()); + logs.write().await.add( + "warn", + &format!("[FeishuGateway] account={} {}", account.account_id, message), + ); + + while !stop_token.is_cancelled() { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + Ok(()) +} + +async fn run_webhook_server( + context: WebhookContext, + stop_token: CancellationToken, +) -> Result<(), String> { + let endpoint = format!( + "http://{}:{}{}", + context.account.webhook_host, + context.account.webhook_port, + normalize_webhook_path(&context.account.webhook_path) + ); + { + let mut status = context.status.write().await; + status.webhook_endpoint = Some(endpoint.clone()); + } + context.logs.write().await.add( + "info", + &format!( + "[FeishuGateway] account={} webhook 监听: {}", + context.account.account_id, endpoint + ), + ); + + let listener = tokio::net::TcpListener::bind(format!( + "{}:{}", + context.account.webhook_host, context.account.webhook_port + )) + .await + .map_err(|e| format!("绑定 webhook 监听失败: {e}"))?; + + let app = Router::new() + .route("/*path", any(feishu_webhook_handler)) + .with_state(Arc::new(context)); + + axum::serve(listener, app.into_make_service()) + .with_graceful_shutdown(async move { + stop_token.cancelled().await; + }) + .await + .map_err(|e| format!("webhook 服务异常退出: {e}")) +} + +async fn feishu_webhook_handler( + State(context): State>, + method: Method, + OriginalUri(uri): OriginalUri, + body: Bytes, +) -> Response { + let expected_path = normalize_webhook_path(&context.account.webhook_path); + if uri.path() != expected_path { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + if method != Method::POST && method != Method::GET { + return (StatusCode::METHOD_NOT_ALLOWED, "method not allowed").into_response(); + } + if body.len() > 2 * 1024 * 1024 { + return (StatusCode::PAYLOAD_TOO_LARGE, "payload too large").into_response(); + } + + let payload: serde_json::Value = if body.is_empty() { + json!({}) + } else { + match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => { + context.logs.write().await.add( + "warn", + &format!( + "[FeishuGateway] account={} webhook JSON 解析失败: path={} bytes={}", + context.account.account_id, + uri.path(), + body.len() + ), + ); + return (StatusCode::BAD_REQUEST, "invalid json").into_response(); + } + } + }; + + let event_type = payload + .pointer("/header/event_type") + .and_then(|value| value.as_str()) + .or_else(|| { + payload + .pointer("/event/type") + .and_then(|value| value.as_str()) + }) + .unwrap_or("") + .to_string(); + let has_encrypt = payload.get("encrypt").is_some(); + let has_challenge = payload.get("challenge").is_some(); + context.logs.write().await.add( + "info", + &format!( + "[FeishuGateway] account={} webhook 请求: method={} path={} bytes={} eventType={} encrypt={} challenge={}", + context.account.account_id, + method.as_str(), + uri.path(), + body.len(), + event_type, + has_encrypt, + has_challenge + ), + ); + + if let Some(challenge) = payload.get("challenge").and_then(|value| value.as_str()) { + return Json(json!({ "challenge": challenge })).into_response(); + } + + if has_encrypt { + context.logs.write().await.add( + "warn", + &format!( + "[FeishuGateway] account={} 收到加密事件(encrypt_key_configured={}),当前未启用 decrypt 实现", + context.account.account_id, + context.account.encrypt_key.is_some() + ), + ); + return (StatusCode::NOT_IMPLEMENTED, "encrypt not supported yet").into_response(); + } + + if let Some(expected_token) = context.account.verification_token.as_deref() { + let actual_token = payload + .pointer("/header/token") + .and_then(|value| value.as_str()) + .or_else(|| payload.get("token").and_then(|value| value.as_str())) + .unwrap_or_default(); + if !expected_token.trim().is_empty() && expected_token.trim() != actual_token { + context.logs.write().await.add( + "warn", + &format!( + "[FeishuGateway] account={} verification token 不匹配: actual={}", + context.account.account_id, actual_token + ), + ); + return (StatusCode::FORBIDDEN, "verification token mismatch").into_response(); + } + } + + context.status.write().await.last_event_at = Some(Utc::now().to_rfc3339()); + + if let Some(inbound) = parse_inbound_message(&payload) { + context.status.write().await.last_message_at = Some(Utc::now().to_rfc3339()); + let ctx = context.clone(); + tokio::spawn(async move { + process_inbound_message(ctx, inbound).await; + }); + } else if event_type != "" { + context.logs.write().await.add( + "info", + &format!( + "[FeishuGateway] account={} 忽略非消息事件: eventType={}", + context.account.account_id, event_type + ), + ); + } + + Json(json!({ "code": 0 })).into_response() +} + +async fn process_inbound_message(context: Arc, inbound: InboundMessage) { + let text = inbound.text.trim(); + if text.is_empty() { + return; + } + + let text_preview = summarize_text_preview(text, 64); + context.logs.write().await.add( + "info", + &format!( + "[FeishuGateway] account={} 收到入站消息: chat={} kind={} sender={:?} messageId={} text={}", + context.account.account_id, + inbound.chat_id, + inbound.chat_kind, + inbound.sender_id, + inbound.message_id, + text_preview + ), + ); + + if let Err(reason) = authorize_message(&context.account, &inbound) { + if let Err(error) = send_text_chunks( + &context.client, + &context.account, + &context.token_cache, + &inbound, + &format!("❌ 拒绝访问: {}", reason), + ) + .await + { + context.logs.write().await.add( + "warn", + &format!( + "[FeishuGateway] account={} 拒绝提示发送失败: chat={} err={}", + context.account.account_id, inbound.chat_id, error + ), + ); + } + context.logs.write().await.add( + "warn", + &format!( + "[FeishuGateway] account={} 拒绝消息: chat={} sender={:?} reason={}", + context.account.account_id, inbound.chat_id, inbound.sender_id, reason + ), + ); + return; + } + + if text.starts_with('/') { + let command = match parse_feishu_command(text) { + Ok(value) => value, + Err(error) => { + if let Err(send_error) = send_text_chunks( + &context.client, + &context.account, + &context.token_cache, + &inbound, + &error, + ) + .await + { + context.logs.write().await.add( + "warn", + &format!( + "[FeishuGateway] account={} 命令错误提示发送失败: chat={} err={}", + context.account.account_id, inbound.chat_id, send_error + ), + ); + } + return; + } + }; + let result = handle_command(&context, &inbound, command).await; + if let Some(reply_text) = match result { + Ok(Some(text)) => Some(text), + Ok(None) => None, + Err(error) => Some(format!("❌ {}", error)), + } { + if let Err(send_error) = send_text_chunks( + &context.client, + &context.account, + &context.token_cache, + &inbound, + &reply_text, + ) + .await + { + context.logs.write().await.add( + "warn", + &format!( + "[FeishuGateway] account={} 命令回包发送失败: chat={} err={}", + context.account.account_id, inbound.chat_id, send_error + ), + ); + } + } + return; + } + + let reply = match handle_plain_text( + &context.client, + &context.account, + context.rpc_handler.as_ref(), + &context.logs, + &context.session_route_state, + &inbound, + text.to_string(), + None, + context.streaming_mode, + context.reply_to_mode, + ) + .await + { + Ok(PlainTextReply::Message(text)) => Some(text), + Err(error) => Some(format!("❌ {}", error)), + }; + + if let Some(reply_text) = reply { + if let Err(send_error) = send_text_chunks( + &context.client, + &context.account, + &context.token_cache, + &inbound, + &reply_text, + ) + .await + { + context.logs.write().await.add( + "warn", + &format!( + "[FeishuGateway] account={} 文本回包发送失败: chat={} err={}", + context.account.account_id, inbound.chat_id, send_error + ), + ); + } + } +} + +fn parse_inbound_message(payload: &serde_json::Value) -> Option { + let event_type = payload + .pointer("/header/event_type") + .and_then(|value| value.as_str()) + .or_else(|| { + payload + .pointer("/event/type") + .and_then(|value| value.as_str()) + }) + .unwrap_or_default(); + + if event_type != "im.message.receive_v1" { + return None; + } + + let event = payload.get("event")?; + let message = event.get("message")?; + let chat_id = message.get("chat_id")?.as_str()?.trim().to_string(); + if chat_id.is_empty() { + return None; + } + let chat_kind = message + .get("chat_type") + .and_then(|value| value.as_str()) + .unwrap_or("group") + .to_string(); + let message_id = message + .get("message_id") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let message_type = message + .get("message_type") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let content_raw = message + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let sender_id = event + .pointer("/sender/sender_id/open_id") + .and_then(|value| value.as_str()) + .or_else(|| { + event + .pointer("/sender/sender_id/user_id") + .and_then(|value| value.as_str()) + }) + .or_else(|| { + event + .pointer("/sender/sender_id/union_id") + .and_then(|value| value.as_str()) + }) + .map(|value| value.to_string()); + + let text = extract_text_from_message_content(message_type, &content_raw); + if text.trim().is_empty() { + return None; + } + + Some(InboundMessage { + message_id, + chat_id, + chat_kind, + sender_id, + text, + raw_content: Some(content_raw), + }) +} + +fn extract_text_from_message_content(message_type: &str, raw_content: &str) -> String { + if raw_content.trim().is_empty() { + return String::new(); + } + match message_type { + "text" => { + if let Ok(value) = serde_json::from_str::(raw_content) { + return value + .get("text") + .and_then(|item| item.as_str()) + .unwrap_or_default() + .to_string(); + } + raw_content.to_string() + } + "post" => { + if let Ok(value) = serde_json::from_str::(raw_content) { + let mut lines = Vec::new(); + if let Some(content) = value.pointer("/zh_cn/content").and_then(|v| v.as_array()) { + for row in content { + if let Some(cols) = row.as_array() { + for cell in cols { + if let Some(text) = cell.get("text").and_then(|v| v.as_str()) { + if !text.trim().is_empty() { + lines.push(text.to_string()); + } + } + } + } + } + } + return lines.join("\n"); + } + raw_content.to_string() + } + _ => String::new(), + } +} + +fn normalize_connection_mode(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "webhook" => "webhook".to_string(), + _ => "websocket".to_string(), + } +} + +fn normalize_webhook_path(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return "/feishu/events".to_string(); + } + if trimmed.starts_with('/') { + return trimmed.to_string(); + } + format!("/{}", trimmed) +} + +fn normalize_allow_entry(raw: &str) -> String { + raw.trim() + .trim_start_matches("feishu:") + .trim_start_matches("lark:") + .trim_start_matches("open_id:") + .to_ascii_lowercase() +} + +fn normalize_allow_set(input: &[String]) -> HashSet { + input + .iter() + .map(|item| normalize_allow_entry(item)) + .filter(|item| !item.is_empty()) + .collect::>() +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| item.to_string()) +} + +fn normalize_dm_policy(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "allowlist" => "allowlist".to_string(), + "open" => "open".to_string(), + "disabled" => "disabled".to_string(), + _ => "pairing".to_string(), + } +} + +fn normalize_group_policy(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "open" => "open".to_string(), + "disabled" => "disabled".to_string(), + _ => "allowlist".to_string(), + } +} + +fn normalize_streaming_mode(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "off" => "off".to_string(), + "block" => "block".to_string(), + _ => "partial".to_string(), + } +} + +fn normalize_reply_to_mode(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "first" => "first".to_string(), + "all" => "all".to_string(), + _ => "off".to_string(), + } +} + +fn parse_streaming_mode(raw: &str) -> StreamingMode { + match normalize_streaming_mode(raw).as_str() { + "off" => StreamingMode::Off, + "block" => StreamingMode::Block, + _ => StreamingMode::Partial, + } +} + +fn parse_reply_to_mode(raw: &str) -> ReplyToMode { + match normalize_reply_to_mode(raw).as_str() { + "first" => ReplyToMode::First, + "all" => ReplyToMode::All, + _ => ReplyToMode::Off, + } +} + +fn resolve_feishu_accounts( + feishu: &FeishuBotConfig, + account_filter: Option<&str>, +) -> Result, String> { + if !feishu.enabled { + return Ok(Vec::new()); + } + + let mut resolved = Vec::new(); + + if feishu.accounts.is_empty() { + if feishu.app_id.trim().is_empty() || feishu.app_secret.trim().is_empty() { + return Ok(Vec::new()); + } + resolved.push(ResolvedFeishuAccount { + account_id: "default".to_string(), + app_id: feishu.app_id.trim().to_string(), + app_secret: feishu.app_secret.trim().to_string(), + verification_token: normalize_optional_text(feishu.verification_token.as_deref()), + encrypt_key: normalize_optional_text(feishu.encrypt_key.as_deref()), + default_model: normalize_optional_text(feishu.default_model.as_deref()), + domain: normalize_optional_text(Some(&feishu.domain)) + .unwrap_or_else(|| "feishu".to_string()), + connection_mode: normalize_connection_mode(&feishu.connection_mode), + webhook_host: normalize_optional_text(feishu.webhook_host.as_deref()) + .unwrap_or_else(|| "127.0.0.1".to_string()), + webhook_port: feishu.webhook_port.unwrap_or(3000), + webhook_path: normalize_webhook_path(feishu.webhook_path.as_deref().unwrap_or("")), + dm_policy: normalize_dm_policy(&feishu.dm_policy), + allow_from: normalize_allow_set(&feishu.allow_from), + group_policy: normalize_group_policy(&feishu.group_policy), + group_allow_from: normalize_allow_set(&feishu.group_allow_from), + groups: feishu.groups.clone(), + streaming: normalize_streaming_mode(&feishu.streaming), + reply_to_mode: normalize_reply_to_mode(&feishu.reply_to_mode), + }); + } else { + for (account_id, account) in &feishu.accounts { + if !account.enabled { + continue; + } + let app_id = account + .app_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| feishu.app_id.trim()); + let app_secret = account + .app_secret + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| feishu.app_secret.trim()); + if app_id.is_empty() || app_secret.is_empty() { + continue; + } + + let allow_from = if account.allow_from.is_empty() { + normalize_allow_set(&feishu.allow_from) + } else { + normalize_allow_set(&account.allow_from) + }; + let group_allow_from = if account.group_allow_from.is_empty() { + normalize_allow_set(&feishu.group_allow_from) + } else { + normalize_allow_set(&account.group_allow_from) + }; + let groups = if account.groups.is_empty() { + feishu.groups.clone() + } else { + account.groups.clone() + }; + + resolved.push(ResolvedFeishuAccount { + account_id: account_id.to_string(), + app_id: app_id.to_string(), + app_secret: app_secret.to_string(), + verification_token: normalize_optional_text( + account + .verification_token + .as_deref() + .or(feishu.verification_token.as_deref()), + ), + encrypt_key: normalize_optional_text( + account + .encrypt_key + .as_deref() + .or(feishu.encrypt_key.as_deref()), + ), + default_model: normalize_optional_text( + account + .default_model + .as_deref() + .or(feishu.default_model.as_deref()), + ), + domain: normalize_optional_text(account.domain.as_deref().or(Some(&feishu.domain))) + .unwrap_or_else(|| "feishu".to_string()), + connection_mode: normalize_connection_mode( + account + .connection_mode + .as_deref() + .unwrap_or(feishu.connection_mode.as_str()), + ), + webhook_host: normalize_optional_text( + account + .webhook_host + .as_deref() + .or(feishu.webhook_host.as_deref()), + ) + .unwrap_or_else(|| "127.0.0.1".to_string()), + webhook_port: account.webhook_port.or(feishu.webhook_port).unwrap_or(3000), + webhook_path: normalize_webhook_path( + account + .webhook_path + .as_deref() + .or(feishu.webhook_path.as_deref()) + .unwrap_or(""), + ), + dm_policy: normalize_dm_policy( + account + .dm_policy + .as_deref() + .unwrap_or(feishu.dm_policy.as_str()), + ), + allow_from, + group_policy: normalize_group_policy( + account + .group_policy + .as_deref() + .unwrap_or(feishu.group_policy.as_str()), + ), + group_allow_from, + groups, + streaming: normalize_streaming_mode( + account + .streaming + .as_deref() + .unwrap_or(feishu.streaming.as_str()), + ), + reply_to_mode: normalize_reply_to_mode( + account + .reply_to_mode + .as_deref() + .unwrap_or(feishu.reply_to_mode.as_str()), + ), + }); + } + + if resolved.is_empty() + && !feishu.app_id.trim().is_empty() + && !feishu.app_secret.trim().is_empty() + { + resolved.push(ResolvedFeishuAccount { + account_id: "default".to_string(), + app_id: feishu.app_id.trim().to_string(), + app_secret: feishu.app_secret.trim().to_string(), + verification_token: normalize_optional_text(feishu.verification_token.as_deref()), + encrypt_key: normalize_optional_text(feishu.encrypt_key.as_deref()), + default_model: normalize_optional_text(feishu.default_model.as_deref()), + domain: normalize_optional_text(Some(&feishu.domain)) + .unwrap_or_else(|| "feishu".to_string()), + connection_mode: normalize_connection_mode(&feishu.connection_mode), + webhook_host: normalize_optional_text(feishu.webhook_host.as_deref()) + .unwrap_or_else(|| "127.0.0.1".to_string()), + webhook_port: feishu.webhook_port.unwrap_or(3000), + webhook_path: normalize_webhook_path(feishu.webhook_path.as_deref().unwrap_or("")), + dm_policy: normalize_dm_policy(&feishu.dm_policy), + allow_from: normalize_allow_set(&feishu.allow_from), + group_policy: normalize_group_policy(&feishu.group_policy), + group_allow_from: normalize_allow_set(&feishu.group_allow_from), + groups: feishu.groups.clone(), + streaming: normalize_streaming_mode(&feishu.streaming), + reply_to_mode: normalize_reply_to_mode(&feishu.reply_to_mode), + }); + } + } + + if let Some(filter) = account_filter { + let selected = resolved + .into_iter() + .filter(|item| item.account_id == filter) + .collect::>(); + if selected.is_empty() { + return Err(format!("未找到 Feishu 账号: {}", filter)); + } + return Ok(selected); + } + + Ok(resolved) +} + +fn build_session_key(account: &ResolvedFeishuAccount, inbound: &InboundMessage) -> String { + if inbound.chat_kind == "p2p" || inbound.chat_kind == "private" { + let sender = inbound + .sender_id + .as_deref() + .filter(|id| !id.trim().is_empty()) + .unwrap_or(inbound.chat_id.as_str()); + return format!("agent:main:feishu:{}:direct:{}", account.account_id, sender); + } + format!( + "agent:main:feishu:{}:group:{}", + account.account_id, inbound.chat_id + ) +} + +async fn resolve_active_session_id( + account: &ResolvedFeishuAccount, + inbound: &InboundMessage, + session_route_state: &SessionRouteState, +) -> String { + let scope_key = build_session_key(account, inbound); + let state = session_route_state.read().await; + state + .get(&scope_key) + .cloned() + .unwrap_or_else(|| scope_key.to_string()) +} + +async fn rotate_active_session_id( + account: &ResolvedFeishuAccount, + inbound: &InboundMessage, + session_route_state: &SessionRouteState, +) -> String { + let scope_key = build_session_key(account, inbound); + let suffix = Uuid::new_v4() + .to_string() + .chars() + .take(8) + .collect::(); + let rotated = format!("{scope_key}:new:{suffix}"); + let mut state = session_route_state.write().await; + state.insert(scope_key, rotated.clone()); + rotated +} + +fn authorize_message( + account: &ResolvedFeishuAccount, + inbound: &InboundMessage, +) -> Result<(), String> { + if inbound.chat_kind == "p2p" || inbound.chat_kind == "private" { + return authorize_dm(account, inbound.sender_id.as_deref()); + } + authorize_group(account, inbound) +} + +fn authorize_dm(account: &ResolvedFeishuAccount, sender_id: Option<&str>) -> Result<(), String> { + let sender = sender_id + .map(normalize_allow_entry) + .filter(|item| !item.is_empty()); + + match account.dm_policy.as_str() { + "disabled" => Err("DM 已禁用".to_string()), + "open" => { + if account.allow_from.contains("*") { + Ok(()) + } else { + Err("dmPolicy=open 但 allow_from 缺少 '*'".to_string()) + } + } + "allowlist" => { + let sender = sender.ok_or_else(|| "无法识别发送者 ID".to_string())?; + if account.allow_from.contains(&sender) || account.allow_from.contains("*") { + Ok(()) + } else { + Err(format!("发送者 {} 不在 DM allow_from", sender)) + } + } + _ => { + let sender = sender.ok_or_else(|| "无法识别发送者 ID".to_string())?; + if account.allow_from.is_empty() { + Err("dmPolicy=pairing 但未配置 allow_from(当前为 fail-closed)".to_string()) + } else if account.allow_from.contains(&sender) || account.allow_from.contains("*") { + Ok(()) + } else { + Err(format!("发送者 {} 未通过 pairing allow_from", sender)) + } + } + } +} + +fn authorize_group( + account: &ResolvedFeishuAccount, + inbound: &InboundMessage, +) -> Result<(), String> { + let chat_id = inbound.chat_id.as_str(); + let sender = inbound + .sender_id + .as_deref() + .map(normalize_allow_entry) + .filter(|item| !item.is_empty()); + + let group_cfg = account + .groups + .get(chat_id) + .or_else(|| account.groups.get("*")); + let effective_group_policy = group_cfg + .and_then(|group| group.group_policy.as_deref()) + .map(normalize_group_policy) + .unwrap_or_else(|| account.group_policy.clone()); + let group_allowed = match group_cfg { + Some(group) => group.enabled.unwrap_or(true), + None => effective_group_policy == "open", + }; + if !group_allowed { + return Err(format!("群组 {} 未加入 allowlist", chat_id)); + } + + let require_mention = group_cfg + .and_then(|group| group.require_mention) + .unwrap_or(true); + if require_mention + && !is_slash_command(&inbound.text) + && !is_message_mentioning_bot(&inbound.text, inbound.raw_content.as_deref()) + { + return Err("群组消息需要 mention 触发".to_string()); + } + + match effective_group_policy.as_str() { + "disabled" => Err("群组消息已禁用".to_string()), + "open" => Ok(()), + _ => { + let sender = sender.ok_or_else(|| "无法识别群组发送者 ID".to_string())?; + let allow_from = if let Some(group) = group_cfg { + if !group.allow_from.is_empty() { + normalize_allow_set(&group.allow_from) + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + } + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + }; + if allow_from.contains(&sender) || allow_from.contains("*") { + Ok(()) + } else { + Err(format!("发送者 {} 不在群组 allow_from", sender)) + } + } + } +} + +fn is_message_mentioning_bot(text: &str, raw_content: Option<&str>) -> bool { + if text.contains('@') { + return true; + } + raw_content + .map(|raw| raw.contains("mentions") || raw.contains(" bool { + text.trim_start().starts_with('/') +} + +fn summarize_text_preview(text: &str, max_chars: usize) -> String { + let mut preview: String = text.chars().take(max_chars).collect(); + if text.chars().count() > max_chars { + preview.push_str("..."); + } + preview +} + +async fn handle_plain_text( + client: &reqwest::Client, + account: &ResolvedFeishuAccount, + rpc_handler: &RpcHandler, + logs: &LogState, + session_route_state: &SessionRouteState, + inbound: &InboundMessage, + text: String, + session_id_override: Option, + _streaming_mode: StreamingMode, + _reply_to_mode: ReplyToMode, +) -> Result { + let session_id = match session_id_override { + Some(value) => value, + None => resolve_active_session_id(account, inbound, session_route_state).await, + }; + logs.write().await.add( + "info", + &format!( + "[FeishuGateway] account={} 收到文本消息: chat={} sender={:?} messageId={} session={} model={} web_search=true", + account.account_id, + inbound.chat_id, + inbound.sender_id, + inbound.message_id, + session_id, + account.default_model.as_deref().unwrap_or("") + ), + ); + + let run_response = rpc_handler + .handle_request(GatewayRpcRequest { + jsonrpc: "2.0".to_string(), + id: Uuid::new_v4().to_string(), + method: RpcMethod::AgentRun, + params: Some(json!({ + "session_id": session_id, + "message": text, + "stream": false, + "model": account.default_model.clone(), + "web_search": true, + })), + }) + .await; + let run_value = run_response + .result + .ok_or_else(|| extract_rpc_error(run_response.error, "agent.run 失败"))?; + let run_result: AgentRunResult = parse_result(run_value)?; + logs.write().await.add( + "info", + &format!( + "[FeishuGateway] account={} agent.run 已创建: runId={} session={}", + account.account_id, run_result.run_id, run_result.session_id + ), + ); + + for round in 0..RUN_WAIT_MAX_ROUNDS { + let wait_response = rpc_handler + .handle_request(GatewayRpcRequest { + jsonrpc: "2.0".to_string(), + id: Uuid::new_v4().to_string(), + method: RpcMethod::AgentWait, + params: Some(json!({ + "run_id": run_result.run_id, + "timeout": RUN_WAIT_TIMEOUT_MS, + })), + }) + .await; + let wait_value = wait_response + .result + .ok_or_else(|| extract_rpc_error(wait_response.error, "agent.wait 失败"))?; + let wait_result: AgentWaitResult = parse_result(wait_value)?; + if wait_result.completed { + let content = wait_result + .content + .unwrap_or_else(|| "任务已完成,但无可展示输出".to_string()); + logs.write().await.add( + "info", + &format!( + "[FeishuGateway] account={} runId={} 已完成: contentLen={}", + account.account_id, + run_result.run_id, + content.chars().count() + ), + ); + return Ok(PlainTextReply::Message(content)); + } + + if round % 20 == 0 { + logs.write().await.add( + "info", + &format!( + "[FeishuGateway] account={} runId={} 等待中: round={} completed={}", + account.account_id, run_result.run_id, round, wait_result.completed + ), + ); + } + if round % 3 == 0 { + let _ = send_chat_typing(client, account, &inbound.chat_id).await; + } + } + + Err("等待任务完成超时,请稍后使用 /status 查询".to_string()) +} + +async fn handle_command( + context: &WebhookContext, + inbound: &InboundMessage, + command: FeishuCommand, +) -> Result, String> { + match command { + FeishuCommand::Help => Ok(Some(help_text())), + FeishuCommand::Cancel => { + *context.pending_confirmation.write().await = None; + Ok(Some("🧹 已取消待确认操作".to_string())) + } + FeishuCommand::Confirm(token) => { + let mut pending_guard = context.pending_confirmation.write().await; + let confirmed = take_pending_confirmation(&mut pending_guard, &token)?; + drop(pending_guard); + dispatch_command(context.rpc_handler.as_ref(), confirmed) + .await + .map(Some) + } + cmd if requires_confirmation(&cmd) => { + let mut pending_guard = context.pending_confirmation.write().await; + let token = set_pending_confirmation(&mut pending_guard, cmd.clone()); + Ok(Some(format!( + "⚠️ 检测到危险操作:{}\n请在 {} 秒内发送 /confirm {} 继续,或发送 /cancel 取消。", + danger_command_label(&cmd), + CONFIRMATION_TTL_SECS, + token + ))) + } + FeishuCommand::Run(prompt) => { + match handle_plain_text( + &context.client, + &context.account, + context.rpc_handler.as_ref(), + &context.logs, + &context.session_route_state, + inbound, + prompt, + None, + context.streaming_mode, + context.reply_to_mode, + ) + .await? + { + PlainTextReply::Message(text) => Ok(Some(text)), + } + } + FeishuCommand::New(first_prompt) => { + let new_session_id = + rotate_active_session_id(&context.account, inbound, &context.session_route_state) + .await; + context.logs.write().await.add( + "info", + &format!( + "[FeishuGateway] account={} 开启新会话: chat={} session={}", + context.account.account_id, inbound.chat_id, new_session_id + ), + ); + if let Some(prompt) = first_prompt { + match handle_plain_text( + &context.client, + &context.account, + context.rpc_handler.as_ref(), + &context.logs, + &context.session_route_state, + inbound, + prompt, + Some(new_session_id.clone()), + context.streaming_mode, + context.reply_to_mode, + ) + .await? + { + PlainTextReply::Message(text) => Ok(Some(text)), + } + } else { + Ok(Some(format!( + "🆕 已开启新对话\nsession_id: {}\n后续消息会在这个新会话中进行。", + new_session_id + ))) + } + } + cmd => dispatch_command(context.rpc_handler.as_ref(), cmd) + .await + .map(Some), + } +} + +fn parse_feishu_command(text: &str) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err(help_text()); + } + let mut parts = trimmed.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or_default().to_ascii_lowercase(); + let rest = parts.next().unwrap_or_default().trim(); + match first.as_str() { + "/run" => { + if rest.is_empty() { + Err("❌ 用法:/run <任务内容>".to_string()) + } else { + Ok(FeishuCommand::Run(rest.to_string())) + } + } + "/new" | "/reset" => { + if rest.is_empty() { + Ok(FeishuCommand::New(None)) + } else { + Ok(FeishuCommand::New(Some(rest.to_string()))) + } + } + "/status" => { + if rest.is_empty() { + Err("❌ 用法:/status ".to_string()) + } else { + Ok(FeishuCommand::Status(rest.to_string())) + } + } + "/stop" => { + if rest.is_empty() { + Err("❌ 用法:/stop ".to_string()) + } else { + Ok(FeishuCommand::Stop(rest.to_string())) + } + } + "/cron_list" => Ok(FeishuCommand::CronList), + "/cron_health" => Ok(FeishuCommand::CronHealth), + "/cron_run" => { + if rest.is_empty() { + Err("❌ 用法:/cron_run ".to_string()) + } else { + Ok(FeishuCommand::CronRun(rest.to_string())) + } + } + "/sessions" => Ok(FeishuCommand::Sessions), + "/session" => { + if rest.is_empty() { + Err("❌ 用法:/session ".to_string()) + } else { + Ok(FeishuCommand::Session(rest.to_string())) + } + } + "/confirm" => { + if rest.is_empty() { + Err("❌ 用法:/confirm ".to_string()) + } else { + Ok(FeishuCommand::Confirm(rest.to_string())) + } + } + "/cancel" => Ok(FeishuCommand::Cancel), + "/help" | "/start" => Ok(FeishuCommand::Help), + _ => Err(help_text()), + } +} + +fn requires_confirmation(command: &FeishuCommand) -> bool { + matches!(command, FeishuCommand::Stop(_) | FeishuCommand::CronRun(_)) +} + +fn danger_command_label(command: &FeishuCommand) -> &'static str { + match command { + FeishuCommand::Stop(_) => "/stop", + FeishuCommand::CronRun(_) => "/cron_run", + _ => "unknown", + } +} + +fn help_text() -> String { + [ + "🤖 ProxyCast Feishu Gateway 命令", + "/run <任务内容> - 启动一个 Agent 任务", + "/new [首条消息] - 开启新对话(/reset 同义)", + "/status - 查看任务状态", + "/stop - 停止任务(需确认)", + "/cron_list - 列出定时任务", + "/cron_health - 查看定时任务健康概览", + "/cron_run - 触发定时任务(需确认)", + "/sessions - 列出会话", + "/session - 查看会话摘要", + "/confirm - 确认危险操作", + "/cancel - 取消待确认操作", + "/help - 查看帮助", + ] + .join("\n") +} + +fn set_pending_confirmation( + pending_confirmation: &mut Option, + command: FeishuCommand, +) -> String { + let token = Uuid::new_v4() + .to_string() + .chars() + .take(8) + .collect::(); + let expires_at = Utc::now() + chrono::Duration::seconds(CONFIRMATION_TTL_SECS); + *pending_confirmation = Some(PendingConfirmation { + token: token.clone(), + command, + expires_at, + }); + token +} + +fn take_pending_confirmation( + pending_confirmation: &mut Option, + token: &str, +) -> Result { + let pending = pending_confirmation + .take() + .ok_or_else(|| "当前没有待确认操作".to_string())?; + if Utc::now() > pending.expires_at { + return Err("确认已过期,请重新发起命令".to_string()); + } + if pending.token != token { + *pending_confirmation = Some(pending); + return Err("确认 token 不匹配".to_string()); + } + if !requires_confirmation(&pending.command) { + return Err("当前命令不需要确认".to_string()); + } + Ok(pending.command) +} + +async fn dispatch_command( + rpc_handler: &RpcHandler, + command: FeishuCommand, +) -> Result { + let request = build_rpc_request(command)?; + let response = rpc_handler.handle_request(request).await; + format_rpc_response(response) +} + +fn build_rpc_request(command: FeishuCommand) -> Result { + let (method, params) = match command { + FeishuCommand::Run(message) => ( + RpcMethod::AgentRun, + Some(json!({ + "message": message, + "stream": false, + "web_search": true + })), + ), + FeishuCommand::Status(run_id) => ( + RpcMethod::AgentWait, + Some(json!({ "run_id": run_id, "timeout": 200 })), + ), + FeishuCommand::Stop(run_id) => (RpcMethod::AgentStop, Some(json!({ "run_id": run_id }))), + FeishuCommand::CronList => (RpcMethod::CronList, None), + FeishuCommand::CronHealth => (RpcMethod::CronHealth, None), + FeishuCommand::CronRun(task_id) => { + (RpcMethod::CronRun, Some(json!({ "task_id": task_id }))) + } + FeishuCommand::Sessions => (RpcMethod::SessionsList, None), + FeishuCommand::Session(session_id) => ( + RpcMethod::SessionsGet, + Some(json!({ "session_id": session_id })), + ), + FeishuCommand::New(_) => return Err("内部错误:new 不应构造 RPC 请求".to_string()), + FeishuCommand::Help => return Err("内部错误:help 不应构造 RPC 请求".to_string()), + FeishuCommand::Confirm(_) => return Err("内部错误:confirm 不应构造 RPC 请求".to_string()), + FeishuCommand::Cancel => return Err("内部错误:cancel 不应构造 RPC 请求".to_string()), + }; + + Ok(GatewayRpcRequest { + jsonrpc: "2.0".to_string(), + id: Uuid::new_v4().to_string(), + method, + params, + }) +} + +enum ResponseHint { + AgentRun, + AgentWait, + AgentStop, + CronList, + CronRun, + CronHealth, + SessionsList, + SessionGet, +} + +fn response_id_hint(value: &serde_json::Value) -> Option { + if value.get("runId").is_some() && value.get("sessionId").is_some() { + return Some(ResponseHint::AgentRun); + } + if value.get("runId").is_some() && value.get("completed").is_some() { + return Some(ResponseHint::AgentWait); + } + if value.get("runId").is_some() && value.get("stopped").is_some() { + return Some(ResponseHint::AgentStop); + } + if value.get("tasks").is_some() { + return Some(ResponseHint::CronList); + } + if value.get("taskId").is_some() && value.get("executionId").is_some() { + return Some(ResponseHint::CronRun); + } + if value.get("totalTasks").is_some() && value.get("cooldownTasks").is_some() { + return Some(ResponseHint::CronHealth); + } + if value.get("sessions").is_some() { + return Some(ResponseHint::SessionsList); + } + if value.get("sessionId").is_some() && value.get("messageCount").is_some() { + return Some(ResponseHint::SessionGet); + } + None +} + +fn format_rpc_response(response: GatewayRpcResponse) -> Result { + if let Some(error) = response.error { + return Err(format!("{} (code={})", error.message, error.code)); + } + let result_value = response + .result + .ok_or_else(|| "RPC 返回缺少 result".to_string())?; + match response_id_hint(&result_value) { + Some(ResponseHint::AgentRun) => { + let payload: AgentRunResult = parse_result(result_value)?; + Ok(format!( + "✅ 已启动\nrun_id: {}\nsession_id: {}\ncompleted: {}", + payload.run_id, payload.session_id, payload.completed + )) + } + Some(ResponseHint::AgentWait) => { + let payload: AgentWaitResult = parse_result(result_value)?; + if payload.completed { + Ok(format!( + "✅ 已完成\nrun_id: {}\n{}", + payload.run_id, + payload.content.unwrap_or_else(|| "无输出内容".to_string()) + )) + } else { + Ok(format!("⏳ 运行中\nrun_id: {}", payload.run_id)) + } + } + Some(ResponseHint::AgentStop) => { + let payload: AgentStopResult = parse_result(result_value)?; + Ok(format!( + "{} run_id: {}", + if payload.stopped { + "🛑 已停止" + } else { + "ℹ️ 未找到活跃任务" + }, + payload.run_id + )) + } + Some(ResponseHint::CronList) => { + let payload: CronListResult = parse_result(result_value)?; + if payload.tasks.is_empty() { + Ok("📭 当前无定时任务".to_string()) + } else { + let lines = payload + .tasks + .iter() + .take(10) + .map(|item| { + format!( + "- {} | {} | enabled={}", + item.task_id, item.name, item.enabled + ) + }) + .collect::>(); + Ok(format!( + "📌 定时任务(前 {} 条)\n{}", + lines.len(), + lines.join("\n") + )) + } + } + Some(ResponseHint::CronRun) => { + let payload: CronRunResult = parse_result(result_value)?; + Ok(format!( + "✅ cron 已触发\ntask_id: {}\nexecution_id: {}", + payload.task_id, payload.execution_id + )) + } + Some(ResponseHint::CronHealth) => { + let payload: CronHealthResult = parse_result(result_value)?; + Ok(format!( + "📊 cron 健康概览\n总任务: {}\n待执行: {}\n运行中: {}\n失败: {}\n冷却中: {}\n悬挂运行: {}\n24h 失败: {}", + payload.total_tasks, + payload.pending_tasks, + payload.running_tasks, + payload.failed_tasks, + payload.cooldown_tasks, + payload.stale_running_tasks, + payload.failed_last_24h, + )) + } + Some(ResponseHint::SessionsList) => { + let payload: SessionsListResult = parse_result(result_value)?; + if payload.sessions.is_empty() { + Ok("📭 当前无会话".to_string()) + } else { + let lines = payload + .sessions + .iter() + .take(10) + .map(|item| { + format!( + "- {} | model={} | msgs={}", + item.session_id, item.model, item.message_count + ) + }) + .collect::>(); + Ok(format!( + "🧵 会话列表(前 {} 条)\n{}", + lines.len(), + lines.join("\n") + )) + } + } + Some(ResponseHint::SessionGet) => { + let payload: SessionGetResult = parse_result(result_value)?; + Ok(format!( + "🧵 会话详情\nsession_id: {}\nmodel: {}\nmessages: {}", + payload.session_id, payload.model, payload.message_count + )) + } + None => Ok(format!("✅ 已处理\n{}", result_value)), + } +} + +fn parse_result(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(|e| format!("解析 RPC 结果失败: {e}")) +} + +fn extract_rpc_error( + error: Option, + fallback: &str, +) -> String { + if let Some(err) = error { + return format!("{} (code={})", err.message, err.code); + } + fallback.to_string() +} + +fn split_message_chunks(text: &str) -> Vec { + let total_chars = text.chars().count(); + if total_chars <= FEISHU_MAX_MESSAGE_LEN { + return vec![text.to_string()]; + } + let chars: Vec = text.chars().collect(); + let mut chunks = Vec::new(); + let mut start = 0usize; + while start < chars.len() { + let end = (start + FEISHU_MAX_MESSAGE_LEN).min(chars.len()); + chunks.push(chars[start..end].iter().collect::()); + start = end; + } + chunks +} + +fn resolve_api_base(domain: &str) -> String { + let normalized = domain.trim().to_ascii_lowercase(); + if normalized.starts_with("http://") || normalized.starts_with("https://") { + return domain.trim_end_matches('/').to_string(); + } + if normalized == "lark" { + "https://open.larksuite.com".to_string() + } else { + "https://open.feishu.cn".to_string() + } +} + +#[derive(Debug, Deserialize)] +struct FeishuTokenResponse { + code: i32, + msg: Option, + tenant_access_token: Option, + expire: Option, +} + +async fn get_tenant_access_token( + client: &reqwest::Client, + account: &ResolvedFeishuAccount, + token_cache: &TokenCacheState, +) -> Result { + let now = Utc::now().timestamp(); + if let Some(cached) = token_cache.read().await.clone() { + if cached.expires_at_epoch > now + 30 { + return Ok(cached.token); + } + } + + let base = resolve_api_base(&account.domain); + let url = format!("{}/open-apis/auth/v3/tenant_access_token/internal", base); + let response = client + .post(url) + .json(&json!({ + "app_id": account.app_id, + "app_secret": account.app_secret, + })) + .send() + .await + .map_err(|e| format!("获取 tenant_access_token 失败: {e}"))?; + + let parsed: FeishuTokenResponse = response + .json() + .await + .map_err(|e| format!("解析 tenant_access_token 响应失败: {e}"))?; + if parsed.code != 0 { + return Err(format!( + "获取 tenant_access_token 失败: {} (code={})", + parsed.msg.unwrap_or_else(|| "unknown".to_string()), + parsed.code + )); + } + let token = parsed + .tenant_access_token + .ok_or_else(|| "tenant_access_token 缺失".to_string())?; + let expire_secs = parsed.expire.unwrap_or(3600); + *token_cache.write().await = Some(TenantTokenCacheEntry { + token: token.clone(), + expires_at_epoch: now + expire_secs, + }); + Ok(token) +} + +#[derive(Debug, Deserialize)] +struct FeishuApiResponse { + code: i32, + msg: Option, + #[serde(rename = "data")] + _data: Option, +} + +async fn send_text_message( + client: &reqwest::Client, + account: &ResolvedFeishuAccount, + token_cache: &TokenCacheState, + target: &ReceiveTarget, + text: &str, +) -> Result<(), String> { + let token = get_tenant_access_token(client, account, token_cache).await?; + let base = resolve_api_base(&account.domain); + let url = format!( + "{}/open-apis/im/v1/messages?receive_id_type={}", + base, target.receive_id_type + ); + let content = serde_json::to_string(&json!({ "text": text })) + .map_err(|e| format!("构造 Feishu 消息体失败: {e}"))?; + let response = client + .post(url) + .bearer_auth(token) + .json(&json!({ + "receive_id": target.receive_id, + "msg_type": "text", + "content": content, + })) + .send() + .await + .map_err(|e| format!("发送 Feishu 消息失败: {e}"))?; + let parsed: FeishuApiResponse = response + .json() + .await + .map_err(|e| format!("解析 Feishu 发送响应失败: {e}"))?; + if parsed.code != 0 { + return Err(format!( + "发送 Feishu 消息失败: {} (code={})", + parsed.msg.unwrap_or_else(|| "unknown".to_string()), + parsed.code + )); + } + Ok(()) +} + +async fn send_text_reply_message( + client: &reqwest::Client, + account: &ResolvedFeishuAccount, + token_cache: &TokenCacheState, + message_id: &str, + text: &str, +) -> Result<(), String> { + let token = get_tenant_access_token(client, account, token_cache).await?; + let base = resolve_api_base(&account.domain); + let url = format!( + "{}/open-apis/im/v1/messages/{}/reply", + base, + urlencoding::encode(message_id) + ); + let content = serde_json::to_string(&json!({ "text": text })) + .map_err(|e| format!("构造 Feishu 回复消息体失败: {e}"))?; + let response = client + .post(url) + .bearer_auth(token) + .json(&json!({ + "msg_type": "text", + "content": content, + })) + .send() + .await + .map_err(|e| format!("发送 Feishu 回复消息失败: {e}"))?; + let parsed: FeishuApiResponse = response + .json() + .await + .map_err(|e| format!("解析 Feishu 回复响应失败: {e}"))?; + if parsed.code != 0 { + return Err(format!( + "发送 Feishu 回复消息失败: {} (code={})", + parsed.msg.unwrap_or_else(|| "unknown".to_string()), + parsed.code + )); + } + Ok(()) +} + +fn resolve_receive_targets(inbound: &InboundMessage) -> Vec { + let mut targets = Vec::new(); + let mut dedupe = HashSet::new(); + let mut push_target = |receive_id_type: &'static str, receive_id: String| { + let key = format!("{}:{}", receive_id_type, receive_id); + if dedupe.insert(key) { + targets.push(ReceiveTarget { + receive_id_type, + receive_id, + }); + } + }; + + push_target("chat_id", inbound.chat_id.clone()); + + if (inbound.chat_kind == "p2p" || inbound.chat_kind == "private") + && inbound.sender_id.as_deref().is_some() + { + let sender = inbound.sender_id.as_deref().unwrap_or_default().trim(); + if !sender.is_empty() { + if sender.starts_with("ou_") { + push_target("open_id", sender.to_string()); + push_target("user_id", sender.to_string()); + } else if sender.starts_with("on_") { + push_target("union_id", sender.to_string()); + push_target("user_id", sender.to_string()); + } else { + push_target("user_id", sender.to_string()); + push_target("open_id", sender.to_string()); + } + } + } + + targets +} + +async fn send_text_chunks( + client: &reqwest::Client, + account: &ResolvedFeishuAccount, + token_cache: &TokenCacheState, + inbound: &InboundMessage, + text: &str, +) -> Result<(), String> { + let chunks = split_message_chunks(text); + if chunks.is_empty() { + return Ok(()); + } + + let mut errors = Vec::new(); + let message_id = inbound.message_id.trim(); + if !message_id.is_empty() { + let mut reply_errors = Vec::new(); + for chunk in &chunks { + if let Err(error) = + send_text_reply_message(client, account, token_cache, message_id, chunk).await + { + reply_errors.push(error); + break; + } + } + if reply_errors.is_empty() { + return Ok(()); + } + errors.push(format!( + "reply:{} => {}", + message_id, + reply_errors.join(" | ") + )); + } + + let targets = resolve_receive_targets(inbound); + + for target in targets { + match send_text_message(client, account, token_cache, &target, &chunks[0]).await { + Ok(_) => { + for chunk in chunks.iter().skip(1) { + send_text_message(client, account, token_cache, &target, chunk).await?; + } + return Ok(()); + } + Err(error) => { + errors.push(format!( + "{}:{} => {}", + target.receive_id_type, target.receive_id, error + )); + } + } + } + + Err(format!( + "发送 Feishu 消息失败(reply + receive_id 均失败): {}", + errors.join(" | ") + )) +} + +async fn send_chat_typing( + client: &reqwest::Client, + account: &ResolvedFeishuAccount, + chat_id: &str, +) -> Result<(), String> { + let token_cache: TokenCacheState = Arc::new(RwLock::new(None)); + let token = get_tenant_access_token(client, account, &token_cache).await?; + let base = resolve_api_base(&account.domain); + let url = format!("{}/open-apis/im/v1/chat", base); + let _ = client + .post(url) + .bearer_auth(token) + .json(&json!({ + "chat_id": chat_id, + "action": "typing", + })) + .send() + .await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{HashMap, HashSet}; + + fn build_account( + dm_policy: &str, + allow_from: &[&str], + group_policy: &str, + ) -> ResolvedFeishuAccount { + let mut allow = HashSet::new(); + for item in allow_from { + allow.insert((*item).to_string()); + } + ResolvedFeishuAccount { + account_id: "default".to_string(), + app_id: "app_id".to_string(), + app_secret: "app_secret".to_string(), + verification_token: None, + encrypt_key: None, + default_model: None, + domain: "feishu".to_string(), + connection_mode: "webhook".to_string(), + webhook_host: "127.0.0.1".to_string(), + webhook_port: 3000, + webhook_path: "/feishu/default".to_string(), + dm_policy: dm_policy.to_string(), + allow_from: allow, + group_policy: group_policy.to_string(), + group_allow_from: HashSet::new(), + groups: HashMap::new(), + streaming: "partial".to_string(), + reply_to_mode: "off".to_string(), + } + } + + #[test] + fn parse_command_supports_new() { + assert!(matches!( + parse_feishu_command("/new").expect("should parse"), + FeishuCommand::New(None) + )); + assert!(matches!( + parse_feishu_command("/reset hi").expect("should parse"), + FeishuCommand::New(Some(_)) + )); + } + + #[test] + fn normalize_webhook_path_adds_prefix() { + assert_eq!(normalize_webhook_path("feishu/events"), "/feishu/events"); + assert_eq!(normalize_webhook_path("/abc"), "/abc"); + } + + #[test] + fn parse_inbound_message_extracts_text_message() { + let payload = json!({ + "header": { "event_type": "im.message.receive_v1" }, + "event": { + "message": { + "message_id": "om_1", + "chat_id": "oc_1", + "chat_type": "p2p", + "message_type": "text", + "content": "{\"text\":\"你好,proxycast\"}" + }, + "sender": { + "sender_id": { + "open_id": "ou_xxx" + } + } + } + }); + + let inbound = parse_inbound_message(&payload).expect("should parse inbound"); + assert_eq!(inbound.message_id, "om_1"); + assert_eq!(inbound.chat_id, "oc_1"); + assert_eq!(inbound.chat_kind, "p2p"); + assert_eq!(inbound.sender_id.as_deref(), Some("ou_xxx")); + assert_eq!(inbound.text, "你好,proxycast"); + } + + #[test] + fn parse_inbound_message_extracts_union_id_sender() { + let payload = json!({ + "header": { "event_type": "im.message.receive_v1" }, + "event": { + "message": { + "message_id": "om_2", + "chat_id": "oc_2", + "chat_type": "p2p", + "message_type": "text", + "content": "{\"text\":\"hello\"}" + }, + "sender": { + "sender_id": { + "union_id": "on_union_xxx" + } + } + } + }); + + let inbound = parse_inbound_message(&payload).expect("should parse inbound"); + assert_eq!(inbound.sender_id.as_deref(), Some("on_union_xxx")); + } + + #[test] + fn parse_inbound_message_ignores_non_message_event() { + let payload = json!({ + "header": { "event_type": "im.message.message_read_v1" }, + "event": {} + }); + assert!(parse_inbound_message(&payload).is_none()); + } + + #[test] + fn authorize_message_dm_pairing_fail_closed_without_allow_from() { + let account = build_account("pairing", &[], "allowlist"); + let inbound = InboundMessage { + message_id: "m1".to_string(), + chat_id: "c1".to_string(), + chat_kind: "p2p".to_string(), + sender_id: Some("ou_abc".to_string()), + text: "你好".to_string(), + raw_content: None, + }; + let err = authorize_message(&account, &inbound).expect_err("should reject"); + assert!(err.contains("fail-closed")); + } + + #[test] + fn authorize_message_dm_allowlist_accepts_sender() { + let account = build_account("allowlist", &["ou_abc"], "allowlist"); + let inbound = InboundMessage { + message_id: "m1".to_string(), + chat_id: "c1".to_string(), + chat_kind: "private".to_string(), + sender_id: Some("ou_abc".to_string()), + text: "你好".to_string(), + raw_content: None, + }; + assert!(authorize_message(&account, &inbound).is_ok()); + } + + #[test] + fn authorize_message_dm_open_allows_missing_sender_when_wildcard_enabled() { + let account = build_account("open", &["*"], "allowlist"); + let inbound = InboundMessage { + message_id: "m-open".to_string(), + chat_id: "c-open".to_string(), + chat_kind: "p2p".to_string(), + sender_id: None, + text: "你好".to_string(), + raw_content: None, + }; + assert!(authorize_message(&account, &inbound).is_ok()); + } + + #[test] + fn authorize_message_group_open_allows_missing_sender() { + let account = build_account("open", &["*"], "open"); + let inbound = InboundMessage { + message_id: "m-group-open".to_string(), + chat_id: "oc_group_open".to_string(), + chat_kind: "group".to_string(), + sender_id: None, + text: "/status xxx".to_string(), + raw_content: Some("{\"text\":\"/status xxx\"}".to_string()), + }; + assert!(authorize_message(&account, &inbound).is_ok()); + } + + #[test] + fn authorize_message_group_requires_mention_by_default() { + let mut account = build_account("open", &["*"], "open"); + account.groups.insert( + "oc_group_1".to_string(), + FeishuGroupConfig { + enabled: Some(true), + require_mention: Some(true), + group_policy: Some("open".to_string()), + allow_from: vec!["*".to_string()], + }, + ); + + let inbound = InboundMessage { + message_id: "m2".to_string(), + chat_id: "oc_group_1".to_string(), + chat_kind: "group".to_string(), + sender_id: Some("ou_abc".to_string()), + text: "普通消息".to_string(), + raw_content: Some("{\"text\":\"普通消息\"}".to_string()), + }; + let err = authorize_message(&account, &inbound).expect_err("should reject"); + assert!(err.contains("mention")); + } + + #[test] + fn resolve_receive_targets_for_p2p_includes_sender_fallbacks() { + let inbound = InboundMessage { + message_id: "m3".to_string(), + chat_id: "oc_123".to_string(), + chat_kind: "p2p".to_string(), + sender_id: Some("ou_abc".to_string()), + text: "hello".to_string(), + raw_content: None, + }; + let targets = resolve_receive_targets(&inbound); + let pairs = targets + .iter() + .map(|item| format!("{}:{}", item.receive_id_type, item.receive_id)) + .collect::>(); + assert!(pairs.contains(&"chat_id:oc_123".to_string())); + assert!(pairs.contains(&"open_id:ou_abc".to_string())); + assert!(pairs.contains(&"user_id:ou_abc".to_string())); + } + + #[test] + fn resolve_receive_targets_for_group_only_chat_id() { + let inbound = InboundMessage { + message_id: "m4".to_string(), + chat_id: "oc_group_1".to_string(), + chat_kind: "group".to_string(), + sender_id: Some("ou_abc".to_string()), + text: "hello".to_string(), + raw_content: None, + }; + let targets = resolve_receive_targets(&inbound); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].receive_id_type, "chat_id"); + assert_eq!(targets[0].receive_id, "oc_group_1"); + } +} diff --git a/src-tauri/crates/gateway/src/lib.rs b/src-tauri/crates/gateway/src/lib.rs new file mode 100644 index 000000000..ddfc797f9 --- /dev/null +++ b/src-tauri/crates/gateway/src/lib.rs @@ -0,0 +1,8 @@ +//! Gateway crate +//! +//! 承载渠道运行时(channel runtime)、路由与策略实现。 + +pub mod discord; +pub mod feishu; +pub mod telegram; +pub mod tunnel; diff --git a/src-tauri/crates/gateway/src/telegram.rs b/src-tauri/crates/gateway/src/telegram.rs new file mode 100644 index 000000000..aac854b85 --- /dev/null +++ b/src-tauri/crates/gateway/src/telegram.rs @@ -0,0 +1,2474 @@ +//! Telegram Gateway 运行时 +//! +//! 目标:将 Telegram 作为标准渠道接入,承载多账号轮询、路由与策略校验。 + +use chrono::Utc; +use proxycast_core::config::{ + Config, TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, +}; +use proxycast_core::database::DbConnection; +use proxycast_core::logger::LogStore; +use proxycast_websocket::handlers::{RpcHandler, RpcHandlerState}; +use proxycast_websocket::protocol::{ + AgentRunResult, AgentStopResult, AgentWaitResult, CronHealthResult, CronListResult, + CronRunResult, GatewayRpcRequest, GatewayRpcResponse, RpcMethod, SessionGetResult, + SessionsListResult, +}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const TELEGRAM_API_BASE: &str = "https://api.telegram.org"; +const DEFAULT_POLL_TIMEOUT_SECS: u64 = 25; +const TELEGRAM_MAX_MESSAGE_LEN: usize = 3800; +const RUN_WAIT_TIMEOUT_MS: u64 = 1200; +const RUN_WAIT_MAX_ROUNDS: usize = 180; +const CONFIRMATION_TTL_SECS: i64 = 90; + +type LogState = Arc>; +type SessionRouteState = Arc>>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StreamingMode { + Off, + Partial, + Block, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReplyToMode { + Off, + First, + All, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PlainTextReply { + AlreadySent, + Message(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TelegramGatewayAccountStatus { + pub account_id: String, + pub running: bool, + pub bot_username: Option, + pub started_at: Option, + pub last_error: Option, + pub last_update_id: Option, + pub last_message_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TelegramGatewayStatus { + pub running_accounts: usize, + pub accounts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelegramProbeResult { + pub account_id: String, + pub ok: bool, + pub bot_id: Option, + pub username: Option, + pub message: String, +} + +pub struct TelegramGatewayState { + inner: Arc>, +} + +struct TelegramGatewayRuntime { + accounts: HashMap, +} + +struct AccountRuntimeHandle { + stop_token: CancellationToken, + task: JoinHandle<()>, + status: Arc>, +} + +impl Default for TelegramGatewayState { + fn default() -> Self { + Self { + inner: Arc::new(RwLock::new(TelegramGatewayRuntime { + accounts: HashMap::new(), + })), + } + } +} + +#[derive(Debug, Clone)] +struct ResolvedTelegramAccount { + account_id: String, + bot_token: String, + default_model: Option, + dm_policy: String, + allow_from: HashSet, + group_policy: String, + group_allow_from: HashSet, + groups: HashMap, + streaming: String, + reply_to_mode: String, +} + +#[derive(Debug, Clone)] +struct InboundMessage { + message_id: i64, + chat_id: i64, + chat_kind: String, + sender_id: Option, + text: String, + message_thread_id: Option, +} + +#[derive(Debug, Clone)] +struct PendingConfirmation { + token: String, + command: TelegramCommand, + expires_at: chrono::DateTime, +} + +#[derive(Debug, Clone)] +enum TelegramCommand { + Run(String), + New(Option), + Status(String), + Stop(String), + CronList, + CronHealth, + CronRun(String), + Sessions, + Session(String), + Confirm(String), + Cancel, + Help, +} + +#[derive(Debug, Deserialize)] +struct TelegramApiResponse { + ok: bool, + result: Option, + description: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdate { + update_id: i64, + message: Option, + edited_message: Option, + callback_query: Option, +} + +#[derive(Debug, Deserialize, Clone)] +struct TelegramMessage { + #[serde(default)] + message_id: i64, + chat: TelegramChat, + from: Option, + text: Option, + caption: Option, + message_thread_id: Option, +} + +#[derive(Debug, Deserialize, Clone)] +struct TelegramCallbackQuery { + from: TelegramUser, + data: Option, + message: Option, +} + +#[derive(Debug, Deserialize, Clone)] +struct TelegramUser { + id: i64, +} + +#[derive(Debug, Deserialize, Clone)] +struct TelegramChat { + id: i64, + #[serde(rename = "type")] + kind: String, +} + +#[derive(Debug, Deserialize)] +struct TelegramMe { + id: i64, + username: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramSendResult { + message_id: i64, +} + +pub async fn start_gateway( + state: &TelegramGatewayState, + db: DbConnection, + logs: LogState, + config: Config, + account_filter: Option, + poll_timeout_secs: Option, +) -> Result { + let state = state.inner.clone(); + let accounts = resolve_telegram_accounts(&config.channels.telegram, account_filter.as_deref())?; + if accounts.is_empty() { + return Err("没有可启动的 Telegram 账号,请检查 channels.telegram 配置".to_string()); + } + + let poll_timeout = poll_timeout_secs + .unwrap_or(DEFAULT_POLL_TIMEOUT_SECS) + .clamp(5, 60); + + for account in accounts { + let existing = { + let runtime = state.read().await; + runtime.accounts.contains_key(&account.account_id) + }; + if existing { + continue; + } + + let status = Arc::new(RwLock::new(TelegramGatewayAccountStatus { + account_id: account.account_id.clone(), + running: true, + bot_username: None, + started_at: Some(Utc::now().to_rfc3339()), + last_error: None, + last_update_id: None, + last_message_at: None, + })); + let status_for_task = status.clone(); + let stop_token = CancellationToken::new(); + let stop_for_task = stop_token.clone(); + let account_for_task = account.clone(); + let state_for_task = state.clone(); + let db_for_task = db.clone(); + let logs_for_task = logs.clone(); + + let task = tokio::spawn(async move { + run_account_loop( + state_for_task, + status_for_task, + db_for_task, + logs_for_task, + account_for_task, + poll_timeout, + stop_for_task, + ) + .await; + }); + + let mut runtime = state.write().await; + runtime.accounts.insert( + account.account_id.clone(), + AccountRuntimeHandle { + stop_token, + task, + status, + }, + ); + } + + snapshot_status(state).await +} + +pub async fn stop_gateway( + state: &TelegramGatewayState, + account_filter: Option, +) -> Result { + let state = state.inner.clone(); + let mut handles = Vec::new(); + { + let mut runtime = state.write().await; + if let Some(account_id) = account_filter { + if let Some(handle) = runtime.accounts.remove(&account_id) { + handles.push(handle); + } + } else { + handles = runtime.accounts.drain().map(|(_, handle)| handle).collect(); + } + } + + for handle in &handles { + handle.stop_token.cancel(); + } + + for handle in handles { + let _ = tokio::time::timeout(std::time::Duration::from_secs(3), handle.task).await; + } + + snapshot_status(state).await +} + +pub async fn status_gateway(state: &TelegramGatewayState) -> Result { + snapshot_status(state.inner.clone()).await +} + +pub async fn probe_gateway_account( + config: &Config, + account_filter: Option, +) -> Result { + let accounts = resolve_telegram_accounts(&config.channels.telegram, account_filter.as_deref())?; + if accounts.is_empty() { + return Err("未找到可用 Telegram 账号".to_string()); + } + + let account = if account_filter.is_none() { + if let Some(default_account) = config.channels.telegram.default_account.as_deref() { + accounts + .iter() + .find(|item| item.account_id == default_account) + .cloned() + .unwrap_or_else(|| accounts[0].clone()) + } else { + accounts[0].clone() + } + } else { + accounts[0].clone() + }; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + match get_me(&client, &account.bot_token).await { + Ok(me) => Ok(TelegramProbeResult { + account_id: account.account_id, + ok: true, + bot_id: Some(me.id), + username: me.username, + message: "Telegram token 可用".to_string(), + }), + Err(error) => Ok(TelegramProbeResult { + account_id: account.account_id, + ok: false, + bot_id: None, + username: None, + message: error, + }), + } +} + +async fn snapshot_status( + state: Arc>, +) -> Result { + let handles = { + let runtime = state.read().await; + runtime + .accounts + .values() + .map(|handle| handle.status.clone()) + .collect::>() + }; + + let mut accounts = Vec::with_capacity(handles.len()); + for status in handles { + accounts.push(status.read().await.clone()); + } + accounts.sort_by(|a, b| a.account_id.cmp(&b.account_id)); + + Ok(TelegramGatewayStatus { + running_accounts: accounts.iter().filter(|item| item.running).count(), + accounts, + }) +} + +async fn run_account_loop( + runtime_state: Arc>, + status: Arc>, + db: DbConnection, + logs: LogState, + account: ResolvedTelegramAccount, + poll_timeout_secs: u64, + stop_token: CancellationToken, +) { + let streaming_mode = parse_streaming_mode(&account.streaming); + let reply_to_mode = parse_reply_to_mode(&account.reply_to_mode); + + logs.write().await.add( + "info", + &format!( + "[TelegramGateway] 启动账号轮询: account={} streaming={} replyToMode={}", + account.account_id, account.streaming, account.reply_to_mode + ), + ); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(poll_timeout_secs + 10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + if let Ok(me) = get_me(&client, &account.bot_token).await { + status.write().await.bot_username = me.username; + } + + let rpc_state = RpcHandlerState::new(Some(db), None, logs.clone()); + let rpc_handler = Arc::new(RpcHandler::new(rpc_state)); + let session_route_state: SessionRouteState = Arc::new(RwLock::new(HashMap::new())); + + let mut offset = status.read().await.last_update_id.unwrap_or(0); + let mut pending_confirmation: Option = None; + + loop { + if stop_token.is_cancelled() { + break; + } + + let updates = + match fetch_updates(&client, &account.bot_token, offset, poll_timeout_secs).await { + Ok(items) => { + status.write().await.last_error = None; + items + } + Err(error) => { + status.write().await.last_error = Some(error.clone()); + logs.write().await.add( + "warn", + &format!( + "[TelegramGateway] account={} 拉取更新失败: {}", + account.account_id, error + ), + ); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + continue; + } + }; + + for update in updates { + offset = offset.max(update.update_id); + status.write().await.last_update_id = Some(offset); + + let inbound = match to_inbound_message(update) { + Some(value) => value, + None => continue, + }; + + status.write().await.last_message_at = Some(Utc::now().to_rfc3339()); + + let bot_username = status.read().await.bot_username.clone().unwrap_or_default(); + let text = inbound.text.trim(); + if text.is_empty() { + continue; + } + let text_preview = summarize_text_preview(text, 64); + let message_thread_id = + sanitize_message_thread_id(&inbound.chat_kind, inbound.message_thread_id); + + logs.write().await.add( + "info", + &format!( + "[TelegramGateway] account={} 收到入站消息: chat={} kind={} sender={:?} messageId={} threadId={:?} text={}", + account.account_id, + inbound.chat_id, + inbound.chat_kind, + inbound.sender_id, + inbound.message_id, + inbound.message_thread_id, + text_preview + ), + ); + + if let Err(reason) = authorize_message(&account, &inbound, &bot_username) { + let _ = send_message_chunks( + &client, + &account.bot_token, + inbound.chat_id, + format!("❌ 拒绝访问: {}", reason), + message_thread_id, + reply_to_mode, + inbound.message_id, + ) + .await; + logs.write().await.add( + "warn", + &format!( + "[TelegramGateway] account={} 拒绝消息: chat={} sender={:?} reason={}", + account.account_id, inbound.chat_id, inbound.sender_id, reason + ), + ); + continue; + } + + let reply = if text.starts_with('/') { + logs.write().await.add( + "info", + &format!( + "[TelegramGateway] account={} 识别命令: chat={} text={}", + account.account_id, inbound.chat_id, text_preview + ), + ); + let command = match parse_telegram_command(text) { + Ok(value) => value, + Err(error) => { + let _ = send_message_chunks( + &client, + &account.bot_token, + inbound.chat_id, + error, + message_thread_id, + reply_to_mode, + inbound.message_id, + ) + .await; + logs.write().await.add( + "warn", + &format!( + "[TelegramGateway] account={} 命令解析失败: chat={} text={}", + account.account_id, inbound.chat_id, text_preview + ), + ); + continue; + } + }; + + match command { + TelegramCommand::Run(prompt) => { + tokio::spawn(process_plain_text_update( + client.clone(), + account.clone(), + rpc_handler.clone(), + logs.clone(), + session_route_state.clone(), + inbound.clone(), + prompt, + streaming_mode, + reply_to_mode, + )); + None + } + other => match handle_command( + &client, + &account, + rpc_handler.as_ref(), + &logs, + &inbound, + other, + &session_route_state, + &mut pending_confirmation, + ) + .await + { + Ok(text) => text, + Err(error) => Some(format!("❌ {}", error)), + }, + } + } else { + tokio::spawn(process_plain_text_update( + client.clone(), + account.clone(), + rpc_handler.clone(), + logs.clone(), + session_route_state.clone(), + inbound.clone(), + text.to_string(), + streaming_mode, + reply_to_mode, + )); + None + }; + + if let Some(reply_text) = reply { + if let Err(error) = send_message_chunks( + &client, + &account.bot_token, + inbound.chat_id, + reply_text, + message_thread_id, + reply_to_mode, + inbound.message_id, + ) + .await + { + logs.write().await.add( + "warn", + &format!( + "[TelegramGateway] account={} 发送消息失败: {}", + account.account_id, error + ), + ); + } + } + } + } + + { + let mut s = status.write().await; + s.running = false; + } + + { + let mut runtime = runtime_state.write().await; + runtime.accounts.remove(&account.account_id); + } + + logs.write().await.add( + "info", + &format!( + "[TelegramGateway] 账号轮询已停止: account={}", + account.account_id + ), + ); +} + +fn resolve_telegram_accounts( + telegram: &TelegramBotConfig, + account_filter: Option<&str>, +) -> Result, String> { + if !telegram.enabled { + return Ok(Vec::new()); + } + + let mut resolved = Vec::new(); + let legacy_allow = telegram + .allowed_user_ids + .iter() + .map(|item| normalize_allow_entry(item)) + .filter(|item| !item.is_empty()) + .collect::>(); + + if telegram.accounts.is_empty() { + if telegram.bot_token.trim().is_empty() { + return Ok(Vec::new()); + } + let allow_from = merge_allow_from(&telegram.allow_from, &legacy_allow); + resolved.push(ResolvedTelegramAccount { + account_id: "default".to_string(), + bot_token: telegram.bot_token.trim().to_string(), + default_model: normalize_optional_text(telegram.default_model.as_deref()), + dm_policy: normalize_dm_policy(&telegram.dm_policy), + allow_from, + group_policy: normalize_group_policy(&telegram.group_policy), + group_allow_from: normalize_allow_set(&telegram.group_allow_from), + groups: telegram.groups.clone(), + streaming: normalize_streaming_mode(&telegram.streaming), + reply_to_mode: normalize_reply_to_mode(&telegram.reply_to_mode), + }); + } else { + for (account_id, account) in &telegram.accounts { + if !account.enabled { + continue; + } + let token = resolve_account_token(telegram, account_id, account); + if token.trim().is_empty() { + continue; + } + let allow_from = if account.allow_from.is_empty() { + merge_allow_from(&telegram.allow_from, &legacy_allow) + } else { + normalize_allow_set(&account.allow_from) + }; + let group_allow_from = if account.group_allow_from.is_empty() { + normalize_allow_set(&telegram.group_allow_from) + } else { + normalize_allow_set(&account.group_allow_from) + }; + let groups = if account.groups.is_empty() { + telegram.groups.clone() + } else { + account.groups.clone() + }; + + resolved.push(ResolvedTelegramAccount { + account_id: account_id.to_string(), + bot_token: token, + default_model: normalize_optional_text( + account + .default_model + .as_deref() + .or(telegram.default_model.as_deref()), + ), + dm_policy: normalize_dm_policy( + account + .dm_policy + .as_deref() + .unwrap_or(telegram.dm_policy.as_str()), + ), + allow_from, + group_policy: normalize_group_policy( + account + .group_policy + .as_deref() + .unwrap_or(telegram.group_policy.as_str()), + ), + group_allow_from, + groups, + streaming: normalize_streaming_mode( + account + .streaming + .as_deref() + .unwrap_or(telegram.streaming.as_str()), + ), + reply_to_mode: normalize_reply_to_mode( + account + .reply_to_mode + .as_deref() + .unwrap_or(telegram.reply_to_mode.as_str()), + ), + }); + } + + if resolved.is_empty() && !telegram.bot_token.trim().is_empty() { + let allow_from = merge_allow_from(&telegram.allow_from, &legacy_allow); + resolved.push(ResolvedTelegramAccount { + account_id: "default".to_string(), + bot_token: telegram.bot_token.trim().to_string(), + default_model: normalize_optional_text(telegram.default_model.as_deref()), + dm_policy: normalize_dm_policy(&telegram.dm_policy), + allow_from, + group_policy: normalize_group_policy(&telegram.group_policy), + group_allow_from: normalize_allow_set(&telegram.group_allow_from), + groups: telegram.groups.clone(), + streaming: normalize_streaming_mode(&telegram.streaming), + reply_to_mode: normalize_reply_to_mode(&telegram.reply_to_mode), + }); + } + } + + if let Some(filter) = account_filter { + let selected = resolved + .into_iter() + .filter(|item| item.account_id == filter) + .collect::>(); + if selected.is_empty() { + return Err(format!("未找到 Telegram 账号: {}", filter)); + } + return Ok(selected); + } + + Ok(resolved) +} + +fn resolve_account_token( + telegram: &TelegramBotConfig, + account_id: &str, + account: &TelegramAccountConfig, +) -> String { + if let Some(token) = account.bot_token.as_ref() { + if !token.trim().is_empty() { + return token.trim().to_string(); + } + } + if let Some(token_file) = account.token_file.as_ref() { + if let Some(token) = read_bot_token_from_file(token_file) { + return token; + } + } + if account_id == "default" { + return telegram.bot_token.trim().to_string(); + } + String::new() +} + +fn read_bot_token_from_file(path: &str) -> Option { + let expanded = proxycast_core::config::expand_tilde(path); + let content = std::fs::read_to_string(expanded).ok()?; + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + + if let Ok(json) = serde_json::from_str::(trimmed) { + for key in ["bot_token", "token", "telegram_bot_token"] { + if let Some(value) = json.get(key).and_then(|item| item.as_str()) { + let candidate = value.trim(); + if !candidate.is_empty() { + return Some(candidate.to_string()); + } + } + } + } + + Some(trimmed.to_string()) +} + +fn merge_allow_from(top_allow_from: &[String], legacy_allow: &HashSet) -> HashSet { + let mut merged = normalize_allow_set(top_allow_from); + merged.extend(legacy_allow.iter().cloned()); + merged +} + +fn normalize_allow_set(input: &[String]) -> HashSet { + input + .iter() + .map(|item| normalize_allow_entry(item)) + .filter(|item| !item.is_empty()) + .collect::>() +} + +fn normalize_allow_entry(raw: &str) -> String { + raw.trim() + .trim_start_matches("telegram:") + .trim_start_matches("tg:") + .to_ascii_lowercase() +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| item.to_string()) +} + +fn normalize_dm_policy(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "allowlist" => "allowlist".to_string(), + "open" => "open".to_string(), + "disabled" => "disabled".to_string(), + _ => "pairing".to_string(), + } +} + +fn normalize_group_policy(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "open" => "open".to_string(), + "disabled" => "disabled".to_string(), + _ => "allowlist".to_string(), + } +} + +fn normalize_streaming_mode(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "off" => "off".to_string(), + "block" => "block".to_string(), + "progress" => "partial".to_string(), + _ => "partial".to_string(), + } +} + +fn normalize_reply_to_mode(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "first" => "first".to_string(), + "all" => "all".to_string(), + _ => "off".to_string(), + } +} + +fn parse_streaming_mode(raw: &str) -> StreamingMode { + match normalize_streaming_mode(raw).as_str() { + "off" => StreamingMode::Off, + "block" => StreamingMode::Block, + _ => StreamingMode::Partial, + } +} + +fn parse_reply_to_mode(raw: &str) -> ReplyToMode { + match normalize_reply_to_mode(raw).as_str() { + "first" => ReplyToMode::First, + "all" => ReplyToMode::All, + _ => ReplyToMode::Off, + } +} + +fn resolve_reply_to_message_id( + reply_to_mode: ReplyToMode, + inbound_message_id: i64, + is_first_chunk: bool, +) -> Option { + if inbound_message_id <= 0 { + return None; + } + + match reply_to_mode { + ReplyToMode::Off => None, + ReplyToMode::First => { + if is_first_chunk { + Some(inbound_message_id) + } else { + None + } + } + ReplyToMode::All => Some(inbound_message_id), + } +} + +fn to_inbound_message(update: TelegramUpdate) -> Option { + if let Some(message) = update.message.or(update.edited_message) { + let text = message.text.or(message.caption).unwrap_or_default(); + return Some(InboundMessage { + message_id: message.message_id, + chat_id: message.chat.id, + chat_kind: message.chat.kind, + sender_id: message.from.map(|item| item.id), + text, + message_thread_id: message.message_thread_id, + }); + } + + if let Some(callback) = update.callback_query { + let message = callback.message?; + let data = callback.data.unwrap_or_default(); + if data.is_empty() { + return None; + } + return Some(InboundMessage { + message_id: message.message_id, + chat_id: message.chat.id, + chat_kind: message.chat.kind, + sender_id: Some(callback.from.id), + text: format!("callback_data:{}", data), + message_thread_id: message.message_thread_id, + }); + } + + None +} + +fn authorize_message( + account: &ResolvedTelegramAccount, + inbound: &InboundMessage, + bot_username: &str, +) -> Result<(), String> { + if inbound.chat_kind == "private" { + return authorize_dm(account, inbound.sender_id); + } + + authorize_group(account, inbound, bot_username) +} + +fn authorize_dm(account: &ResolvedTelegramAccount, sender_id: Option) -> Result<(), String> { + let sender = sender_id + .map(|id| id.to_string()) + .ok_or_else(|| "无法识别发送者 ID".to_string())?; + + match account.dm_policy.as_str() { + "disabled" => Err("DM 已禁用".to_string()), + "open" => { + if account.allow_from.contains("*") { + Ok(()) + } else { + Err("dmPolicy=open 但 allow_from 缺少 '*'".to_string()) + } + } + "allowlist" => { + if account.allow_from.contains(&sender) || account.allow_from.contains("*") { + Ok(()) + } else { + Err(format!("发送者 {} 不在 DM allow_from", sender)) + } + } + _ => { + if account.allow_from.is_empty() { + Err("dmPolicy=pairing 但未配置 allow_from(当前为 fail-closed)".to_string()) + } else if account.allow_from.contains(&sender) || account.allow_from.contains("*") { + Ok(()) + } else { + Err(format!("发送者 {} 未通过 pairing allow_from", sender)) + } + } + } +} + +fn authorize_group( + account: &ResolvedTelegramAccount, + inbound: &InboundMessage, + bot_username: &str, +) -> Result<(), String> { + let chat_id = inbound.chat_id.to_string(); + let sender = inbound + .sender_id + .map(|item| item.to_string()) + .ok_or_else(|| "无法识别群组发送者 ID".to_string())?; + + let group_cfg = account + .groups + .get(&chat_id) + .or_else(|| account.groups.get("*")); + + let topic_cfg = group_cfg.and_then(|group| { + inbound + .message_thread_id + .map(|thread_id| thread_id.to_string()) + .and_then(|thread_id| { + group + .topics + .get(&thread_id) + .or_else(|| group.topics.get("*")) + }) + }); + + let effective_group_policy = topic_cfg + .and_then(|topic| topic.group_policy.as_deref()) + .or_else(|| group_cfg.and_then(|group| group.group_policy.as_deref())) + .map(normalize_group_policy) + .unwrap_or_else(|| account.group_policy.clone()); + + let group_allowed = match group_cfg { + Some(group) => group.enabled.unwrap_or(true), + None => effective_group_policy == "open", + }; + if !group_allowed { + return Err(format!("群组 {} 未加入 allowlist", chat_id)); + } + + let require_mention = topic_cfg + .and_then(|topic| topic.require_mention) + .or_else(|| group_cfg.and_then(|group| group.require_mention)) + .unwrap_or(true); + + if require_mention + && !is_slash_command(&inbound.text) + && !is_message_mentioning_bot(&inbound.text, bot_username) + { + return Err("群组消息需要 mention 触发".to_string()); + } + + match effective_group_policy.as_str() { + "disabled" => Err("群组消息已禁用".to_string()), + "open" => Ok(()), + _ => { + let allow_from = if let Some(topic) = topic_cfg { + if !topic.allow_from.is_empty() { + normalize_allow_set(&topic.allow_from) + } else if let Some(group) = group_cfg { + if !group.allow_from.is_empty() { + normalize_allow_set(&group.allow_from) + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + } + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + } + } else if let Some(group) = group_cfg { + if !group.allow_from.is_empty() { + normalize_allow_set(&group.allow_from) + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + } + } else if !account.group_allow_from.is_empty() { + account.group_allow_from.clone() + } else { + account.allow_from.clone() + }; + + if allow_from.contains(&sender) || allow_from.contains("*") { + Ok(()) + } else { + Err(format!("发送者 {} 不在群组 allow_from", sender)) + } + } + } +} + +fn is_message_mentioning_bot(text: &str, bot_username: &str) -> bool { + if text.trim().is_empty() { + return false; + } + + if bot_username.is_empty() { + return text.trim_start().starts_with('/'); + } + + let lowered = text.to_ascii_lowercase(); + let mention = format!("@{}", bot_username.to_ascii_lowercase()); + lowered.contains(&mention) +} + +fn is_slash_command(text: &str) -> bool { + text.trim_start().starts_with('/') +} + +fn summarize_text_preview(text: &str, max_chars: usize) -> String { + let mut preview: String = text.chars().take(max_chars).collect(); + if text.chars().count() > max_chars { + preview.push_str("..."); + } + preview +} + +fn sanitize_message_thread_id(chat_kind: &str, message_thread_id: Option) -> Option { + if chat_kind == "supergroup" { + return message_thread_id; + } + None +} + +async fn handle_plain_text_with_mode( + client: &reqwest::Client, + account: &ResolvedTelegramAccount, + rpc_handler: &RpcHandler, + logs: &LogState, + session_route_state: &SessionRouteState, + inbound: &InboundMessage, + text: String, + session_id_override: Option, + streaming_mode: StreamingMode, + reply_to_mode: ReplyToMode, +) -> Result { + let session_id = match session_id_override { + Some(value) => value, + None => resolve_active_session_id(account, inbound, session_route_state).await, + }; + let message_thread_id = + sanitize_message_thread_id(&inbound.chat_kind, inbound.message_thread_id); + let first_reply_to_message_id = + resolve_reply_to_message_id(reply_to_mode, inbound.message_id, true); + + logs.write().await.add( + "info", + &format!( + "[TelegramGateway] account={} 收到文本消息: chat={} sender={:?} messageId={} session={} streaming={:?} model={} web_search=true", + account.account_id, + inbound.chat_id, + inbound.sender_id, + inbound.message_id, + session_id, + streaming_mode, + account.default_model.as_deref().unwrap_or("") + ), + ); + + let mut progress_message_id = if streaming_mode == StreamingMode::Partial { + match send_message_get_id( + client, + &account.bot_token, + inbound.chat_id, + "⏳ 正在处理请求...", + message_thread_id, + first_reply_to_message_id, + ) + .await + { + Ok(message_id) => Some(message_id), + Err(error) => { + logs.write().await.add( + "warn", + &format!( + "[TelegramGateway] account={} 发送进度消息失败: {}", + account.account_id, error + ), + ); + None + } + } + } else { + None + }; + + let run_response = rpc_handler + .handle_request(GatewayRpcRequest { + jsonrpc: "2.0".to_string(), + id: Uuid::new_v4().to_string(), + method: RpcMethod::AgentRun, + params: Some(json!({ + "session_id": session_id, + "message": text, + "stream": false, + "model": account.default_model.clone(), + "web_search": true, + })), + }) + .await; + + let run_value = match run_response.result { + Some(value) => value, + None => { + let error = extract_rpc_error(run_response.error, "agent.run 失败"); + if let Some(message_id) = progress_message_id { + let _ = edit_message( + client, + &account.bot_token, + inbound.chat_id, + message_id, + &format!("❌ {}", error), + ) + .await; + return Ok(PlainTextReply::AlreadySent); + } + return Err(error); + } + }; + let run_result: AgentRunResult = parse_result(run_value)?; + logs.write().await.add( + "info", + &format!( + "[TelegramGateway] account={} agent.run 已创建: runId={} session={}", + account.account_id, run_result.run_id, run_result.session_id + ), + ); + + for round in 0..RUN_WAIT_MAX_ROUNDS { + let wait_response = rpc_handler + .handle_request(GatewayRpcRequest { + jsonrpc: "2.0".to_string(), + id: Uuid::new_v4().to_string(), + method: RpcMethod::AgentWait, + params: Some(json!({ + "run_id": run_result.run_id, + "timeout": RUN_WAIT_TIMEOUT_MS, + })), + }) + .await; + + let wait_value = match wait_response.result { + Some(value) => value, + None => { + let error = extract_rpc_error(wait_response.error, "agent.wait 失败"); + if let Some(message_id) = progress_message_id { + let _ = edit_message( + client, + &account.bot_token, + inbound.chat_id, + message_id, + &format!("❌ {}", error), + ) + .await; + return Ok(PlainTextReply::AlreadySent); + } + return Err(error); + } + }; + let wait_result: AgentWaitResult = parse_result(wait_value)?; + + if wait_result.completed { + let content = wait_result + .content + .unwrap_or_else(|| "任务已完成,但无可展示输出".to_string()); + logs.write().await.add( + "info", + &format!( + "[TelegramGateway] account={} runId={} 已完成: contentLen={}", + account.account_id, + run_result.run_id, + content.chars().count() + ), + ); + if let Some(message_id) = progress_message_id { + let chunks = split_message_chunks(&content); + if let Some(first_chunk) = chunks.first() { + if edit_message( + client, + &account.bot_token, + inbound.chat_id, + message_id, + first_chunk, + ) + .await + .is_ok() + { + for chunk in chunks.iter().skip(1) { + let reply_to = if reply_to_mode == ReplyToMode::All { + Some(inbound.message_id) + } else { + None + }; + let _ = send_message( + client, + &account.bot_token, + inbound.chat_id, + chunk, + message_thread_id, + reply_to, + ) + .await; + } + return Ok(PlainTextReply::AlreadySent); + } + } + } + return Ok(PlainTextReply::Message(content)); + } + + if matches!( + streaming_mode, + StreamingMode::Partial | StreamingMode::Block + ) && round % 3 == 0 + { + let _ = send_chat_action( + client, + &account.bot_token, + inbound.chat_id, + "typing", + message_thread_id, + ) + .await; + } + + if streaming_mode == StreamingMode::Partial && round % 5 == 0 { + if let Some(message_id) = progress_message_id { + let elapsed = ((round as u64) * RUN_WAIT_TIMEOUT_MS) / 1000; + let progress_text = format!("⏳ 正在处理,请稍候... {}s", elapsed.max(1)); + if edit_message( + client, + &account.bot_token, + inbound.chat_id, + message_id, + &progress_text, + ) + .await + .is_err() + { + progress_message_id = None; + } + } + } + + if round % 20 == 0 { + logs.write().await.add( + "info", + &format!( + "[TelegramGateway] account={} runId={} 等待中: round={} completed={}", + account.account_id, run_result.run_id, round, wait_result.completed + ), + ); + } + } + + let timeout_message = "等待任务完成超时,请稍后使用 /status 查询".to_string(); + logs.write().await.add( + "warn", + &format!( + "[TelegramGateway] account={} runId={} 等待超时: maxRounds={}", + account.account_id, run_result.run_id, RUN_WAIT_MAX_ROUNDS + ), + ); + if let Some(message_id) = progress_message_id.take() { + let _ = edit_message( + client, + &account.bot_token, + inbound.chat_id, + message_id, + &format!("⏱️ {}", timeout_message), + ) + .await; + return Ok(PlainTextReply::AlreadySent); + } + + Err(timeout_message) +} + +async fn process_plain_text_update( + client: reqwest::Client, + account: ResolvedTelegramAccount, + rpc_handler: Arc, + logs: LogState, + session_route_state: SessionRouteState, + inbound: InboundMessage, + text: String, + streaming_mode: StreamingMode, + reply_to_mode: ReplyToMode, +) { + let message_thread_id = + sanitize_message_thread_id(&inbound.chat_kind, inbound.message_thread_id); + let reply = match handle_plain_text_with_mode( + &client, + &account, + rpc_handler.as_ref(), + &logs, + &session_route_state, + &inbound, + text, + None, + streaming_mode, + reply_to_mode, + ) + .await + { + Ok(PlainTextReply::AlreadySent) => None, + Ok(PlainTextReply::Message(text)) => Some(text), + Err(error) => Some(format!("❌ {}", error)), + }; + + if let Some(reply_text) = reply { + if let Err(error) = send_message_chunks( + &client, + &account.bot_token, + inbound.chat_id, + reply_text, + message_thread_id, + reply_to_mode, + inbound.message_id, + ) + .await + { + logs.write().await.add( + "warn", + &format!( + "[TelegramGateway] account={} 文本任务回包失败: {}", + account.account_id, error + ), + ); + } + } +} + +fn build_session_key(account: &ResolvedTelegramAccount, inbound: &InboundMessage) -> String { + let thread_suffix = inbound + .message_thread_id + .map(|thread| format!(":topic:{}", thread)) + .unwrap_or_default(); + + if inbound.chat_kind == "private" { + return format!( + "agent:main:telegram:{}:direct:{}{}", + account.account_id, inbound.chat_id, thread_suffix + ); + } + + format!( + "agent:main:telegram:{}:group:{}{}", + account.account_id, inbound.chat_id, thread_suffix + ) +} + +async fn resolve_active_session_id( + account: &ResolvedTelegramAccount, + inbound: &InboundMessage, + session_route_state: &SessionRouteState, +) -> String { + let scope_key = build_session_key(account, inbound); + let state = session_route_state.read().await; + state + .get(&scope_key) + .cloned() + .unwrap_or_else(|| scope_key.to_string()) +} + +async fn rotate_active_session_id( + account: &ResolvedTelegramAccount, + inbound: &InboundMessage, + session_route_state: &SessionRouteState, +) -> String { + let scope_key = build_session_key(account, inbound); + let suffix = Uuid::new_v4() + .to_string() + .chars() + .take(8) + .collect::(); + let rotated = format!("{scope_key}:new:{suffix}"); + let mut state = session_route_state.write().await; + state.insert(scope_key, rotated.clone()); + rotated +} + +async fn handle_command( + client: &reqwest::Client, + account: &ResolvedTelegramAccount, + rpc_handler: &RpcHandler, + logs: &LogState, + inbound: &InboundMessage, + command: TelegramCommand, + session_route_state: &SessionRouteState, + pending_confirmation: &mut Option, +) -> Result, String> { + match command { + TelegramCommand::Help => Ok(Some(help_text())), + TelegramCommand::Cancel => { + *pending_confirmation = None; + Ok(Some("🧹 已取消待确认操作".to_string())) + } + TelegramCommand::Confirm(token) => { + let confirmed = take_pending_confirmation(pending_confirmation, &token)?; + dispatch_command(rpc_handler, confirmed).await.map(Some) + } + cmd if requires_confirmation(&cmd) => { + let token = set_pending_confirmation(pending_confirmation, cmd.clone()); + Ok(Some(format!( + "⚠️ 检测到危险操作:{}\n请在 {} 秒内发送 /confirm {} 继续,或发送 /cancel 取消。", + danger_command_label(&cmd), + CONFIRMATION_TTL_SECS, + token + ))) + } + TelegramCommand::Run(prompt) => { + match handle_plain_text_with_mode( + client, + account, + rpc_handler, + logs, + session_route_state, + inbound, + prompt, + None, + parse_streaming_mode(&account.streaming), + parse_reply_to_mode(&account.reply_to_mode), + ) + .await? + { + PlainTextReply::AlreadySent => Ok(None), + PlainTextReply::Message(text) => Ok(Some(text)), + } + } + TelegramCommand::New(first_prompt) => { + let new_session_id = + rotate_active_session_id(account, inbound, session_route_state).await; + logs.write().await.add( + "info", + &format!( + "[TelegramGateway] account={} 开启新会话: chat={} session={}", + account.account_id, inbound.chat_id, new_session_id + ), + ); + + if let Some(prompt) = first_prompt { + match handle_plain_text_with_mode( + client, + account, + rpc_handler, + logs, + session_route_state, + inbound, + prompt, + Some(new_session_id.clone()), + parse_streaming_mode(&account.streaming), + parse_reply_to_mode(&account.reply_to_mode), + ) + .await? + { + PlainTextReply::AlreadySent => Ok(None), + PlainTextReply::Message(text) => Ok(Some(text)), + } + } else { + Ok(Some(format!( + "🆕 已开启新对话\nsession_id: {}\n后续消息会在这个新会话中进行。", + new_session_id + ))) + } + } + cmd => dispatch_command(rpc_handler, cmd).await.map(Some), + } +} + +fn set_pending_confirmation( + pending_confirmation: &mut Option, + command: TelegramCommand, +) -> String { + let token = Uuid::new_v4() + .to_string() + .chars() + .take(8) + .collect::(); + let expires_at = Utc::now() + chrono::Duration::seconds(CONFIRMATION_TTL_SECS); + *pending_confirmation = Some(PendingConfirmation { + token: token.clone(), + command, + expires_at, + }); + token +} + +fn take_pending_confirmation( + pending_confirmation: &mut Option, + token: &str, +) -> Result { + let pending = pending_confirmation + .take() + .ok_or_else(|| "当前没有待确认操作".to_string())?; + + if Utc::now() > pending.expires_at { + return Err("确认已过期,请重新发起命令".to_string()); + } + if pending.token != token { + *pending_confirmation = Some(pending); + return Err("确认 token 不匹配".to_string()); + } + if !requires_confirmation(&pending.command) { + return Err("当前命令不需要确认".to_string()); + } + + Ok(pending.command) +} + +async fn dispatch_command( + rpc_handler: &RpcHandler, + command: TelegramCommand, +) -> Result { + let request = build_rpc_request(command)?; + let response = rpc_handler.handle_request(request).await; + format_rpc_response(response) +} + +fn build_rpc_request(command: TelegramCommand) -> Result { + let (method, params) = match command { + TelegramCommand::Run(message) => ( + RpcMethod::AgentRun, + Some(json!({ + "message": message, + "stream": false, + "web_search": true + })), + ), + TelegramCommand::Status(run_id) => ( + RpcMethod::AgentWait, + Some(json!({ "run_id": run_id, "timeout": 200 })), + ), + TelegramCommand::Stop(run_id) => (RpcMethod::AgentStop, Some(json!({ "run_id": run_id }))), + TelegramCommand::CronList => (RpcMethod::CronList, None), + TelegramCommand::CronHealth => (RpcMethod::CronHealth, None), + TelegramCommand::CronRun(task_id) => { + (RpcMethod::CronRun, Some(json!({ "task_id": task_id }))) + } + TelegramCommand::Sessions => (RpcMethod::SessionsList, None), + TelegramCommand::Session(session_id) => ( + RpcMethod::SessionsGet, + Some(json!({ "session_id": session_id })), + ), + TelegramCommand::New(_) => return Err("内部错误:new 不应构造 RPC 请求".to_string()), + TelegramCommand::Help => return Err("内部错误:help 不应构造 RPC 请求".to_string()), + TelegramCommand::Confirm(_) => { + return Err("内部错误:confirm 不应构造 RPC 请求".to_string()) + } + TelegramCommand::Cancel => return Err("内部错误:cancel 不应构造 RPC 请求".to_string()), + }; + + Ok(GatewayRpcRequest { + jsonrpc: "2.0".to_string(), + id: Uuid::new_v4().to_string(), + method, + params, + }) +} + +fn format_rpc_response(response: GatewayRpcResponse) -> Result { + if let Some(error) = response.error { + return Err(format!("{} (code={})", error.message, error.code)); + } + + let result_value = response + .result + .ok_or_else(|| "RPC 返回缺少 result".to_string())?; + match response_id_hint(&result_value) { + Some(ResponseHint::AgentRun) => { + let payload: AgentRunResult = parse_result(result_value)?; + Ok(format!( + "✅ 已启动\nrun_id: {}\nsession_id: {}\ncompleted: {}", + payload.run_id, payload.session_id, payload.completed + )) + } + Some(ResponseHint::AgentWait) => { + let payload: AgentWaitResult = parse_result(result_value)?; + if payload.completed { + Ok(format!( + "✅ 已完成\nrun_id: {}\n{}", + payload.run_id, + payload.content.unwrap_or_else(|| "无输出内容".to_string()) + )) + } else { + Ok(format!("⏳ 运行中\nrun_id: {}", payload.run_id)) + } + } + Some(ResponseHint::AgentStop) => { + let payload: AgentStopResult = parse_result(result_value)?; + Ok(format!( + "{} run_id: {}", + if payload.stopped { + "🛑 已停止" + } else { + "ℹ️ 未找到活跃任务" + }, + payload.run_id + )) + } + Some(ResponseHint::CronList) => { + let payload: CronListResult = parse_result(result_value)?; + if payload.tasks.is_empty() { + Ok("📭 当前无定时任务".to_string()) + } else { + let lines = payload + .tasks + .iter() + .take(10) + .map(|item| { + format!( + "- {} | {} | enabled={}", + item.task_id, item.name, item.enabled + ) + }) + .collect::>(); + Ok(format!( + "📌 定时任务(前 {} 条)\n{}", + lines.len(), + lines.join("\n") + )) + } + } + Some(ResponseHint::CronRun) => { + let payload: CronRunResult = parse_result(result_value)?; + Ok(format!( + "✅ cron 已触发\ntask_id: {}\nexecution_id: {}", + payload.task_id, payload.execution_id + )) + } + Some(ResponseHint::CronHealth) => { + let payload: CronHealthResult = parse_result(result_value)?; + Ok(format!( + "📊 cron 健康概览\n总任务: {}\n待执行: {}\n运行中: {}\n失败: {}\n冷却中: {}\n悬挂运行: {}\n24h 失败: {}", + payload.total_tasks, + payload.pending_tasks, + payload.running_tasks, + payload.failed_tasks, + payload.cooldown_tasks, + payload.stale_running_tasks, + payload.failed_last_24h, + )) + } + Some(ResponseHint::SessionsList) => { + let payload: SessionsListResult = parse_result(result_value)?; + if payload.sessions.is_empty() { + Ok("📭 当前无会话".to_string()) + } else { + let lines = payload + .sessions + .iter() + .take(10) + .map(|item| { + format!( + "- {} | model={} | msgs={}", + item.session_id, item.model, item.message_count + ) + }) + .collect::>(); + Ok(format!( + "🧵 会话列表(前 {} 条)\n{}", + lines.len(), + lines.join("\n") + )) + } + } + Some(ResponseHint::SessionGet) => { + let payload: SessionGetResult = parse_result(result_value)?; + Ok(format!( + "🧵 会话详情\nsession_id: {}\nmodel: {}\nmessages: {}", + payload.session_id, payload.model, payload.message_count + )) + } + None => Ok(format!("✅ 已处理\n{}", result_value)), + } +} + +enum ResponseHint { + AgentRun, + AgentWait, + AgentStop, + CronList, + CronRun, + CronHealth, + SessionsList, + SessionGet, +} + +fn response_id_hint(value: &serde_json::Value) -> Option { + if value.get("runId").is_some() && value.get("sessionId").is_some() { + return Some(ResponseHint::AgentRun); + } + if value.get("runId").is_some() && value.get("completed").is_some() { + return Some(ResponseHint::AgentWait); + } + if value.get("runId").is_some() && value.get("stopped").is_some() { + return Some(ResponseHint::AgentStop); + } + if value.get("tasks").is_some() { + return Some(ResponseHint::CronList); + } + if value.get("taskId").is_some() && value.get("executionId").is_some() { + return Some(ResponseHint::CronRun); + } + if value.get("totalTasks").is_some() && value.get("cooldownTasks").is_some() { + return Some(ResponseHint::CronHealth); + } + if value.get("sessions").is_some() { + return Some(ResponseHint::SessionsList); + } + if value.get("sessionId").is_some() && value.get("messageCount").is_some() { + return Some(ResponseHint::SessionGet); + } + None +} + +fn parse_result(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(|e| format!("解析 RPC 结果失败: {e}")) +} + +fn extract_rpc_error( + error: Option, + fallback: &str, +) -> String { + if let Some(err) = error { + return format!("{} (code={})", err.message, err.code); + } + fallback.to_string() +} + +fn parse_telegram_command(text: &str) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err(help_text()); + } + let mut parts = trimmed.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or_default(); + let rest = parts.next().unwrap_or_default().trim(); + let normalized_cmd = first + .split('@') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + + match normalized_cmd.as_str() { + "/run" => { + if rest.is_empty() { + Err("❌ 用法:/run <任务内容>".to_string()) + } else { + Ok(TelegramCommand::Run(rest.to_string())) + } + } + "/new" | "/reset" => { + if rest.is_empty() { + Ok(TelegramCommand::New(None)) + } else { + Ok(TelegramCommand::New(Some(rest.to_string()))) + } + } + "/status" => { + if rest.is_empty() { + Err("❌ 用法:/status ".to_string()) + } else { + Ok(TelegramCommand::Status(rest.to_string())) + } + } + "/stop" => { + if rest.is_empty() { + Err("❌ 用法:/stop ".to_string()) + } else { + Ok(TelegramCommand::Stop(rest.to_string())) + } + } + "/cron_list" => Ok(TelegramCommand::CronList), + "/cron_health" => Ok(TelegramCommand::CronHealth), + "/cron_run" => { + if rest.is_empty() { + Err("❌ 用法:/cron_run ".to_string()) + } else { + Ok(TelegramCommand::CronRun(rest.to_string())) + } + } + "/sessions" => Ok(TelegramCommand::Sessions), + "/session" => { + if rest.is_empty() { + Err("❌ 用法:/session ".to_string()) + } else { + Ok(TelegramCommand::Session(rest.to_string())) + } + } + "/confirm" => { + if rest.is_empty() { + Err("❌ 用法:/confirm ".to_string()) + } else { + Ok(TelegramCommand::Confirm(rest.to_string())) + } + } + "/cancel" => Ok(TelegramCommand::Cancel), + "/help" | "/start" => Ok(TelegramCommand::Help), + _ => Err(help_text()), + } +} + +fn requires_confirmation(command: &TelegramCommand) -> bool { + matches!( + command, + TelegramCommand::Stop(_) | TelegramCommand::CronRun(_) + ) +} + +fn danger_command_label(command: &TelegramCommand) -> &'static str { + match command { + TelegramCommand::Stop(_) => "/stop", + TelegramCommand::CronRun(_) => "/cron_run", + _ => "unknown", + } +} + +fn help_text() -> String { + [ + "🤖 ProxyCast Telegram Gateway 命令", + "/run <任务内容> - 启动一个 Agent 任务", + "/new [首条消息] - 开启新对话(/reset 同义)", + "/status - 查看任务状态", + "/stop - 停止任务(需确认)", + "/cron_list - 列出定时任务", + "/cron_health - 查看定时任务健康概览", + "/cron_run - 触发定时任务(需确认)", + "/sessions - 列出会话", + "/session - 查看会话摘要", + "/confirm - 确认危险操作", + "/cancel - 取消待确认操作", + "/help - 查看帮助", + ] + .join("\n") +} + +async fn fetch_updates( + client: &reqwest::Client, + bot_token: &str, + current_offset: i64, + timeout_secs: u64, +) -> Result, String> { + let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/getUpdates"); + let offset = current_offset.saturating_add(1); + let response = client + .get(url) + .query(&[ + ("timeout", timeout_secs.to_string()), + ("offset", offset.to_string()), + ( + "allowed_updates", + r#"[\"message\",\"edited_message\",\"callback_query\",\"message_reaction\"]"# + .to_string(), + ), + ]) + .send() + .await + .map_err(|e| format!("HTTP 请求失败: {e}"))?; + + let body = response + .text() + .await + .map_err(|e| format!("读取响应失败: {e}"))?; + let parsed: TelegramApiResponse> = + serde_json::from_str(&body).map_err(|e| format!("响应解析失败: {e}"))?; + + if !parsed.ok { + return Err(parsed + .description + .unwrap_or_else(|| "Telegram API 返回失败".to_string())); + } + + Ok(parsed.result.unwrap_or_default()) +} + +async fn get_me(client: &reqwest::Client, bot_token: &str) -> Result { + let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/getMe"); + let response = client + .get(url) + .send() + .await + .map_err(|e| format!("请求 getMe 失败: {e}"))?; + let body = response + .text() + .await + .map_err(|e| format!("读取 getMe 响应失败: {e}"))?; + let parsed: TelegramApiResponse = + serde_json::from_str(&body).map_err(|e| format!("解析 getMe 响应失败: {e}"))?; + if !parsed.ok { + return Err(parsed + .description + .unwrap_or_else(|| "Telegram getMe 失败".to_string())); + } + parsed + .result + .ok_or_else(|| "Telegram getMe 缺少 result".to_string()) +} + +fn split_message_chunks(text: &str) -> Vec { + let total_chars = text.chars().count(); + if total_chars <= TELEGRAM_MAX_MESSAGE_LEN { + return vec![text.to_string()]; + } + + let chars: Vec = text.chars().collect(); + let mut chunks = Vec::new(); + let mut start = 0usize; + while start < chars.len() { + let end = (start + TELEGRAM_MAX_MESSAGE_LEN).min(chars.len()); + let chunk: String = chars[start..end].iter().collect(); + chunks.push(chunk); + start = end; + } + + chunks +} + +fn build_send_message_payload( + chat_id: i64, + text: &str, + message_thread_id: Option, + reply_to_message_id: Option, +) -> serde_json::Value { + let mut payload = serde_json::Map::new(); + payload.insert("chat_id".to_string(), json!(chat_id)); + payload.insert("text".to_string(), json!(truncate_message(text))); + if let Some(thread_id) = message_thread_id { + payload.insert("message_thread_id".to_string(), json!(thread_id)); + } + if let Some(reply_id) = reply_to_message_id { + payload.insert("reply_to_message_id".to_string(), json!(reply_id)); + } + serde_json::Value::Object(payload) +} + +fn is_thread_related_send_error(error: &str) -> bool { + let lowered = error.to_ascii_lowercase(); + lowered.contains("message thread") + || lowered.contains("message_thread_id") + || lowered.contains("topic") +} + +fn is_reply_related_send_error(error: &str) -> bool { + let lowered = error.to_ascii_lowercase(); + lowered.contains("reply_to_message_id") + || lowered.contains("message to be replied not found") + || lowered.contains("reply message not found") +} + +async fn do_send_message( + client: &reqwest::Client, + bot_token: &str, + chat_id: i64, + text: &str, + message_thread_id: Option, + reply_to_message_id: Option, +) -> Result<(), String> { + let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/sendMessage"); + let payload = build_send_message_payload(chat_id, text, message_thread_id, reply_to_message_id); + let response = client + .post(url) + .json(&payload) + .send() + .await + .map_err(|e| format!("发送消息失败: {e}"))?; + let body = response + .text() + .await + .map_err(|e| format!("读取响应失败: {e}"))?; + let parsed: TelegramApiResponse = + serde_json::from_str(&body).map_err(|e| format!("响应解析失败: {e}"))?; + if !parsed.ok { + return Err(parsed + .description + .unwrap_or_else(|| "Telegram API 返回失败".to_string())); + } + Ok(()) +} + +async fn do_send_message_get_id( + client: &reqwest::Client, + bot_token: &str, + chat_id: i64, + text: &str, + message_thread_id: Option, + reply_to_message_id: Option, +) -> Result { + let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/sendMessage"); + let payload = build_send_message_payload(chat_id, text, message_thread_id, reply_to_message_id); + let response = client + .post(url) + .json(&payload) + .send() + .await + .map_err(|e| format!("发送消息失败: {e}"))?; + let body = response + .text() + .await + .map_err(|e| format!("读取响应失败: {e}"))?; + let parsed: TelegramApiResponse = + serde_json::from_str(&body).map_err(|e| format!("响应解析失败: {e}"))?; + if !parsed.ok { + return Err(parsed + .description + .unwrap_or_else(|| "Telegram API 返回失败".to_string())); + } + parsed + .result + .map(|item| item.message_id) + .ok_or_else(|| "Telegram sendMessage 缺少 message_id".to_string()) +} + +async fn send_message_chunks( + client: &reqwest::Client, + bot_token: &str, + chat_id: i64, + text: String, + message_thread_id: Option, + reply_to_mode: ReplyToMode, + inbound_message_id: i64, +) -> Result<(), String> { + let chunks = split_message_chunks(&text); + for (index, chunk) in chunks.iter().enumerate() { + let reply_to = resolve_reply_to_message_id(reply_to_mode, inbound_message_id, index == 0); + send_message( + client, + bot_token, + chat_id, + chunk, + message_thread_id, + reply_to, + ) + .await?; + } + Ok(()) +} + +async fn send_message( + client: &reqwest::Client, + bot_token: &str, + chat_id: i64, + text: &str, + message_thread_id: Option, + reply_to_message_id: Option, +) -> Result<(), String> { + match do_send_message( + client, + bot_token, + chat_id, + text, + message_thread_id, + reply_to_message_id, + ) + .await + { + Ok(()) => Ok(()), + Err(primary) => { + if message_thread_id.is_some() && is_thread_related_send_error(&primary) { + return do_send_message( + client, + bot_token, + chat_id, + text, + None, + reply_to_message_id, + ) + .await + .map_err(|fallback| { + format!("{}; 去掉 message_thread_id 重试失败: {}", primary, fallback) + }); + } + if reply_to_message_id.is_some() && is_reply_related_send_error(&primary) { + return do_send_message(client, bot_token, chat_id, text, message_thread_id, None) + .await + .map_err(|fallback| { + format!( + "{}; 去掉 reply_to_message_id 重试失败: {}", + primary, fallback + ) + }); + } + Err(primary) + } + } +} + +async fn send_message_get_id( + client: &reqwest::Client, + bot_token: &str, + chat_id: i64, + text: &str, + message_thread_id: Option, + reply_to_message_id: Option, +) -> Result { + match do_send_message_get_id( + client, + bot_token, + chat_id, + text, + message_thread_id, + reply_to_message_id, + ) + .await + { + Ok(message_id) => Ok(message_id), + Err(primary) => { + if message_thread_id.is_some() && is_thread_related_send_error(&primary) { + return do_send_message_get_id( + client, + bot_token, + chat_id, + text, + None, + reply_to_message_id, + ) + .await + .map_err(|fallback| { + format!("{}; 去掉 message_thread_id 重试失败: {}", primary, fallback) + }); + } + if reply_to_message_id.is_some() && is_reply_related_send_error(&primary) { + return do_send_message_get_id( + client, + bot_token, + chat_id, + text, + message_thread_id, + None, + ) + .await + .map_err(|fallback| { + format!( + "{}; 去掉 reply_to_message_id 重试失败: {}", + primary, fallback + ) + }); + } + Err(primary) + } + } +} + +async fn edit_message( + client: &reqwest::Client, + bot_token: &str, + chat_id: i64, + message_id: i64, + text: &str, +) -> Result<(), String> { + let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/editMessageText"); + let payload = json!({ + "chat_id": chat_id, + "message_id": message_id, + "text": truncate_message(text), + }); + let response = client + .post(url) + .json(&payload) + .send() + .await + .map_err(|e| format!("编辑消息失败: {e}"))?; + let body = response + .text() + .await + .map_err(|e| format!("读取响应失败: {e}"))?; + let parsed: TelegramApiResponse = + serde_json::from_str(&body).map_err(|e| format!("响应解析失败: {e}"))?; + if !parsed.ok { + return Err(parsed + .description + .unwrap_or_else(|| "Telegram editMessageText 失败".to_string())); + } + Ok(()) +} + +async fn send_chat_action( + client: &reqwest::Client, + bot_token: &str, + chat_id: i64, + action: &str, + message_thread_id: Option, +) -> Result<(), String> { + let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/sendChatAction"); + let mut payload = serde_json::Map::new(); + payload.insert("chat_id".to_string(), json!(chat_id)); + payload.insert("action".to_string(), json!(action)); + if let Some(thread_id) = message_thread_id { + payload.insert("message_thread_id".to_string(), json!(thread_id)); + } + let response = client + .post(url) + .json(&serde_json::Value::Object(payload)) + .send() + .await + .map_err(|e| format!("发送 chat action 失败: {e}"))?; + let body = response + .text() + .await + .map_err(|e| format!("读取响应失败: {e}"))?; + let parsed: TelegramApiResponse = + serde_json::from_str(&body).map_err(|e| format!("响应解析失败: {e}"))?; + if !parsed.ok { + return Err(parsed + .description + .unwrap_or_else(|| "Telegram sendChatAction 失败".to_string())); + } + Ok(()) +} + +fn truncate_message(text: &str) -> String { + if text.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN { + return text.to_string(); + } + let truncated: String = text.chars().take(TELEGRAM_MAX_MESSAGE_LEN).collect(); + format!("{truncated}\n...[truncated]") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::Arc; + use tokio::sync::RwLock; + + fn build_account(token: Option<&str>) -> TelegramAccountConfig { + let mut account = TelegramAccountConfig::default(); + account.enabled = true; + account.bot_token = token.map(|item| item.to_string()); + account + } + + #[test] + fn resolve_accounts_keeps_all_enabled_accounts() { + let mut telegram = TelegramBotConfig::default(); + telegram.enabled = true; + telegram.default_account = Some("alpha".to_string()); + telegram + .accounts + .insert("alpha".to_string(), build_account(Some("token-alpha"))); + telegram + .accounts + .insert("beta".to_string(), build_account(Some("token-beta"))); + + let resolved = resolve_telegram_accounts(&telegram, None).expect("账号解析失败"); + let mut ids = resolved + .iter() + .map(|item| item.account_id.clone()) + .collect::>(); + ids.sort(); + + assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]); + } + + #[test] + fn resolve_account_supports_token_file_json() { + let mut token_file = tempfile::NamedTempFile::new().expect("创建临时文件失败"); + writeln!(token_file, "{{\"bot_token\":\"token-from-file\"}}").expect("写入 token 文件失败"); + + let mut telegram = TelegramBotConfig::default(); + telegram.enabled = true; + let mut account = build_account(None); + account.token_file = Some(token_file.path().to_string_lossy().to_string()); + telegram.accounts.insert("alpha".to_string(), account); + + let resolved = resolve_telegram_accounts(&telegram, Some("alpha")).expect("账号解析失败"); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].bot_token, "token-from-file".to_string()); + } + + #[test] + fn split_message_chunks_respects_limit() { + let source = "x".repeat(TELEGRAM_MAX_MESSAGE_LEN + 12); + let chunks = split_message_chunks(&source); + + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].chars().count(), TELEGRAM_MAX_MESSAGE_LEN); + assert_eq!(chunks[1].chars().count(), 12); + } + + #[test] + fn resolve_reply_to_message_id_by_mode() { + assert_eq!( + resolve_reply_to_message_id(ReplyToMode::Off, 99, true), + None + ); + assert_eq!( + resolve_reply_to_message_id(ReplyToMode::First, 99, true), + Some(99) + ); + assert_eq!( + resolve_reply_to_message_id(ReplyToMode::First, 99, false), + None + ); + assert_eq!( + resolve_reply_to_message_id(ReplyToMode::All, 99, false), + Some(99) + ); + } + + #[test] + fn authorize_group_allows_slash_command_without_mention() { + let account = ResolvedTelegramAccount { + account_id: "default".to_string(), + bot_token: "token".to_string(), + default_model: None, + dm_policy: "allowlist".to_string(), + allow_from: HashSet::new(), + group_policy: "open".to_string(), + group_allow_from: HashSet::new(), + groups: HashMap::new(), + streaming: "partial".to_string(), + reply_to_mode: "off".to_string(), + }; + let inbound = InboundMessage { + message_id: 1, + chat_id: -10012345, + chat_kind: "group".to_string(), + sender_id: Some(42), + text: "/help".to_string(), + message_thread_id: None, + }; + + let result = authorize_group(&account, &inbound, "proxycast_bot"); + assert!(result.is_ok(), "slash command 应该允许直接触发"); + } + + #[test] + fn sanitize_message_thread_id_only_for_supergroup() { + assert_eq!(sanitize_message_thread_id("private", Some(123)), None); + assert_eq!(sanitize_message_thread_id("group", Some(123)), None); + assert_eq!( + sanitize_message_thread_id("supergroup", Some(123)), + Some(123) + ); + } + + #[test] + fn send_error_classifier_detects_thread_and_reply_cases() { + assert!(is_thread_related_send_error( + "Bad Request: message thread not found" + )); + assert!(is_reply_related_send_error( + "Bad Request: message to be replied not found" + )); + } + + #[test] + fn parse_new_command_supports_optional_prompt() { + assert!(matches!( + parse_telegram_command("/new"), + Ok(TelegramCommand::New(None)) + )); + assert!(matches!( + parse_telegram_command("/reset"), + Ok(TelegramCommand::New(None)) + )); + assert!(matches!( + parse_telegram_command("/new 你好"), + Ok(TelegramCommand::New(Some(prompt))) if prompt == "你好" + )); + } + + #[test] + fn help_text_contains_new_command() { + let text = help_text(); + assert!(text.contains("/new")); + } + + #[tokio::test] + async fn rotate_session_id_switches_active_session() { + let account = ResolvedTelegramAccount { + account_id: "default".to_string(), + bot_token: "token".to_string(), + default_model: None, + dm_policy: "allowlist".to_string(), + allow_from: HashSet::new(), + group_policy: "open".to_string(), + group_allow_from: HashSet::new(), + groups: HashMap::new(), + streaming: "partial".to_string(), + reply_to_mode: "off".to_string(), + }; + let inbound = InboundMessage { + message_id: 1, + chat_id: 42, + chat_kind: "private".to_string(), + sender_id: Some(42), + text: "hello".to_string(), + message_thread_id: None, + }; + let state: SessionRouteState = Arc::new(RwLock::new(HashMap::new())); + + let before = resolve_active_session_id(&account, &inbound, &state).await; + let rotated = rotate_active_session_id(&account, &inbound, &state).await; + let after = resolve_active_session_id(&account, &inbound, &state).await; + + assert_eq!(after, rotated); + assert_ne!(before, rotated); + } +} diff --git a/src-tauri/crates/gateway/src/tunnel.rs b/src-tauri/crates/gateway/src/tunnel.rs new file mode 100644 index 000000000..43004b5f8 --- /dev/null +++ b/src-tauri/crates/gateway/src/tunnel.rs @@ -0,0 +1,682 @@ +//! Gateway 全局隧道运行时 +//! +//! 目标:为 webhook 渠道提供公网入口(优先 Cloudflare Tunnel)。 + +use chrono::Utc; +use proxycast_core::config::{Config, GatewayTunnelConfig}; +use proxycast_core::logger::LogStore; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::{ffi::OsStr, process::Stdio}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; +use tokio::time::{timeout, Duration}; + +type LogState = Arc>; +const MANUAL_STOP_REASON: &str = "tunnel 已手动停止"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GatewayTunnelStatus { + pub running: bool, + pub provider: String, + pub mode: String, + pub binary: String, + pub local_url: String, + pub public_base_url: Option, + pub pid: Option, + pub started_at: Option, + pub last_error: Option, + pub last_exit: Option, + pub command_preview: Option, + pub connector_active: Option, + pub connector_message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GatewayTunnelProbeResult { + pub ok: bool, + pub provider: String, + pub mode: String, + pub binary: String, + pub version: Option, + pub config_ready: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CloudflareTunnelCreateResult { + pub ok: bool, + pub tunnel_name: String, + pub tunnel_id: Option, + pub credentials_file: Option, + pub dns_name: Option, + pub public_base_url: Option, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CloudflareTunnelCreateRequest { + pub tunnel_name: String, + pub dns_name: Option, +} + +#[derive(Default, Clone)] +pub struct GatewayTunnelState { + inner: Arc>, +} + +#[derive(Default)] +struct GatewayTunnelRuntime { + process: Option, + status: GatewayTunnelStatus, +} + +struct RunningProcess { + child: Child, + stdout_task: Option>, + stderr_task: Option>, +} + +pub async fn probe_tunnel(config: &Config) -> GatewayTunnelProbeResult { + let tunnel = &config.gateway.tunnel; + let provider = normalize_provider(&tunnel.provider); + let mode = normalize_mode(&tunnel.mode); + let binary = resolve_binary(tunnel).to_string(); + + if provider != "cloudflare" { + return GatewayTunnelProbeResult { + ok: false, + provider, + mode, + binary, + version: None, + config_ready: false, + message: "当前仅支持 cloudflare provider".to_string(), + }; + } + + let version_output = Command::new(&binary).arg("--version").output().await; + let version = version_output + .ok() + .and_then(|out| { + String::from_utf8(out.stdout) + .ok() + .or_else(|| String::from_utf8(out.stderr).ok()) + }) + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()); + + let has_runtime_auth = tunnel + .cloudflare + .run_token + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .is_some() + || tunnel + .cloudflare + .tunnel_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .is_some(); + + GatewayTunnelProbeResult { + ok: version.is_some(), + provider, + mode, + binary, + version, + config_ready: has_runtime_auth, + message: if has_runtime_auth { + "探测成功,可尝试启动隧道".to_string() + } else { + "探测成功,但缺少 run_token 或 tunnel_id".to_string() + }, + } +} + +pub async fn create_cloudflare_tunnel( + config: &Config, + logs: LogState, + request: CloudflareTunnelCreateRequest, +) -> Result { + let tunnel = &config.gateway.tunnel; + let binary = resolve_binary(tunnel); + let tunnel_name = request.tunnel_name.trim(); + if tunnel_name.is_empty() { + return Err("tunnel_name 不能为空".to_string()); + } + + logs.write().await.add( + "info", + &format!( + "[GatewayTunnel] 开始创建 Cloudflare Tunnel: name={}", + tunnel_name + ), + ); + + let create_output = Command::new(binary) + .args(["tunnel", "create", tunnel_name]) + .output() + .await + .map_err(|e| format!("执行 cloudflared tunnel create 失败: {e}"))?; + + let merged_output = merge_output(&create_output.stdout, &create_output.stderr); + if !create_output.status.success() { + return Err(format!( + "cloudflared tunnel create 失败: {}", + trim_output_for_error(&merged_output) + )); + } + + let tunnel_id = extract_uuid(&merged_output); + let credentials_file = extract_json_path(&merged_output); + let dns_name = request + .dns_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let public_base_url = dns_name.as_ref().map(|host| format!("https://{}", host)); + + if let (Some(id), Some(host)) = (tunnel_id.as_deref(), dns_name.as_deref()) { + let dns_output = Command::new(binary) + .args(["tunnel", "route", "dns", id, host]) + .output() + .await + .map_err(|e| format!("执行 cloudflared tunnel route dns 失败: {e}"))?; + let dns_text = merge_output(&dns_output.stdout, &dns_output.stderr); + if !dns_output.status.success() { + return Err(format!( + "cloudflared tunnel route dns 失败: {}", + trim_output_for_error(&dns_text) + )); + } + } + + Ok(CloudflareTunnelCreateResult { + ok: true, + tunnel_name: tunnel_name.to_string(), + tunnel_id, + credentials_file, + dns_name, + public_base_url, + message: "Cloudflare Tunnel 创建成功".to_string(), + }) +} + +pub async fn start_tunnel( + state: &GatewayTunnelState, + logs: LogState, + config: Config, +) -> Result { + { + let mut runtime = state.inner.write().await; + poll_runtime_process(&mut runtime)?; + if runtime.status.running { + return Ok(runtime.status.clone()); + } + } + + let tunnel = &config.gateway.tunnel; + if !tunnel.enabled { + return Err("gateway.tunnel.enabled=false,请先启用隧道".to_string()); + } + + let provider = normalize_provider(&tunnel.provider); + if provider != "cloudflare" { + return Err(format!("暂不支持的 tunnel provider: {}", tunnel.provider)); + } + + let mode = normalize_mode(&tunnel.mode); + if mode == "external" { + return Err( + "external 模式不支持应用内启动,请手动启动后仅填写 public_base_url".to_string(), + ); + } + + let binary = resolve_binary(tunnel).to_string(); + let local_url = build_local_url(tunnel); + let public_base_url = resolve_public_base_url(tunnel); + let (args, preview) = build_cloudflare_run_args(tunnel, &local_url)?; + + let mut command = Command::new(&binary); + command + .args(args.iter().map(String::as_str)) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command + .spawn() + .map_err(|e| format!("启动 tunnel 进程失败: {e}"))?; + + let pid = child.id(); + let stdout_task = child.stdout.take().map(|stdout| { + let logs = logs.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + logs.write() + .await + .add("info", &format!("[GatewayTunnel][stdout] {}", line)); + } + }) + }); + let stderr_task = child.stderr.take().map(|stderr| { + let logs = logs.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + logs.write() + .await + .add("warn", &format!("[GatewayTunnel][stderr] {}", line)); + } + }) + }); + + logs.write().await.add( + "info", + &format!( + "[GatewayTunnel] 已启动: provider={} mode={} local={} public={:?}", + provider, mode, local_url, public_base_url + ), + ); + + let mut runtime = state.inner.write().await; + runtime.process = Some(RunningProcess { + child, + stdout_task, + stderr_task, + }); + runtime.status = GatewayTunnelStatus { + running: true, + provider, + mode, + binary, + local_url, + public_base_url, + pid, + started_at: Some(Utc::now().to_rfc3339()), + last_error: None, + last_exit: None, + command_preview: Some(preview), + connector_active: None, + connector_message: None, + }; + Ok(runtime.status.clone()) +} + +pub async fn stop_tunnel( + state: &GatewayTunnelState, + logs: LogState, +) -> Result { + let process = { + let mut runtime = state.inner.write().await; + runtime.status.running = false; + runtime.process.take() + }; + + if let Some(mut process) = process { + let _ = process.child.start_kill(); + let _ = timeout(Duration::from_secs(3), process.child.wait()).await; + if let Some(task) = process.stdout_task { + task.abort(); + } + if let Some(task) = process.stderr_task { + task.abort(); + } + logs.write() + .await + .add("info", "[GatewayTunnel] 隧道进程已停止"); + } + + let mut runtime = state.inner.write().await; + runtime.status.running = false; + runtime.status.pid = None; + runtime.status.started_at = None; + runtime.status.last_exit = None; + runtime.status.last_error = Some(MANUAL_STOP_REASON.to_string()); + Ok(runtime.status.clone()) +} + +pub async fn status_tunnel(state: &GatewayTunnelState) -> Result { + let mut runtime = state.inner.write().await; + poll_runtime_process(&mut runtime)?; + Ok(runtime.status.clone()) +} + +pub async fn status_tunnel_with_config( + state: &GatewayTunnelState, + config: Option, +) -> Result { + let mut status = status_tunnel(state).await?; + if let Some(cfg) = config.as_ref() { + apply_connector_diagnostics(cfg, &mut status).await; + + if normalize_mode(&cfg.gateway.tunnel.mode) == "external" { + status.running = status.connector_active.unwrap_or(false); + if !status.running && status.last_error.is_none() { + status.last_error = Some( + "external 模式未检测到活跃 cloudflared 连接,请先在系统服务中启动 tunnel" + .to_string(), + ); + } + } + } + Ok(status) +} + +fn poll_runtime_process(runtime: &mut GatewayTunnelRuntime) -> Result<(), String> { + let mut exited = None; + if let Some(process) = runtime.process.as_mut() { + match process.child.try_wait() { + Ok(Some(status)) => { + exited = Some(status.to_string()); + } + Ok(None) => {} + Err(e) => { + runtime.status.last_error = Some(format!("查询 tunnel 进程状态失败: {e}")); + } + } + } + if let Some(exit_status) = exited { + runtime.process = None; + runtime.status.running = false; + runtime.status.pid = None; + runtime.status.started_at = None; + runtime.status.last_exit = Some(exit_status.clone()); + runtime.status.last_error = Some(format!("tunnel 进程已退出: {}", exit_status)); + } + Ok(()) +} + +fn resolve_binary(tunnel: &GatewayTunnelConfig) -> &str { + tunnel + .binary_path + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or("cloudflared") +} + +fn build_local_url(tunnel: &GatewayTunnelConfig) -> String { + format!( + "http://{}:{}", + tunnel.local_host.trim(), + tunnel.local_port.max(1) + ) +} + +fn resolve_public_base_url(tunnel: &GatewayTunnelConfig) -> Option { + tunnel + .public_base_url + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(strip_trailing_slash) + .or_else(|| { + tunnel + .cloudflare + .dns_name + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(|host| format!("https://{}", host)) + }) +} + +fn strip_trailing_slash(value: &str) -> String { + value.trim_end_matches('/').to_string() +} + +async fn apply_connector_diagnostics(config: &Config, status: &mut GatewayTunnelStatus) { + let tunnel = &config.gateway.tunnel; + if !tunnel.enabled { + status.connector_active = None; + status.connector_message = None; + return; + } + if normalize_provider(&tunnel.provider) != "cloudflare" { + status.connector_active = None; + status.connector_message = None; + return; + } + let Some(tunnel_id) = tunnel + .cloudflare + .tunnel_id + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + else { + status.connector_active = None; + status.connector_message = + Some("缺少 cloudflare.tunnel_id,无法诊断连接器状态".to_string()); + return; + }; + + let binary = resolve_binary(tunnel).to_string(); + let output_result = timeout( + Duration::from_secs(8), + Command::new(binary) + .args(["tunnel", "info", tunnel_id]) + .output(), + ) + .await; + + let output = match output_result { + Ok(Ok(out)) => out, + Ok(Err(e)) => { + status.connector_active = None; + status.connector_message = Some(format!("查询 cloudflared tunnel info 失败: {e}")); + return; + } + Err(_) => { + status.connector_active = None; + status.connector_message = Some("查询 cloudflared tunnel info 超时".to_string()); + return; + } + }; + + let merged = merge_output(&output.stdout, &output.stderr); + let (active, message) = parse_cloudflare_tunnel_info(&merged); + status.connector_active = Some(active); + status.connector_message = Some(message.clone()); + if !active { + status.last_error = Some(format!( + "Cloudflare Tunnel 无活跃连接器(可能导致 1033/回调不可达): {}", + message + )); + } +} + +fn parse_cloudflare_tunnel_info(raw: &str) -> (bool, String) { + let text = raw.trim(); + if text.is_empty() { + return (false, "cloudflared tunnel info 返回为空".to_string()); + } + let normalized = text.to_ascii_lowercase(); + if normalized.contains("does not have any active connection") + || normalized.contains("no active connectors") + { + return (false, "无活跃连接器".to_string()); + } + if normalized.contains("error") + && !normalized.contains("0 errors") + && !normalized.contains("0 error") + { + return (false, trim_output_for_error(text)); + } + if normalized.contains("registered tunnel connection") + || normalized.contains("connector id") + || normalized.contains("connections") + { + return (true, "检测到活跃连接器".to_string()); + } + (false, trim_output_for_error(text)) +} + +fn build_cloudflare_run_args( + tunnel: &GatewayTunnelConfig, + local_url: &str, +) -> Result<(Vec, String), String> { + if let Some(token) = tunnel + .cloudflare + .run_token + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + let args = vec![ + "tunnel".to_string(), + "--no-autoupdate".to_string(), + "run".to_string(), + "--token".to_string(), + token.to_string(), + ]; + let preview = "cloudflared tunnel --no-autoupdate run --token ****".to_string(); + return Ok((args, preview)); + } + + let tunnel_id = tunnel + .cloudflare + .tunnel_id + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| "缺少 cloudflare.tunnel_id(或 cloudflare.run_token)".to_string())?; + + let mut args = vec![ + "tunnel".to_string(), + "--no-autoupdate".to_string(), + "--url".to_string(), + local_url.to_string(), + ]; + let mut preview = format!("cloudflared tunnel --no-autoupdate --url {} ", local_url); + + if let Some(credentials_file) = tunnel + .cloudflare + .credentials_file + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + args.push("--credentials-file".to_string()); + args.push(credentials_file.to_string()); + preview.push_str("--credentials-file "); + preview.push_str(credentials_file); + preview.push(' '); + } + + args.push("run".to_string()); + args.push(tunnel_id.to_string()); + preview.push_str("run "); + preview.push_str(tunnel_id); + Ok((args, preview)) +} + +fn normalize_provider(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "ngrok" => "ngrok".to_string(), + "none" => "none".to_string(), + _ => "cloudflare".to_string(), + } +} + +fn normalize_mode(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "external" => "external".to_string(), + _ => "managed".to_string(), + } +} + +pub fn is_manual_stop_error(error: Option<&str>) -> bool { + error + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(|v| v.contains(MANUAL_STOP_REASON)) + .unwrap_or(false) +} + +fn merge_output(stdout: &[u8], stderr: &[u8]) -> String { + let mut text = String::new(); + if let Ok(s) = String::from_utf8(stdout.to_vec()) { + text.push_str(&s); + } + if let Ok(s) = String::from_utf8(stderr.to_vec()) { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(&s); + } + text +} + +fn trim_output_for_error(output: &str) -> String { + let trimmed = output.trim(); + if trimmed.len() > 500 { + format!("{}...", &trimmed[..500]) + } else { + trimmed.to_string() + } +} + +fn extract_uuid(text: &str) -> Option { + text.split_whitespace() + .map(clean_token) + .find(|token| looks_like_uuid(token)) + .map(str::to_string) +} + +fn extract_json_path(text: &str) -> Option { + text.split_whitespace() + .map(clean_token) + .find(|token| token.ends_with(".json") && token.contains(std::path::MAIN_SEPARATOR)) + .map(str::to_string) +} + +fn clean_token(token: &str) -> &str { + token.trim_matches(|c: char| c == '\'' || c == '"' || c == '(' || c == ')' || c == ',') +} + +fn looks_like_uuid(token: &str) -> bool { + let parts = token.split('-').collect::>(); + if parts.len() != 5 { + return false; + } + let lens = [8_usize, 4, 4, 4, 12]; + for (idx, part) in parts.iter().enumerate() { + if part.len() != lens[idx] || !part.chars().all(|ch| ch.is_ascii_hexdigit()) { + return false; + } + } + true +} + +#[allow(dead_code)] +fn as_str_lossy>(value: T) -> String { + value.as_ref().to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use super::is_manual_stop_error; + + #[test] + fn manual_stop_error_marker_detected() { + assert!(is_manual_stop_error(Some("tunnel 已手动停止"))); + assert!(is_manual_stop_error(Some("xxx tunnel 已手动停止 yyy"))); + } + + #[test] + fn manual_stop_error_marker_not_detected() { + assert!(!is_manual_stop_error(None)); + assert!(!is_manual_stop_error(Some(""))); + assert!(!is_manual_stop_error(Some( + "tunnel 进程已退出: exit status: 1" + ))); + } +} diff --git a/src-tauri/crates/providers/src/providers/antigravity.rs b/src-tauri/crates/providers/src/providers/antigravity.rs index 38ecade0c..ad42ba524 100644 --- a/src-tauri/crates/providers/src/providers/antigravity.rs +++ b/src-tauri/crates/providers/src/providers/antigravity.rs @@ -98,10 +98,19 @@ const ANTIGRAVITY_API_VERSION: &str = "v1internal"; const CREDENTIALS_DIR: &str = ".antigravity"; const CREDENTIALS_FILE: &str = "oauth_creds.json"; -// OAuth credentials - 与 Antigravity CLI 相同 -pub const OAUTH_CLIENT_ID: &str = - "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; -pub const OAUTH_CLIENT_SECRET: &str = "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"; +// OAuth credentials - 从环境变量读取 +// 必须通过环境变量配置: +// - ANTIGRAVITY_OAUTH_CLIENT_ID +// - ANTIGRAVITY_OAUTH_CLIENT_SECRET +fn oauth_client_id() -> String { + std::env::var("ANTIGRAVITY_OAUTH_CLIENT_ID") + .expect("ANTIGRAVITY_OAUTH_CLIENT_ID environment variable must be set") +} + +fn oauth_client_secret() -> String { + std::env::var("ANTIGRAVITY_OAUTH_CLIENT_SECRET") + .expect("ANTIGRAVITY_OAUTH_CLIENT_SECRET environment variable must be set") +} // OAuth scopes const OAUTH_SCOPES: &[&str] = &[ @@ -655,9 +664,11 @@ impl AntigravityProvider { })? .clone(); + let client_id = oauth_client_id(); + let client_secret = oauth_client_secret(); let params = [ - ("client_id", OAUTH_CLIENT_ID), - ("client_secret", OAUTH_CLIENT_SECRET), + ("client_id", client_id.as_str()), + ("client_secret", client_secret.as_str()), ("refresh_token", refresh_token.as_str()), ("grant_type", "refresh_token"), ]; @@ -779,9 +790,11 @@ impl AntigravityProvider { .as_ref() .ok_or("No refresh token available")?; + let client_id = oauth_client_id(); + let client_secret = oauth_client_secret(); let params = [ - ("client_id", OAUTH_CLIENT_ID), - ("client_secret", OAUTH_CLIENT_SECRET), + ("client_id", client_id.as_str()), + ("client_secret", client_secret.as_str()), ("refresh_token", refresh_token.as_str()), ("grant_type", "refresh_token"), ]; @@ -1177,10 +1190,11 @@ pub struct AntigravityOAuthResult { pub fn generate_auth_url(port: u16, state: &str) -> String { let scopes = OAUTH_SCOPES.join(" "); let redirect_uri = format!("http://localhost:{port}/oauth-callback"); + let client_id = oauth_client_id(); let params = [ ("access_type", "offline"), - ("client_id", OAUTH_CLIENT_ID), + ("client_id", client_id.as_str()), ("prompt", "consent"), ("redirect_uri", &redirect_uri), ("response_type", "code"), @@ -1203,10 +1217,12 @@ pub async fn exchange_code_for_token( code: &str, redirect_uri: &str, ) -> Result> { + let client_id = oauth_client_id(); + let client_secret = oauth_client_secret(); let params = [ ("code", code), - ("client_id", OAUTH_CLIENT_ID), - ("client_secret", OAUTH_CLIENT_SECRET), + ("client_id", client_id.as_str()), + ("client_secret", client_secret.as_str()), ("redirect_uri", redirect_uri), ("grant_type", "authorization_code"), ]; @@ -2283,7 +2299,7 @@ mod tests { /// Property 1: 对于任何有效的过期时间(RFC3339 格式),validate_token() 应正确判断状态 #[test] fn prop_validate_token_rfc3339_format( - expires_in_secs in -3600i64..7200i64, // -1小时到2小时 + expires_in_secs in -3600i64..7200i64, // -1小时到2小时 (边界 ±2s 容差避免时间竞争) ) { let now = chrono::Utc::now(); let expires_at = now + chrono::Duration::seconds(expires_in_secs); @@ -2295,13 +2311,14 @@ mod tests { let result = provider.validate_token(); - if expires_in_secs <= 0 { + if expires_in_secs <= -2 { prop_assert!(is_expired(&result), "Expected Expired for expires_in_secs={}", expires_in_secs); - } else if expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD { + } else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 { prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs); - } else { + } else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 { prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs); } + // 边界值 ±2s 内跳过断言,避免时间竞争 } /// Property 1: 对于任何有效的过期时间(毫秒时间戳格式),validate_token() 应正确判断状态 @@ -2319,13 +2336,14 @@ mod tests { let result = provider.validate_token(); - if expires_in_secs <= 0 { + if expires_in_secs <= -2 { prop_assert!(is_expired(&result), "Expected Expired for expires_in_secs={}", expires_in_secs); - } else if expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD { + } else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 { prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs); - } else { + } else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 { prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs); } + // 边界值 ±2s 内跳过断言,避免时间竞争 } /// Property 1: 对于任何有效的过期时间(timestamp + expires_in 格式),validate_token() 应正确判断状态 @@ -2343,14 +2361,15 @@ mod tests { let result = provider.validate_token(); - // 由于时间精度问题,允许 1 秒的误差 + // 由于时间精度问题,允许 2 秒的误差 if expires_in_secs <= 1 { prop_assert!(is_expired(&result) || is_expiring_soon(&result), "Expected Expired or ExpiringSoon for expires_in_secs={}", expires_in_secs); - } else if expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD { + } else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 { prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs); - } else { + } else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 { prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs); } + // 边界值 ±2s 内跳过断言,避免时间竞争 } } diff --git a/src-tauri/crates/scheduler/Cargo.toml b/src-tauri/crates/scheduler/Cargo.toml index 2415661fb..3a224c368 100644 --- a/src-tauri/crates/scheduler/Cargo.toml +++ b/src-tauri/crates/scheduler/Cargo.toml @@ -11,6 +11,7 @@ serde_json = { workspace = true } # 异步运行时 tokio = { workspace = true } async-trait = { workspace = true } +futures = { workspace = true } # 错误处理 anyhow = { workspace = true } @@ -29,3 +30,4 @@ uuid = { workspace = true, features = ["v4", "serde"] } # 项目内依赖 proxycast-core = { workspace = true } proxycast-agent = { workspace = true } +aster = { workspace = true } diff --git a/src-tauri/crates/scheduler/src/executor.rs b/src-tauri/crates/scheduler/src/executor.rs index b3d31e429..9c018fac1 100644 --- a/src-tauri/crates/scheduler/src/executor.rs +++ b/src-tauri/crates/scheduler/src/executor.rs @@ -5,7 +5,15 @@ use super::types::ScheduledTask; use async_trait::async_trait; use proxycast_agent::credential_bridge::CredentialBridge; +#[cfg(test)] +use proxycast_agent::request_tool_policy::REQUEST_TOOL_POLICY_MARKER; +use proxycast_agent::request_tool_policy::{ + merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy, + stream_reply_with_policy, +}; +use proxycast_agent::{AsterAgentState, SessionConfigBuilder}; use proxycast_core::database::DbConnection; +use serde_json::Value; use std::sync::Arc; /// 任务执行器 Trait @@ -118,11 +126,44 @@ impl TaskExecutor for AgentExecutor { } impl AgentExecutor { + fn resolve_agent_session_id(task: &ScheduledTask) -> String { + task.params + .get("session_id") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(|id| id.to_string()) + .unwrap_or_else(|| format!("scheduler-agent-chat-{}", task.id)) + } + + fn resolve_bool_param(task: &ScheduledTask, key: &str) -> Option { + let value = task.params.get(key)?; + match value { + Value::Bool(flag) => Some(*flag), + Value::String(raw) => match raw.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "on" => Some(true), + "false" | "0" | "no" | "off" => Some(false), + _ => None, + }, + Value::Number(number) => number.as_i64().map(|v| v != 0), + _ => None, + } + } + + fn resolve_system_prompt(task: &ScheduledTask) -> Option { + task.params + .get("system_prompt") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + } + /// 执行 Agent 对话任务 async fn execute_agent_chat( &self, task: &ScheduledTask, - _db: &DbConnection, + db: &DbConnection, _aster_config: &proxycast_agent::credential_bridge::AsterProviderConfig, ) -> Result { // 从任务参数中提取对话内容 @@ -134,13 +175,101 @@ impl AgentExecutor { tracing::info!("[AgentExecutor] 执行 Agent 对话: {}", prompt); - // TODO: 实际调用 Aster Agent 执行对话 - // 这里需要集成 AsterAgentState 来执行对话 - // 暂时返回模拟结果 + let session_id = Self::resolve_agent_session_id(task); + let request_tool_policy = + resolve_request_tool_policy(Self::resolve_bool_param(task, "web_search"), false); + let merged_system_prompt = merge_system_prompt_with_request_tool_policy( + Self::resolve_system_prompt(task), + &request_tool_policy, + ); + // 对齐主对话入口:执行前刷新一次 Skills 注册,避免运行期安装/更新后不可见。 + AsterAgentState::reload_proxycast_skills(); + tracing::info!( + "[AgentExecutor] agent_chat 会话策略: session={} web_search={} system_prompt={}", + session_id, + request_tool_policy.effective_web_search, + if merged_system_prompt.is_some() { + "provided" + } else { + "none" + } + ); + + let state = AsterAgentState::new(); + state + .configure_provider_from_pool(db, &task.provider_type, &task.model, &session_id) + .await + .map_err(|e| format!("配置 Agent Provider 失败: {e}"))?; + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or_else(|| "Agent 未初始化".to_string())?; + let available_tools = { + let registry = agent.tool_registry().read().await; + registry + .get_definitions() + .iter() + .map(|definition| definition.name.clone()) + .collect::>() + }; + tracing::info!( + "[AgentExecutor] 当前可用工具({}): {}", + available_tools.len(), + available_tools.join(", ") + ); + + let mut session_builder = SessionConfigBuilder::new(&session_id); + if let Some(system_prompt) = merged_system_prompt { + session_builder = session_builder.system_prompt(system_prompt); + } + let session_config = session_builder.build(); + let execution = stream_reply_with_policy( + agent, + prompt, + None, + session_config, + None, + &request_tool_policy, + |event| match event { + proxycast_agent::TauriAgentEvent::ToolStart { + tool_name, tool_id, .. + } => { + tracing::info!( + "[AgentExecutor] 工具调用开始: {} (tool_id={})", + tool_name, + tool_id + ); + } + proxycast_agent::TauriAgentEvent::ToolEnd { tool_id, result } => { + tracing::info!( + "[AgentExecutor] 工具调用结束: tool_id={} success={}", + tool_id, + result.success + ); + } + _ => {} + }, + ) + .await + .map_err(|error| { + format!( + "Agent 执行失败: {} (emitted_any={})", + error.message, error.emitted_any + ) + })?; + + let response = execution.text_output; + if response.trim().is_empty() { + if let Some(last_error) = execution.event_errors.last() { + return Err(format!("Agent 未返回有效文本输出: {last_error}")); + } + return Err("Agent 未返回有效文本输出".to_string()); + } + Ok(serde_json::json!({ "type": "agent_chat", "prompt": prompt, - "response": "任务已调度执行", + "response": response, "status": "success" })) } @@ -256,4 +385,39 @@ mod tests { err ); } + + #[test] + fn test_resolve_bool_param_supports_string_and_bool() { + let task = ScheduledTask::new( + "bool-test".to_string(), + "agent_chat".to_string(), + serde_json::json!({ + "prompt": "hello", + "web_search": "true", + "feature_flag": false + }), + "openai".to_string(), + "gpt-4".to_string(), + Utc::now(), + ); + assert_eq!( + AgentExecutor::resolve_bool_param(&task, "web_search"), + Some(true) + ); + assert_eq!( + AgentExecutor::resolve_bool_param(&task, "feature_flag"), + Some(false) + ); + } + + #[test] + fn test_merge_system_prompt_with_web_search_policy() { + let policy = resolve_request_tool_policy(Some(true), false); + let merged = + merge_system_prompt_with_request_tool_policy(Some("你是助手".to_string()), &policy) + .expect("merged prompt should exist"); + assert!(merged.contains("你是助手")); + assert!(merged.contains(REQUEST_TOOL_POLICY_MARKER)); + assert!(merged.contains("WebSearch")); + } } diff --git a/src-tauri/crates/terminal/src/connections/connection_router.rs b/src-tauri/crates/terminal/src/connections/connection_router.rs index cd3c61985..0fe106160 100644 --- a/src-tauri/crates/terminal/src/connections/connection_router.rs +++ b/src-tauri/crates/terminal/src/connections/connection_router.rs @@ -91,8 +91,8 @@ impl ConnectionRouter { /// 对应的连接类型 /// /// # 示例 - /// ``` - /// use proxycast::terminal::connections::ConnectionRouter; + /// ```ignore + /// use proxycast_terminal::connections::{ConnectionRouter, ConnectionType}; /// /// assert_eq!(ConnectionRouter::route(""), ConnectionType::Local); /// assert_eq!(ConnectionRouter::route("local"), ConnectionType::Local); diff --git a/src-tauri/crates/terminal/src/connections/local_pty.rs b/src-tauri/crates/terminal/src/connections/local_pty.rs index 6c6b02579..a1cfa72d7 100644 --- a/src-tauri/crates/terminal/src/connections/local_pty.rs +++ b/src-tauri/crates/terminal/src/connections/local_pty.rs @@ -52,9 +52,40 @@ fn resolve_default_shell() -> String { #[cfg(target_os = "windows")] { + let is_valid_windows_shell = |candidate: &str| -> bool { + let cleaned = candidate.trim(); + if cleaned.is_empty() { + return false; + } + + // 拒绝 Unix 风格路径(如 /bin/bash) + if cleaned.starts_with('/') { + return false; + } + + let path = Path::new(cleaned); + if path.is_absolute() { + if !path.exists() { + return false; + } + + let ext = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + + return matches!(ext.as_deref(), Some("exe" | "cmd" | "bat" | "com")); + } + + if cleaned.contains('/') || cleaned.contains('\\') { + return false; + } + + true + }; + if let Some(shell) = shell_from_env { - let path = Path::new(&shell); - if path.is_absolute() && path.exists() { + if is_valid_windows_shell(&shell) { return shell; } } @@ -66,7 +97,7 @@ fn resolve_default_shell() -> String { .unwrap_or_default() .trim() .to_string(); - if !cleaned.is_empty() && Path::new(&cleaned).exists() { + if is_valid_windows_shell(&cleaned) { return cleaned; } } diff --git a/src-tauri/crates/terminal/src/integration/osc_parser.rs b/src-tauri/crates/terminal/src/integration/osc_parser.rs index 29015c45f..072b99407 100644 --- a/src-tauri/crates/terminal/src/integration/osc_parser.rs +++ b/src-tauri/crates/terminal/src/integration/osc_parser.rs @@ -136,8 +136,8 @@ impl OSCParser { /// 解析出的 OSC 序列列表,每个元素包含序列和位置范围 /// /// # 示例 - /// ``` - /// use proxycast::terminal::integration::osc_parser::{OSCParser, OSCSequence}; + /// ```ignore + /// use proxycast_terminal::integration::osc_parser::{OSCParser, OSCSequence}; /// /// let data = b"\x1b]7;file://localhost/home/user\x07"; /// let results = OSCParser::parse(data); diff --git a/src-tauri/crates/terminal/src/pty_session.rs b/src-tauri/crates/terminal/src/pty_session.rs index 0b398cb30..7c4d6b7c5 100644 --- a/src-tauri/crates/terminal/src/pty_session.rs +++ b/src-tauri/crates/terminal/src/pty_session.rs @@ -90,6 +90,40 @@ pub struct PtySession { } impl PtySession { + #[cfg(target_os = "windows")] + fn is_valid_windows_shell(shell: &str) -> bool { + let cleaned = shell.trim(); + if cleaned.is_empty() { + return false; + } + + // 拒绝 Unix 风格路径(如 /bin/bash),避免在 Windows 下误判为可执行 shell + if cleaned.starts_with('/') { + return false; + } + + let path = Path::new(cleaned); + if path.is_absolute() { + if !path.exists() { + return false; + } + + let ext = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + + return matches!(ext.as_deref(), Some("exe" | "cmd" | "bat" | "com")); + } + + // 相对路径包含分隔符时通常不可控,直接拒绝;仅允许命令名(由 PATH 解析) + if cleaned.contains('/') || cleaned.contains('\\') { + return false; + } + + true + } + fn resolve_default_shell() -> String { let shell_from_env = std::env::var("SHELL").ok().and_then(|value| { let cleaned = value @@ -104,8 +138,7 @@ impl PtySession { #[cfg(target_os = "windows")] { if let Some(shell) = shell_from_env { - let path = Path::new(&shell); - if path.is_absolute() && path.exists() { + if Self::is_valid_windows_shell(&shell) { return shell; } } @@ -117,7 +150,7 @@ impl PtySession { .unwrap_or_default() .trim() .to_string(); - if !cleaned.is_empty() && Path::new(&cleaned).exists() { + if Self::is_valid_windows_shell(&cleaned) { return cleaned; } } diff --git a/src-tauri/crates/websocket/src/handlers/rpc_handler.rs b/src-tauri/crates/websocket/src/handlers/rpc_handler.rs index 2062614e6..0d89bd020 100644 --- a/src-tauri/crates/websocket/src/handlers/rpc_handler.rs +++ b/src-tauri/crates/websocket/src/handlers/rpc_handler.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Timelike, Utc}; use proxycast_core::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; use proxycast_core::database::dao::chat::{ChatDao, ChatMessage, ChatMode, ChatSession}; use proxycast_scheduler::{ - AgentExecutor, ScheduledTask, SchedulerDao, TaskExecutor, TaskFilter, + AgentExecutor, AgentScheduler, ScheduledTask, SchedulerDao, TaskExecutor, TaskFilter, DEFAULT_TASK_COOLDOWN_SECS, DEFAULT_TASK_FAILURE_THRESHOLD, }; use serde_json::json; @@ -149,6 +149,7 @@ impl RpcHandler { "message": message, "stream": params.stream, "model": params.model.clone(), + "web_search": params.web_search, }); self.create_run_record( &db, @@ -163,20 +164,46 @@ impl RpcHandler { let run_id_for_task = run_id.clone(); let session_id_for_task = session_id.clone(); let message_for_task = message.clone(); - let model_for_task = params + let raw_model_for_task = params .model .clone() .unwrap_or_else(|| "claude-sonnet-4-5".to_string()); + let (provider_type_for_task, model_for_task) = + resolve_provider_and_model(&raw_model_for_task); let active_runs = self.state.active_runs.clone(); + let logs_for_task = self.state.logs.clone(); let db_for_task = db.clone(); let handle = tokio::spawn(async move { let started_ms = Utc::now().timestamp_millis(); - let provider_type = infer_provider_from_model(&model_for_task); + logs_for_task.write().await.add( + "info", + &format!( + "[RPC] agent.run dispatch: run_id={} raw_model={} resolved_model={} provider={} web_search={}", + run_id_for_task, + raw_model_for_task, + model_for_task, + provider_type_for_task, + params.web_search.unwrap_or(false) + ), + ); + let mut task_params = json!({ + "prompt": message_for_task, + "session_id": session_id_for_task, + }); + if let Some(web_search) = params.web_search { + task_params["web_search"] = serde_json::Value::Bool(web_search); + } + if let Some(system_prompt) = params.system_prompt.as_deref() { + let trimmed = system_prompt.trim(); + if !trimmed.is_empty() { + task_params["system_prompt"] = serde_json::Value::String(trimmed.to_string()); + } + } let mut task = ScheduledTask::new( format!("rpc-agent-run-{}", &run_id_for_task[..8]), "agent_chat".to_string(), - json!({ "prompt": message_for_task }), - provider_type, + task_params, + provider_type_for_task, model_for_task, Utc::now(), ); @@ -590,6 +617,8 @@ impl RpcHandler { .require_db() .await .map_err(|e| RpcError::internal_error(e.message))?; + AgentScheduler::init_tables(&db) + .map_err(|e| RpcError::internal_error(format!("init cron tables failed: {e}")))?; let conn = proxycast_core::database::lock_db(&db) .map_err(|e| RpcError::internal_error(format!("DB lock failed: {e}")))?; let tasks = SchedulerDao::list_tasks(&conn, &TaskFilter::default()) @@ -892,7 +921,18 @@ fn resolve_wait_content(run: &AgentRun) -> Option { } fn infer_provider_from_model(model: &str) -> String { - let normalized = model.to_ascii_lowercase(); + let normalized = model.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return "openai".to_string(); + } + + if let Some((provider_prefix, _)) = normalized.split_once('/') { + let provider_prefix = provider_prefix.trim(); + if !provider_prefix.is_empty() && provider_prefix != "models" { + return provider_prefix.to_string(); + } + } + if normalized.contains("claude") { return "anthropic".to_string(); } @@ -902,10 +942,30 @@ fn infer_provider_from_model(model: &str) -> String { if normalized.contains("qwen") { return "qwen".to_string(); } + if normalized.contains("glm") || normalized.contains("zhipu") { + return "zhipuai".to_string(); + } if normalized.contains("gpt") || normalized.contains("o1") || normalized.contains("o3") { return "openai".to_string(); } - "anthropic".to_string() + "openai".to_string() +} + +fn resolve_provider_and_model(model: &str) -> (String, String) { + let trimmed = model.trim(); + if trimmed.is_empty() { + return ("openai".to_string(), String::new()); + } + + if let Some((provider_prefix, model_suffix)) = trimmed.split_once('/') { + let provider_prefix = provider_prefix.trim().to_ascii_lowercase(); + let model_suffix = model_suffix.trim(); + if !provider_prefix.is_empty() && provider_prefix != "models" && !model_suffix.is_empty() { + return (provider_prefix, model_suffix.to_string()); + } + } + + (infer_provider_from_model(trimmed), trimmed.to_string()) } fn finalize_run( @@ -1163,6 +1223,9 @@ mod tests { }; let run_response = handler.handle_request(request).await; assert!(run_response.error.is_none()); + let run_result: AgentRunResult = + serde_json::from_value(run_response.result.expect("缺少 run result")) + .expect("解析 agent.run 失败"); let list_request = GatewayRpcRequest { jsonrpc: "2.0".to_string(), @@ -1176,7 +1239,10 @@ mod tests { serde_json::from_value(list_response.result.expect("缺少 sessions.list result")) .expect("解析 sessions.list 返回失败"); assert!(!result.sessions.is_empty()); - assert!(result.sessions[0].message_count >= 1); + assert!(result + .sessions + .iter() + .any(|session| session.session_id == run_result.session_id)); } #[tokio::test] @@ -1401,4 +1467,23 @@ mod tests { assert!(result.alerts.len() >= 2); assert!(!result.top_risky_tasks.is_empty()); } + + #[test] + fn test_infer_provider_from_model_should_support_provider_prefix_and_glm() { + assert_eq!(infer_provider_from_model("zhipuai/glm-4.7"), "zhipuai"); + assert_eq!(infer_provider_from_model("glm-4-plus"), "zhipuai"); + assert_eq!(infer_provider_from_model("openai/gpt-4o-mini"), "openai"); + assert_eq!(infer_provider_from_model(""), "openai"); + } + + #[test] + fn test_resolve_provider_and_model_should_strip_provider_prefix() { + let (provider, model) = resolve_provider_and_model("zhipuai/glm-4.7"); + assert_eq!(provider, "zhipuai"); + assert_eq!(model, "glm-4.7"); + + let (provider, model) = resolve_provider_and_model("glm-4-plus"); + assert_eq!(provider, "zhipuai"); + assert_eq!(model, "glm-4-plus"); + } } diff --git a/src-tauri/crates/websocket/src/protocol.rs b/src-tauri/crates/websocket/src/protocol.rs index f5e54e136..303874a24 100644 --- a/src-tauri/crates/websocket/src/protocol.rs +++ b/src-tauri/crates/websocket/src/protocol.rs @@ -142,6 +142,8 @@ pub struct AgentRunParams { pub temperature: Option, /// 最大 token 数(可选) pub max_tokens: Option, + /// 是否开启联网搜索策略(可选) + pub web_search: Option, /// 是否流式响应 #[serde(default)] pub stream: bool, diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 8c6145f58..7937cd92d 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -32,6 +32,7 @@ use crate::services::heartbeat_service::{HeartbeatService, HeartbeatServiceState use crate::telemetry; use crate::voice::recording_service::{create_recording_service_state, RecordingServiceState}; use proxycast_core::config::{Config, ConfigManager}; +use proxycast_scheduler::AgentScheduler; use proxycast_server as server; use proxycast_services::api_key_provider_service::ApiKeyProviderService; use proxycast_services::aster_session_store::ProxyCastSessionStore; @@ -240,6 +241,13 @@ pub fn init_states(config: &Config) -> Result { .map_err(|e| format!("初始化默认技能仓库失败: {e}"))?; } + // 初始化调度器表,避免运行期健康检查出现缺表错误 + if let Err(error) = AgentScheduler::init_tables(&db) { + tracing::error!("[Bootstrap] 调度器表初始化失败: {}", error); + } else { + tracing::info!("[Bootstrap] 调度器表初始化成功"); + } + // 初始化上下文记忆服务 let context_memory_config = build_context_memory_config(config); let context_memory_service = ContextMemoryService::new(context_memory_config) diff --git a/src-tauri/src/app/commands/config.rs b/src-tauri/src/app/commands/config.rs index d32707a07..65353092c 100644 --- a/src-tauri/src/app/commands/config.rs +++ b/src-tauri/src/app/commands/config.rs @@ -6,7 +6,7 @@ use crate::app::types::{AppState, LogState}; use crate::app::utils::is_valid_bind_host; use crate::config::{ self, - observer::{ConfigChangeEvent, FullReloadEvent, RoutingChangeEvent}, + observer::{ConfigChangeEvent, RoutingChangeEvent}, ConfigChangeSource, GlobalConfigManagerState, }; @@ -52,21 +52,9 @@ pub async fn save_config( s.config = config.clone(); } - let save_result = config::save_config(&config).map_err(|e| e.to_string()); + let save_result = config_manager.0.save_config(&config).await; match save_result { Ok(()) => { - proxycast_core::tool_calling::apply_tool_calling_runtime_config(&config); - - let full_reload_event = ConfigChangeEvent::FullReload(FullReloadEvent { - timestamp_ms: chrono::Utc::now().timestamp_millis() as u64, - source: ConfigChangeSource::FrontendUI, - }); - config_manager - .0 - .subject() - .notify_event(full_reload_event) - .await; - tracing::info!("[CONFIG] 配置保存成功: host={}", config.server.host); Ok(()) } diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index d5b42dfef..e825e0a83 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -94,6 +94,10 @@ pub fn run() { let shared_tokens_clone = shared_tokens.clone(); let shared_logger_clone = shared_logger.clone(); let update_check_service_clone = update_check_service_state.0.clone(); + let gateway_tunnel_state = proxycast_gateway::tunnel::GatewayTunnelState::default(); + let gateway_tunnel_state_for_setup = gateway_tunnel_state.clone(); + let global_config_manager_for_setup = global_config_manager_state.clone(); + let tunnel_logs_clone = logs.clone(); let mut builder = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) @@ -155,6 +159,10 @@ pub fn run() { .manage(recording_service) .manage(mcp_manager_state) .manage(heartbeat_service_state) + .manage(proxycast_gateway::telegram::TelegramGatewayState::default()) + .manage(proxycast_gateway::discord::DiscordGatewayState::default()) + .manage(proxycast_gateway::feishu::FeishuGatewayState::default()) + .manage(gateway_tunnel_state) .manage(commands::telegram_remote_cmd::TelegramRemoteState::default()) .on_window_event(move |window, event| { // 处理窗口关闭事件 @@ -722,6 +730,108 @@ pub fn run() { }); } + // 启动 Gateway Tunnel 守护(managed 模式自动拉起并持续保活) + { + let tunnel_state = gateway_tunnel_state_for_setup.clone(); + let config_manager = global_config_manager_for_setup.clone(); + let logs = tunnel_logs_clone.clone(); + tauri::async_runtime::spawn(async move { + let mut round: u64 = 0; + loop { + let config = config_manager.config(); + if !config.gateway.tunnel.enabled { + round = 0; + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + continue; + } + + let mode = config.gateway.tunnel.mode.trim().to_ascii_lowercase(); + if mode == "managed" { + match proxycast_gateway::tunnel::status_tunnel_with_config( + &tunnel_state, + Some(config.clone()), + ) + .await + { + Ok(status) => { + if status.running { + if round == 0 { + logs.write().await.add( + "info", + &format!( + "[GatewayTunnel] managed 隧道运行中: pid={:?} local={} public={:?}", + status.pid, status.local_url, status.public_base_url + ), + ); + } + } else if proxycast_gateway::tunnel::is_manual_stop_error( + status.last_error.as_deref(), + ) { + if round % 6 == 0 { + logs.write().await.add( + "info", + "[GatewayTunnel] managed 隧道处于手动停止状态,守护器不自动拉起", + ); + } + } else { + logs.write().await.add( + "warn", + &format!( + "[GatewayTunnel] managed 隧道未运行,守护器尝试拉起: last_exit={:?} last_error={:?}", + status.last_exit, status.last_error + ), + ); + if let Err(e) = proxycast_gateway::tunnel::start_tunnel( + &tunnel_state, + logs.clone(), + config.clone(), + ) + .await + { + logs.write().await.add( + "warn", + &format!("[GatewayTunnel] 守护拉起失败: {e}"), + ); + } + } + } + Err(e) => { + logs.write() + .await + .add("warn", &format!("[GatewayTunnel] 守护状态检查失败: {e}")); + } + } + } else if mode == "external" && round % 6 == 0 { + match proxycast_gateway::tunnel::status_tunnel_with_config( + &tunnel_state, + Some(config), + ) + .await + { + Ok(status) => { + logs.write().await.add( + "info", + &format!( + "[GatewayTunnel] external 模式诊断: active={:?} detail={:?}", + status.connector_active, status.connector_message + ), + ); + } + Err(e) => { + logs.write().await.add( + "warn", + &format!("[GatewayTunnel] external 模式诊断失败: {e}"), + ); + } + } + } + + round = round.saturating_add(1); + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + } + }); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -1483,6 +1593,21 @@ pub fn run() { commands::heartbeat_cmd::preview_heartbeat_schedule, commands::heartbeat_cmd::validate_heartbeat_schedule, // Telegram 远程触发命令 + commands::gateway_channel_cmd::gateway_channel_start, + commands::gateway_channel_cmd::gateway_channel_stop, + commands::gateway_channel_cmd::gateway_channel_status, + commands::gateway_channel_cmd::telegram_channel_probe, + commands::gateway_channel_cmd::feishu_channel_probe, + commands::gateway_channel_cmd::discord_channel_probe, + commands::gateway_tunnel_cmd::gateway_tunnel_probe, + commands::gateway_tunnel_cmd::gateway_tunnel_detect_cloudflared, + commands::gateway_tunnel_cmd::gateway_tunnel_install_cloudflared, + commands::gateway_tunnel_cmd::gateway_tunnel_create, + commands::gateway_tunnel_cmd::gateway_tunnel_start, + commands::gateway_tunnel_cmd::gateway_tunnel_stop, + commands::gateway_tunnel_cmd::gateway_tunnel_restart, + commands::gateway_tunnel_cmd::gateway_tunnel_status, + commands::gateway_tunnel_cmd::gateway_tunnel_sync_webhook_url, commands::telegram_remote_cmd::start_telegram_remote, commands::telegram_remote_cmd::stop_telegram_remote, commands::telegram_remote_cmd::get_telegram_remote_status, diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index 4c05021f9..0f4a32666 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -19,12 +19,6 @@ use crate::mcp::{McpManagerState, McpServerConfig}; use crate::services::execution_tracker_service::{ExecutionTracker, RunFinalizeOptions, RunSource}; use crate::services::heartbeat_service::HeartbeatServiceState; use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile; -#[cfg(test)] -use crate::services::request_tool_policy_prompt_service::REQUEST_TOOL_POLICY_MARKER; -use crate::services::request_tool_policy_prompt_service::{ - execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy, - resolve_request_tool_policy, RequestToolPolicy, WebSearchExecutionTracker, -}; use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; use crate::services::web_search_runtime_service::apply_web_search_runtime_env; use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; @@ -48,7 +42,12 @@ use aster::tools::{ }; use async_trait::async_trait; use futures::StreamExt; -use proxycast_agent::event_converter::convert_agent_event; +#[cfg(test)] +use proxycast_agent::request_tool_policy::REQUEST_TOOL_POLICY_MARKER; +use proxycast_agent::request_tool_policy::{ + merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy, + stream_reply_with_policy, ReplyAttemptError, RequestToolPolicy, +}; use proxycast_services::mcp_service::McpService; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -389,12 +388,6 @@ impl AsterExecutionStrategy { } } -#[derive(Debug)] -struct ReplyAttemptError { - message: String, - emitted_any: bool, -} - fn should_force_react_for_message(message: &str) -> bool { let lowered = message.to_lowercase(); let default_hints = [ @@ -415,29 +408,6 @@ fn should_force_react_for_message(message: &str) -> bool { .any(|kw| lowered.contains(kw)) } -fn extract_inline_agent_provider_error(message: &Message) -> Option { - let text = message.as_concat_text(); - if !text.contains("Ran into this error:") { - return None; - } - if !text.contains("Please retry if you think this is a transient or recoverable error.") { - return None; - } - - let after_prefix = text.split_once("Ran into this error:")?.1.trim(); - let detail = after_prefix - .split_once("\n\nPlease retry if you think this is a transient or recoverable error.") - .map(|(left, _)| left.trim()) - .unwrap_or(after_prefix) - .trim_end_matches('.'); - - if detail.is_empty() { - return Some("Agent provider execution failed".to_string()); - } - - Some(format!("Agent provider execution failed: {detail}")) -} - fn should_use_code_orchestrated_for_message(message: &str) -> bool { let lowered = message.to_lowercase(); // 默认不做消息关键词硬编码推断,Auto 模式优先走 ReAct。 @@ -510,104 +480,21 @@ async fn stream_reply_once( cancel_token: CancellationToken, request_tool_policy: &RequestToolPolicy, ) -> Result<(), ReplyAttemptError> { - let mut web_search_tracker = WebSearchExecutionTracker::default(); - let preflight = execute_web_search_preflight_if_needed( + stream_reply_with_policy( agent, - &session_config.id, message_text, working_directory, - Some(cancel_token.clone()), + session_config, + Some(cancel_token), request_tool_policy, - &mut web_search_tracker, + |event| { + if let Err(error) = app.emit(event_name, event) { + tracing::error!("[AsterAgent] 发送事件失败: {}", error); + } + }, ) - .await; - match preflight { - Ok(preflight_execution) => { - for event in preflight_execution.events { - if let Err(error) = app.emit(event_name, &event) { - tracing::error!("[AsterAgent] 发送预调用事件失败: {}", error); - } - } - } - Err(error) => { - return Err(ReplyAttemptError { - message: format!( - "{error}\n尝试记录: {}", - web_search_tracker.format_attempts() - ), - emitted_any: false, - }); - } - } - - let user_message = Message::user().with_text(message_text); - let mut stream = agent - .reply(user_message, session_config, Some(cancel_token)) - .await - .map_err(|e| ReplyAttemptError { - message: format!("Agent error: {e}"), - emitted_any: false, - })?; - - let mut emitted_any = false; - while let Some(event_result) = stream.next().await { - match event_result { - Ok(agent_event) => { - emitted_any = true; - let inline_provider_error = match &agent_event { - AgentEvent::Message(message) => extract_inline_agent_provider_error(message), - _ => None, - }; - let tauri_events = convert_agent_event(agent_event); - for tauri_event in tauri_events { - match &tauri_event { - TauriAgentEvent::ToolStart { - tool_name, tool_id, .. - } => web_search_tracker.record_tool_start( - request_tool_policy, - tool_id, - tool_name, - ), - TauriAgentEvent::ToolEnd { tool_id, result } => web_search_tracker - .record_tool_end( - request_tool_policy, - tool_id, - result.success, - result.error.as_deref(), - ), - _ => {} - } - if let Err(e) = app.emit(event_name, &tauri_event) { - tracing::error!("[AsterAgent] 发送事件失败: {}", e); - } - } - if let Some(message) = inline_provider_error { - tracing::warn!("[AsterAgent] 捕获到消息级 Provider 错误: {}", message); - return Err(ReplyAttemptError { - message, - emitted_any: true, - }); - } - } - Err(e) => { - return Err(ReplyAttemptError { - message: format!("Stream error: {e}"), - emitted_any, - }); - } - } - } - - if let Err(validation_error) = - web_search_tracker.validate_web_search_requirement(request_tool_policy) - { - return Err(ReplyAttemptError { - message: validation_error, - emitted_any, - }); - } - - Ok(()) + .await + .map(|_| ()) } /// 基于 aster::sandbox 的本地 bash 强隔离工具 diff --git a/src-tauri/src/commands/gateway_channel_cmd.rs b/src-tauri/src/commands/gateway_channel_cmd.rs new file mode 100644 index 000000000..53563d417 --- /dev/null +++ b/src-tauri/src/commands/gateway_channel_cmd.rs @@ -0,0 +1,204 @@ +//! Gateway 渠道命令 +//! +//! 负责启动/停止/查询渠道运行时状态,当前优先支持 Telegram。 + +use crate::app::LogState; +use crate::config::GlobalConfigManagerState; +use crate::database::DbConnection; +use crate::services::web_search_runtime_service::apply_web_search_runtime_env; +use proxycast_gateway::discord::{ + probe_gateway_account as probe_discord_gateway_account, start_gateway as start_discord_gateway, + status_gateway as status_discord_gateway, stop_gateway as stop_discord_gateway, + DiscordGatewayState, DiscordProbeResult, +}; +use proxycast_gateway::feishu::{ + probe_gateway_account as probe_feishu_gateway_account, start_gateway as start_feishu_gateway, + status_gateway as status_feishu_gateway, stop_gateway as stop_feishu_gateway, + FeishuGatewayState, FeishuProbeResult, +}; +use proxycast_gateway::telegram::{ + probe_gateway_account as probe_telegram_gateway_account, + start_gateway as start_telegram_gateway, status_gateway as status_telegram_gateway, + stop_gateway as stop_telegram_gateway, TelegramGatewayState, TelegramProbeResult, +}; +use serde::{Deserialize, Serialize}; +use tauri::State; + +#[derive(Debug, Clone, Deserialize)] +pub struct GatewayChannelStartRequest { + pub channel: String, + #[serde(default)] + pub account_id: Option, + #[serde(default)] + pub poll_timeout_secs: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GatewayChannelStopRequest { + pub channel: String, + #[serde(default)] + pub account_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GatewayChannelStatusRequest { + pub channel: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TelegramProbeRequest { + #[serde(default)] + pub account_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct GatewayChannelStatusResponse { + pub channel: String, + pub status: serde_json::Value, +} + +fn normalize_channel(channel: &str) -> Result { + let normalized = channel.trim().to_ascii_lowercase(); + match normalized.as_str() { + "telegram" | "feishu" | "discord" => Ok(normalized), + _ => Err(format!( + "暂不支持的渠道: {}(当前支持 telegram / feishu / discord)", + channel + )), + } +} + +#[tauri::command] +pub async fn gateway_channel_start( + telegram_state: State<'_, TelegramGatewayState>, + feishu_state: State<'_, FeishuGatewayState>, + discord_state: State<'_, DiscordGatewayState>, + db: State<'_, DbConnection>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + request: GatewayChannelStartRequest, +) -> Result { + let channel = normalize_channel(&request.channel)?; + + let config = config_manager.config(); + apply_web_search_runtime_env(&config); + let status = if channel == "telegram" { + let status = start_telegram_gateway( + &telegram_state, + db.inner().clone(), + logs.inner().clone(), + config, + request.account_id, + request.poll_timeout_secs, + ) + .await?; + serde_json::to_value(status).map_err(|e| e.to_string())? + } else if channel == "feishu" { + let status = start_feishu_gateway( + &feishu_state, + db.inner().clone(), + logs.inner().clone(), + config, + request.account_id, + request.poll_timeout_secs, + ) + .await?; + serde_json::to_value(status).map_err(|e| e.to_string())? + } else { + let status = start_discord_gateway( + &discord_state, + db.inner().clone(), + logs.inner().clone(), + config, + request.account_id, + request.poll_timeout_secs, + ) + .await?; + serde_json::to_value(status).map_err(|e| e.to_string())? + }; + + Ok(GatewayChannelStatusResponse { channel, status }) +} + +#[tauri::command] +pub async fn gateway_channel_stop( + telegram_state: State<'_, TelegramGatewayState>, + feishu_state: State<'_, FeishuGatewayState>, + discord_state: State<'_, DiscordGatewayState>, + request: GatewayChannelStopRequest, +) -> Result { + let channel = normalize_channel(&request.channel)?; + + let status = if channel == "telegram" { + let status = stop_telegram_gateway(&telegram_state, request.account_id).await?; + serde_json::to_value(status).map_err(|e| e.to_string())? + } else if channel == "feishu" { + let status = stop_feishu_gateway(&feishu_state, request.account_id).await?; + serde_json::to_value(status).map_err(|e| e.to_string())? + } else { + let status = stop_discord_gateway(&discord_state, request.account_id).await?; + serde_json::to_value(status).map_err(|e| e.to_string())? + }; + Ok(GatewayChannelStatusResponse { channel, status }) +} + +#[tauri::command] +pub async fn gateway_channel_status( + telegram_state: State<'_, TelegramGatewayState>, + feishu_state: State<'_, FeishuGatewayState>, + discord_state: State<'_, DiscordGatewayState>, + request: GatewayChannelStatusRequest, +) -> Result { + let channel = normalize_channel(&request.channel)?; + + let status = if channel == "telegram" { + let status = status_telegram_gateway(&telegram_state).await?; + serde_json::to_value(status).map_err(|e| e.to_string())? + } else if channel == "feishu" { + let status = status_feishu_gateway(&feishu_state).await?; + serde_json::to_value(status).map_err(|e| e.to_string())? + } else { + let status = status_discord_gateway(&discord_state).await?; + serde_json::to_value(status).map_err(|e| e.to_string())? + }; + Ok(GatewayChannelStatusResponse { channel, status }) +} + +#[tauri::command] +pub async fn telegram_channel_probe( + config_manager: State<'_, GlobalConfigManagerState>, + request: TelegramProbeRequest, +) -> Result { + let config = config_manager.config(); + probe_telegram_gateway_account(&config, request.account_id).await +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FeishuProbeRequest { + #[serde(default)] + pub account_id: Option, +} + +#[tauri::command] +pub async fn feishu_channel_probe( + config_manager: State<'_, GlobalConfigManagerState>, + request: FeishuProbeRequest, +) -> Result { + let config = config_manager.config(); + probe_feishu_gateway_account(&config, request.account_id).await +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DiscordProbeRequest { + #[serde(default)] + pub account_id: Option, +} + +#[tauri::command] +pub async fn discord_channel_probe( + config_manager: State<'_, GlobalConfigManagerState>, + request: DiscordProbeRequest, +) -> Result { + let config = config_manager.config(); + probe_discord_gateway_account(&config, request.account_id).await +} diff --git a/src-tauri/src/commands/gateway_tunnel_cmd.rs b/src-tauri/src/commands/gateway_tunnel_cmd.rs new file mode 100644 index 000000000..dc44e54a2 --- /dev/null +++ b/src-tauri/src/commands/gateway_tunnel_cmd.rs @@ -0,0 +1,572 @@ +//! Gateway 公共隧道命令 +//! +//! 提供 Cloudflare Tunnel 的探测、创建、启停与回调 URL 同步能力。 + +use crate::app::{AppState, LogState}; +use crate::config::GlobalConfigManagerState; +use proxycast_gateway::tunnel::{ + create_cloudflare_tunnel, probe_tunnel, start_tunnel, status_tunnel_with_config, stop_tunnel, + CloudflareTunnelCreateRequest, CloudflareTunnelCreateResult, GatewayTunnelProbeResult, + GatewayTunnelState, GatewayTunnelStatus, +}; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tokio::process::Command; + +#[derive(Debug, Clone, Deserialize)] +pub struct GatewayTunnelCreateRequestPayload { + #[serde(default)] + pub tunnel_name: Option, + #[serde(default)] + pub dns_name: Option, + #[serde(default = "default_true")] + pub persist: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct GatewayTunnelCreateResponse { + pub result: CloudflareTunnelCreateResult, + pub status: GatewayTunnelStatus, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GatewayTunnelSyncWebhookRequest { + pub channel: String, + #[serde(default)] + pub account_id: Option, + #[serde(default)] + pub webhook_path: Option, + #[serde(default = "default_true")] + pub persist: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct GatewayTunnelSyncWebhookResponse { + pub channel: String, + pub account_id: Option, + pub webhook_path: String, + pub public_base_url: String, + pub webhook_url: String, + pub persisted: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CloudflaredInstallStatus { + pub installed: bool, + pub binary: String, + pub version: Option, + pub platform: String, + pub package_manager: Option, + pub install_supported: bool, + pub install_command: Option, + pub requires_privilege: bool, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CloudflaredInstallRequest { + #[serde(default)] + pub confirm: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CloudflaredInstallResult { + pub ok: bool, + pub attempted: bool, + pub platform: String, + pub package_manager: Option, + pub command: Option, + pub exit_code: Option, + pub installed: bool, + pub version: Option, + pub stdout: String, + pub stderr: String, + pub message: String, +} + +fn default_true() -> bool { + true +} + +#[tauri::command] +pub async fn gateway_tunnel_probe( + config_manager: State<'_, GlobalConfigManagerState>, +) -> Result { + let config = config_manager.config(); + Ok(probe_tunnel(&config).await) +} + +#[tauri::command] +pub async fn gateway_tunnel_detect_cloudflared( + config_manager: State<'_, GlobalConfigManagerState>, +) -> Result { + detect_cloudflared_status(&config_manager.config()).await +} + +#[tauri::command] +pub async fn gateway_tunnel_install_cloudflared( + config_manager: State<'_, GlobalConfigManagerState>, + request: CloudflaredInstallRequest, +) -> Result { + if !request.confirm { + return Err( + "请先确认安装:该操作会在系统范围安装 cloudflared。请传入 confirm=true 再执行。" + .to_string(), + ); + } + install_cloudflared(&config_manager.config()).await +} + +#[tauri::command] +pub async fn gateway_tunnel_create( + tunnel_state: State<'_, GatewayTunnelState>, + state: State<'_, AppState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + request: GatewayTunnelCreateRequestPayload, +) -> Result { + let config = config_manager.config(); + let tunnel_name = request + .tunnel_name + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + .or_else(|| { + config + .gateway + .tunnel + .cloudflare + .tunnel_name + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }) + .unwrap_or_else(|| "proxycast-gateway".to_string()); + + let result = create_cloudflare_tunnel( + &config, + logs.inner().clone(), + CloudflareTunnelCreateRequest { + tunnel_name: tunnel_name.clone(), + dns_name: request.dns_name.clone(), + }, + ) + .await?; + + if request.persist { + let mut next = config.clone(); + let tunnel = &mut next.gateway.tunnel; + tunnel.enabled = true; + tunnel.provider = "cloudflare".to_string(); + tunnel.mode = "managed".to_string(); + tunnel.cloudflare.tunnel_name = Some(tunnel_name); + if let Some(tunnel_id) = result.tunnel_id.clone() { + tunnel.cloudflare.tunnel_id = Some(tunnel_id); + } + if let Some(credentials_file) = result.credentials_file.clone() { + tunnel.cloudflare.credentials_file = Some(credentials_file); + } + if let Some(dns_name) = result.dns_name.clone() { + tunnel.cloudflare.dns_name = Some(dns_name); + } + if let Some(public_base_url) = result.public_base_url.clone() { + tunnel.public_base_url = Some(public_base_url); + } + persist_full_config(state, config_manager.clone(), next).await?; + } + + let status = status_tunnel_with_config(&tunnel_state, Some(config_manager.config())) + .await + .unwrap_or_default(); + Ok(GatewayTunnelCreateResponse { result, status }) +} + +#[tauri::command] +pub async fn gateway_tunnel_start( + tunnel_state: State<'_, GatewayTunnelState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, +) -> Result { + let config = config_manager.config(); + start_tunnel(&tunnel_state, logs.inner().clone(), config).await +} + +#[tauri::command] +pub async fn gateway_tunnel_stop( + tunnel_state: State<'_, GatewayTunnelState>, + logs: State<'_, LogState>, +) -> Result { + stop_tunnel(&tunnel_state, logs.inner().clone()).await +} + +#[tauri::command] +pub async fn gateway_tunnel_restart( + tunnel_state: State<'_, GatewayTunnelState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, +) -> Result { + let _ = stop_tunnel(&tunnel_state, logs.inner().clone()).await; + let config = config_manager.config(); + start_tunnel(&tunnel_state, logs.inner().clone(), config).await +} + +#[tauri::command] +pub async fn gateway_tunnel_status( + tunnel_state: State<'_, GatewayTunnelState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, +) -> Result { + let config = config_manager.config(); + let mut status = status_tunnel_with_config(&tunnel_state, Some(config.clone())).await?; + + let is_managed = config.gateway.tunnel.enabled + && config + .gateway + .tunnel + .provider + .trim() + .eq_ignore_ascii_case("cloudflare") + && config + .gateway + .tunnel + .mode + .trim() + .eq_ignore_ascii_case("managed"); + if is_managed && !status.running && status.last_exit.is_some() { + logs.write().await.add( + "warn", + "[GatewayTunnel] 检测到 managed 隧道已退出,尝试自动重启一次", + ); + if let Ok(restarted) = start_tunnel(&tunnel_state, logs.inner().clone(), config).await { + status = restarted; + } + } + + Ok(status) +} + +#[tauri::command] +pub async fn gateway_tunnel_sync_webhook_url( + state: State<'_, AppState>, + config_manager: State<'_, GlobalConfigManagerState>, + request: GatewayTunnelSyncWebhookRequest, +) -> Result { + let channel = request.channel.trim().to_ascii_lowercase(); + if channel != "feishu" { + return Err(format!("暂不支持的 channel: {}", request.channel)); + } + + let config = config_manager.config(); + let base_url = resolve_public_base_url(&config)?; + let webhook_path = request + .webhook_path + .as_deref() + .map(normalize_webhook_path) + .unwrap_or_else(|| { + normalize_webhook_path( + config + .channels + .feishu + .webhook_path + .as_deref() + .unwrap_or("/feishu/default"), + ) + }); + let webhook_url = format!("{}{}", base_url, webhook_path); + + if request.persist { + let mut next = config.clone(); + next.channels.feishu.connection_mode = "webhook".to_string(); + next.channels.feishu.webhook_path = Some(webhook_path.clone()); + next.channels.feishu.webhook_host = Some(next.gateway.tunnel.local_host.clone()); + next.channels.feishu.webhook_port = Some(next.gateway.tunnel.local_port); + + if let Some(account_id) = request + .account_id + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + if let Some(account) = next.channels.feishu.accounts.get_mut(account_id) { + account.connection_mode = Some("webhook".to_string()); + account.webhook_path = Some(webhook_path.clone()); + account.webhook_host = Some(next.gateway.tunnel.local_host.clone()); + account.webhook_port = Some(next.gateway.tunnel.local_port); + } + } + + persist_full_config(state, config_manager, next).await?; + } + + Ok(GatewayTunnelSyncWebhookResponse { + channel, + account_id: request.account_id, + webhook_path, + public_base_url: base_url, + webhook_url, + persisted: request.persist, + }) +} + +async fn persist_full_config( + state: State<'_, AppState>, + config_manager: State<'_, GlobalConfigManagerState>, + config: proxycast_core::config::Config, +) -> Result<(), String> { + { + let mut app_state = state.write().await; + app_state.config = config.clone(); + } + config_manager.0.save_config(&config).await +} + +fn resolve_public_base_url(config: &proxycast_core::config::Config) -> Result { + let value = config + .gateway + .tunnel + .public_base_url + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(|v| v.trim_end_matches('/').to_string()) + .or_else(|| { + config + .gateway + .tunnel + .cloudflare + .dns_name + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(|host| format!("https://{}", host)) + }); + value.ok_or_else(|| "缺少 gateway.tunnel.public_base_url 或 cloudflare.dns_name".to_string()) +} + +fn normalize_webhook_path(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return "/feishu/default".to_string(); + } + if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{}", trimmed) + } +} + +#[derive(Debug, Clone)] +struct InstallerSpec { + package_manager: &'static str, + command: String, + requires_privilege: bool, +} + +fn detect_platform() -> String { + if cfg!(target_os = "windows") { + "windows".to_string() + } else if cfg!(target_os = "macos") { + "macos".to_string() + } else if cfg!(target_os = "linux") { + "linux".to_string() + } else { + std::env::consts::OS.to_string() + } +} + +async fn command_exists(name: &str) -> bool { + match Command::new(name).arg("--version").output().await { + Ok(_) => true, + Err(error) => error.kind() != std::io::ErrorKind::NotFound, + } +} + +async fn cloudflared_version(binary: &str) -> Option { + let output = Command::new(binary).arg("--version").output().await.ok()?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let merged = if !stdout.is_empty() { stdout } else { stderr }; + if merged.is_empty() { + None + } else { + Some(merged) + } +} + +async fn resolve_installer_spec(platform: &str) -> Option { + match platform { + "macos" => { + if command_exists("brew").await { + Some(InstallerSpec { + package_manager: "brew", + command: "brew install cloudflared".to_string(), + requires_privilege: false, + }) + } else { + None + } + } + "windows" => { + if command_exists("winget").await { + Some(InstallerSpec { + package_manager: "winget", + command: "winget install --id Cloudflare.cloudflared -e --accept-source-agreements --accept-package-agreements".to_string(), + requires_privilege: true, + }) + } else if command_exists("choco").await { + Some(InstallerSpec { + package_manager: "choco", + command: "choco install cloudflared -y".to_string(), + requires_privilege: true, + }) + } else if command_exists("scoop").await { + Some(InstallerSpec { + package_manager: "scoop", + command: "scoop install cloudflared".to_string(), + requires_privilege: false, + }) + } else { + None + } + } + "linux" => { + if command_exists("apt-get").await { + Some(InstallerSpec { + package_manager: "apt-get", + command: "sudo apt-get update && sudo apt-get install -y cloudflared" + .to_string(), + requires_privilege: true, + }) + } else if command_exists("dnf").await { + Some(InstallerSpec { + package_manager: "dnf", + command: "sudo dnf install -y cloudflared".to_string(), + requires_privilege: true, + }) + } else if command_exists("yum").await { + Some(InstallerSpec { + package_manager: "yum", + command: "sudo yum install -y cloudflared".to_string(), + requires_privilege: true, + }) + } else { + None + } + } + _ => None, + } +} + +async fn detect_cloudflared_status( + config: &proxycast_core::config::Config, +) -> Result { + let binary = config + .gateway + .tunnel + .binary_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("cloudflared") + .to_string(); + let version = cloudflared_version(&binary).await; + let platform = detect_platform(); + let installer = resolve_installer_spec(&platform).await; + let installed = version.is_some(); + + Ok(CloudflaredInstallStatus { + installed, + binary, + version, + platform, + package_manager: installer + .as_ref() + .map(|value| value.package_manager.to_string()), + install_supported: installer.is_some(), + install_command: installer.as_ref().map(|value| value.command.clone()), + requires_privilege: installer + .as_ref() + .map(|value| value.requires_privilege) + .unwrap_or(false), + message: if installed { + "检测到 cloudflared 已安装".to_string() + } else if let Some(spec) = installer { + format!("检测到可用安装器:{},可执行一键安装", spec.package_manager) + } else { + "未检测到可用包管理器,请手动安装 cloudflared".to_string() + }, + }) +} + +async fn install_cloudflared( + config: &proxycast_core::config::Config, +) -> Result { + let platform = detect_platform(); + let Some(spec) = resolve_installer_spec(&platform).await else { + return Ok(CloudflaredInstallResult { + ok: false, + attempted: false, + platform, + package_manager: None, + command: None, + exit_code: None, + installed: cloudflared_version("cloudflared").await.is_some(), + version: cloudflared_version("cloudflared").await, + stdout: String::new(), + stderr: String::new(), + message: "当前系统未检测到可用包管理器,请手动安装 cloudflared".to_string(), + }); + }; + + let output = if cfg!(target_os = "windows") { + Command::new("cmd") + .args(["/C", &spec.command]) + .output() + .await + .map_err(|error| format!("执行安装命令失败: {error}"))? + } else { + Command::new("sh") + .arg("-lc") + .arg(&spec.command) + .output() + .await + .map_err(|error| format!("执行安装命令失败: {error}"))? + }; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code(); + + let binary = config + .gateway + .tunnel + .binary_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("cloudflared"); + let version = cloudflared_version(binary).await; + let installed = version.is_some(); + let ok = output.status.success() && installed; + + Ok(CloudflaredInstallResult { + ok, + attempted: true, + platform, + package_manager: Some(spec.package_manager.to_string()), + command: Some(spec.command), + exit_code, + installed, + version, + stdout, + stderr, + message: if ok { + "cloudflared 安装成功".to_string() + } else { + "cloudflared 安装命令执行完成,但未检测到可用二进制,请根据输出排查权限或网络问题" + .to_string() + }, + }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6306b98fb..4c07b7421 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -14,6 +14,8 @@ pub mod ecommerce_review_reply_cmd; pub mod execution_run_cmd; pub mod external_tools_cmd; pub mod file_upload_cmd; +pub mod gateway_channel_cmd; +pub mod gateway_tunnel_cmd; pub mod general_chat_cmd; pub mod heartbeat_cmd; pub mod image_search_cmd; diff --git a/src-tauri/src/config/observer/mod.rs b/src-tauri/src/config/observer/mod.rs index a5fd8c773..dbb410330 100644 --- a/src-tauri/src/config/observer/mod.rs +++ b/src-tauri/src/config/observer/mod.rs @@ -8,8 +8,7 @@ mod tauri_observer; // 从 proxycast-config crate 重新导出被使用的类型 pub use proxycast_config::observer::events::{ - ConfigChangeEvent, ConfigChangeSource, EndpointProvidersChangeEvent, FullReloadEvent, - RoutingChangeEvent, + ConfigChangeEvent, ConfigChangeSource, EndpointProvidersChangeEvent, RoutingChangeEvent, }; // Tauri 相关实现 diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index e012f2ce5..c11a1ac75 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -208,6 +208,7 @@ fn arb_config() -> impl Strategy { heartbeat: proxycast_core::config::HeartbeatSettings::default(), channels: proxycast_core::config::ChannelsConfig::default(), crash_reporting: proxycast_core::config::CrashReportingConfig::default(), + gateway: proxycast_core::config::GatewayConfig::default(), }) } @@ -464,6 +465,7 @@ fn arb_valid_config() -> impl Strategy { heartbeat: proxycast_core::config::HeartbeatSettings::default(), channels: proxycast_core::config::ChannelsConfig::default(), crash_reporting: proxycast_core::config::CrashReportingConfig::default(), + gateway: proxycast_core::config::GatewayConfig::default(), }) } @@ -530,6 +532,7 @@ fn arb_invalid_config() -> impl Strategy { heartbeat: proxycast_core::config::HeartbeatSettings::default(), channels: proxycast_core::config::ChannelsConfig::default(), crash_reporting: proxycast_core::config::CrashReportingConfig::default(), + gateway: proxycast_core::config::GatewayConfig::default(), }; // 根据类型使配置无效 match invalid_type { diff --git a/src-tauri/src/services/request_tool_policy_prompt_service.rs b/src-tauri/src/services/request_tool_policy_prompt_service.rs index 0e67876c6..c8a50e197 100644 --- a/src-tauri/src/services/request_tool_policy_prompt_service.rs +++ b/src-tauri/src/services/request_tool_policy_prompt_service.rs @@ -1,566 +1,10 @@ -//! 请求级工具策略提示词服务 +//! 请求级工具策略服务(兼容层) //! -//! 将本次请求的工具偏好(例如“开启联网搜索”)统一转换为系统提示词附加项, -//! 避免通过改写用户原始消息来注入策略。 -//! -//! 设计目标: -//! - 单一策略入口:Aster 聊天入口与统一执行入口复用同一策略解析/校验逻辑 -//! - 请求级优先:`effective_web_search = request.web_search ?? mode_default` -//! - 配置驱动:工具白/黑名单支持环境变量覆盖,保留扩展性 +//! 真实实现已迁移到 `proxycast-agent::request_tool_policy`, +//! 此处保留旧路径仅用于兼容主 crate 既有调用与测试引用。 -use aster::agents::Agent; -use aster::tools::ToolContext; -use proxycast_agent::event_converter::{TauriAgentEvent, TauriToolResult}; -use std::collections::HashMap; -use std::path::Path; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -pub const REQUEST_TOOL_POLICY_MARKER: &str = "【请求级工具策略】"; - -const DEFAULT_REQUIRED_TOOLS: &[&str] = &["WebSearch"]; -const DEFAULT_ALLOWED_TOOLS: &[&str] = &["WebSearch", "WebFetch"]; -const WEB_SEARCH_REQUIRED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_REQUIRED_TOOLS"; -const WEB_SEARCH_ALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_ALLOWED_TOOLS"; -const WEB_SEARCH_DISALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_DISALLOWED_TOOLS"; -const WEB_SEARCH_PREFLIGHT_ENABLED_ENV: &str = "PROXYCAST_WEB_SEARCH_PREFLIGHT_ENABLED"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RequestToolPolicy { - /// 本次请求是否开启联网搜索策略 - pub effective_web_search: bool, - /// 必须至少成功一次的工具(默认 WebSearch) - pub required_tools: Vec, - /// 允许的联网工具集合(默认 WebSearch/WebFetch) - pub allowed_tools: Vec, - /// 禁止工具集合(可配置) - pub disallowed_tools: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ToolAttemptRecord { - pub tool_id: String, - pub tool_name: String, - pub success: Option, - pub error: Option, -} - -#[derive(Debug, Default)] -pub struct WebSearchExecutionTracker { - ordered_tool_ids: Vec, - attempts_by_id: HashMap, -} - -impl WebSearchExecutionTracker { - pub fn record_tool_start( - &mut self, - policy: &RequestToolPolicy, - tool_id: &str, - tool_name: &str, - ) { - if !policy.effective_web_search || tool_id.trim().is_empty() || tool_name.trim().is_empty() - { - return; - } - - if !self.attempts_by_id.contains_key(tool_id) { - self.ordered_tool_ids.push(tool_id.to_string()); - self.attempts_by_id.insert( - tool_id.to_string(), - ToolAttemptRecord { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - success: None, - error: None, - }, - ); - } - } - - pub fn record_tool_end( - &mut self, - policy: &RequestToolPolicy, - tool_id: &str, - success: bool, - error: Option<&str>, - ) { - if !policy.effective_web_search || tool_id.trim().is_empty() { - return; - } - if let Some(record) = self.attempts_by_id.get_mut(tool_id) { - record.success = Some(success); - record.error = error - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); - } - } - - pub fn validate_web_search_requirement( - &self, - policy: &RequestToolPolicy, - ) -> Result<(), String> { - if !policy.effective_web_search { - return Ok(()); - } - - let disallowed_attempts: Vec<&ToolAttemptRecord> = self - .ordered_tool_ids - .iter() - .filter_map(|tool_id| self.attempts_by_id.get(tool_id)) - .filter(|record| matches_tool_list(&record.tool_name, &policy.disallowed_tools)) - .collect(); - if !disallowed_attempts.is_empty() { - let disallowed_names = disallowed_attempts - .iter() - .map(|record| record.tool_name.clone()) - .collect::>() - .join(", "); - return Err(format!( - "联网搜索策略阻止了禁止工具调用: {}。\n尝试记录: {}", - disallowed_names, - self.format_attempts() - )); - } - - let required_attempts: Vec<&ToolAttemptRecord> = self - .ordered_tool_ids - .iter() - .filter_map(|tool_id| self.attempts_by_id.get(tool_id)) - .filter(|record| policy.matches_any_required_tool(&record.tool_name)) - .collect(); - - if required_attempts.is_empty() { - return Err(format!( - "联网搜索已开启,但未检测到必需工具调用。必须先调用 {} 至少一次后再给出最终答复。\n尝试记录: {}", - policy.required_tools.join(", "), - self.format_attempts() - )); - } - - if required_attempts - .iter() - .any(|record| record.success.unwrap_or(false)) - { - return Ok(()); - } - - Err(format!( - "联网搜索已开启,但必需工具调用全部失败,无法给出符合约束的最终答复。\n失败原因与尝试记录: {}", - self.format_attempts() - )) - } - - pub fn format_attempts(&self) -> String { - if self.ordered_tool_ids.is_empty() { - return "无工具调用".to_string(); - } - - self.ordered_tool_ids - .iter() - .filter_map(|tool_id| self.attempts_by_id.get(tool_id)) - .map(|record| { - let status = match record.success { - Some(true) => "success".to_string(), - Some(false) => { - format!("failed({})", record.error.as_deref().unwrap_or("unknown")) - } - None => "pending".to_string(), - }; - format!("{}#{}:{}", record.tool_name, record.tool_id, status) - }) - .collect::>() - .join("; ") - } -} - -#[derive(Debug, Clone)] -pub struct PreflightToolExecution { - pub events: Vec, -} - -impl PreflightToolExecution { - fn none() -> Self { - Self { events: Vec::new() } - } -} - -impl RequestToolPolicy { - pub fn matches_any_required_tool(&self, tool_name: &str) -> bool { - matches_tool_list(tool_name, &self.required_tools) - } - - pub fn matches_any_allowed_tool(&self, tool_name: &str) -> bool { - matches_tool_list(tool_name, &self.allowed_tools) - } -} - -/// 解析请求级工具策略 -/// -/// 规则: -/// - `effective_web_search = request_web_search.unwrap_or(mode_default)` -/// - 白/黑名单支持环境变量覆盖: -/// - `PROXYCAST_WEB_SEARCH_REQUIRED_TOOLS` -/// - `PROXYCAST_WEB_SEARCH_ALLOWED_TOOLS` -/// - `PROXYCAST_WEB_SEARCH_DISALLOWED_TOOLS` -pub fn resolve_request_tool_policy( - request_web_search: Option, - mode_default: bool, -) -> RequestToolPolicy { - let effective_web_search = request_web_search.unwrap_or(mode_default); - let required_tools = parse_tool_list_env(WEB_SEARCH_REQUIRED_TOOLS_ENV, DEFAULT_REQUIRED_TOOLS); - let mut allowed_tools = - parse_tool_list_env(WEB_SEARCH_ALLOWED_TOOLS_ENV, DEFAULT_ALLOWED_TOOLS); - let disallowed_tools = parse_tool_list_env(WEB_SEARCH_DISALLOWED_TOOLS_ENV, &[]); - - for required in &required_tools { - if !allowed_tools - .iter() - .any(|candidate| is_same_tool(candidate, required)) - { - allowed_tools.push(required.clone()); - } - } - - RequestToolPolicy { - effective_web_search, - required_tools, - allowed_tools, - disallowed_tools, - } -} - -/// 合并请求级工具策略到系统提示词 -/// -/// - `effective_web_search=false`:保持原始 system prompt 不变 -/// - 已包含 marker 时:不重复追加 -pub fn merge_system_prompt_with_request_tool_policy( - base_prompt: Option, - policy: &RequestToolPolicy, -) -> Option { - if !policy.effective_web_search { - return base_prompt; - } - - let disallowed_line = if policy.disallowed_tools.is_empty() { - "无".to_string() - } else { - policy.disallowed_tools.join(", ") - }; - - let policy_prompt = format!( - "{REQUEST_TOOL_POLICY_MARKER}\n\ -- 用户在本次请求中已开启“联网搜索”开关。\n\ -- 必须先调用 {} 至少一次(必要时再调用 WebFetch),再输出最终答复。\n\ -- 若工具调用失败,必须返回失败原因与尝试记录;不要在未完成必需工具调用前直接给最终结论。\n\ -- 允许工具: {}\n\ -- 禁止工具: {}", - policy.required_tools.join(", "), - policy.allowed_tools.join(", "), - disallowed_line - ); - - match base_prompt { - Some(base) => { - if base.contains(REQUEST_TOOL_POLICY_MARKER) { - Some(base) - } else if base.trim().is_empty() { - Some(policy_prompt) - } else { - Some(format!("{base}\n\n{policy_prompt}")) - } - } - None => Some(policy_prompt), - } -} - -fn parse_tool_list_env(key: &str, default_values: &[&str]) -> Vec { - let from_env = std::env::var(key) - .ok() - .map(|raw| parse_tool_list(&raw)) - .filter(|tools| !tools.is_empty()); - - let values = - from_env.unwrap_or_else(|| default_values.iter().map(|item| item.to_string()).collect()); - dedup_tools(values) -} - -fn parse_tool_list(raw: &str) -> Vec { - raw.split(',') - .map(str::trim) - .filter(|item| !item.is_empty()) - .map(|item| item.to_string()) - .collect() -} - -fn dedup_tools(values: Vec) -> Vec { - let mut result: Vec = Vec::new(); - for value in values { - if !result.iter().any(|existing| is_same_tool(existing, &value)) { - result.push(value); - } - } - result -} - -fn matches_tool_list(tool_name: &str, list: &[String]) -> bool { - list.iter() - .any(|candidate| is_same_tool(tool_name, candidate)) -} - -fn is_same_tool(a: &str, b: &str) -> bool { - let normalized_a = normalize_tool_name(a); - let normalized_b = normalize_tool_name(b); - if normalized_a.is_empty() || normalized_b.is_empty() { - return false; - } - normalized_a == normalized_b - || normalized_a.contains(&normalized_b) - || normalized_b.contains(&normalized_a) -} - -fn normalize_tool_name(value: &str) -> String { - value - .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .flat_map(|ch| ch.to_lowercase()) - .collect::() -} - -/// 当开启联网搜索时,在正式回复前执行一次 WebSearch 预调用。 -/// -/// 目标: -/// - 通过执行层保证至少一次 WebSearch 调用(而非仅依赖提示词) -/// - 统一生成 tool_start/tool_end 事件,供前端落地 -/// - 若预调用失败,返回失败原因并由上层中断本次回答 -pub async fn execute_web_search_preflight_if_needed( - agent: &Agent, - session_id: &str, - message_text: &str, - working_directory: Option<&Path>, - cancel_token: Option, - policy: &RequestToolPolicy, - tracker: &mut WebSearchExecutionTracker, -) -> Result { - if !policy.effective_web_search || !is_web_search_preflight_enabled() { - return Ok(PreflightToolExecution::none()); - } - - let registry_arc = agent.tool_registry().clone(); - let registry = registry_arc.read().await; - let available_tools = registry.get_definitions(); - let preflight_tool = available_tools - .iter() - .find(|definition| { - policy.matches_any_required_tool(&definition.name) - && normalize_tool_name(&definition.name).contains("websearch") - }) - .ok_or_else(|| { - format!( - "联网搜索已开启,但未找到可执行的必需工具定义。required_tools={}, available_tools={}", - policy.required_tools.join(", "), - available_tools - .iter() - .map(|definition| definition.name.clone()) - .collect::>() - .join(", ") - ) - })?; - - let query = derive_preflight_query(message_text); - let params = serde_json::json!({ "query": query }); - let arguments = serde_json::to_string(¶ms).ok(); - let tool_id = format!("preflight-websearch-{}", Uuid::new_v4()); - tracker.record_tool_start(policy, &tool_id, &preflight_tool.name); - - let mut context = ToolContext::new( - working_directory - .map(Path::to_path_buf) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_default(), - ) - .with_session_id(session_id.to_string()); - if let Some(token) = cancel_token { - context = context.with_cancellation_token(token); - } - - let mut events = vec![TauriAgentEvent::ToolStart { - tool_name: preflight_tool.name.clone(), - tool_id: tool_id.clone(), - arguments, - }]; - - let result = registry - .execute(&preflight_tool.name, params, &context, None) - .await - .map_err(|error| format!("执行 WebSearch 预调用失败: {}", error.to_string())); - - match result { - Ok(tool_result) => { - tracker.record_tool_end( - policy, - &tool_id, - tool_result.success, - tool_result.error.as_deref(), - ); - let event = TauriAgentEvent::ToolEnd { - tool_id, - result: TauriToolResult { - success: tool_result.success, - output: tool_result.output.unwrap_or_default(), - error: tool_result.error, - images: None, - }, - }; - events.push(event); - - if events - .last() - .and_then(|event| match event { - TauriAgentEvent::ToolEnd { result, .. } => Some(result.success), - _ => None, - }) - .unwrap_or(false) - { - Ok(PreflightToolExecution { events }) - } else { - let failure = events.last().and_then(|event| match event { - TauriAgentEvent::ToolEnd { result, .. } => result.error.clone(), - _ => None, - }); - Err(format!( - "联网搜索预调用失败: {}", - failure.unwrap_or_else(|| "unknown".to_string()) - )) - } - } - Err(error) => { - tracker.record_tool_end(policy, &tool_id, false, Some(error.as_str())); - events.push(TauriAgentEvent::ToolEnd { - tool_id, - result: TauriToolResult { - success: false, - output: String::new(), - error: Some(error.clone()), - images: None, - }, - }); - Err(error) - } - } -} - -fn is_web_search_preflight_enabled() -> bool { - match std::env::var(WEB_SEARCH_PREFLIGHT_ENABLED_ENV) { - Ok(raw) => match raw.trim().to_ascii_lowercase().as_str() { - "0" | "false" | "no" | "off" => false, - _ => true, - }, - Err(_) => true, - } -} - -fn derive_preflight_query(message_text: &str) -> String { - let trimmed = message_text.trim(); - if trimmed.chars().count() >= 2 { - return trimmed.to_string(); - } - if trimmed.is_empty() { - return "最新信息".to_string(); - } - - // 兜底补齐最短长度,避免触发 WebSearch.query minLength 校验失败 - let mut fallback = trimmed.to_string(); - while fallback.chars().count() < 2 { - fallback.push_str(" 信息"); - } - fallback -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolves_effective_web_search_with_request_override() { - let policy = resolve_request_tool_policy(Some(false), true); - assert!(!policy.effective_web_search); - - let policy = resolve_request_tool_policy(Some(true), false); - assert!(policy.effective_web_search); - } - - #[test] - fn resolves_effective_web_search_with_mode_default() { - let policy = resolve_request_tool_policy(None, true); - assert!(policy.effective_web_search); - - let policy = resolve_request_tool_policy(None, false); - assert!(!policy.effective_web_search); - } - - #[test] - fn keeps_original_prompt_when_disabled() { - let base = Some("base".to_string()); - let policy = resolve_request_tool_policy(Some(false), false); - assert_eq!( - merge_system_prompt_with_request_tool_policy(base.clone(), &policy), - base - ); - } - - #[test] - fn appends_policy_prompt_when_enabled() { - let policy = resolve_request_tool_policy(Some(true), false); - let merged = - merge_system_prompt_with_request_tool_policy(Some("base".to_string()), &policy) - .expect("merged prompt should exist"); - assert!(merged.contains(REQUEST_TOOL_POLICY_MARKER)); - assert!(merged.contains("必须先调用")); - assert!(merged.contains("WebSearch")); - } - - #[test] - fn no_duplicate_when_marker_exists() { - let base = Some(format!("{REQUEST_TOOL_POLICY_MARKER}\nexists")); - let policy = resolve_request_tool_policy(Some(true), false); - assert_eq!( - merge_system_prompt_with_request_tool_policy(base.clone(), &policy), - base - ); - } - - #[test] - fn tracker_requires_websearch_when_enabled() { - let policy = resolve_request_tool_policy(Some(true), false); - let mut tracker = WebSearchExecutionTracker::default(); - tracker.record_tool_start(&policy, "tool-1", "WebFetch"); - tracker.record_tool_end(&policy, "tool-1", true, None); - let err = tracker - .validate_web_search_requirement(&policy) - .expect_err("missing web search should fail"); - assert!(err.contains("未检测到必需工具调用")); - } - - #[test] - fn tracker_accepts_successful_websearch() { - let policy = resolve_request_tool_policy(Some(true), false); - let mut tracker = WebSearchExecutionTracker::default(); - tracker.record_tool_start(&policy, "tool-1", "WebSearch"); - tracker.record_tool_end(&policy, "tool-1", true, None); - assert!(tracker.validate_web_search_requirement(&policy).is_ok()); - } - - #[test] - fn tracker_reports_failure_record() { - let policy = resolve_request_tool_policy(Some(true), false); - let mut tracker = WebSearchExecutionTracker::default(); - tracker.record_tool_start(&policy, "tool-1", "WebSearch"); - tracker.record_tool_end(&policy, "tool-1", false, Some("network timeout")); - let err = tracker - .validate_web_search_requirement(&policy) - .expect_err("failed required tool should fail"); - assert!(err.contains("network timeout")); - assert!(err.contains("尝试记录")); - } -} +pub use proxycast_agent::request_tool_policy::{ + execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy, + resolve_request_tool_policy, stream_reply_with_policy, ReplyAttemptError, RequestToolPolicy, + StreamReplyExecution, WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER, +}; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9c014e87b..92c243ad7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.79.0", + "version": "0.80.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index 8f65c5c74..56789318e 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -245,9 +245,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { seedSession(workspaceId, "session-ask-fallback"); const harness = mountHook(workspaceId); - let streamHandler: - | ((event: { payload: unknown }) => void) - | null = null; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; mockSafeListen.mockImplementationOnce(async (_eventName, handler) => { streamHandler = handler as (event: { payload: unknown }) => void; return () => { @@ -283,10 +281,12 @@ describe("useAsterAgentChat action_required 渲染链路", () => { .find((msg) => msg.role === "assistant"); expect(assistantMessage).toBeDefined(); - expect(assistantMessage?.actionRequests?.[0]?.actionType).toBe("ask_user"); - expect(assistantMessage?.actionRequests?.[0]?.questions?.[0]?.question).toBe( - "你希望海报主色调是什么?", + expect(assistantMessage?.actionRequests?.[0]?.actionType).toBe( + "ask_user", ); + expect( + assistantMessage?.actionRequests?.[0]?.questions?.[0]?.question, + ).toBe("你希望海报主色调是什么?"); expect( assistantMessage?.actionRequests?.[0]?.questions?.[0]?.options?.map( (item) => item.label, @@ -302,9 +302,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { seedSession(workspaceId, "session-ask-fallback-id"); const harness = mountHook(workspaceId); - let streamHandler: - | ((event: { payload: unknown }) => void) - | null = null; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; mockSafeListen.mockImplementationOnce(async (_eventName, handler) => { streamHandler = handler as (event: { payload: unknown }) => void; return () => { @@ -352,9 +350,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { seedSession(workspaceId, "session-action-required"); const harness = mountHook(workspaceId); - let streamHandler: - | ((event: { payload: unknown }) => void) - | null = null; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; mockSafeListen.mockImplementationOnce(async (_eventName, handler) => { streamHandler = handler as (event: { payload: unknown }) => void; return () => { @@ -414,9 +410,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { seedSession(workspaceId, "session-action-required-options"); const harness = mountHook(workspaceId); - let streamHandler: - | ((event: { payload: unknown }) => void) - | null = null; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; mockSafeListen.mockImplementationOnce(async (_eventName, handler) => { streamHandler = handler as (event: { payload: unknown }) => void; return () => { @@ -469,9 +463,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { seedSession(workspaceId, "session-ask-submit-keep"); const harness = mountHook(workspaceId); - let streamHandler: - | ((event: { payload: unknown }) => void) - | null = null; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; mockSafeListen.mockImplementationOnce(async (_eventName, handler) => { streamHandler = handler as (event: { payload: unknown }) => void; return () => { @@ -543,9 +535,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { seedSession(workspaceId, "session-ask-fallback-pending"); const harness = mountHook(workspaceId); - let streamHandler: - | ((event: { payload: unknown }) => void) - | null = null; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; mockSafeListen.mockImplementationOnce(async (_eventName, handler) => { streamHandler = handler as (event: { payload: unknown }) => void; return () => { @@ -599,9 +589,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { seedSession(workspaceId, "session-auto-confirm"); const harness = mountHook(workspaceId); - let streamHandler: - | ((event: { payload: unknown }) => void) - | null = null; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; mockSafeListen.mockImplementationOnce(async (_eventName, handler) => { streamHandler = handler as (event: { payload: unknown }) => void; return () => { @@ -653,9 +641,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { seedSession(workspaceId, "session-context-trace"); const harness = mountHook(workspaceId); - let streamHandler: - | ((event: { payload: unknown }) => void) - | null = null; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; mockSafeListen.mockImplementationOnce(async (_eventName, handler) => { streamHandler = handler as (event: { payload: unknown }) => void; return () => { @@ -845,8 +831,7 @@ describe("useAsterAgentChat 偏好持久化", () => { ).toBe("gemini-2.5-pro"); expect( JSON.parse( - localStorage.getItem(`agent_pref_migrated_${workspaceId}`) || - "false", + localStorage.getItem(`agent_pref_migrated_${workspaceId}`) || "false", ), ).toBe(true); } finally { @@ -899,12 +884,14 @@ describe("useAsterAgentChat 偏好持久化", () => { const value = secondMount.getValue(); expect(value.providerType).toBe("gemini"); expect(value.model).toBe("gemini-2.5-pro"); - expect(JSON.parse(localStorage.getItem("agent_pref_provider_global") || "null")).toBe( - "gemini", - ); - expect(JSON.parse(localStorage.getItem("agent_pref_model_global") || "null")).toBe( - "gemini-2.5-pro", - ); + expect( + JSON.parse( + localStorage.getItem("agent_pref_provider_global") || "null", + ), + ).toBe("gemini"); + expect( + JSON.parse(localStorage.getItem("agent_pref_model_global") || "null"), + ).toBe("gemini-2.5-pro"); } finally { secondMount.unmount(); } @@ -958,6 +945,47 @@ describe("useAsterAgentChat 偏好持久化", () => { } }); + it("恢复失效会话时不应请求不存在的会话详情", async () => { + const workspaceId = "ws-stale-session"; + const staleSessionId = "session-stale"; + const activeSessionId = "session-active"; + const now = Math.floor(Date.now() / 1000); + + seedSession(workspaceId, staleSessionId); + mockListAsterSessions.mockResolvedValue([ + { + id: activeSessionId, + name: "可用会话", + created_at: now - 10, + updated_at: now, + messages_count: 1, + }, + ]); + mockGetAsterSession.mockResolvedValue({ + id: activeSessionId, + created_at: now - 10, + updated_at: now, + messages: [], + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await flushEffects(); + await flushEffects(); + + expect( + mockGetAsterSession.mock.calls.some( + ([sessionId]) => sessionId === staleSessionId, + ), + ).toBe(false); + expect(harness.getValue().sessionId).toBe(activeSessionId); + } finally { + harness.unmount(); + } + }); + it("话题列表应按工作区映射过滤,排除其他项目会话", async () => { const workspaceId = "ws-filter-current"; const createdAt = Math.floor(Date.now() / 1000); @@ -1210,8 +1238,7 @@ describe("useAsterAgentChat 偏好持久化", () => { }); expect( value.messages[0]?.contentParts?.some( - (part) => - part.type === "tool_use" && part.toolCall.id === "tool-1", + (part) => part.type === "tool_use" && part.toolCall.id === "tool-1", ), ).toBe(true); expect(value.messages[1]).toMatchObject({ @@ -1295,7 +1322,9 @@ describe("useAsterAgentChat 偏好持久化", () => { { role: "user", timestamp: now, - content: [{ type: "text", text: "/canvas-design 帮我设计一张科技感的海报" }], + content: [ + { type: "text", text: "/canvas-design 帮我设计一张科技感的海报" }, + ], }, { role: "assistant", @@ -1336,11 +1365,15 @@ describe("useAsterAgentChat 偏好持久化", () => { "user", "assistant", ]); - expect(value.messages[1]?.content).toContain("我来帮你设计一张科技感的海报!"); - expect(value.messages[1]?.content).toContain("好的!让我为你创建一张科技海报。"); - expect(value.messages.some((msg) => msg.content.trim().length === 0)).toBe( - false, + expect(value.messages[1]?.content).toContain( + "我来帮你设计一张科技感的海报!", ); + expect(value.messages[1]?.content).toContain( + "好的!让我为你创建一张科技海报。", + ); + expect( + value.messages.some((msg) => msg.content.trim().length === 0), + ).toBe(false); expect( value.messages[1]?.contentParts?.some( (part) => @@ -1522,6 +1555,59 @@ describe("useAsterAgentChat 偏好持久化", () => { harness.unmount(); } }); + + it("切换话题时应去重相邻重复历史消息", async () => { + const workspaceId = "ws-history-adjacent-dedupe"; + const now = Math.floor(Date.now() / 1000); + mockGetAsterSession.mockResolvedValue({ + id: "topic-adjacent-dedupe", + execution_strategy: "react", + messages: [ + { + role: "user", + timestamp: now, + content: [{ type: "text", text: "你好" }], + }, + { + role: "user", + timestamp: now + 1, + content: [{ type: "text", text: "你好" }], + }, + { + role: "assistant", + timestamp: now + 2, + content: [{ type: "text", text: "你好,我在。" }], + }, + { + role: "assistant", + timestamp: now + 3, + content: [{ type: "text", text: "你好,我在。" }], + }, + ], + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().switchTopic("topic-adjacent-dedupe"); + }); + + const value = harness.getValue(); + expect(value.messages).toHaveLength(2); + expect(value.messages[0]).toMatchObject({ + role: "user", + content: "你好", + }); + expect(value.messages[1]).toMatchObject({ + role: "assistant", + content: "你好,我在。", + }); + } finally { + harness.unmount(); + } + }); }); describe("useAsterAgentChat 兼容接口", () => { diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index e1e687ad5..c86ccd0e6 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -29,6 +29,10 @@ import { type AsterExecutionStrategy, type ToolResultImage, } from "@/lib/api/agent"; +import { + isAsterSessionNotFoundError, + resolveRestorableSessionId, +} from "@/lib/asterSessionRecovery"; import { Message, MessageImage, @@ -44,6 +48,7 @@ export interface Topic { id: string; title: string; createdAt: Date; + updatedAt: Date; messagesCount: number; executionStrategy: AsterExecutionStrategy; } @@ -154,8 +159,7 @@ const normalizeAskOptions = ( return null; }) .filter( - (item): item is { label: string; description?: string } => - item !== null, + (item): item is { label: string; description?: string } => item !== null, ); return normalized.length > 0 ? normalized : undefined; @@ -207,9 +211,7 @@ const normalizeActionQuestions = ( }; const normalized = Array.isArray(value) - ? value - .map(toQuestion) - .filter((item): item is Question => item !== null) + ? value.map(toQuestion).filter((item): item is Question => item !== null) : []; if (normalized.length > 0) return normalized; @@ -322,7 +324,9 @@ const resolveHistoryUserDataText = (userData: unknown): string | undefined => { return String(userData); }; -const stringifyToolArguments = (argumentsValue: unknown): string | undefined => { +const stringifyToolArguments = ( + argumentsValue: unknown, +): string | undefined => { if (argumentsValue === null || argumentsValue === undefined) return undefined; if (typeof argumentsValue === "string") { const value = argumentsValue.trim(); @@ -536,7 +540,11 @@ const mergeAdjacentAssistantMessages = (messages: Message[]): Message[] => { } const previous = merged[merged.length - 1]; - if (!previous || previous.role !== "assistant" || current.role !== "assistant") { + if ( + !previous || + previous.role !== "assistant" || + current.role !== "assistant" + ) { merged.push(current); continue; } @@ -550,8 +558,7 @@ const mergeAdjacentAssistantMessages = (messages: Message[]): Message[] => { if (part.type === "tool_use") { const existingIndex = nextParts.findIndex( (item) => - item.type === "tool_use" && - item.toolCall.id === part.toolCall.id, + item.type === "tool_use" && item.toolCall.id === part.toolCall.id, ); if (existingIndex >= 0) { // 同一工具调用在历史里会先有 running,再有 completed/failed。 @@ -581,15 +588,24 @@ const mergeAdjacentAssistantMessages = (messages: Message[]): Message[] => { } return nextParts; })(); - const toolCallMap = new Map[number]>(); - for (const toolCall of [...(previous.toolCalls || []), ...(current.toolCalls || [])]) { + const toolCallMap = new Map< + string, + NonNullable[number] + >(); + for (const toolCall of [ + ...(previous.toolCalls || []), + ...(current.toolCalls || []), + ]) { toolCallMap.set(toolCall.id, toolCall); } const toolCalls = Array.from(toolCallMap.values()); const contextTrace = (() => { const seen = new Set(); const mergedSteps: ContextTraceStep[] = []; - for (const step of [...(previous.contextTrace || []), ...(current.contextTrace || [])]) { + for (const step of [ + ...(previous.contextTrace || []), + ...(current.contextTrace || []), + ]) { const key = `${step.stage}::${step.detail}`; if (!seen.has(key)) { seen.add(key); @@ -614,6 +630,90 @@ const mergeAdjacentAssistantMessages = (messages: Message[]): Message[] => { return merged; }; +const normalizeSignatureText = (text: string): string => + text.replace(/\s+/g, " ").trim(); + +const messageImageSignature = (images?: MessageImage[]): string => { + if (!images || images.length === 0) return ""; + return images + .map((image) => `${image.mediaType}:${image.data.slice(0, 64)}`) + .join("|"); +}; + +const messageToolCallsSignature = ( + toolCalls?: Message["toolCalls"], +): string => { + if (!toolCalls || toolCalls.length === 0) return ""; + return toolCalls + .map((toolCall) => { + const output = toolCall.result?.output + ? normalizeSignatureText(toolCall.result.output) + : ""; + const error = toolCall.result?.error + ? normalizeSignatureText(toolCall.result.error) + : ""; + return `${toolCall.id}:${toolCall.status}:${toolCall.name}:${output}:${error}`; + }) + .join("|"); +}; + +const messageContentPartsSignature = (parts?: ContentPart[]): string => { + if (!parts || parts.length === 0) return ""; + return parts + .map((part) => { + if (part.type === "text" || part.type === "thinking") { + return `${part.type}:${normalizeSignatureText(part.text)}`; + } + if (part.type === "tool_use") { + const output = part.toolCall.result?.output + ? normalizeSignatureText(part.toolCall.result.output) + : ""; + const error = part.toolCall.result?.error + ? normalizeSignatureText(part.toolCall.result.error) + : ""; + return `tool_use:${part.toolCall.id}:${part.toolCall.status}:${part.toolCall.name}:${output}:${error}`; + } + const prompt = part.actionRequired.prompt + ? normalizeSignatureText(part.actionRequired.prompt) + : ""; + return `action_required:${part.actionRequired.requestId}:${part.actionRequired.actionType}:${prompt}`; + }) + .join("|"); +}; + +const buildHistoryMessageSignature = (message: Message): string => { + return [ + message.role, + normalizeSignatureText(message.content), + messageImageSignature(message.images), + messageToolCallsSignature(message.toolCalls), + messageContentPartsSignature(message.contentParts), + ].join("::"); +}; + +const dedupeAdjacentHistoryMessages = (messages: Message[]): Message[] => { + const deduped: Message[] = []; + let previousSignature: string | null = null; + let previousTimestampMs: number | null = null; + + for (const message of messages) { + const signature = buildHistoryMessageSignature(message); + const timestampMs = message.timestamp.getTime(); + const isDuplicate = + previousSignature === signature && + previousTimestampMs !== null && + Math.abs(timestampMs - previousTimestampMs) <= 5000; + + if (!isDuplicate) { + deduped.push(message); + previousSignature = signature; + previousTimestampMs = timestampMs; + } + } + + return deduped; +}; + const resolveActionPromptKey = (action: ActionRequired): string | null => { if (typeof action.prompt === "string" && action.prompt.trim()) { return action.prompt.trim(); @@ -721,11 +821,10 @@ const loadTransient = (key: string, defaultValue: T): T => { if (stored) { const parsed = JSON.parse(stored); if (key.startsWith("aster_messages") && Array.isArray(parsed)) { - const normalizedMessages = parsed - .map((msg: any) => ({ - ...msg, - timestamp: new Date(msg.timestamp), - })) as Message[]; + const normalizedMessages = parsed.map((msg: any) => ({ + ...msg, + timestamp: new Date(msg.timestamp), + })) as Message[]; const normalized = normalizeHistoryMessages(normalizedMessages); // 清理旧版本历史缓存中的 fallback 工具名,触发回源重建真实工具名称。 if (hasLegacyFallbackToolNames(normalized)) { @@ -914,15 +1013,22 @@ const mapProviderName = (providerType: string): string => { return mapping[providerType.toLowerCase()] || providerType; }; -const mapSessionToTopic = (session: AsterSessionInfo): Topic => ({ - id: session.id, - title: - session.name || - `话题 ${new Date(session.created_at * 1000).toLocaleDateString("zh-CN")}`, - createdAt: new Date(session.created_at * 1000), - messagesCount: session.messages_count ?? 0, - executionStrategy: normalizeExecutionStrategy(session.execution_strategy), -}); +const mapSessionToTopic = (session: AsterSessionInfo): Topic => { + const updatedAtEpoch = Number.isFinite(session.updated_at) + ? session.updated_at + : session.created_at; + + return { + id: session.id, + title: + session.name || + `话题 ${new Date(session.created_at * 1000).toLocaleDateString("zh-CN")}`, + createdAt: new Date(session.created_at * 1000), + updatedAt: new Date(updatedAtEpoch * 1000), + messagesCount: session.messages_count ?? 0, + executionStrategy: normalizeExecutionStrategy(session.execution_strategy), + }; +}; export function useAsterAgentChat(options: UseAsterAgentChatOptions) { const { onWriteFile, workspaceId } = options; @@ -993,6 +1099,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { }, [sessionId, workspaceId]); const [topics, setTopics] = useState([]); + const [topicsReady, setTopicsReady] = useState(false); const [isSending, setIsSending] = useState(false); const [pendingActions, setPendingActions] = useState([]); const [workspacePathMissing, setWorkspacePathMissing] = useState<{ @@ -1048,11 +1155,18 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { modelRef.current = model; const persistSessionModelPreference = useCallback( - (targetSessionId: string, targetProviderType: string, targetModel: string) => { - savePersisted(getSessionModelPreferenceKey(workspaceId, targetSessionId), { - providerType: targetProviderType, - model: targetModel, - }); + ( + targetSessionId: string, + targetProviderType: string, + targetModel: string, + ) => { + savePersisted( + getSessionModelPreferenceKey(workspaceId, targetSessionId), + { + providerType: targetProviderType, + model: targetModel, + }, + ); }, [workspaceId], ); @@ -1294,38 +1408,63 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { }, []); useEffect(() => { + let cancelled = false; + if (!isInitialized) { return; } if (!workspaceId?.trim()) { setTopics([]); + setTopicsReady(true); return; } + setTopicsReady(false); listAsterSessions() .then((sessions) => { - const topicList = filterSessionsByWorkspace(sessions).map(mapSessionToTopic); + if (cancelled) { + return; + } + const topicList = + filterSessionsByWorkspace(sessions).map(mapSessionToTopic); setTopics(topicList); }) .catch((error) => { + if (cancelled) { + return; + } console.error("[AsterChat] 加载话题失败:", error); + }) + .finally(() => { + if (!cancelled) { + setTopicsReady(true); + } }); + + return () => { + cancelled = true; + }; }, [filterSessionsByWorkspace, isInitialized, workspaceId]); // 加载话题列表 const loadTopics = useCallback(async () => { if (!workspaceId?.trim()) { setTopics([]); + setTopicsReady(true); return; } + setTopicsReady(false); try { const sessions = await listAsterSessions(); - const topicList = filterSessionsByWorkspace(sessions).map(mapSessionToTopic); + const topicList = + filterSessionsByWorkspace(sessions).map(mapSessionToTopic); setTopics(topicList); } catch (error) { console.error("[AsterChat] 加载话题失败:", error); + } finally { + setTopicsReady(true); } }, [filterSessionsByWorkspace, workspaceId]); @@ -1504,7 +1643,11 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { ); } - if (nextRequests.some((item) => item.requestId === actionData.requestId)) { + if ( + nextRequests.some( + (item) => item.requestId === actionData.requestId, + ) + ) { return msg; } @@ -1622,14 +1765,15 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { toolArgs?.options || toolArgs?.choices || toolArgs?.enum, ); const explicitRequestId = requestIdFromArgs?.trim(); - const normalizedQuestions = - normalizeActionQuestions(toolArgs?.questions) ?? [ - { - question, - options, - multiSelect: false, - }, - ]; + const normalizedQuestions = normalizeActionQuestions( + toolArgs?.questions, + ) ?? [ + { + question, + options, + multiSelect: false, + }, + ]; const fallbackAction: ActionRequired = { requestId: @@ -1666,7 +1810,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { eventType, status: isSuccess ? "success" : "error", duration, - description: outputText || (isSuccess ? "工具执行完成" : "工具执行失败"), + description: + outputText || (isSuccess ? "工具执行完成" : "工具执行失败"), error: isSuccess ? undefined : outputText || "工具返回失败状态", @@ -1676,7 +1821,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { eventType, status: isSuccess ? "success" : "error", title: `工具 ${toolName}`, - description: outputText || (isSuccess ? "工具执行完成" : "工具执行失败"), + description: + outputText || (isSuccess ? "工具执行完成" : "工具执行失败"), duration, workspaceId: resolvedWorkspaceId, sessionId: activeSessionId, @@ -1753,7 +1899,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { toolName: data.tool_name, arguments: data.arguments, prompt: data.prompt, - questions: normalizeActionQuestions(data.questions, data.prompt), + questions: normalizeActionQuestions( + data.questions, + data.prompt, + ), requestedSchema: data.requested_schema, isFallback: false, }; @@ -1878,7 +2027,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { error: data.message, }); } - if (data.message.includes("429") || data.message.toLowerCase().includes("rate limit")) { + if ( + data.message.includes("429") || + data.message.toLowerCase().includes("rate limit") + ) { toast.warning("请求过于频繁,请稍后重试"); } else { toast.error(`响应错误: ${data.message}`); @@ -1957,9 +2109,11 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { }); } console.error("[AsterChat] 发送失败:", error); - const errMsg = - error instanceof Error ? error.message : String(error); - if (errMsg.includes("429") || errMsg.toLowerCase().includes("rate limit")) { + const errMsg = error instanceof Error ? error.message : String(error); + if ( + errMsg.includes("429") || + errMsg.toLowerCase().includes("rate limit") + ) { toast.warning("请求过于频繁,请稍后重试"); } else if (isWorkspacePathErrorMessage(errMsg)) { setWorkspacePathMissing({ content, images }); @@ -1972,12 +2126,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { if (unlisten) unlisten(); } }, - [ - ensureSession, - executionStrategy, - getRequiredWorkspaceId, - onWriteFile, - ], + [ensureSession, executionStrategy, getRequiredWorkspaceId, onWriteFile], ); // 停止发送 @@ -2016,7 +2165,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { const fixWorkspacePathAndRetry = useCallback( async (newPath: string) => { if (!workspacePathMissing) return; - const { content: retryContent, images: retryImages } = workspacePathMissing; + const { content: retryContent, images: retryImages } = + workspacePathMissing; setWorkspacePathMissing(null); try { await invoke("workspace_update", { @@ -2044,9 +2194,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { const pendingAction = pendingActions.find( (item) => item.requestId === response.requestId, ); - const actionType = - response.actionType || - pendingAction?.actionType; + const actionType = response.actionType || pendingAction?.actionType; const normalizedResponse = typeof response.response === "string" ? response.response.trim() : ""; let submittedUserData: unknown = response.userData; @@ -2243,12 +2391,17 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { skipAutoRestoreRef.current = false; try { const detail = await getAsterSession(topicId); - const topicPreference = loadSessionModelPreference(workspaceId, topicId); + const topicPreference = loadSessionModelPreference( + workspaceId, + topicId, + ); const historyToolNameById = new Map(); const loadedMessages: Message[] = detail.messages .filter( (msg) => - msg.role === "user" || msg.role === "assistant" || msg.role === "tool", + msg.role === "user" || + msg.role === "assistant" || + msg.role === "tool", ) .flatMap((msg, index) => { // 从 TauriMessageContent 数组中提取文本和 contentParts @@ -2313,27 +2466,25 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { ? (part.tool_call as Record) : undefined; const nestedToolCallValue = - nestedToolCall?.value && typeof nestedToolCall.value === "object" + nestedToolCall?.value && + typeof nestedToolCall.value === "object" ? (nestedToolCall.value as Record) : undefined; const toolName = - ( - (typeof part.tool_name === "string" && part.tool_name.trim()) || - (typeof part.toolName === "string" && part.toolName.trim()) || - (typeof part.name === "string" && part.name.trim()) || - (typeof nestedToolCallValue?.name === "string" && - nestedToolCallValue.name.trim()) - ) - ? ( - (typeof part.tool_name === "string" && - part.tool_name.trim()) || - (typeof part.toolName === "string" && - part.toolName.trim()) || - (typeof part.name === "string" && part.name.trim()) || - (typeof nestedToolCallValue?.name === "string" && - nestedToolCallValue.name.trim()) || - "" - ) + (typeof part.tool_name === "string" && + part.tool_name.trim()) || + (typeof part.toolName === "string" && part.toolName.trim()) || + (typeof part.name === "string" && part.name.trim()) || + (typeof nestedToolCallValue?.name === "string" && + nestedToolCallValue.name.trim()) + ? (typeof part.tool_name === "string" && + part.tool_name.trim()) || + (typeof part.toolName === "string" && + part.toolName.trim()) || + (typeof part.name === "string" && part.name.trim()) || + (typeof nestedToolCallValue?.name === "string" && + nestedToolCallValue.name.trim()) || + "" : resolveHistoryToolName(part.id, historyToolNameById); const rawArguments = part.arguments ?? @@ -2355,23 +2506,25 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { if (partType === "tool_response") { if (!part.id || typeof part.id !== "string") continue; const success = part.success !== false; - const toolName = - resolveHistoryToolName(part.id, historyToolNameById); + const toolName = resolveHistoryToolName( + part.id, + historyToolNameById, + ); const outputText = typeof part.output === "string" ? part.output : ""; const toolCall = { id: part.id, name: toolName, - status: success ? ("completed" as const) : ("failed" as const), + status: success + ? ("completed" as const) + : ("failed" as const), startTime: messageTimestamp, endTime: messageTimestamp, result: { success, output: outputText, error: - typeof part.error === "string" - ? part.error - : undefined, + typeof part.error === "string" ? part.error : undefined, images: normalizeToolResultImages(part.images, outputText), }, }; @@ -2401,7 +2554,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { const content = textParts.join("\n").trim(); let normalizedRole = - msg.role === "tool" ? "assistant" : (msg.role as "user" | "assistant"); + msg.role === "tool" + ? "assistant" + : (msg.role as "user" | "assistant"); const hasToolMetadata = toolCalls.length > 0 || contentParts.some((part) => part.type === "tool_use"); @@ -2429,7 +2584,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { role: normalizedRole, content, images: images.length > 0 ? images : undefined, - contentParts: contentParts.length > 0 ? contentParts : undefined, + contentParts: + contentParts.length > 0 ? contentParts : undefined, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, timestamp: messageTimestamp, isThinking: false, @@ -2437,7 +2593,11 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { ]; }); - setMessages(mergeAdjacentAssistantMessages(loadedMessages)); + setMessages( + mergeAdjacentAssistantMessages( + dedupeAdjacentHistoryMessages(loadedMessages), + ), + ); const selectedTopic = topics.find((topic) => topic.id === topicId); setExecutionStrategyState( normalizeExecutionStrategy( @@ -2464,11 +2624,21 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { } catch (error) { console.error("[AsterChat] 切换话题失败:", error); console.error("[AsterChat] 错误详情:", JSON.stringify(error, null, 2)); + if (isAsterSessionNotFoundError(error)) { + setMessages([]); + setSessionId(null); + saveTransient(getScopedSessionKey(), null); + savePersisted(getScopedPersistedSessionKey(), null); + void loadTopics(); + return; + } setMessages([]); setSessionId(null); saveTransient(getScopedSessionKey(), null); savePersisted(getScopedPersistedSessionKey(), null); - toast.error(`加载对话历史失败: ${error instanceof Error ? error.message : String(error)}`); + toast.error( + `加载对话历史失败: ${error instanceof Error ? error.message : String(error)}`, + ); } }, [ @@ -2477,6 +2647,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { messages.length, persistSessionModelPreference, sessionId, + loadTopics, topics, workspaceId, ], @@ -2487,6 +2658,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { const resolvedWorkspaceId = workspaceId?.trim(); if (!resolvedWorkspaceId) return; if (!isInitialized) return; + if (!topicsReady) return; if (skipAutoRestoreRef.current) return; if (sessionId) return; if (topics.length === 0) return; @@ -2497,16 +2669,14 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { const scopedCandidate = loadTransient(getScopedSessionKey(), null) || loadPersisted(getScopedPersistedSessionKey(), null); - const mappedFallbackCandidate = - topics.find( - (topic) => - loadPersisted( - `agent_session_workspace_${topic.id}`, - null, - ) === resolvedWorkspaceId, - )?.id || null; - - const targetSessionId = scopedCandidate || mappedFallbackCandidate; + const targetSessionId = resolveRestorableSessionId({ + candidateSessionId: scopedCandidate, + sessions: topics.map((topic) => ({ + id: topic.id, + createdAt: Math.floor(topic.createdAt.getTime() / 1000), + updatedAt: Math.floor(topic.updatedAt.getTime() / 1000), + })), + }); if (!targetSessionId) { return; } @@ -2522,6 +2692,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { isInitialized, sessionId, switchTopic, + topicsReady, topics, workspaceId, ]); @@ -2535,6 +2706,16 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { // 有 sessionId 但消息为空时,主动回填历史 useEffect(() => { if (!sessionId) return; + if (!topicsReady) return; + + if (topics.length > 0 && !topics.some((topic) => topic.id === sessionId)) { + setSessionId(null); + setMessages([]); + saveTransient(getScopedSessionKey(), null); + savePersisted(getScopedPersistedSessionKey(), null); + hydratedSessionRef.current = null; + return; + } if (messages.length > 0) { hydratedSessionRef.current = sessionId; @@ -2551,7 +2732,15 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { console.warn("[AsterChat] 会话水合失败:", error); hydratedSessionRef.current = null; }); - }, [messages.length, sessionId, switchTopic]); + }, [ + getScopedPersistedSessionKey, + getScopedSessionKey, + messages.length, + sessionId, + switchTopic, + topics, + topicsReady, + ]); // 删除话题 const deleteTopic = useCallback( @@ -2578,30 +2767,28 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { toast.error("删除话题失败"); } }, - [ - getScopedPersistedSessionKey, - getScopedSessionKey, - loadTopics, - sessionId, - ], + [getScopedPersistedSessionKey, getScopedSessionKey, loadTopics, sessionId], ); // 重命名话题(持久化) - const renameTopic = useCallback(async (topicId: string, newTitle: string) => { - const normalizedTitle = newTitle.trim(); - if (!normalizedTitle) { - return; - } + const renameTopic = useCallback( + async (topicId: string, newTitle: string) => { + const normalizedTitle = newTitle.trim(); + if (!normalizedTitle) { + return; + } - try { - await renameAsterSession(topicId, normalizedTitle); - await loadTopics(); - toast.success("话题已重命名"); - } catch (error) { - console.error("[AsterChat] 重命名话题失败:", error); - toast.error("重命名失败"); - } - }, [loadTopics]); + try { + await renameAsterSession(topicId, normalizedTitle); + await loadTopics(); + toast.success("话题已重命名"); + } catch (error) { + console.error("[AsterChat] 重命名话题失败:", error); + toast.error("重命名失败"); + } + }, + [loadTopics], + ); // 兼容接口 const handleStartProcess = useCallback(async () => { diff --git a/src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx b/src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx new file mode 100644 index 000000000..cd8f17073 --- /dev/null +++ b/src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx @@ -0,0 +1,259 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Copy, Pause, Play, Trash2 } from "lucide-react"; +import { clearLogs, getPersistedLogsTail, type LogEntry } from "@/hooks/useTauri"; +import { + type ChannelLogPreset, + buildChannelLogRegex, + filterChannelLogs, +} from "./channel-log-filter"; + +const POLL_INTERVAL_MS = 1000; +const TAIL_LINES = 800; +const MAX_DISPLAY_LINES = 500; + +function formatTime(timestamp: string): string { + const hmsMatch = timestamp.match(/(\d{2}:\d{2}:\d{2})/); + if (hmsMatch) { + return hmsMatch[1]; + } + + const date = new Date(timestamp); + if (!Number.isNaN(date.getTime())) { + return date.toLocaleTimeString(); + } + + return timestamp; +} + +function formatExportLine(entry: LogEntry): string { + return `[${entry.timestamp}] [${entry.level.toUpperCase()}] ${entry.message}`; +} + +export function ChannelLogTailPanel() { + const [logs, setLogs] = useState([]); + const [preset, setPreset] = useState("all"); + const [customPattern, setCustomPattern] = useState(""); + const [paused, setPaused] = useState(false); + const [autoScroll, setAutoScroll] = useState(true); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copyTip, setCopyTip] = useState(null); + const listRef = useRef(null); + + const { regex, error: regexError } = useMemo( + () => buildChannelLogRegex(preset, customPattern), + [preset, customPattern], + ); + + const filteredLogs = useMemo(() => { + const matched = filterChannelLogs(logs, regex); + if (matched.length <= MAX_DISPLAY_LINES) { + return matched; + } + return matched.slice(-MAX_DISPLAY_LINES); + }, [logs, regex]); + + useEffect(() => { + let active = true; + + const poll = async () => { + try { + const entries = await getPersistedLogsTail(TAIL_LINES); + if (!active) return; + setLogs(entries); + setError(null); + } catch (e) { + if (!active) return; + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + } finally { + if (active) { + setLoading(false); + } + } + }; + + void poll(); + if (paused) { + return () => { + active = false; + }; + } + + const timer = window.setInterval(() => { + void poll(); + }, POLL_INTERVAL_MS); + + return () => { + active = false; + window.clearInterval(timer); + }; + }, [paused]); + + useEffect(() => { + if (!autoScroll) { + return; + } + const container = listRef.current; + if (!container) { + return; + } + container.scrollTop = container.scrollHeight; + }, [filteredLogs, autoScroll]); + + const handleCopy = async () => { + const content = filteredLogs.map(formatExportLine).join("\n"); + if (!content) { + setCopyTip("当前无可复制日志"); + return; + } + + try { + await navigator.clipboard.writeText(content); + setCopyTip("已复制当前视图"); + window.setTimeout(() => setCopyTip(null), 1500); + } catch { + setCopyTip("复制失败,请检查系统剪贴板权限"); + } + }; + + const handleClear = async () => { + const confirmed = window.confirm( + "确认清空日志吗?\n这会清空当前内存日志和 ~/.proxycast/logs/proxycast.log,且无法恢复。", + ); + if (!confirmed) { + return; + } + + try { + await clearLogs(); + setLogs([]); + setError(null); + setCopyTip("日志已清空"); + window.setTimeout(() => setCopyTip(null), 1500); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError(`清空日志失败: ${msg}`); + } + }; + + return ( +
+
+
+

渠道日志 Tail

+

+ 数据源:~/.proxycast/logs/proxycast.log(每秒刷新) +

+
+
+ + + +
+
+ +
+ + + {preset === "custom" ? ( + + ) : ( + + )} +
+ + {preset === "custom" && ( + + )} + + {(error || regexError || copyTip) && ( +
+ {error &&

拉取日志失败: {error}

} + {regexError &&

{regexError}

} + {copyTip &&

{copyTip}

} +
+ )} + +
+ {loading ? ( +

加载中...

+ ) : filteredLogs.length === 0 ? ( +

+ 暂无匹配日志{paused ? "(已暂停刷新)" : ",等待新日志写入"} +

+ ) : ( + filteredLogs.map((entry, index) => ( +
+ [{formatTime(entry.timestamp)}]{" "} + [{entry.level.toUpperCase()}] {entry.message} +
+ )) + )} +
+
+ ); +} + +export default ChannelLogTailPanel; diff --git a/src/components/settings-v2/system/channels/channel-log-filter.test.ts b/src/components/settings-v2/system/channels/channel-log-filter.test.ts new file mode 100644 index 000000000..b4cf12376 --- /dev/null +++ b/src/components/settings-v2/system/channels/channel-log-filter.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import type { LogEntry } from "@/hooks/useTauri"; +import { + buildChannelLogRegex, + filterChannelLogs, +} from "./channel-log-filter"; + +const MOCK_LOGS: LogEntry[] = [ + { + timestamp: "2026-03-05 10:00:00.000", + level: "info", + message: "[TelegramGateway] account=default 启动成功", + }, + { + timestamp: "2026-03-05 10:00:01.000", + level: "info", + message: "[RPC] agent.run created runId=abc", + }, + { + timestamp: "2026-03-05 10:00:02.000", + level: "info", + message: "[FeishuGateway] account=default 启动成功", + }, +]; + +describe("channel-log-filter", () => { + it("预置 telegram 过滤应命中 TelegramGateway", () => { + const { regex, error } = buildChannelLogRegex("telegram", ""); + expect(error).toBeNull(); + const result = filterChannelLogs(MOCK_LOGS, regex); + expect(result).toHaveLength(1); + expect(result[0].message).toContain("TelegramGateway"); + }); + + it("预置 rpc 过滤应命中 RPC 与 agent.run", () => { + const { regex, error } = buildChannelLogRegex("rpc", ""); + expect(error).toBeNull(); + const result = filterChannelLogs(MOCK_LOGS, regex); + expect(result).toHaveLength(1); + expect(result[0].message).toContain("agent.run"); + }); + + it("自定义正则非法时应返回错误并回退不过滤", () => { + const { regex, error } = buildChannelLogRegex("custom", "[invalid"); + expect(regex).toBeNull(); + expect(error).toContain("正则表达式无效"); + const result = filterChannelLogs(MOCK_LOGS, regex); + expect(result).toHaveLength(3); + }); + + it("all 模式应不过滤", () => { + const { regex, error } = buildChannelLogRegex("all", ""); + expect(error).toBeNull(); + const result = filterChannelLogs(MOCK_LOGS, regex); + expect(result).toHaveLength(3); + }); +}); + diff --git a/src/components/settings-v2/system/channels/channel-log-filter.ts b/src/components/settings-v2/system/channels/channel-log-filter.ts new file mode 100644 index 000000000..4e279ea07 --- /dev/null +++ b/src/components/settings-v2/system/channels/channel-log-filter.ts @@ -0,0 +1,42 @@ +import type { LogEntry } from "@/hooks/useTauri"; + +export type ChannelLogPreset = "all" | "telegram" | "rpc" | "feishu" | "custom"; + +const PRESET_PATTERNS: Record, string> = { + telegram: "TelegramGateway", + rpc: "\\bRPC\\b|agent\\.run|cron\\.run", + feishu: "FeishuGateway", +}; + +export interface BuildRegexResult { + regex: RegExp | null; + error: string | null; +} + +export function buildChannelLogRegex( + preset: ChannelLogPreset, + customPattern: string, +): BuildRegexResult { + if (preset === "all") { + return { regex: null, error: null }; + } + + const pattern = preset === "custom" ? customPattern.trim() : PRESET_PATTERNS[preset]; + if (!pattern) { + return { regex: null, error: null }; + } + + try { + return { regex: new RegExp(pattern), error: null }; + } catch { + return { regex: null, error: "正则表达式无效,已回退为不过滤" }; + } +} + +export function filterChannelLogs(entries: LogEntry[], regex: RegExp | null): LogEntry[] { + if (!regex) { + return entries; + } + return entries.filter((entry) => regex.test(entry.message)); +} + diff --git a/src/components/settings-v2/system/channels/index.tsx b/src/components/settings-v2/system/channels/index.tsx index 373dce204..5747d8566 100644 --- a/src/components/settings-v2/system/channels/index.tsx +++ b/src/components/settings-v2/system/channels/index.tsx @@ -15,17 +15,38 @@ import { Save, RotateCcw, AlertCircle, + LayoutDashboard, + SlidersHorizontal, + Network, + ScrollText, } from "lucide-react"; import { getConfig, saveConfig, Config, ChannelsConfig, + GatewayConfig, TelegramBotConfig, DiscordBotConfig, FeishuBotConfig, + gatewayTunnelCreate, + gatewayTunnelDetectCloudflared, + gatewayTunnelInstallCloudflared, + gatewayTunnelProbe, + gatewayTunnelRestart, + gatewayTunnelStart, + gatewayTunnelStatus, + gatewayTunnelStop, + gatewayTunnelSyncWebhookUrl, + gatewayChannelStart, + gatewayChannelStatus, + gatewayChannelStop, + discordChannelProbe, + feishuChannelProbe, + telegramChannelProbe, } from "@/hooks/useTauri"; import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { ChannelLogTailPanel } from "./ChannelLogTailPanel"; // ============================================================================ // 默认值 @@ -33,11 +54,46 @@ import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; const DEFAULT_CHANNELS: ChannelsConfig = { telegram: { enabled: false, bot_token: "", allowed_user_ids: [], default_model: undefined }, - discord: { enabled: false, bot_token: "", allowed_server_ids: [], default_model: undefined }, - feishu: { enabled: false, app_id: "", app_secret: "", default_model: undefined }, + discord: { + enabled: false, + bot_token: "", + allowed_server_ids: [], + default_model: undefined, + default_account: "default", + accounts: {}, + dm_policy: "pairing", + allow_from: [], + group_policy: "allowlist", + group_allow_from: [], + streaming: "partial", + reply_to_mode: "off", + }, + feishu: { + enabled: false, + app_id: "", + app_secret: "", + default_model: undefined, + dm_policy: "open", + allow_from: ["*"], + group_policy: "allowlist", + group_allow_from: [], + }, +}; + +const DEFAULT_GATEWAY: GatewayConfig = { + tunnel: { + enabled: false, + provider: "cloudflare", + mode: "managed", + local_host: "127.0.0.1", + local_port: 3000, + cloudflare: {}, + }, }; type TabKey = "telegram" | "discord" | "feishu"; +type ChannelSubPage = "overview" | "config" | "gateway" | "logs"; +type DebugTabKey = "telegram" | "feishu" | "discord"; // ============================================================================ // 模型选择器子组件 @@ -192,6 +248,371 @@ function StringListInput({ ); } +function ConfigGuideCard({ + title, + steps, + note, +}: { + title: string; + steps: string[]; + note?: string; +}) { + return ( +
+

{title}

+
+ {steps.map((step, index) => ( +

+ {index + 1}. {step} +

+ ))} +
+ {note &&

{note}

} +
+ ); +} + +function GatewayTunnelPanel({ + config, + onChange, + defaultFeishuAccountId, + onReloadConfig, +}: { + config: GatewayConfig; + onChange: (c: GatewayConfig) => void; + defaultFeishuAccountId?: string; + onReloadConfig: () => Promise; +}) { + const tunnel = config.tunnel ?? DEFAULT_GATEWAY.tunnel!; + const cloudflare = tunnel.cloudflare ?? {}; + const [busyAction, setBusyAction] = useState(null); + const [output, setOutput] = useState(""); + const [feishuAccountId, setFeishuAccountId] = useState(defaultFeishuAccountId ?? "default"); + + const busy = busyAction !== null; + + const patchTunnel = (patch: Partial>) => { + onChange({ + ...config, + tunnel: { + ...tunnel, + ...patch, + }, + }); + }; + + const patchCloudflare = ( + patch: Partial["cloudflare"]>>, + ) => { + patchTunnel({ + cloudflare: { + ...cloudflare, + ...patch, + }, + }); + }; + + const runAction = async (action: string, executor: () => Promise) => { + setBusyAction(action); + try { + const result = await executor(); + setOutput(JSON.stringify(result, null, 2)); + if (action === "create" || action === "sync") { + await onReloadConfig(); + } + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); + } finally { + setBusyAction(null); + } + }; + + return ( +
+
+

Gateway 公共隧道

+

+ 全局 webhook 公网入口配置(供 Feishu/Telegram 等渠道复用) +

+
+ +
+ + + + +
+ +
+ + + +
+ +
+ + + +
+ + patchCloudflare({ run_token: value || undefined })} + placeholder="cloudflared tunnel run --token ..." + /> + +
+ + + patchCloudflare({ credentials_file: event.target.value || undefined }) + } + placeholder="~/.cloudflared/.json" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+ +
+ +
+ +
+ + + + + + + + + +
+ + {busyAction && ( +
正在执行:{busyAction}
+ )} + +
+
最近结果
+
+          {output || "暂无结果"}
+        
+
+
+ ); +} + // ============================================================================ // Telegram 表单 // ============================================================================ @@ -264,6 +685,593 @@ function TelegramForm({ ); } +function TelegramGatewayDebugPanel() { + const [busyAction, setBusyAction] = useState(null); + const [accountId, setAccountId] = useState("default"); + const [pollTimeoutSecs, setPollTimeoutSecs] = useState("25"); + const [output, setOutput] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const resolveAccountId = () => { + const normalized = accountId.trim(); + return normalized.length > 0 ? normalized : undefined; + }; + + const resolvePollTimeoutSecs = () => { + const parsed = Number.parseInt(pollTimeoutSecs.trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return undefined; + } + return parsed; + }; + + const runAction = async ( + action: string, + executor: () => Promise, + successText: string, + ) => { + setBusyAction(action); + setMessage(null); + try { + const result = await executor(); + setOutput(JSON.stringify(result, null, 2)); + setMessage({ type: "success", text: successText }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); + setMessage({ type: "error", text: `执行失败: ${text}` }); + } finally { + setBusyAction(null); + } + }; + + const busy = busyAction !== null; + + return ( +
+
+

Telegram Gateway 调试工具

+

+ 用于快速执行启停、重启、状态查询和账号探测 +

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+ + +
+ +
+ + + + + +
+ + {busyAction && ( +
正在执行:{busyAction}
+ )} + +
+
最近结果
+
+          {output || "暂无结果"}
+        
+
+
+ ); +} + +function FeishuGatewayDebugPanel() { + const [busyAction, setBusyAction] = useState(null); + const [accountId, setAccountId] = useState("default"); + const [output, setOutput] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const resolveAccountId = () => { + const normalized = accountId.trim(); + return normalized.length > 0 ? normalized : undefined; + }; + + const runAction = async ( + action: string, + executor: () => Promise, + successText: string, + ) => { + setBusyAction(action); + setMessage(null); + try { + const result = await executor(); + setOutput(JSON.stringify(result, null, 2)); + setMessage({ type: "success", text: successText }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); + setMessage({ type: "error", text: `执行失败: ${text}` }); + } finally { + setBusyAction(null); + } + }; + + const busy = busyAction !== null; + + return ( +
+
+

Feishu Gateway 调试工具

+

+ 用于快速执行启停、重启、状态查询和账号探测 +

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+ +
+ +
+ + + + + +
+ + {busyAction && ( +
正在执行:{busyAction}
+ )} + +
+
最近结果
+
+          {output || "暂无结果"}
+        
+
+
+ ); +} + +function DiscordGatewayDebugPanel() { + const [busyAction, setBusyAction] = useState(null); + const [accountId, setAccountId] = useState("default"); + const [output, setOutput] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const resolveAccountId = () => { + const normalized = accountId.trim(); + return normalized.length > 0 ? normalized : undefined; + }; + + const runAction = async ( + action: string, + executor: () => Promise, + successText: string, + ) => { + setBusyAction(action); + setMessage(null); + try { + const result = await executor(); + setOutput(JSON.stringify(result, null, 2)); + setMessage({ type: "success", text: successText }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); + setMessage({ type: "error", text: `执行失败: ${text}` }); + } finally { + setBusyAction(null); + } + }; + + const busy = busyAction !== null; + + return ( +
+
+

Discord Gateway 调试工具

+

+ 用于快速执行启停、重启、状态查询和账号探测 +

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+ +
+ +
+ + + + + +
+ + {busyAction && ( +
正在执行:{busyAction}
+ )} + +
+
最近结果
+
+          {output || "暂无结果"}
+        
+
+
+ ); +} + // ============================================================================ // Discord 表单 // ============================================================================ @@ -275,6 +1283,85 @@ function DiscordForm({ config: DiscordBotConfig; onChange: (c: DiscordBotConfig) => void; }) { + const accountIds = useMemo(() => Object.keys(config.accounts ?? {}), [config.accounts]); + const [activeAccountId, setActiveAccountId] = useState( + config.default_account || accountIds[0] || "default", + ); + + useEffect(() => { + if (!accountIds.length) { + setActiveAccountId(config.default_account || "default"); + return; + } + if (!accountIds.includes(activeAccountId)) { + setActiveAccountId(config.default_account || accountIds[0]); + } + }, [accountIds, config.default_account, activeAccountId]); + + const patch = (next: Partial) => { + onChange({ ...config, ...next }); + }; + + const patchAccount = ( + accountId: string, + updater: (current: NonNullable[string]) => NonNullable< + DiscordBotConfig["accounts"] + >[string], + ) => { + const baseAccounts = config.accounts ?? {}; + const current = baseAccounts[accountId] ?? { enabled: true }; + patch({ + accounts: { + ...baseAccounts, + [accountId]: updater(current), + }, + }); + }; + + const addAccount = () => { + const baseAccounts = config.accounts ?? {}; + let index = 1; + let accountId = "account_1"; + while (baseAccounts[accountId]) { + index += 1; + accountId = `account_${index}`; + } + const nextAccounts = { + ...baseAccounts, + [accountId]: { + enabled: true, + name: `Discord ${index}`, + }, + }; + patch({ + accounts: nextAccounts, + default_account: config.default_account || accountId, + }); + setActiveAccountId(accountId); + }; + + const removeAccount = (accountId: string) => { + const baseAccounts = { ...(config.accounts ?? {}) }; + delete baseAccounts[accountId]; + const nextIds = Object.keys(baseAccounts); + patch({ + accounts: baseAccounts, + default_account: + config.default_account === accountId ? (nextIds[0] || "default") : config.default_account, + }); + setActiveAccountId(nextIds[0] || "default"); + }; + + const currentAccount = (config.accounts ?? {})[activeAccountId]; + const intents = config.intents ?? {}; + const actions = config.actions ?? {}; + const threadBindings = config.thread_bindings ?? {}; + const autoPresence = config.auto_presence ?? {}; + const dmPolicy = (config.dm_policy || "pairing").toLowerCase(); + const groupPolicy = (config.group_policy || "allowlist").toLowerCase(); + const streamingMode = (config.streaming || "partial").toLowerCase(); + const replyToMode = (config.reply_to_mode || "off").toLowerCase(); + return (
@@ -300,9 +1387,9 @@ function DiscordForm({
onChange({ ...config, bot_token: v })} + onChange={(v) => patch({ bot_token: v })} placeholder="MTIz..." hint={ <> @@ -323,14 +1410,312 @@ function DiscordForm({ onChange({ ...config, allowed_server_ids: v })} + onChange={(v) => patch({ allowed_server_ids: v })} placeholder="输入 Discord Server ID" hint="留空则允许所有服务器" /> +
+ + patch({ default_account: e.target.value || undefined })} + placeholder="default" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+ +
+
+
+

多账号配置

+

+ 支持账号级 token/模型/服务器范围覆盖 +

+
+ +
+ + {accountIds.length > 0 ? ( + <> +
+ {accountIds.map((accountId) => ( + + ))} +
+ + {currentAccount && ( +
+
+ + +
+ +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + name: e.target.value || undefined, + })) + } + placeholder="运营号 / 机器人 1" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm" + /> +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + default_model: v, + })) + } + /> +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + bot_token: v || undefined, + })) + } + placeholder="MTIz..." + /> + + + patchAccount(activeAccountId, (item) => ({ + ...item, + allowed_server_ids: v, + })) + } + placeholder="输入 Discord Server ID" + /> +
+ )} + + ) : ( +

未配置账号,将使用全局 Bot Token 运行。

+ )} +
+ +
+
+ + +
+
+ + +
+
+ + patch({ allow_from: v })} + placeholder="用户 ID 或 *" + /> + + patch({ group_allow_from: v })} + placeholder="用户 ID 或 *" + /> + +
+
+ + +
+
+ + +
+
+ +
+

高级能力开关

+
+ + + + + + +
+
+ onChange({ ...config, default_model: v })} + onChange={(v) => patch({ default_model: v })} />
); @@ -347,6 +1732,25 @@ function FeishuForm({ config: FeishuBotConfig; onChange: (c: FeishuBotConfig) => void; }) { + const connectionMode = + (config.connection_mode || "websocket").toLowerCase() === "webhook" + ? "webhook" + : "websocket"; + const dmPolicy = (() => { + const value = (config.dm_policy || "open").toLowerCase(); + if ( + value === "open" || + value === "allowlist" || + value === "pairing" || + value === "disabled" + ) { + return value; + } + return "open"; + })(); + const streamingMode = (config.streaming || "partial").toLowerCase(); + const replyToMode = (config.reply_to_mode || "off").toLowerCase(); + return (
@@ -411,6 +1815,159 @@ function FeishuForm({ placeholder="事件加密密钥" /> +
+ + + {connectionMode === "websocket" && ( +

+ 当前版本 WebSocket 模式尚未实装,请使用 Webhook 模式接入飞书事件回调。 +

+ )} +
+ + {connectionMode === "webhook" && ( +
+
+ + + onChange({ + ...config, + webhook_host: e.target.value.trim() || undefined, + }) + } + placeholder="0.0.0.0" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ + { + const value = Number.parseInt(e.target.value, 10); + onChange({ + ...config, + webhook_port: Number.isFinite(value) && value > 0 ? value : undefined, + }); + }} + placeholder="3000" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ + + onChange({ + ...config, + webhook_path: e.target.value.trim() || undefined, + }) + } + placeholder="/feishu/default" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ )} + +
+
+ + +

+ 建议联调使用 open;生产可切换为 allowlist / pairing。 +

+
+ onChange({ ...config, allow_from: v })} + placeholder="输入 open_id / user_id,或 *" + hint="open 策略建议至少包含 *;allowlist/pairing 需填写具体用户。" + /> +
+ +
+
+ + +
+
+ + +
+
+ onChange({ ...config, default_model: v })} @@ -429,15 +1986,18 @@ export interface ChannelsSettingsProps { export function ChannelsSettings({ className }: ChannelsSettingsProps) { const [activeTab, setActiveTab] = useState("telegram"); + const [activeSubPage, setActiveSubPage] = useState("overview"); + const [activeDebugTab, setActiveDebugTab] = useState("telegram"); const [config, setConfig] = useState(null); const [channels, setChannels] = useState(DEFAULT_CHANNELS); + const [gateway, setGateway] = useState(DEFAULT_GATEWAY); const [initialJson, setInitialJson] = useState(""); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const isDirty = useMemo( - () => JSON.stringify(channels) !== initialJson, - [channels, initialJson], + () => JSON.stringify({ channels, gateway }) !== initialJson, + [channels, gateway, initialJson], ); const loadConfig = useCallback(async () => { @@ -445,8 +2005,15 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) { const c = await getConfig(); setConfig(c); const ch = c.channels ?? DEFAULT_CHANNELS; + const gw = c.gateway ?? DEFAULT_GATEWAY; setChannels(ch); - setInitialJson(JSON.stringify(ch)); + setGateway(gw); + setInitialJson( + JSON.stringify({ + channels: ch, + gateway: gw, + }), + ); } catch (e) { console.error("加载配置失败", e); } @@ -461,8 +2028,13 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) { setSaving(true); setMessage(null); try { - await saveConfig({ ...config, channels }); - setInitialJson(JSON.stringify(channels)); + await saveConfig({ ...config, channels, gateway }); + setInitialJson( + JSON.stringify({ + channels, + gateway, + }), + ); setMessage({ type: "success", text: "渠道配置已保存" }); setTimeout(() => setMessage(null), 3000); } catch (e: unknown) { @@ -474,7 +2046,9 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) { const handleCancel = () => { if (initialJson) { - setChannels(JSON.parse(initialJson)); + const snapshot = JSON.parse(initialJson) as { channels: ChannelsConfig; gateway: GatewayConfig }; + setChannels(snapshot.channels); + setGateway(snapshot.gateway); } }; @@ -491,9 +2065,74 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) { discord: "Discord", feishu: "飞书", }; + const SUB_PAGE_LABELS: Record = { + overview: "概览", + config: "渠道配置", + gateway: "网关与隧道", + logs: "日志与调试", + }; + + const channelOverview = [ + { + key: "telegram", + label: "Telegram", + enabled: channels.telegram.enabled, + model: channels.telegram.default_model || "跟随全局默认", + }, + { + key: "discord", + label: "Discord", + enabled: channels.discord.enabled, + model: channels.discord.default_model || "跟随全局默认", + }, + { + key: "feishu", + label: "飞书", + enabled: channels.feishu.enabled, + model: channels.feishu.default_model || "跟随全局默认", + }, + ]; + + const subPages: Array<{ + key: ChannelSubPage; + label: string; + description: string; + icon: React.ComponentType<{ className?: string }>; + }> = [ + { + key: "overview", + label: "概览", + description: "查看渠道状态和默认模型", + icon: LayoutDashboard, + }, + { + key: "config", + label: "渠道配置", + description: "维护 Telegram / Discord / 飞书基础参数", + icon: SlidersHorizontal, + }, + { + key: "gateway", + label: "网关与隧道", + description: "管理公网入口与回调同步", + icon: Network, + }, + { + key: "logs", + label: "日志与调试", + description: "tail 观察与渠道调试操作", + icon: ScrollText, + }, + ]; + + const enabledCount = channelOverview.filter((item) => item.enabled).length; + const currentScopeLabel = + activeSubPage === "config" + ? `${SUB_PAGE_LABELS[activeSubPage]} / ${TAB_LABELS[activeTab]}` + : SUB_PAGE_LABELS[activeSubPage]; return ( -
+
{message && (
)} - setActiveTab(v as TabKey)} - className="w-full" - > - - Telegram - Discord - 飞书 - +
+
+ {subPages.map((page) => { + const Icon = page.icon; + const isActive = activeSubPage === page.key; + return ( + + ); + })} +
+
- - setChannels({ ...channels, telegram: tg })} - /> - + {activeSubPage === "overview" && ( +
+
+
+
+

渠道总览

+

+ 已启用 {enabledCount} / {channelOverview.length} 个渠道 +

+
+
+ 建议流程:先完成「渠道配置」,再进入「网关与隧道」与「日志与调试」验证。 +
+
+
+ {channelOverview.map((item) => ( +
+
+

{item.label}

+ + {item.enabled ? "已启用" : "未启用"} + +
+

+ 默认模型:{item.model} +

+
+ ))} +
+
+
+ )} - - setChannels({ ...channels, discord: dc })} - /> - + {activeSubPage === "config" && ( +
+
+

渠道配置

+

+ 仅维护渠道基础参数,不包含运行调试操作。 +

+
+
+ +
+ setActiveTab(v as TabKey)} + className="w-full" + > + + Telegram + Discord + 飞书 + - - setChannels({ ...channels, feishu: fs })} + +
+ + setChannels({ ...channels, telegram: tg })} + /> +
+
+ + +
+ + setChannels({ ...channels, discord: dc })} + /> +
+
+ + +
+ + setChannels({ ...channels, feishu: fs })} + /> +
+
+
+
+ )} + + {activeSubPage === "gateway" && ( +
+
+

网关与隧道

+

+ 统一管理公网隧道、回调同步与连通性探测。 +

+
+ - - + +
+ )} + + {activeSubPage === "logs" && ( +
+
+
+

渠道日志观察

+

+ 支持实时 tail、过滤与清空,快速定位 TelegramGateway / RPC 问题。 +

+
+ + +
+ +
+
+

运行调试

+

+ 用于执行渠道启停、重启、状态查询与账号探测。 +

+
+
+ setActiveDebugTab(v as DebugTabKey)} + className="w-full" + > + + Telegram + 飞书 + Discord + + + + + + + + + + + + + + +
+
+
+ )} {/* 底部固定栏 */} {isDirty && ( @@ -546,7 +2384,7 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) { 未保存的更改 - {TAB_LABELS[activeTab]} + {currentScopeLabel}
@@ -572,4 +2410,4 @@ export function ChannelsSettings({ className }: ChannelsSettingsProps) { ); } -export default ChannelsSettings; \ No newline at end of file +export default ChannelsSettings; diff --git a/src/components/settings-v2/system/developer/index.tsx b/src/components/settings-v2/system/developer/index.tsx index 1a3a81373..d3d2529cd 100644 --- a/src/components/settings-v2/system/developer/index.tsx +++ b/src/components/settings-v2/system/developer/index.tsx @@ -6,7 +6,11 @@ import { useCallback, useState } from "react"; import { Bug, Code2, Eye } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { useComponentDebug } from "@/contexts/ComponentDebugContext"; -import { getConfig, getLogs, getPersistedLogsTail } from "@/hooks/useTauri"; +import { + getConfig, + getLogs, + getPersistedLogsTail, +} from "@/hooks/useTauri"; import { buildCrashDiagnosticPayload, copyCrashDiagnosticToClipboard, diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index d18ed5eb9..d2d0c9c36 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -491,6 +491,129 @@ export interface DiscordBotConfig { bot_token: string; allowed_server_ids: string[]; default_model?: string; + default_account?: string; + accounts?: Record; + dm_policy?: string; + allow_from?: string[]; + group_policy?: string; + group_allow_from?: string[]; + groups?: Record; + streaming?: string; + reply_to_mode?: string; + intents?: DiscordIntentsConfig; + actions?: DiscordActionsConfig; + thread_bindings?: DiscordThreadBindingsConfig; + auto_presence?: DiscordAutoPresenceConfig; + voice?: DiscordVoiceConfig; + agent_components?: DiscordAgentComponentsConfig; + ui?: DiscordUiConfig; + exec_approvals?: DiscordExecApprovalsConfig; + response_prefix?: string; + ack_reaction?: string; +} + +export interface DiscordAccountConfig { + enabled?: boolean; + name?: string; + bot_token?: string; + allowed_server_ids?: string[]; + default_model?: string; + dm_policy?: string; + allow_from?: string[]; + group_policy?: string; + group_allow_from?: string[]; + groups?: Record; + streaming?: string; + reply_to_mode?: string; + intents?: DiscordIntentsConfig; + actions?: DiscordActionsConfig; + thread_bindings?: DiscordThreadBindingsConfig; + auto_presence?: DiscordAutoPresenceConfig; + voice?: DiscordVoiceConfig; + agent_components?: DiscordAgentComponentsConfig; + ui?: DiscordUiConfig; + exec_approvals?: DiscordExecApprovalsConfig; + response_prefix?: string; + ack_reaction?: string; +} + +export interface DiscordGuildConfig { + enabled?: boolean; + require_mention?: boolean; + group_policy?: string; + allow_from?: string[]; + channels?: Record; +} + +export interface DiscordChannelConfig { + enabled?: boolean; + require_mention?: boolean; + group_policy?: string; + allow_from?: string[]; +} + +export interface DiscordIntentsConfig { + message_content?: boolean; + guild_members?: boolean; + presence?: boolean; +} + +export interface DiscordActionsConfig { + reactions?: boolean; + messages?: boolean; + threads?: boolean; + moderation?: boolean; + presence?: boolean; +} + +export interface DiscordThreadBindingsConfig { + enabled?: boolean; + idle_hours?: number; + max_age_hours?: number; + spawn_subagent_sessions?: boolean; + spawn_acp_sessions?: boolean; +} + +export interface DiscordAutoPresenceConfig { + enabled?: boolean; + interval_ms?: number; + min_update_interval_ms?: number; + healthy_text?: string; + degraded_text?: string; + exhausted_text?: string; +} + +export interface DiscordVoiceAutoJoinConfig { + guild_id: string; + channel_id: string; +} + +export interface DiscordVoiceConfig { + enabled?: boolean; + auto_join?: DiscordVoiceAutoJoinConfig[]; + dave_encryption?: boolean; + decryption_failure_tolerance?: number; +} + +export interface DiscordAgentComponentsConfig { + enabled?: boolean; +} + +export interface DiscordUiComponentsConfig { + accent_color?: string; +} + +export interface DiscordUiConfig { + components?: DiscordUiComponentsConfig; +} + +export interface DiscordExecApprovalsConfig { + enabled?: boolean; + approvers?: string[]; + agent_filter?: string[]; + session_filter?: string[]; + cleanup_after_resolve?: boolean; + target?: string; } export interface FeishuBotConfig { @@ -500,6 +623,73 @@ export interface FeishuBotConfig { verification_token?: string; encrypt_key?: string; default_model?: string; + default_account?: string; + accounts?: Record; + domain?: string; + connection_mode?: string; + webhook_host?: string; + webhook_port?: number; + webhook_path?: string; + dm_policy?: string; + allow_from?: string[]; + group_policy?: string; + group_allow_from?: string[]; + groups?: Record; + streaming?: string; + reply_to_mode?: string; +} + +export interface FeishuGroupConfig { + enabled?: boolean; + require_mention?: boolean; + group_policy?: string; + allow_from?: string[]; +} + +export interface FeishuAccountConfig { + enabled?: boolean; + name?: string; + app_id?: string; + app_secret?: string; + verification_token?: string; + encrypt_key?: string; + default_model?: string; + domain?: string; + connection_mode?: string; + webhook_host?: string; + webhook_port?: number; + webhook_path?: string; + dm_policy?: string; + allow_from?: string[]; + group_policy?: string; + group_allow_from?: string[]; + groups?: Record; + streaming?: string; + reply_to_mode?: string; +} + +export interface CloudflareTunnelConfig { + account_id?: string; + tunnel_name?: string; + tunnel_id?: string; + run_token?: string; + credentials_file?: string; + dns_name?: string; +} + +export interface GatewayTunnelConfig { + enabled?: boolean; + provider?: string; + mode?: string; + binary_path?: string; + local_host?: string; + local_port?: number; + public_base_url?: string; + cloudflare?: CloudflareTunnelConfig; +} + +export interface GatewayConfig { + tunnel?: GatewayTunnelConfig; } export interface ChannelsConfig { @@ -601,6 +791,8 @@ export interface Config { assistant?: AssistantConfig; /** 用户资料 */ user_profile?: UserProfile; + /** Gateway 全局配置 */ + gateway?: GatewayConfig; /** 渠道配置(Telegram / Discord / 飞书 Bot) */ channels?: ChannelsConfig; /** 崩溃上报配置(Sentry 协议兼容) */ @@ -613,6 +805,162 @@ export interface LogEntry { message: string; } +export interface TelegramGatewayAccountStatus { + account_id: string; + running: boolean; + bot_username?: string | null; + started_at?: string | null; + last_error?: string | null; + last_update_id?: number | null; + last_message_at?: string | null; +} + +export interface TelegramGatewayStatus { + running_accounts: number; + accounts: TelegramGatewayAccountStatus[]; +} + +export interface FeishuGatewayAccountStatus { + account_id: string; + running: boolean; + connection_mode: string; + started_at?: string | null; + last_error?: string | null; + last_event_at?: string | null; + last_message_at?: string | null; + webhook_endpoint?: string | null; +} + +export interface FeishuGatewayStatus { + running_accounts: number; + accounts: FeishuGatewayAccountStatus[]; +} + +export interface DiscordGatewayAccountStatus { + account_id: string; + running: boolean; + connected: boolean; + bot_id?: string | null; + bot_username?: string | null; + application_id?: string | null; + message_content_intent?: string | null; + started_at?: string | null; + last_error?: string | null; + last_event_at?: string | null; + last_message_at?: string | null; + last_disconnect?: string | null; + reconnect_attempts?: number | null; +} + +export interface DiscordGatewayStatus { + running_accounts: number; + accounts: DiscordGatewayAccountStatus[]; +} + +export interface GatewayChannelStatusResponse { + channel: string; + status: unknown; +} + +export interface TelegramProbeResult { + account_id: string; + ok: boolean; + bot_id?: number | null; + username?: string | null; + message: string; +} + +export interface FeishuProbeResult { + account_id: string; + ok: boolean; + app_id?: string | null; + message: string; +} + +export interface DiscordProbeResult { + account_id: string; + ok: boolean; + bot_id?: string | null; + username?: string | null; + application_id?: string | null; + message_content_intent?: string | null; + message: string; +} + +export interface GatewayTunnelStatus { + running: boolean; + provider: string; + mode: string; + binary: string; + local_url: string; + public_base_url?: string | null; + pid?: number | null; + started_at?: string | null; + last_error?: string | null; + last_exit?: string | null; + command_preview?: string | null; + connector_active?: boolean | null; + connector_message?: string | null; +} + +export interface GatewayTunnelProbeResult { + ok: boolean; + provider: string; + mode: string; + binary: string; + version?: string | null; + config_ready: boolean; + message: string; +} + +export interface CloudflaredInstallStatus { + installed: boolean; + binary: string; + version?: string | null; + platform: string; + package_manager?: string | null; + install_supported: boolean; + install_command?: string | null; + requires_privilege: boolean; + message: string; +} + +export interface CloudflaredInstallResult { + ok: boolean; + attempted: boolean; + platform: string; + package_manager?: string | null; + command?: string | null; + exit_code?: number | null; + installed: boolean; + version?: string | null; + stdout: string; + stderr: string; + message: string; +} + +export interface GatewayTunnelCreateResponse { + result: { + ok: boolean; + tunnel_name: string; + tunnel_id?: string | null; + credentials_file?: string | null; + dns_name?: string | null; + public_base_url?: string | null; + message: string; + }; + status: GatewayTunnelStatus; +} + +export interface GatewayTunnelSyncWebhookResponse { + channel: string; + account_id?: string | null; + webhook_path: string; + public_base_url: string; + webhook_url: string; + persisted: boolean; +} + export async function startServer(): Promise { return safeInvoke("start_server"); } @@ -629,6 +977,136 @@ export async function getServerDiagnostics(): Promise { return safeInvoke("get_server_diagnostics"); } +export async function gatewayChannelStart(params?: { + channel?: "telegram" | "feishu" | "discord"; + accountId?: string; + pollTimeoutSecs?: number; +}): Promise { + return safeInvoke("gateway_channel_start", { + request: { + channel: params?.channel ?? "telegram", + account_id: params?.accountId?.trim() || undefined, + poll_timeout_secs: params?.pollTimeoutSecs, + }, + }); +} + +export async function gatewayChannelStop(params?: { + channel?: "telegram" | "feishu" | "discord"; + accountId?: string; +}): Promise { + return safeInvoke("gateway_channel_stop", { + request: { + channel: params?.channel ?? "telegram", + account_id: params?.accountId?.trim() || undefined, + }, + }); +} + +export async function gatewayChannelStatus(params?: { + channel?: "telegram" | "feishu" | "discord"; +}): Promise { + return safeInvoke("gateway_channel_status", { + request: { + channel: params?.channel ?? "telegram", + }, + }); +} + +export async function telegramChannelProbe(params?: { + accountId?: string; +}): Promise { + return safeInvoke("telegram_channel_probe", { + request: { + account_id: params?.accountId?.trim() || undefined, + }, + }); +} + +export async function feishuChannelProbe(params?: { + accountId?: string; +}): Promise { + return safeInvoke("feishu_channel_probe", { + request: { + account_id: params?.accountId?.trim() || undefined, + }, + }); +} + +export async function discordChannelProbe(params?: { + accountId?: string; +}): Promise { + return safeInvoke("discord_channel_probe", { + request: { + account_id: params?.accountId?.trim() || undefined, + }, + }); +} + +export async function gatewayTunnelProbe(): Promise { + return safeInvoke("gateway_tunnel_probe"); +} + +export async function gatewayTunnelDetectCloudflared(): Promise { + return safeInvoke("gateway_tunnel_detect_cloudflared"); +} + +export async function gatewayTunnelInstallCloudflared(params?: { + confirm?: boolean; +}): Promise { + return safeInvoke("gateway_tunnel_install_cloudflared", { + request: { + confirm: params?.confirm ?? false, + }, + }); +} + +export async function gatewayTunnelCreate(params?: { + tunnelName?: string; + dnsName?: string; + persist?: boolean; +}): Promise { + return safeInvoke("gateway_tunnel_create", { + request: { + tunnel_name: params?.tunnelName?.trim() || undefined, + dns_name: params?.dnsName?.trim() || undefined, + persist: params?.persist ?? true, + }, + }); +} + +export async function gatewayTunnelStart(): Promise { + return safeInvoke("gateway_tunnel_start"); +} + +export async function gatewayTunnelStop(): Promise { + return safeInvoke("gateway_tunnel_stop"); +} + +export async function gatewayTunnelRestart(): Promise { + return safeInvoke("gateway_tunnel_restart"); +} + +export async function gatewayTunnelStatus(): Promise { + return safeInvoke("gateway_tunnel_status"); +} + +export async function gatewayTunnelSyncWebhookUrl(params: { + channel: "feishu"; + accountId?: string; + webhookPath?: string; + persist?: boolean; +}): Promise { + return safeInvoke("gateway_tunnel_sync_webhook_url", { + request: { + channel: params.channel, + account_id: params.accountId?.trim() || undefined, + webhook_path: params.webhookPath?.trim() || undefined, + persist: params.persist ?? true, + }, + }); +} + export async function getConfig(): Promise { return safeInvoke("get_config"); } diff --git a/src/lib/api/channels.ts b/src/lib/api/channels.ts index a0ca2f363..de40150ca 100644 --- a/src/lib/api/channels.ts +++ b/src/lib/api/channels.ts @@ -156,6 +156,129 @@ export interface DiscordBotConfig { bot_token: string; allowed_server_ids: string[]; default_model?: string; + default_account?: string; + accounts?: Record; + dm_policy?: string; + allow_from?: string[]; + group_policy?: string; + group_allow_from?: string[]; + groups?: Record; + streaming?: string; + reply_to_mode?: string; + intents?: DiscordIntentsConfig; + actions?: DiscordActionsConfig; + thread_bindings?: DiscordThreadBindingsConfig; + auto_presence?: DiscordAutoPresenceConfig; + voice?: DiscordVoiceConfig; + agent_components?: DiscordAgentComponentsConfig; + ui?: DiscordUiConfig; + exec_approvals?: DiscordExecApprovalsConfig; + response_prefix?: string; + ack_reaction?: string; +} + +export interface DiscordAccountConfig { + enabled?: boolean; + name?: string; + bot_token?: string; + allowed_server_ids?: string[]; + default_model?: string; + dm_policy?: string; + allow_from?: string[]; + group_policy?: string; + group_allow_from?: string[]; + groups?: Record; + streaming?: string; + reply_to_mode?: string; + intents?: DiscordIntentsConfig; + actions?: DiscordActionsConfig; + thread_bindings?: DiscordThreadBindingsConfig; + auto_presence?: DiscordAutoPresenceConfig; + voice?: DiscordVoiceConfig; + agent_components?: DiscordAgentComponentsConfig; + ui?: DiscordUiConfig; + exec_approvals?: DiscordExecApprovalsConfig; + response_prefix?: string; + ack_reaction?: string; +} + +export interface DiscordGuildConfig { + enabled?: boolean; + require_mention?: boolean; + group_policy?: string; + allow_from?: string[]; + channels?: Record; +} + +export interface DiscordChannelConfig { + enabled?: boolean; + require_mention?: boolean; + group_policy?: string; + allow_from?: string[]; +} + +export interface DiscordIntentsConfig { + message_content?: boolean; + guild_members?: boolean; + presence?: boolean; +} + +export interface DiscordActionsConfig { + reactions?: boolean; + messages?: boolean; + threads?: boolean; + moderation?: boolean; + presence?: boolean; +} + +export interface DiscordThreadBindingsConfig { + enabled?: boolean; + idle_hours?: number; + max_age_hours?: number; + spawn_subagent_sessions?: boolean; + spawn_acp_sessions?: boolean; +} + +export interface DiscordAutoPresenceConfig { + enabled?: boolean; + interval_ms?: number; + min_update_interval_ms?: number; + healthy_text?: string; + degraded_text?: string; + exhausted_text?: string; +} + +export interface DiscordVoiceAutoJoinConfig { + guild_id: string; + channel_id: string; +} + +export interface DiscordVoiceConfig { + enabled?: boolean; + auto_join?: DiscordVoiceAutoJoinConfig[]; + dave_encryption?: boolean; + decryption_failure_tolerance?: number; +} + +export interface DiscordAgentComponentsConfig { + enabled?: boolean; +} + +export interface DiscordUiComponentsConfig { + accent_color?: string; +} + +export interface DiscordUiConfig { + components?: DiscordUiComponentsConfig; +} + +export interface DiscordExecApprovalsConfig { + enabled?: boolean; + approvers?: string[]; + agent_filter?: string[]; + session_filter?: string[]; + cleanup_after_resolve?: boolean; + target?: string; } export interface FeishuBotConfig { @@ -165,6 +288,49 @@ export interface FeishuBotConfig { verification_token?: string; encrypt_key?: string; default_model?: string; + default_account?: string; + accounts?: Record; + domain?: string; + connection_mode?: string; + webhook_host?: string; + webhook_port?: number; + webhook_path?: string; + dm_policy?: string; + allow_from?: string[]; + group_policy?: string; + group_allow_from?: string[]; + groups?: Record; + streaming?: string; + reply_to_mode?: string; +} + +export interface FeishuGroupConfig { + enabled?: boolean; + require_mention?: boolean; + group_policy?: string; + allow_from?: string[]; +} + +export interface FeishuAccountConfig { + enabled?: boolean; + name?: string; + app_id?: string; + app_secret?: string; + verification_token?: string; + encrypt_key?: string; + default_model?: string; + domain?: string; + connection_mode?: string; + webhook_host?: string; + webhook_port?: number; + webhook_path?: string; + dm_policy?: string; + allow_from?: string[]; + group_policy?: string; + group_allow_from?: string[]; + groups?: Record; + streaming?: string; + reply_to_mode?: string; } export interface ChannelsConfig { diff --git a/src/lib/asterSessionRecovery.test.ts b/src/lib/asterSessionRecovery.test.ts new file mode 100644 index 000000000..9e3f33e49 --- /dev/null +++ b/src/lib/asterSessionRecovery.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + isAsterSessionNotFoundError, + resolveRestorableSessionId, + sortSessionsByRecency, +} from "./asterSessionRecovery"; + +describe("asterSessionRecovery", () => { + it("优先返回候选会话", () => { + const sessionId = resolveRestorableSessionId({ + candidateSessionId: "session-b", + sessions: [ + { id: "session-a", updatedAt: 2 }, + { id: "session-b", updatedAt: 1 }, + ], + }); + + expect(sessionId).toBe("session-b"); + }); + + it("候选失效时回退到最近会话", () => { + const sessionId = resolveRestorableSessionId({ + candidateSessionId: "session-missing", + sessions: [ + { id: "session-a", updatedAt: 100 }, + { id: "session-b", updatedAt: 200 }, + ], + }); + + expect(sessionId).toBe("session-b"); + }); + + it("按更新时间降序排序", () => { + const sorted = sortSessionsByRecency([ + { id: "session-a", updatedAt: 100 }, + { id: "session-b", updatedAt: 300 }, + { id: "session-c", updatedAt: 200 }, + ]); + + expect(sorted.map((item) => item.id)).toEqual([ + "session-b", + "session-c", + "session-a", + ]); + }); + + it("识别会话不存在错误", () => { + expect(isAsterSessionNotFoundError("会话不存在: abc")).toBe(true); + expect( + isAsterSessionNotFoundError(new Error("Session not found: abc")), + ).toBe(true); + expect(isAsterSessionNotFoundError("network error")).toBe(false); + }); +}); diff --git a/src/lib/asterSessionRecovery.ts b/src/lib/asterSessionRecovery.ts new file mode 100644 index 000000000..ce4e817e7 --- /dev/null +++ b/src/lib/asterSessionRecovery.ts @@ -0,0 +1,59 @@ +export interface RecoverableAsterSession { + id: string; + createdAt?: number; + updatedAt?: number; +} + +export interface ResolveRestorableSessionIdOptions { + candidateSessionId?: string | null; + sessions: RecoverableAsterSession[]; +} + +function toEpochSecond(value?: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Number(value); +} + +export function sortSessionsByRecency( + sessions: RecoverableAsterSession[], +): RecoverableAsterSession[] { + return [...sessions].sort((a, b) => { + const bRank = toEpochSecond(b.updatedAt) || toEpochSecond(b.createdAt); + const aRank = toEpochSecond(a.updatedAt) || toEpochSecond(a.createdAt); + return bRank - aRank; + }); +} + +export function resolveRestorableSessionId({ + candidateSessionId, + sessions, +}: ResolveRestorableSessionIdOptions): string | null { + const normalizedCandidate = candidateSessionId?.trim(); + if (normalizedCandidate) { + const matched = sessions.some((session) => session.id === normalizedCandidate); + if (matched) { + return normalizedCandidate; + } + } + + const [fallback] = sortSessionsByRecency(sessions); + return fallback?.id ?? null; +} + +export function isAsterSessionNotFoundError(error: unknown): boolean { + const message = + typeof error === "string" + ? error + : error instanceof Error + ? error.message + : String(error ?? ""); + const normalized = message.toLowerCase(); + + return ( + message.includes("会话不存在") || + normalized.includes("session not found") || + normalized.includes("no such session") + ); +} diff --git a/src/stores/agentStore.ts b/src/stores/agentStore.ts index 028978282..ffd27744e 100644 --- a/src/stores/agentStore.ts +++ b/src/stores/agentStore.ts @@ -8,6 +8,10 @@ import { create } from "zustand"; import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { + isAsterSessionNotFoundError, + resolveRestorableSessionId, +} from "@/lib/asterSessionRecovery"; // ============ 类型定义 ============ @@ -398,6 +402,39 @@ export const useAgentStore = create((set, get) => ({ }); } catch (error) { console.error("[AgentStore] 切换会话失败:", error); + if (isAsterSessionNotFoundError(error)) { + try { + const sessions = await invoke< + Array<{ + id: string; + created_at: number; + updated_at: number; + }> + >("aster_session_list"); + const recoveredSessionId = resolveRestorableSessionId({ + candidateSessionId: null, + sessions: sessions.map((item) => ({ + id: item.id, + createdAt: item.created_at, + updatedAt: item.updated_at, + })), + }); + + if (recoveredSessionId && recoveredSessionId !== sessionId) { + await get().switchSession(recoveredSessionId); + return; + } + + await get().createSession(); + set({ + messages: [], + pendingActions: [], + }); + return; + } catch (recoveryError) { + console.error("[AgentStore] 恢复会话失败:", recoveryError); + } + } throw error; } },