diff --git a/.flocks/plugins/skills/onesig-use/SKILL.md b/.flocks/plugins/skills/onesig-use/SKILL.md index e1d39c563..9a4eafcaf 100644 --- a/.flocks/plugins/skills/onesig-use/SKILL.md +++ b/.flocks/plugins/skills/onesig-use/SKILL.md @@ -1,80 +1,79 @@ --- name: onesig-use -description: 用于处理 OneSIG(安全互联网网关 / Secure Internet Gateway)相关任务,适合通过 API 或者结合浏览器进行以下任务:威胁监控(仪表盘、防护大屏、失陷主机、入站/出站威胁事件、报告管理)、防护策略(全局白/黑名单、多维封锁、IPS、HTTP 黑名单、高危端口防护、API 联动、Syslog 自动封禁、FTP/SFTP 联动)、资产管理、平台管理(告警/审计/用户、HTTPS 解密、网口路由 DNS、HA、OneCC、设备升级与备份、license、MDR、诊断)、登录会话与改密、帮助文档。只要用户提到 OneSIG、SIG、安全互联网网关、微步互联网网关等相关操作时,必须先加载本 skill。本 skill 是 OneSIG 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `onesig_*` tool。 +description: 用于处理 OneSIG(安全互联网网关 / Secure Internet Gateway)相关任务,当前项目内优先适配 OneSIG Strategy API v2.5.3(`onesig_strategy_api_query` / `onesig_strategy_api_ops`):设备状态、资产、策略、全局白名单、全局黑名单、封禁白名单、HTTP 黑名单的查询与写操作。只要用户提到 OneSIG、SIG、安全互联网网关、微步互联网网关等相关操作时,必须先加载本 skill。本 skill 是 OneSIG 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `onesig_*` tool,也不要把 OneSEC 的调用约定套用到 OneSIG。 --- # OneSIG Use ## First -操作模式 API V.S Browser +先判断操作模式:API V.S Browser。 ### 何时使用 API -- 默认模式,默认使用 API -- !!! important: 如果已经进入了浏览器模式,就不要走 API 了 -- 查询类请求与处置 / 配置写入类请求要严格区分;用户没有明确要求执行写操作时,默认只使用只读查询能力 +- 默认先判断当前诉求是否被新版 Strategy API 覆盖;覆盖时优先使用 API +- 查询类请求与配置写入类请求必须严格区分;用户没有明确要求写操作时,默认只使用只读查询能力 +- 当前项目内最新工具包是 `.flocks/plugins/tools/device/onesig_v2_5_3`,只暴露两个 grouped tool: + - `onesig_strategy_api_query`:只读查询,`requires_confirmation=false` + - `onesig_strategy_api_ops`:写操作,`requires_confirmation=true` + +当前 Strategy API 覆盖的能力: + +- 设备状态:平台状态、系统状态、网络状态 +- 资产:资产组、资产列表、资产类型,以及资产 / 资产组的新增、更新、删除 +- 防护策略:策略列表、策略更新、策略删除 +- 全局白名单、全局黑名单、封禁白名单 +- HTTP 黑名单 ### 何时使用浏览器 -- 现有 API 没有覆盖目标能力 -- 未检测到对应 API 工具 -- API 当前不可用,例如未配置、未开通、无权限、认证失败、Cookie 过期、SSL 验证失败或服务不可达 +- 当前 Strategy API 没有覆盖目标能力,例如威胁监控、仪表盘、失陷主机、入站 / 出站事件、报告、IPS、Syslog 自动封禁、FTP/SFTP 联动、高危端口防护、用户管理、HTTPS 解密、网口 / 路由 / DNS、HA、OneCC、升级备份、license、MDR、诊断、帮助文档 +- API 工具未检测到、未配置、无权限、ApiKey / Secret 缺失、认证失败、SSL 验证失败或服务不可达 - 任务必须查看页面级详情、攻击链、威胁图、报表预览或人工确认弹窗 - 页面需要人工登录、图形验证码、TOTP、强制改密或页面级确认 - 用户明确要求使用浏览器,或者已经在浏览器操作过程中 ### 请求确认 -除非是用户要求使用浏览器,否则提示用户 API 不可用,请检查 API 配置或直接使用浏览器模式。 +除非用户要求使用浏览器,否则如果 API 不可用,应提示用户检查 OneSIG Strategy API 配置,或确认是否改用浏览器模式。 当确定操作模式后: -- API 模式:请阅读 API 模式使用指南 -- 浏览器模式:请阅读浏览器模式使用指南 +- API 模式:必须阅读 API 模式使用指南 +- 浏览器模式:必须阅读浏览器模式使用指南 ## API 模式使用指南 -OneSIG 一共 6 个 grouped tool,按用户语义分流: +必须阅读: -- 用户说"仪表盘""威胁防护大屏""失陷主机""入站/出站威胁事件""设备状态""报告""导出报表"时,优先走 `onesig_monitoring` -- 用户说"白名单""黑名单""多维封锁""API 联动密钥""Syslog 自动封禁""FTP/SFTP 联动""IPS 规则""HTTP 黑名单""端口防护组""一键 bypass""防护策略"时,优先走 `onesig_strategy` -- 用户说"资产""资产组""资产类型""资产导入/导出"时,优先走 `onesig_assets` -- 用户说"告警/通知配置""管理日志/审计""用户管理/登录策略""HTTPS 解密""网口/部署引导""路由 / DNS / 代理""HA 高可用""集中管控/OneCC""设备升级/重启/备份/恢复""日志外发""license""MDR""coredump""pcap""设备诊断"时,优先走 `onesig_device` -- 用户说"登录""退出""改密""账户信息""图形验证码""TOTP""恢复码""产品动态红点"时,优先走 `onesig_login` -- 用户说"帮助文档""产品版本""提交问题反馈"时,优先走 `onesig_helper` +各 grouped tool 与 action 的详细说明、最小调用示例、失败处理见 [references/api-reference.md](references/api-reference.md)。 -OneSIG 与 OneSEC 在调用约定上有几个**关键差异**,agent 不要混用: +核心调用约定: -- 时间字段是 `startTime` / `endTime`(不是 OneSEC 的 `time_from` / `time_to`),单位 **Unix 秒** -- 分页字段是 `pageNo` / `pageSize`(不是 `cur_page` / `page_size`,也不是 `page_items_num`) -- OneSIG 主要走 **Cookie 会话** + RSA-OAEP 加密密码登录,处理器会自动登录、会话过期会自动重登;只有当设备启用了图形验证码或 TOTP 时才需要显式调 `onesig_login` 的 `login` 动作,并把 `captcha` / `totp` 作为入参传入 -- 每个 grouped tool 的 action 除了 `action` 都按业务键平铺;POST 类 action 多数走 passthrough body,把所有筛选 / 分页字段直接放在请求体顶层即可 +- `onesig_strategy_api_query` 用于所有只读查询 +- `onesig_strategy_api_ops` 用于所有写操作;调用前必须确认影响范围 +- 入参优先使用 `{ "action": "...", "body": { ... } }` +- Strategy API 认证是 `ApiKey + Secret` 的 HMAC-SHA1 签名,handler 会自动生成 `apikey` / `timestamp` / `sign`,agent 不要手工拼签名 +- Strategy API 走 `/api/v3/...` 路径;不要混用旧控制台 Cookie 版 `/v3/...` 工具说明 +- 分页字段使用 `pageNo` / `pageSize`;不要传 OneSEC 的 `cur_page` / `page_size` / `page_items_num` +- 当前 Strategy API action 没有 OneSEC 风格的 `time_from` / `time_to`;不要把 OneSEC 时间参数套进 OneSIG +- 查询单条或写操作前,如果缺少业务 ID,应先调用对应 list action 获取主键,再执行下一步 高风险写操作要特别谨慎,例如: -- 设备级动作:`device_onekey_bypass` / `device_quick_bypass`(流量直通)、`device_reboot` / `device_shutdown` / `device_reinit`、`device_upgrade` / `device_upgrade_upload` / `system_upgrade` -- 用户与口令:`user_create` / `user_delete` / `user_update` / `user_secret_reset`、`change_password`、`regenerate_recovery_code` -- 黑白名单与封禁:`whitelist_*` / `blacklist_*` 系列写操作、`multiblock_rule_create` / `multiblock_rule_update` / `multiblock_rule_delete` / `multiblock_rule_active`、`auto_blacklist_create` / `auto_blacklist_update` / `auto_blacklist_delete` -- IPS / HTTP / 端口防护:`ips_rule_create` / `ips_rule_apply` / `ips_rule_all`、`ips_ruleset_create` / `ips_ruleset_update` / `ips_ruleset_delete`、`http_blacklist_*` 写操作、`port_protect_group_*` / `port_protect_port_*` 写操作、`protection_policy_update` / `protection_policy_delete` -- HTTPS 解密:`tls_decrypt_policy_create` / `_update` / `_enable` / `_delete` / `_batch`、`tls_cert_create` / `_update` / `_delete` / `_set_default`、`tls_detect_delete` / `set_decrypt_config` -- 网口 / 路由 / 网络:`interface_update`(涉及启停时需 password)、`interface_*_create/update/delete`(virtualLine / listen / bridge)、`route_static_*` / `ipv6_route_static_*` 写操作、`set_dns_config` / `set_proxy_config`、`hosts_create/update/delete` -- 高可用 / 集中管控:`set_ha_config` / `ha_switching` / `ha_sync_config`、`set_onecc_config` / `set_onecc_status` -- 备份与升级:`backup_create` / `backup_recover` / `backup_delete` / `backup_import` / `backup_update`、`device_download_package` -- 离线情报库 / 授权:`basic_information_import` / `basic_information_enable`、`basic_license_upload`、`mdr_service_enable` -- 诊断写操作:`device_pcap_set`、`device_coredump_delete` / `device_pcap_file_delete` -- 报表与日志外发:`report_form_create` / `report_form_delete`、`report_task_create` / `_update` / `_delete` / `_test`、`logaccess_create` / `_update` / `_delete`、`set_dnslog_config`、`set_clean_config` / `set_storage_config` -- API Key 与密钥:`apikey_create` / `apikey_update` / `apikey_delete`、`apikey_secret`(会回吐明文 secret,需当前用户密码二次校验) - -必须阅读: - -各 grouped tool 与 action 的详细说明、必填参数、最小调用示例见 [references/api-reference.md](references/api-reference.md)。 +- `asset_group_create` / `asset_group_update` / `asset_group_delete` +- `asset_create` / `asset_update` / `asset_delete` +- `protection_policy_update` / `protection_policy_delete` +- `whitelist_create` / `whitelist_update` / `whitelist_delete` / `whitelist_remove` +- `blacklist_create` / `blacklist_update` / `blacklist_delete` / `blacklist_remove` +- `banned_whitelist_create` / `banned_whitelist_update` / `banned_whitelist_delete` +- `http_blacklist_create` / `http_blacklist_update` / `http_blacklist_enable` / `http_blacklist_delete` ## 浏览器模式使用指南 -- ⚠️ 如果 OneSIG 设备的访问地址不清楚,请先询问用户,不要擅自填写域名。 -- ⚠️ 用 `--headed` 打开浏览器,人工完成登录(OneSIG 多数部署启用了图形验证码 / TOTP / 强制改密策略)。 -- ⚠️ OneSIG 控制台与 OneSEC / 青藤是不同产品;不要把 OneSEC 的页面路径或 OneSEC 的 API 套用到 OneSIG。 +- 如果 OneSIG 设备的访问地址不清楚,请先询问用户,不要擅自填写域名 +- 用 `--headed` 打开浏览器,人工完成登录(OneSIG 部署可能启用了图形验证码 / TOTP / 强制改密策略) +- OneSIG 控制台与 OneSEC / 青藤是不同产品;不要把 OneSEC 的页面路径或 OneSEC 的 API 套用到 OneSIG 只要进入浏览器模式,就请阅读并按照 browser-workflow 操作,不要直接跳过本 skill 去套用其他通用浏览器 skill。 diff --git a/.flocks/plugins/skills/onesig-use/references/api-reference.md b/.flocks/plugins/skills/onesig-use/references/api-reference.md index 80bb8316c..9fc3b6c82 100644 --- a/.flocks/plugins/skills/onesig-use/references/api-reference.md +++ b/.flocks/plugins/skills/onesig-use/references/api-reference.md @@ -1,695 +1,318 @@ -# OneSIG API 调用指南 +# OneSIG Strategy API 调用指南 -OneSIG 当前的 6 个 grouped tool(`onesig_login` / `onesig_assets` / `onesig_device` / `onesig_helper` / `onesig_monitoring` / `onesig_strategy`)都遵循"`action` + 业务键平铺"的调用模式。处理器会自动登录、自动续会话、自动按需做 RSA-OAEP 密码加密,所以业务调用通常**不需要**先单独调登录接口。 +当前项目内最新 OneSIG 工具包来自 `.flocks/plugins/tools/device/onesig_v2_5_3`,它不是旧控制台 Cookie 登录版的 6 个 grouped tool,而是 OneSIG v2.5.3 第三方 Strategy API 接入: + +- `onesig_strategy_api_query`:只读查询 +- `onesig_strategy_api_ops`:写操作 + +这两个工具使用 `ApiKey + Secret` 做 HMAC-SHA1 签名,调用 `/api/v3/...` 接口。不要把旧控制台插件的 `onesig_monitoring` / `onesig_strategy` / `onesig_assets` / `onesig_device` / `onesig_login` / `onesig_helper` 文档套到当前 Strategy API 工具上。 ## 先看这张路由表 -| 用户意图 | 推荐 tool | 推荐 action | 必备参数 | +| 用户意图 | 推荐 tool | 推荐 action | body 说明 | |---|---|---|---| -| 看仪表盘总览 / 出入站 / 零日 | `onesig_monitoring` | `dashboard_overview` / `dashboard_outbound` / `dashboard_inbound` / `dashboard_zeroday` | 通常空参,部分需 `startTime`/`endTime` | -| 看威胁防护大屏(事件/资产/趋势/占比) | `onesig_monitoring` | `overview_*` 系列 | 多数必填 `startTime`+`endTime`,部分要 `incIntervalSec` 或 `interval` | -| 看入站/出站威胁事件列表与详情 | `onesig_monitoring` | `event_inbound_*` / `event_outbound_*` | 列表与详情类必填 `startTime`+`endTime` | -| 看失陷主机统计 / 列表 / 详情 | `onesig_monitoring` | `alert_host_*` | 列表与详情类必填 `startTime`+`endTime`,详情类还要 `source` | -| 看 / 改报表 | `onesig_monitoring` | `report_form_*` / `report_task_*` | 下载需 `uniqueId`+`fileName`,删除需 `uniqueId` | -| 看设备状态(CPU/内存/网口/平台) | `onesig_monitoring` | `device_platform_status` / `device_system_status` / `device_network_status` / `common_interface_list` / `basic_cpu_attr` | `device_system_status` 必填 `time` | -| 改 / 查白黑名单 | `onesig_strategy` | `whitelist_*` / `blacklist_*` | 列表用 `*_list`,删除用 `uniqueIds` | -| 多维封锁规则 | `onesig_strategy` | `multiblock_rule_*` / `multiblock_executelog_*` | 单条规则用 `name` 做主键 | -| API 联动密钥 | `onesig_strategy` | `apikey_*` | `apikey_secret` 必填 `key`+`password` | -| Syslog 自动封禁 | `onesig_strategy` | `auto_blacklist_*` | `auto_blacklist_trend`/`_sample` 必填 `srcIp`(+组合键) | -| FTP/SFTP 联动 | `onesig_strategy` | `linkage_*` | `linkage_info` 必填 `uniqueId` | -| IPS 规则 / 规则集 | `onesig_strategy` | `ips_rule_*` / `ips_ruleset_*` / `ips_threat_types` | 单条规则集查询用 `name`,引用关系用 `ruleId`+`assetIp` | -| HTTP 黑名单 / 高级 / XFF | `onesig_strategy` | `http_blacklist_*` / `*_advanced_config` / `*_xff_config` | 列表 `_list`、写操作走 `_create` / `_update` / `_delete` | -| 高危端口防护 | `onesig_strategy` | `port_protect_group_*` / `port_protect_port_*` | 端口列表 `port_protect_port_list` 必填 `groupName`+`pageNo`+`pageSize` | -| 防护策略首页 | `onesig_strategy` | `protection_policy_*` / `device_onekey_bypass` | `protection_policy_get` 必填 `uniqueId` | -| 资产 / 资产组 / 资产类型 | `onesig_assets` | `asset_*` / `asset_group_*` / `asset_type_*` / `common_asset_group_tree` | 资产用 `uniqueId`(删除还需 `password`);资产组写操作用 `uid`(新增是 `pid`+`name`);导入用 `file_path` | -| 告警 / 通知策略 | `onesig_device` | `alert_policy_*` / `*_notice_config` / `test_email/syslog/webhook` | `alert_policy_find_by_config` 必填 `search`+`type` | -| 管理日志(审计) | `onesig_device` | `aclog_*` / `*_clean_config` | 列表 / 导出常需 `startTime`+`endTime`;删除需 `password` | -| 用户与登录策略 | `onesig_device` | `user_*` / `*_login_config` | 改密 / 删除 / 重置密码需 `password`(自动 RSA 加密) | -| HTTPS 解密 | `onesig_device` | `tls_decrypt_policy_*` / `tls_cert_*` / `tls_detect_*` | TLS 详情必填 `server`+`port`+`orderBy`+`sortBy` | -| 网口 / 部署引导 | `onesig_device` | `interface_*` | `interface_select_list` 必填 `workMode` | -| 路由(v4/v6) | `onesig_device` | `route_*` / `ipv6_route_*` | 写操作要带完整路由项 | -| DNS / 代理 / 网络测试 | `onesig_device` | `*_dns_config` / `hosts_*` / `*_proxy_config` / `test_network` / `test_proxy` | hosts 写操作走 create/update/delete | -| 高可用 HA | `onesig_device` | `ha_*` / `*_ha_config` | `ha_sync_status` 必填 `syncId`(前置 `ha_sync_config` 返回) | -| 集中管控 OneCC | `onesig_device` | `*_onecc_config` / `onecc_status` / `set_onecc_status` / `test_onecc` | 写操作前先看 status | -| 设备升级 / 重启 / 备份 / 恢复 | `onesig_device` | `device_upgrade*` / `device_reboot/shutdown/reinit` / `backup_*` / `system_upgrade` | 升级 / 上传备份用 `file_path`,敏感写操作需 `password` | -| 日志外发 | `onesig_device` | `logaccess_*` | 列表必填 `pageNo`+`pageSize`+`type`;样例必填 `srcIp`+`protocol`+`type` | -| 基本信息 / license / MDR | `onesig_device` | `basic_*` / `mdr_service_*` | license / 离线情报库导入用 `file_path` | -| 设备诊断 | `onesig_device` | `device_coredump_*` / `device_pcap_*` | coredump/pcap 删除走 DELETE | -| 帮助文档 / 产品反馈 | `onesig_helper` | `document_list` / `document_preview` / `product_*` | `document_preview` 必填 `id`(来自 `document_list`) | -| 登录 / 退出 / 改密 / 账户 | `onesig_login` | `login` / `logout` / `change_password` / `get_account` 等 | 改密必填 `new_password`,启用 captcha/TOTP 时 `login` 要传 `captcha`/`totp` | +| 查平台运行状态 | `onesig_strategy_api_query` | `platform_status` | 通常空对象 | +| 查系统状态 | `onesig_strategy_api_query` | `system_status` | 通常空对象 | +| 查网络状态 | `onesig_strategy_api_query` | `network_status` | 通常空对象 | +| 查资产组 | `onesig_strategy_api_query` | `asset_group_list` | GET 类接口,通常空对象 | +| 查资产列表 | `onesig_strategy_api_query` | `asset_list` | 可传 `pageNo`、`pageSize`、`search` 等厂商文档字段 | +| 查资产类型 | `onesig_strategy_api_query` | `asset_type_list` | GET 类接口,通常空对象 | +| 查防护策略 | `onesig_strategy_api_query` | `protection_policy_list` | 可传分页 / 筛选字段 | +| 查全局白名单 | `onesig_strategy_api_query` | `whitelist_list` | 可传分页 / 筛选字段 | +| 查全局黑名单 | `onesig_strategy_api_query` | `blacklist_list` | 可传分页 / 筛选字段 | +| 查封禁白名单 | `onesig_strategy_api_query` | `banned_whitelist_list` | 可传分页 / 筛选字段 | +| 查 HTTP 黑名单 | `onesig_strategy_api_query` | `http_blacklist_list` | 可传分页 / 筛选字段 | +| 新建 / 更新 / 删除资产组 | `onesig_strategy_api_ops` | `asset_group_create` / `asset_group_update` / `asset_group_delete` | 按 api-onesig-2.5.3 文档填写 | +| 新建 / 更新 / 删除资产 | `onesig_strategy_api_ops` | `asset_create` / `asset_update` / `asset_delete` | 按 api-onesig-2.5.3 文档填写 | +| 更新 / 删除防护策略 | `onesig_strategy_api_ops` | `protection_policy_update` / `protection_policy_delete` | 先查策略列表拿业务 ID | +| 新建 / 更新 / 删除 / 移除全局白名单 | `onesig_strategy_api_ops` | `whitelist_create` / `whitelist_update` / `whitelist_delete` / `whitelist_remove` | 写入前先确认方向、条件和影响范围 | +| 新建 / 更新 / 删除 / 移除全局黑名单 | `onesig_strategy_api_ops` | `blacklist_create` / `blacklist_update` / `blacklist_delete` / `blacklist_remove` | 写入前先确认对象和影响范围 | +| 新建 / 更新 / 删除封禁白名单 | `onesig_strategy_api_ops` | `banned_whitelist_create` / `banned_whitelist_update` / `banned_whitelist_delete` | 写入前先确认对象和影响范围 | +| 新建 / 更新 / 启停 / 删除 HTTP 黑名单 | `onesig_strategy_api_ops` | `http_blacklist_create` / `http_blacklist_update` / `http_blacklist_enable` / `http_blacklist_delete` | `enable` 也属于写操作 | ## 通用规则 -- OneSIG 全部 grouped tool 的入参形态:`{action: "...", ...业务字段}`(不像 TDP 把所有筛选放进统一 `body`,也不像 OneSEC 用 `time_from`/`time_to`) -- 时间字段统一是 **Unix 秒**:`startTime` / `endTime` / `time`,没有"recent"系列接口 -- 分页字段统一是 `pageNo`(默认 1)+ `pageSize`(默认 20);不要传 `cur_page` / `page_size` / `page_items_num` -- 排序统一是 `sortBy`(字段名)+ `orderBy`(`asc` / `desc`) -- POST 类 action 多数 passthrough body —— 任何过滤字段直接放在请求体顶层即可,与 Web 控制台抓包字段一一对应 -- 查询类 action 默认优先;写操作只有用户明确授权时才执行(特别是黑白名单批量写、设备升级、HTTPS 解密策略、HA / OneCC 配置) -- 业务键参考第二节的"业务 ID 字段";如果没有 ID,应先调对应列表 / namelist / tree 接口拿主键 - -## 业务 ID 字段对照(`required` 主键) - -OneSIG 不同模块用不同字段做"主键",没有 ID 是无法调单条接口的。如果 agent 拿不到主键,应该**先调对应的列表 / namelist / tree** action 取得主键,再调单条接口。 - -| 字段 | 用在哪些 action | 含义 | -|---|---|---| -| `id` | `document_preview` | 帮助文档主键(先 `document_list`) | -| `key` + `password` | `apikey_secret` | API Key 名称 + 当前用户登录密码(自动 RSA 加密) | -| `uniqueId` | `asset_update` / `asset_delete`(+`password`)/ `linkage_info` / `protection_policy_get` / `report_form_download` / 多数 `*_delete` 单条目 / 多数全局白黑名单 `*_update` | 资源唯一 ID,先调列表/树拿到 | -| `uniqueIds` | 批量删除(`whitelist_remove_batch` 等于 `globalWhitelist` DELETE、`blacklist_remove_batch` 等于 `globalBlacklist` DELETE、`*_delete` 批量) | 数组形式,多个资源一起删 | -| `uid` | `asset_group_update` / `asset_group_delete`(+`password`) | **资产组**主键(不是 `uniqueId`!) | -| `pid` + `name` | `asset_group_create` | 父级资产组 ID(根 = `0`) + 新组名 | -| `syncId` | `ha_sync_status` | HA 配置同步任务 ID(由前置 `ha_sync_config` 返回) | -| `name` | `multiblock_rule_get` / `multiblock_rule_preview` / `multiblock_executelog_list` / `ips_ruleset_info` / `ips_ruleset_delete` / `asset_type_delete` | OneSIG 用对象名当主键,没有独立数字 ID | -| `groupId` | `port_protect_group_update` / `_delete` / `_clone` 的 `fromGroupId`、`port_protect_port_*` 写操作、`port_protect_portinfo` | 端口防护**组**主键(写操作用) | -| `groupName` | `port_protect_port_list`(端口列表)/ `port_protect_port_export` | 端口防护组**名**(仅查列表 / 导出用,注意与 `groupId` 区分) | -| `ruleId` | `ips_rule_create`(**实际是查单条规则详情**,文档名为「新增 IPS 自定义规则」与实际语义不符)、`ips_ruleset_referred` 的一半 | IPS 规则 ID | -| `ruleId` + `assetIp` | `ips_ruleset_referred` | 单条 IPS 规则 + 涉事资产 IP,反查规则在哪台资产上命中 | -| `srcIp`(+`protocol`+`direction`) | `auto_blacklist_trend` / `auto_blacklist_sample` | 来自 syslog 自动黑名单的源 IP 复合主键 | -| `server` + `port` + `orderBy` + `sortBy` | `tls_detect_list_detail` | TLS 解密目标"主机:端口"复合键 | -| `workMode` | `interface_select_list` | 网口选择列表必须先选定工作模式(内联/旁路) | -| `search` + `type` | `alert_policy_find_by_config` | 按配置反查告警策略 | -| `time` | `device_system_status` | 系统资源采样时间窗(Unix 秒) | -| `incIntervalSec` / `interval` | `overview_asset_brief` / `overview_event_trend` / `overview_stat` | 聚合粒度(秒) | -| `key` (Body) + Query `type=physical` | `apikey_delete` / `_update`,新增 `apikey_create` 也要 `type=physical` | API 联动密钥的接口要求 Query `?type=physical`(厂商文档强制)—— 当前 handler 走 passthrough body,按目前实测把 `type:"physical"` 一起放进 body 即可,但若服务端严格要求 query,请改用浏览器流程 | - -## 时间窗口与时区 - -- 时间戳一律 Unix **秒**(不是毫秒) -- 文档示例按 `Asia/Shanghai`,实际调用时按业务时区计算 -- OneSIG 没有"最近 24 小时增量"专用接口;要"最近一段"统一用 `now - 24*3600`、`now - 7*86400` 之类自行算 -- 未传时间走服务端默认窗口,仅作兜底,不推荐依赖 - -## 高频场景 - -### 1. 看入站 / 出站威胁事件 - -推荐:`onesig_monitoring` + `event_inbound_list` 或 `event_outbound_list`。 - -最小示例(查最近 24 小时入站事件): +- 入参优先使用: ```json { - "action": "event_inbound_list", - "startTime": 1745683200, - "endTime": 1745769600, - "pageNo": 1, - "pageSize": 20 + "action": "asset_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -后续动作: +- handler 也兼容把业务字段平铺在 `action` 同级,但文档和新请求默认使用 `body`,避免和旧控制台工具混淆。 +- `GET` 类 action 只发送签名 query,不发送 JSON body;不要依赖 `body` 给 `asset_group_list` / `asset_type_list` 传筛选条件。 +- 分页字段使用 `pageNo` / `pageSize`。 +- 排序 / 筛选字段按厂商 `api-onesig-2.5.3` 文档填写;如果不确定字段名,先用最小参数查询,不要猜旧控制台字段。 +- 当前 Strategy API action 没有 `startTime` / `endTime`,也没有 OneSEC 的 `time_from` / `time_to`;不要混用时间字段。 +- 查询优先;写操作必须在用户明确授权后执行。 +- 写操作前应先通过 list action 获取当前对象和业务 ID,确认不会误改 / 误删。 +- 当 `delete` 与 `remove` 的语义不确定时,不要猜;先查厂商文档或只执行只读查询。 -- 拿到事件后调 `event_inbound_detail` / `_detail_trend` / `_detail_list` 看详情、趋势、关联记录 -- 导出文件用 `event_inbound_export` / `event_inbound_detail_export`(返回的是文件,不是 JSON) +## 认证与配置 -返回结果重点关注: +`onesig_v2_5_3` 的 provider 信息: -- 事件 `uniqueId`、源/目的 IP、威胁类型 `threatType`、威胁等级 `severity`、威胁标签 `threatLabel`、是否 TLS `isTls` +- `service_id`: `onesig_api` +- `version`: `2.5.3` +- `base_url`: OneSIG 设备地址;缺少协议时 handler 自动补 `https://` +- `api_key`: ApiKey,优先读服务配置,也支持 secret `onesig_v2_5_3_api_key` +- `secret`: Secret,优先读服务配置,也支持 secret `onesig_v2_5_3_secret` +- 环境变量兜底:`ONESIG_V2_5_3_BASE_URL`、`ONESIG_V2_5_3_API_KEY`、`ONESIG_V2_5_3_SECRET` +- `verify_ssl`: 默认 `false`,适合自签证书设备;需要严格校验证书时在服务配置中开启 -何时回退浏览器:需要事件原始报文、PCAP 取证、详情页攻击链。 +handler 会自动把以下签名参数追加到请求 query: -### 2. 失陷主机调查 +```text +apikey= +timestamp= +sign= +``` -推荐:`onesig_monitoring` + `alert_host_*`。 +agent 不需要、也不应该手工生成签名。 -```json -{ - "action": "alert_host_list", - "startTime": 1745683200, - "endTime": 1745769600, - "pageNo": 1, - "pageSize": 20 -} -``` +## 只读查询示例 -详情维度: +### 1. 查设备平台状态 ```json { - "action": "alert_host_detail", - "startTime": 1745683200, - "endTime": 1745769600, - "source": "10.0.0.5" + "action": "platform_status", + "body": {} } ``` -`source` 是失陷主机的 IP(必填)。`alert_host_detail_list` / `_export` 同步要求 `startTime` / `endTime` / `source`。 - -### 3. 看仪表盘 / 大屏 - -仪表盘:`dashboard_overview` / `dashboard_outbound` / `dashboard_inbound` / `dashboard_zeroday` / `dashboard_status` / `dashboard_ioc_type_sum` —— 通常空参或只需时间窗。 - -威胁防护大屏(`overview_*`)—— 多数需要时间窗 + 聚合粒度: +### 2. 查系统状态 ```json { - "action": "overview_asset_brief", - "startTime": 1745683200, - "endTime": 1745769600, - "incIntervalSec": 3600 + "action": "system_status", + "body": {} } ``` -```json -{ - "action": "overview_event_trend", - "startTime": 1745683200, - "endTime": 1745769600, - "interval": 3600 -} -``` +### 3. 查网络状态 ```json { - "action": "overview_event_inbound_agg", - "startTime": 1745683200, - "endTime": 1745769600, - "type": "threatType", - "pageNo": 1, - "pageSize": 20 + "action": "network_status", + "body": {} } ``` -注意:`overview_*_export` 系列返回文件,不要拿来在 chat 里展开。 - -### 4. 资产管理 - -推荐:`onesig_assets`。 - -查列表: +### 4. 查资产列表 ```json { "action": "asset_list", - "pageNo": 1, - "pageSize": 20, - "search": "10.0.0." + "body": { + "pageNo": 1, + "pageSize": 20, + "search": "10.0.0." + } } ``` -新增 / 更新 / 删除(参数严格按厂商 `assetsSegment.md`): +返回后重点关注资产唯一标识、资产名称、IP / 网段、资产组和资产类型字段。字段名以设备实际返回为准。 + +### 5. 查资产组与资产类型 ```json { - "action": "asset_create", - "name": "host-1", - "type": "服务器", - "groupId": 101, - "ip": ["10.0.0.1", "10.0.0.2"], - "remark": "生产环境" + "action": "asset_group_list", + "body": {} } ``` -`type` 是字符串字段(不是 `assetType`),`ip` 必须是**字符串数组**(支持单 IP / 网段 / 区间),`groupId` 是数字(先调 `asset_group_get` 拿)。 - ```json { - "action": "asset_update", - "uniqueId": "asset-id-1", - "name": "host-1-renamed", - "type": "服务器", - "groupId": 101, - "ip": ["10.0.0.1"] + "action": "asset_type_list", + "body": {} } ``` -更新接口必填 `uniqueId` + `name` + `type` + `groupId` + `ip`(厂商文档要求**全量**字段,不是部分更新)。 - -```json -{ "action": "asset_delete", "uniqueId": "asset-id-1", "password": "<当前登录用户密码>" } -``` - -⚠️ **删除资产是单条调用**(仅 `uniqueId`),并且**必须带 `password`**(当前登录用户密码),处理器会自动 RSA-OAEP 加密。要批量删,请按行循环调用。 - -资产组:`asset_group_get`(拿整树)/ `asset_group_create`(`pid`+`name`)/ `asset_group_update`(`uid`+`name`)/ `asset_group_delete`(`uid`+`password`)。注意资产组主键叫 `uid`,**不是** `uniqueId`。整树缓存用 `common_asset_group_tree`。 - -资产导入: - -```json -{ "action": "asset_import", "file_path": "/abs/path/to/assets.csv" } -``` - -`asset_template` / `asset_export` 返回文件(CSV)。 +资产写操作前,先查这两个接口确认可用分组和类型。 -### 5. 黑白名单 / 多维封锁 / API 联动 - -白名单(写)—— 注意 `condition` / `comments` 与 `whiteList` **平级**,不是嵌进数组元素: +### 6. 查防护策略列表 ```json { - "action": "whitelist_add", - "whiteList": [{ "direction": "inbound" }], - "condition": [{ "type": "srcIp", "cond": "equal", "value": "10.0.0.1" }], - "comments": "策略备注" + "action": "protection_policy_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -新增时 `whiteList` 数组每项**只含 `direction`**(值为 `inbound` / `outbound` / `both`),`condition` 与 `comments` 在顶层。 +策略更新 / 删除前,必须先用列表结果确认目标策略 ID、名称、作用范围和当前状态。 + +### 7. 查全局白名单 / 黑名单 / 封禁白名单 ```json { - "action": "whitelist_update", - "uniqueId": "id-1", - "direction": "inbound", - "condition": [{ "type": "srcIp", "cond": "equal", "value": "10.0.0.1" }], - "comments": "更新备注" + "action": "whitelist_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -更新时**没有** `whiteList` 字段,只有 `uniqueId` + 单值 `direction` + `condition` + `comments`。 - -```json -{ "action": "whitelist_remove_batch", "uniqueIds": ["id-1", "id-2"] } -``` - -黑名单(同 OneSIG `globalBlacklist`): - ```json { - "action": "blacklist_add", - "blackList": [ - { "object": "1.1.1.1", "direction": "inbound", "threatName": "", "sLifeCycle": "", "comments": "" } - ] + "action": "blacklist_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -新增时整批用 `blackList` 数组;写入前可先 `blacklist_check`(IP 类冲突校验)。`_update` / `_delete`(按 `uniqueIds` 批量)/ `_list` 用法与白名单类似。 - -多维封锁: - ```json { - "action": "multiblock_executelog_list", - "name": "rule-A", - "startTime": 1745683200, - "endTime": 1745769600, - "pageNo": 1, - "pageSize": 20 + "action": "banned_whitelist_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -`multiblock_rule_get` / `_preview` 必填 `name`;`_create` / `_update` 是写操作,要谨慎。 - -API 联动: - -```json -{ "action": "apikey_list", "pageNo": 1, "pageSize": 20 } -``` - -`apikey_list` 必填 `pageNo`+`pageSize`,否则服务端回 `responseCode=1004`。 - -```json -{ "action": "apikey_secret", "key": "my-key", "password": "<当前登录密码>" } -``` - -`apikey_secret` 会回吐明文 secret,处理器自动用 RSA-OAEP 加密 `password` 后发送(注意:`password` 在该接口走 Query 而非 Body)—— 不要替用户保存这个值,更不要回显在 chat 里。 - -写操作(创建 / 更新 / 删除):厂商接口要求带 Query `?type=physical`。当前 handler 走 passthrough body,请把 `type:"physical"` 一起放进 body 顶层(实测多数实例服务端会接受),如果遇到 `responseCode=1004` 提示 `type` 缺失则回退浏览器模式: - -```json -{ "action": "apikey_create", "type": "physical", "name": "soc-bridge" } -``` - -```json -{ "action": "apikey_update", "type": "physical", "name": "soc-bridge", "key": "k1", "status": 1 } -``` - -```json -{ "action": "apikey_delete", "type": "physical", "key": "k1" } -``` - -### 6. IPS 规则 / 规则集 - -```json -{ "action": "ips_ruleset_namelist" } -``` - -```json -{ "action": "ips_ruleset_info", "name": "default-ruleset" } -``` - -```json -{ "action": "ips_ruleset_referred", "ruleId": "rule-001", "assetIp": "10.0.0.5" } -``` - -⚠️ 注意 `ips_rule_create`(厂商 `POST /v3/ips/rule`)**实际语义是「查单条规则详情」**,body 必填 `ruleId`,不会真的"新增"自定义规则;这是 yaml/handler 命名遗留问题。需要新增/编辑规则集请走 `ips_ruleset_create` / `_update`。`ips_rule_apply` 才是"应用规则变更到目标规则集"的写操作(body:`rulesetsName`数组+`rules`数组),调用前确认变更已就绪。 - -### 7. HTTP 黑名单 / 端口防护 - -HTTP 黑:`http_blacklist_list` / `_create` / `_update` / `_delete` / `_enable` / `_export`。 - -端口防护组: - -```json -{ "action": "port_protect_group_list_full" } -``` +### 8. 查 HTTP 黑名单 ```json { - "action": "port_protect_port_list", - "groupName": "默认高危端口组", - "pageNo": 1, - "pageSize": 20, - "sortBy": "updateTime", - "orderBy": "desc" + "action": "http_blacklist_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -⚠️ action 名是 `port_protect_port_list`(**不是** `port_protect_group_port_list`),必填 `groupName`+`pageNo`+`pageSize`;响应里总数字段叫 `data.totalCount`(不是 `data.total`)。 - -写操作: +## 写操作示例 -- `port_protect_group_create` Body `groupName`;`_update` Body `groupId`+`groupName`;`_delete` Body `groupId`;`_clone` Body `fromGroupId`+`groupName` -- 端口规则:`port_protect_port_create` / `_update` Body `groupId`+`ports`(字符串)+`serviceName`(可选)+`comments`(可选);`_delete` Body `groupId`+`ports`(数组);`_onekey_import` Body `groupId`+`ports`(对象数组 `[{ports, serviceName, comments}]`) -- 端口占用预查:`port_protect_portinfo` Body `groupId`+`ports` +写操作统一调用 `onesig_strategy_api_ops`,且必须确认用户授权。下面示例只展示调用形态;业务字段必须按设备版本对应的 `api-onesig-2.5.3` 文档填写,不要从旧控制台插件文档里硬搬字段。 -### 8. Syslog 自动封禁 - -```json -{ "action": "auto_blacklist_list", "pageNo": 1, "pageSize": 20 } -``` - -```json -{ "action": "auto_blacklist_trend", "srcIp": "1.2.3.4", "startTime": 1745683200, "endTime": 1745769600 } -``` - -```json -{ "action": "auto_blacklist_sample", "srcIp": "1.2.3.4", "protocol": "tcp", "direction": "inbound" } -``` - -`auto_blacklist_check`(必填 `name` / `port` / `srcIp` / `protocol` / `direction`)是冲突校验,写入前调用。 - -### 9. 用户管理与改密 - -查询:`user_list` / `user_export`。 - -新增用户(写,敏感)—— 字段严格按 `deviceLoginManagement.md`: +### 1. 新建资产组 ```json { - "action": "user_create", - "username": "alice", - "role": 2, - "nickname": "", - "phone": "", - "expireTime": 0, - "loginLimit": [], - "password": "<明文新密码>", - "dupPassword": "<明文新密码>" + "action": "asset_group_create", + "body": { + "...": "按厂商文档填写资产组字段" + } } ``` -字段说明: - -- `username`(必填,**不是 `name`**):1~20 字符,符合 `userEditor` 用户名正则 -- `role`(必填):数字角色(如 `0`/`2`/`3`/`4`,`role=3` 跳审计页、`role=4` 跳大屏) -- `expireTime`(必填):到期时间 Unix 秒;选「长期」时填 `0` -- `loginLimit`(必填):可登录 IP 字符串数组,ALL 时填 `[]` -- `password` / `dupPassword`(必填):明文新密码,处理器自动 RSA-OAEP 加密。注意 **handler 只对 `password` 与 `dupPassword`(驼峰)做加密**,不要传 `dup_password`(蛇形不会被加密) - -更新用户用 `user_update`,必填 `username`(定位主键,**不可改**)+ `expireTime` + `loginLimit`,可选 `nickname` / `phone`;改密**不在** `user_update` 路径,要走 `Main/changePwdModalData`(即顶栏改密弹窗,agent 用 `change_password` 走 `onesig_login`)。 - -改密(当前用户)走 `onesig_login`: +### 2. 更新资产 ```json { - "action": "change_password", - "old_password": "<明文旧>", - "new_password": "<明文新>", - "dup_password": "<明文新>" + "action": "asset_update", + "body": { + "...": "先从 asset_list 获取目标资产 ID,再按厂商文档填写完整更新字段" + } } ``` -`user_secret_reset` / `user_delete` / `aclog_delete` / `interface_update`(启停场景)/ `device_upgrade*` 同样要传 `password`,规则一致。 - -如果 `POST /v3/login` 返回 responseCode `1009` / `1017` 但密码确实正确,先把 `oaep_hash` 切到 `sha256` 重试 —— OneSIG v2.5.x 多数走 SHA-1(JSEncrypt 默认),但有个别部署是 SHA-256。 - -### 10. HTTPS 解密 - -策略:`tls_decrypt_policy_list` / `_create` / `_update` / `_enable` / `_delete` / `_batch`。 - -证书:`tls_cert_list` / `_create`(multipart:`file_path` 指向 `.crt`/`.pem`)/ `_update` / `_delete` / `_set_default`。 - -检测对象: +### 3. 更新防护策略 ```json { - "action": "tls_detect_list_detail", - "server": "internal-svc.example.com", - "port": 443, - "orderBy": "desc", - "sortBy": "lastSeenTime" + "action": "protection_policy_update", + "body": { + "...": "先从 protection_policy_list 获取目标策略,再按厂商文档填写" + } } ``` -`server` + `port` + `orderBy` + `sortBy` 这 4 个是必填的复合主键。 - -### 11. 网口 / 部署引导 / 路由 / DNS - -网口:`interface_list` / `interface_select_list`(必填 `workMode`,"内联"/"旁路"二选一)/ `interface_update`(启停时 `password` 必填)/ `interface_check_loop` / `interface_relation_list`。 - -虚拟线 / 监听 / 桥:`interface_virtual_line_*` / `interface_listen_*` / `interface_bridge_*`。 - -路由:`route_outif_list` / `route_static_list` / `_create` / `_update` / `_delete` / `route_table_list`,IPv6 同名加 `ipv6_` 前缀。 - -DNS:`get_dns_config` / `set_dns_config` / `hosts_get` / `hosts_create` / `_update` / `_delete` / `test_network`。 - -### 12. HA 高可用 - -```json -{ "action": "ha_status" } -``` - -```json -{ "action": "ha_sync_config" } -``` - -```json -{ "action": "ha_sync_status", "syncId": "<上一步返回的 syncId>" } -``` - -`ha_switching` 是主备切换,**强破坏性写操作**,没有用户明确授权不要调。 - -### 13. 集中管控 OneCC - -```json -{ "action": "onecc_status" } -``` - -```json -{ "action": "test_onecc", "...": "..." } -``` - -`set_onecc_config` / `set_onecc_status` 都会改设备纳管状态,调用前先用 `get_onecc_config` / `onecc_status` 看清当前形态。 - -### 14. 设备配置 / 升级 / 备份 - -升级: - -- `basic_version` / `device_upgrade_info` —— 看版本与可升级状态 -- `get_upgrade_config` / `set_upgrade_config` —— 在线升级源配置 -- `device_download_package` —— 触发后台下载升级包(写) -- `device_upgrade` —— 已下载的包升级(写,敏感,`password` 必填) -- `device_upgrade_upload` —— 本地上传升级包(multipart:`file_path`,`password` 必填) -- `system_upgrade` —— 系统级升级(multipart:`file_path`) - -备份: - -```json -{ "action": "backup_list", "pageNo": 1, "pageSize": 20 } -``` - -```json -{ "action": "backup_create", "name": "manual-2026-04-27" } -``` - -```json -{ "action": "backup_recover", "uniqueId": "" } -``` - -```json -{ "action": "backup_import", "file_path": "/abs/path/to/backup.tar" } -``` - -`backup_recover` / `backup_import` / `backup_delete` 都强破坏性,确认后再调。 - -设备其它:`device_quick_bypass` / `device_onekey_bypass`(流量直通)、`device_reboot` / `device_shutdown` / `device_reinit`(重启 / 关机 / 出厂重置)—— 全是高风险,不要在不需要的时候触发。 - -### 15. 日志外发 +### 4. 新建全局黑名单 ```json { - "action": "logaccess_list", - "pageNo": 1, - "pageSize": 20, - "type": "syslog" + "action": "blacklist_create", + "body": { + "...": "按厂商文档填写黑名单对象、方向、备注等字段" + } } ``` +### 5. 启停 HTTP 黑名单 + ```json { - "action": "logaccess_sample", - "srcIp": "10.0.0.1", - "protocol": "udp", - "type": "syslog" + "action": "http_blacklist_enable", + "body": { + "...": "按厂商文档填写目标 ID 与启停状态" + } } ``` -`logaccess_check` 用来诊断外发链路是否正常。 - -### 16. 基本信息 / license / MDR - -- `basic_information` —— 设备基础信息 -- `basic_information_enable` —— 启用模块(写) -- `basic_information_import` —— 离线情报库更新(multipart:`file_path` + Query 需 `name`) -- `basic_license_get` —— 查 license -- `basic_license_upload` —— 授权 license 文件上传(multipart:`file_path`) -- `mdr_service_status` / `mdr_service_enable` —— MDR 服务 - -### 17. 设备诊断 - -- `device_coredump_list` / `_download` / `_delete` -- `device_pcap_get` / `_set` / `_file_list` / `_download` / `_file_delete` - -抓包写 (`device_pcap_set`) 与删除 (`*_delete`) 是写操作。 - -### 18. 帮助文档与产品反馈 - -```json -{ "action": "document_list", "pageNo": 1, "pageSize": 20, "search": "ips" } -``` - -```json -{ "action": "document_preview", "id": "<文档列表项里的 id>" } -``` - -⚠️ `document_preview` 必填 Query `id`(**不是 `fileName`**),其值来自 `document_list` 返回的 `data.list[].id`。返回的 `data` 是**路径字符串**(不是对象),前端会拼到 `window.location.origin` 后用 `window.open` 打开 —— agent 拿到这个路径后通常需要走浏览器模式才能查看 PDF/HTML 内容。 - -`product_news_get` / `product_news_mark_read` / `product_version` / `product_issue` 用于看红点 / 标已读 / 看版本 / 提反馈。其中 `product_news_mark_read` 的 body 应是先 `product_news_get` 返回对象的副本,再把要清除的标记位(如 `documentUpdate`)置 `false`,其它字段保持原值。 - -### 19. 登录 / 会话 - -绝大多数业务场景**不需要**显式登录 —— 处理器会按需自动登录、Cookie 过期会自动重登。下面这些场景才需要主动调 `onesig_login`: - -- 设备启用了图形验证码(`GET /v3/captcha` 返回 `enableCaptcha=true`): - - ```json - { "action": "login", "captcha": "xyzw" } - ``` - - 前端校验长度恰为 4。 - -- 设备启用了 TOTP / 双因素(`enableTotp=true` 或 `POST /v3/login` 返回 `responseCode=1012` 进入扫码页): - - ```json - { "action": "login", "totp": "123456" } - ``` - - 注意:handler 入参统一叫 `totp`(**不要传 `checksum`**),底层会按场景自动映射 —— inline 模式(`/v3/login` 同屏 TOTP)作为 `checksum` 字段拼进登录 body;扫码模式(先 `/v3/login` 拿到 `responseCode=1012`、再补一次 `/v3/login/totp`)作为 `{"checksum": "..."}` 体提交。也可以传恢复码(最长 12 字符)。 - -- 想立刻拿账户信息: - - ```json - { "action": "get_account" } - ``` - -- 改当前用户密码: - - ```json - { - "action": "change_password", - "old_password": "<明文旧>", - "new_password": "<明文新>", - "dup_password": "<明文新>" - } - ``` - -- 显式退出(调试 / 切换账号): - - ```json - { "action": "logout" } - ``` - -注意: - -- `logout` 会清空内存里的会话**和**已落盘的 Cookie 持久化(`~/.flocks/config/.secret.json` 中以 `onesig_session_cookie__` 命名的条目),下次调用会从 captcha → pubkey → /v3/login 重走完整链路 -- `regenerate_recovery_code` 一旦调用,旧恢复码立即失效,请在用户明确授权后再用 -- `get_pubkey` / `get_captcha` 通常不需要手动调 —— 处理器在登录前与每次发送 RSA 加密字段前都会自动拉 - -## 文件类返回 / 上传 - -下列 action 返回的是**二进制文件**(导出 / 模板 / 下载),不应该在 chat 里直接展开: - -- 导出:`asset_export` / `whitelist_export` / `blacklist_export` / `http_blacklist_export` / `port_protect_port_export` / `event_inbound_export` / `event_inbound_detail_export` / `event_outbound_export` / `event_outbound_detail_export` / `alert_host_export` / `alert_host_detail_export` / `tls_detect_group_export` / `tls_detect_list_export` / `aclog_export` / `user_export` / `alert_policy_export` / `multiblock_executelog_export` / `overview_export_*` -- 模板:`asset_template` / `whitelist_template` / `blacklist_template` / `linkage_template` -- 下载:`backup_download` / `report_form_download`(必填 `uniqueId`+`fileName`)/ `device_coredump_download` / `device_pcap_download` - -下列 action 是 **multipart 上传**,需要 `file_path` 指向本地绝对路径: - -- `asset_import`(CSV) -- `tls_cert_create` / `tls_cert_update`(`.crt`/`.pem`) -- `basic_information_import`(离线情报库包,Query 需 `name`) -- `basic_license_upload` / `system_upgrade` / `device_upgrade_upload`(升级 / 授权包) -- `backup_import`(备份包) +`http_blacklist_enable` 会改变防护行为,视为写操作。 ## 高风险写操作清单 -以下 action 默认视为高风险,agent 在执行前**必须**先确认用户授权: - -- `device_onekey_bypass` / `device_quick_bypass` -- `device_reboot` / `device_shutdown` / `device_reinit` -- `device_upgrade` / `device_upgrade_upload` / `system_upgrade` -- `ha_switching` / `ha_sync_config`(同步会改对端) -- `set_ha_config` / `set_onecc_config` / `set_onecc_status` -- `user_create` / `user_delete` / `user_update` / `user_secret_reset` / `change_password` -- `aclog_delete` -- `whitelist_add` / `whitelist_update` / `whitelist_delete` / `whitelist_remove_batch` / `whitelist_import` -- `blacklist_add` / `blacklist_update` / `blacklist_delete` / `blacklist_remove_batch` / `blacklist_import` -- `multiblock_rule_create` / `multiblock_rule_update` / `multiblock_rule_delete` / `multiblock_rule_active` -- `auto_blacklist_create` / `auto_blacklist_update` / `auto_blacklist_delete` -- `linkage_create` / `linkage_update` / `linkage_delete` / `linkage_enable` -- `ips_rule_create` / `ips_rule_apply` / `ips_rule_all` / `ips_ruleset_create` / `ips_ruleset_update` / `ips_ruleset_delete` -- `http_blacklist_create` / `http_blacklist_update` / `http_blacklist_delete` / `http_blacklist_enable` -- `port_protect_group_create` / `_update` / `_delete` / `_clone`、`port_protect_port_create` / `_update` / `_delete` / `_onekey_import` -- `protection_policy_update` / `protection_policy_delete` -- `tls_decrypt_policy_create` / `_update` / `_enable` / `_delete` / `_batch` -- `tls_cert_create` / `_update` / `_delete` / `_set_default`、`tls_detect_delete` -- `set_decrypt_config` / `set_detect_config` -- `interface_update`(启停)/ `interface_*_create/update/delete` -- `route_static_create` / `_update` / `_delete`、`ipv6_route_static_create` / `_update` / `_delete` -- `set_dns_config` / `hosts_create` / `_update` / `_delete`、`set_proxy_config` -- `backup_create` / `backup_recover` / `backup_delete` / `backup_update` / `backup_import` -- `set_storage_config` / `set_clean_config` / `set_dnslog_config` -- `logaccess_create` / `_update` / `_delete` -- `basic_information_enable` / `basic_information_import` / `basic_license_upload` -- `mdr_service_enable` -- `device_pcap_set` / `device_coredump_delete` / `device_pcap_file_delete` -- `apikey_create` / `apikey_update` / `apikey_delete` / `apikey_secret`(回吐明文 secret,需 `password`) -- `regenerate_recovery_code` -- `report_form_create` / `_delete`、`report_task_create` / `_update` / `_delete` / `_test` -- `set_advanced_config` / `set_xff_config` / `set_scan_config` / `set_login_config` / `set_upgrade_config` / `set_overview_config` / `set_custom_config` / `web_custom_column_set` - -## 常见失败原因 - -- 时间戳传成毫秒(要用秒) -- 错传 `cur_page` / `page_size` / `page_items_num`(应为 `pageNo` / `pageSize`) -- 漏传 OneSIG 必填的"业务 ID"主键 —— `name` / `uniqueId` / `srcIp` / `groupName` / `server`+`port` / `ruleId`+`assetIp` 任意一个缺失都会回 `responseCode=1004`(请求数据非法) -- 单条 GET 类接口忘了先调列表拿主键 -- 改密 / 删除 / 启停接口手动加密了 `password` —— 处理器会自己加密,要传**明文** -- `oaep_hash` 配置和设备实际不匹配(多数 v2.5.x 走 SHA-1,少数走 SHA-256),登录返回 `responseCode=1009`/`1017` 时优先怀疑这里 -- `api_prefix` 与设备反代部署形态不一致 —— 厂商前端代码里写的是 `/api/v3/...`,但接口规范文档里去掉了 `/api`。我们 v2.5.x 实测的实例多数是直连后端(`/v3/...`),所以 handler 的 `DEFAULT_API_PREFIX=""`;如果换到一个走 nginx 代理的实例,需要把 `api_prefix` 设成 `"/api"`。**现象**:登录前的 `/v3/pubkey` 直接 404,handler 会在错误信息里给出对应提示 -- `verify_ssl` 与设备证书不匹配(OneSIG 多用自签证书,默认应**关闭**)—— 现象是 SSL handshake 失败 -- 报错"Cookie 过期":调一下 `onesig_login` 的 `logout` 再让处理器自动重登 -- API Key 写操作返回 `responseCode=1004` 提示 `type` 缺失:服务端要求 Query `type=physical`;先尝试把 `type:"physical"` 放进 body 透传,如果仍然 1004 请回退浏览器模式 +以下 action 默认视为高风险,agent 在执行前必须先确认用户授权、对象、范围和回滚预案: + +- `asset_group_create` +- `asset_group_update` +- `asset_group_delete` +- `asset_create` +- `asset_update` +- `asset_delete` +- `protection_policy_update` +- `protection_policy_delete` +- `whitelist_create` +- `whitelist_update` +- `whitelist_delete` +- `whitelist_remove` +- `blacklist_create` +- `blacklist_update` +- `blacklist_delete` +- `blacklist_remove` +- `banned_whitelist_create` +- `banned_whitelist_update` +- `banned_whitelist_delete` +- `http_blacklist_create` +- `http_blacklist_update` +- `http_blacklist_enable` +- `http_blacklist_delete` + +## 返回与错误处理 + +- handler 会把成功响应中的 `data` 解包为 tool output;如果响应不是对象,会原样返回。 +- 如果响应包含 `response_code` 且不是 `0`,handler 会把结果标记为失败,并返回 `verbose_msg` 或原始响应。 +- HTTP `4xx` / `5xx` 会返回状态码和前 500 字符响应体。 +- `OneSIG Strategy API base_url is not configured.` 表示服务地址未配置。 +- `OneSIG Strategy API ApiKey and Secret are required.` 表示 ApiKey / Secret 未配置或 secret 引用无法解析。 +- `Request failed` 常见原因是设备网络不可达、证书验证配置不匹配或代理 / 防火墙拦截。 + +## 当前 Strategy API 不覆盖的场景 + +以下任务不要调用当前两个 Strategy API 工具硬凑,应改用浏览器模式,或在确认存在额外旧控制台工具并阅读对应文档后再处理: + +- 威胁监控、仪表盘、威胁防护大屏 +- 入站 / 出站威胁事件、失陷主机、报告管理、导出报表 +- 多维封锁、API 联动密钥管理、Syslog 自动封禁、FTP/SFTP 联动 +- IPS 规则 / 规则集、高危端口防护 +- 告警通知、审计日志、用户管理、登录 / 改密 +- HTTPS 解密、网口、部署引导、路由、DNS、代理 +- HA、OneCC、设备升级 / 重启 / 备份 / 恢复 +- 日志外发、license、MDR、coredump、pcap、帮助文档、产品反馈 +- 图形验证码、TOTP、强制改密等人工交互 ## 何时回退浏览器 以下情况优先回退浏览器(参考 [browser-workflow.md](browser-workflow.md)): -- 需要威胁防护大屏的可视化、IOC 关系图、攻击链 -- 需要看事件 / 失陷主机的页面级详情、报文 hex view、PCAP 在线播放 -- 需要点表格右侧抽屉、复杂筛选弹窗、一些深层报表预览 +- 当前 Strategy API 没有覆盖用户目标能力 +- 需要页面级图表、详情抽屉、攻击链、威胁图、报表预览或文件下载 - 需要图形验证码 / TOTP / 强制改密之类的人工交互 -- 需要看 Web 控制台原生导出文件并下载 +- API 未配置、认证失败、设备不可达或反复返回权限 / 签名错误 +- 用户明确要求使用浏览器 diff --git a/.flocks/plugins/skills/onesig-use/references/browser-workflow.md b/.flocks/plugins/skills/onesig-use/references/browser-workflow.md index 30ee77dfd..dbf3f1590 100644 --- a/.flocks/plugins/skills/onesig-use/references/browser-workflow.md +++ b/.flocks/plugins/skills/onesig-use/references/browser-workflow.md @@ -2,12 +2,12 @@ 只在以下情况进入浏览器模式: -- API 不可用(未配置 / 未开通 / 认证失败 / Cookie 持久反复过期 / SSL 校验失败 / 网络不通) +- API 不可用(未配置 / 未开通 / ApiKey 或 Secret 缺失 / 签名失败 / SSL 校验失败 / 网络不通) - 任务必须看页面级详情(攻击链、威胁图、报表预览、报文 hex view、PCAP 在线播放) - 需要图形验证码 / TOTP / 强制改密之类的人工交互 - 用户明确要求使用浏览器,或者已经在浏览器操作过程中 -如果走 API 能完成,请回到 [api-reference.md](api-reference.md) —— 浏览器操作不稳定、不可批量、字段不一定完整。 +如果当前 Strategy API 能完成,请回到 [api-reference.md](api-reference.md);浏览器操作不稳定、不可批量、字段不一定完整。 > ⚠️ OneSIG 的 Web 控制台与 OneSEC、青藤是不同产品;不要把 OneSEC / 青藤的页面路径或 API 套用到 OneSIG。 > 本文档统一按 `browser-use` 的 `cdp-direct` 流程执行:先 `flocks browser --doctor`,doctor 通过后只使用 `flocks browser`。 @@ -145,14 +145,17 @@ print(js("document.body.innerText.slice(0, 2000)")) ## 二、浏览器与 API 的互补建议 -进入浏览器模式后,对于"查询类"诉求,应该**优先回到 API**(除非 API 真的不可用): +进入浏览器模式后,只有当前 Strategy API 覆盖的诉求才优先回到 API;未覆盖的控制台能力继续走浏览器: | 任务 | 优先方案 | |---|---| -| 列威胁事件 / 失陷主机 / 趋势数据 | API(`onesig_monitoring`) —— 浏览器只在需要威胁图、报文 hex 时用 | -| 增删改黑白名单 / IPS 规则 / 多维封锁 | API(`onesig_strategy`),写操作前要二次确认 | -| 看资产清单、增改资产 | API(`onesig_assets`) —— 浏览器仅做导入文件预览 | -| 设备升级 / 重启 / HA 切换 | 浏览器 + API 并用:先在浏览器里走完确认弹窗、备份提示,再用 API 触发;或者全程在浏览器里完成(更稳) | +| 看设备平台 / 系统 / 网络状态 | API(`onesig_strategy_api_query`) | +| 看资产组、资产列表、资产类型 | API(`onesig_strategy_api_query`) | +| 增删改资产或资产组 | API(`onesig_strategy_api_ops`),写操作前要二次确认 | +| 看 / 改防护策略、全局白名单、全局黑名单、封禁白名单、HTTP 黑名单 | API(`onesig_strategy_api_query` / `onesig_strategy_api_ops`),写操作前要二次确认 | +| 列威胁事件 / 失陷主机 / 趋势数据 | 浏览器(当前 Strategy API 不覆盖) | +| IPS 规则、多维封锁、Syslog 自动封禁、FTP/SFTP 联动、高危端口防护 | 浏览器(当前 Strategy API 不覆盖) | +| 设备升级 / 重启 / HA 切换 | 浏览器;强破坏性动作必须显式确认 | | 看证书 / 解密策略详情 | 浏览器(API 返回的字段比页面少) | | 看页面级图表 / 攻击链 / IOC 关联 | 浏览器(API 没有) | | 处理图形验证码 / TOTP / 强制改密 | 浏览器(API 不能完成人工交互) | @@ -175,10 +178,9 @@ print(js("document.body.innerText.slice(0, 2000)")) ## 四、文件下载与导出 -OneSIG 控制台的导出按钮(资产 / 黑白名单 / 报表 / 审计 / coredump / pcap)多数会触发浏览器下载。优先顺序如下: +OneSIG 控制台的导出按钮(资产 / 黑白名单 / 报表 / 审计 / coredump / pcap)多数会触发浏览器下载。当前 Strategy API 工具没有导出 / 下载 action;需要导出文件时走浏览器页面。 -1. **优先用 API 的导出 / 下载 action**(参见 [api-reference.md](api-reference.md) "文件类返回 / 上传"小节)。 -2. 如果必须走页面下载,先用 `js(...)` 或稳定 selector 触发下载,再用浏览器系统的下载目录或页面提示确认下载已开始;不要在 skill 里承诺不存在的“等待下载完成”专有命令。 +先用 `js(...)` 或稳定 selector 触发下载,再用浏览器系统的下载目录或页面提示确认下载已开始;不要在 skill 里承诺不存在的“等待下载完成”专有命令。 页面下载最小模板: diff --git a/README.md b/README.md index fe03c6307..32e0acd68 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,10 @@

Docs - Release + License

- License English Chinese

@@ -125,10 +124,9 @@ flocks restart flocks stop ``` -The default service URLs are: -- Backend API: `http://127.0.0.1:8000` by default -- WebUI: `http://127.0.0.1:5173` by default -- Remote access configurable via `flocks start --webui-host ` +The default service address is: +- Flocks WebUI: `http://127.0.0.1:5173` +- Remote access configurable via `flocks start --host ` Flocks CLI usage: `flocks --help` @@ -213,16 +211,13 @@ sudo chown -R : ~/.flocks ### 4.3 Remote Access to Flocks Service ```bash -__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS= \ -flocks start --webui-host 0.0.0.0 -# windows powershell -# $env:__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS="your_domain"; flocks start --webui-host 0.0.0.0 +flocks start --host 0.0.0.0 ``` If remote access from a virtual machine fails, please specify the host as the virtual machine's IP. -The WebUI now defaults to same-origin `/api` proxy mode even when the backend -binds to a non-loopback IP. This keeps browser cookies and SSE on a single -origin, which is the safest choice for LAN access and reverse proxies. +The WebUI and API share the same service address and port. API requests use the +same-origin `/api` path, keeping browser cookies and SSE on a single origin for +LAN access and reverse-proxy deployments. Only enable direct browser-to-backend URLs when you explicitly need them: diff --git a/README_zh.md b/README_zh.md index 0c05f3966..84ef87929 100644 --- a/README_zh.md +++ b/README_zh.md @@ -6,11 +6,10 @@

文档 - Release + License

- License English 中文

@@ -125,9 +124,8 @@ flocks stop ``` 默认服务地址: -- 后端 API:默认 `http://127.0.0.1:8000` -- WebUI:默认 `http://127.0.0.1:5173` -- 远程访问可通过 `flocks start --webui-host ` 配置 +- Flocks WebUI:`http://127.0.0.1:5173` +- 远程访问可通过 `flocks start --host ` 配置 更多 CLI 命令使用 `flocks --help` @@ -214,14 +212,11 @@ sudo chown -R : ~/.flocks ### 4.3 远程访问 Flocks 服务 ```bash -__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS= \ -flocks start --webui-host 0.0.0.0 -# Windows PowerShell -# $env:__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS="your_domain"; flocks start --webui-host 0.0.0.0 +flocks start --host 0.0.0.0 ``` 若从虚拟机远程访问失败,请将 host 指定为虚拟机的 IP。 -WebUI 在后端绑定到非回环 IP 时,默认仍使用同源 `/api` 代理模式。这样浏览器 Cookie 与 SSE 保持在同一源,是局域网访问与反向代理场景下更安全的选择。 +WebUI 与 API 共用同一个服务地址和端口,API 请求使用同源 `/api` 路径。这样浏览器 Cookie 与 SSE 保持在同一源,适用于局域网访问与反向代理场景。 仅在确实需要浏览器直连后端 URL 时,再显式启用: diff --git a/flocks/agent/registry.py b/flocks/agent/registry.py index c2b001985..b5b004b46 100644 --- a/flocks/agent/registry.py +++ b/flocks/agent/registry.py @@ -252,7 +252,7 @@ async def _load_agents() -> Dict[str, AgentInfo]: from flocks.tool.registry import ToolRegistry cfg = await Config.get() - ToolRegistry.init() + await ToolRegistry.init_async() # ── ① Collect context ───────────────────────────────────────────── available_tools = [t.name for t in ToolRegistry.list_tools() if t.enabled] @@ -303,7 +303,7 @@ async def _load_agents() -> Dict[str, AgentInfo]: log.warn("agent.logic.deprecated", {"message": "non-rex agent logic is deprecated; using rex"}) # ── ② YAML agents ───────────────────────────────────────────────── - result = scan_and_load() + result = await asyncio.to_thread(scan_and_load) # Apply global base permissions on top of per-agent YAML permissions. # For agents with explicit tool lists, their deny-all baseline is kept; diff --git a/flocks/channel/builtin/whatsapp/__init__.py b/flocks/channel/builtin/whatsapp/__init__.py new file mode 100644 index 000000000..4b4679c2e --- /dev/null +++ b/flocks/channel/builtin/whatsapp/__init__.py @@ -0,0 +1,6 @@ +"""WhatsApp built-in channel.""" + +from .channel import WhatsAppChannel + +CHANNELS = [WhatsAppChannel()] + diff --git a/flocks/channel/builtin/whatsapp/bridge/bridge.js b/flocks/channel/builtin/whatsapp/bridge/bridge.js new file mode 100644 index 000000000..f215a3e57 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/bridge/bridge.js @@ -0,0 +1,450 @@ +#!/usr/bin/env node + +import { + DisconnectReason, + downloadMediaMessage, + fetchLatestBaileysVersion, + makeWASocket, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; +import { Boom } from '@hapi/boom'; +import express from 'express'; +import pino from 'pino'; +import path from 'path'; +import { createHash, randomBytes } from 'crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { tmpdir } from 'os'; + +const args = process.argv.slice(2); + +function getArg(name, defaultValue) { + const idx = args.indexOf(`--${name}`); + return idx >= 0 && args[idx + 1] ? args[idx + 1] : defaultValue; +} + +function envFlag(name, defaultValue = false) { + const raw = process.env[name]; + if (raw === undefined) return defaultValue; + return ['1', 'true', 'yes', 'on'].includes(String(raw).toLowerCase()); +} + +const PORT = Number.parseInt(getArg('port', '3100'), 10); +const SESSION_DIR = getArg('session', path.join(process.env.HOME || '.', '.flocks', 'workspace', 'channels', 'whatsapp', 'session')); +const MEDIA_DIR = process.env.FLOCKS_WHATSAPP_MEDIA_DIR + || path.join(process.env.HOME || '.', '.flocks', 'workspace', 'channels', 'whatsapp', 'media'); +const MODE = getArg('mode', process.env.FLOCKS_WHATSAPP_MODE || 'bot'); +const REPLY_PREFIX = process.env.FLOCKS_WHATSAPP_REPLY_PREFIX || ''; +const CHUNK_DELAY_MS = Number.parseInt(process.env.FLOCKS_WHATSAPP_CHUNK_DELAY_MS || '300', 10); +const SEND_TIMEOUT_MS = Number.parseInt(process.env.FLOCKS_WHATSAPP_SEND_TIMEOUT_MS || '60000', 10); +const BRIDGE_TOKEN = process.env.FLOCKS_WHATSAPP_BRIDGE_TOKEN || ''; +const CONFIG_HASH = process.env.FLOCKS_WHATSAPP_CONFIG_HASH || ''; +const ALLOWED_MEDIA_ROOTS = String(process.env.FLOCKS_WHATSAPP_ALLOWED_MEDIA_ROOTS || '') + .split(path.delimiter) + .map(item => item.trim()) + .filter(Boolean) + .map(item => path.resolve(item)); +const PAIR_ONLY = args.includes('--pair-only'); +const PAIR_JSON = args.includes('--pair-json'); +const PAIR_TIMEOUT_MS = Number.parseInt(process.env.FLOCKS_WHATSAPP_PAIR_TIMEOUT_MS || '120000', 10); +const DEBUG = envFlag('FLOCKS_WHATSAPP_DEBUG'); + +mkdirSync(SESSION_DIR, { recursive: true }); +mkdirSync(MEDIA_DIR, { recursive: true }); + +let scriptHash = ''; +let tokenHash = ''; +try { + scriptHash = createHash('sha256') + .update(readFileSync(fileURLToPath(import.meta.url))) + .digest('hex') + .slice(0, 16); +} catch {} +if (BRIDGE_TOKEN) { + tokenHash = createHash('sha256').update(BRIDGE_TOKEN).digest('hex').slice(0, 12); +} + +let sock = null; +let connectionState = 'disconnected'; +let messageQueue = []; +const messageStore = new Map(); +const recentlySent = new Map(); +let sendQueue = Promise.resolve(); + +const logger = pino({ level: DEBUG ? 'debug' : 'silent' }); + +function emitPairEvent(event) { + if (!PAIR_JSON) return; + try { + console.log(JSON.stringify({ ts: Date.now(), ...event })); + } catch {} +} + +function rememberSent(id) { + if (!id) return; + recentlySent.set(id, Date.now()); + if (recentlySent.size > 1000) { + const cutoff = Date.now() - 10 * 60 * 1000; + for (const [key, ts] of recentlySent.entries()) { + if (ts < cutoff) recentlySent.delete(key); + } + } +} + +function normalizeJid(value) { + if (!value) return ''; + return String(value).replace(/:\d+@/, '@'); +} + +function splitLongMessage(message, limit = 4096) { + const text = String(message || ''); + if (!text) return []; + if (text.length <= limit) return [text]; + const chunks = []; + let remaining = text; + while (remaining.length > limit) { + let splitAt = remaining.lastIndexOf('\n', limit); + if (splitAt < Math.floor(limit / 2)) splitAt = remaining.lastIndexOf(' ', limit); + if (splitAt < 1) splitAt = limit; + chunks.push(remaining.slice(0, splitAt).trimEnd()); + remaining = remaining.slice(splitAt).trimStart(); + } + if (remaining) chunks.push(remaining); + return chunks; +} + +function enqueueSend(fn) { + const task = sendQueue.then(() => fn(), () => fn()); + sendQueue = task.catch(() => {}); + return task; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function sendWithTimeout(chatId, payload, options = {}) { + return enqueueSend(() => { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`sendMessage timed out after ${SEND_TIMEOUT_MS}ms`)), SEND_TIMEOUT_MS); + }); + return Promise.race([ + sock.sendMessage(chatId, payload, options), + timeout, + ]).finally(() => clearTimeout(timer)); + }); +} + +function getMessageContent(msg) { + const message = msg?.message || {}; + return message.ephemeralMessage?.message + || message.viewOnceMessage?.message + || message.viewOnceMessageV2?.message + || message; +} + +function extractText(content) { + return content.conversation + || content.extendedTextMessage?.text + || content.imageMessage?.caption + || content.videoMessage?.caption + || content.documentMessage?.caption + || ''; +} + +function detectMediaBlock(content) { + if (content.imageMessage) return { block: content.imageMessage, type: 'image', ext: '.jpg' }; + if (content.videoMessage) return { block: content.videoMessage, type: 'video', ext: '.mp4' }; + if (content.audioMessage) return { block: content.audioMessage, type: content.audioMessage.ptt ? 'ptt' : 'audio', ext: '.ogg' }; + if (content.documentMessage) return { block: content.documentMessage, type: 'document', ext: path.extname(content.documentMessage.fileName || '') || '.bin' }; + if (content.stickerMessage) return { block: content.stickerMessage, type: 'sticker', ext: '.webp' }; + return null; +} + +function safeExt(ext) { + const cleaned = String(ext || '').toLowerCase().replace(/[^a-z0-9.]/g, ''); + return cleaned && cleaned.startsWith('.') ? cleaned : '.bin'; +} + +async function cacheInboundMedia(msg, mediaInfo) { + try { + const buffer = await downloadMediaMessage( + msg, + 'buffer', + {}, + { logger, reuploadRequest: sock.updateMediaMessage }, + ); + const fileName = `${mediaInfo.type}_${randomBytes(8).toString('hex')}${safeExt(mediaInfo.ext)}`; + const filePath = path.join(MEDIA_DIR, fileName); + writeFileSync(filePath, buffer); + return filePath; + } catch (err) { + console.warn('[whatsapp-bridge] failed to cache media:', err.message); + return ''; + } +} + +function buildBridgeEvent({ msg, chatId, senderId, isGroup, body, mediaInfo, mediaPath, fromOwner }) { + const content = getMessageContent(msg); + const contextInfo = content.extendedTextMessage?.contextInfo + || content.imageMessage?.contextInfo + || content.videoMessage?.contextInfo + || content.documentMessage?.contextInfo + || {}; + const botIds = new Set([ + normalizeJid(sock?.user?.id), + normalizeJid(sock?.user?.lid), + ].filter(Boolean)); + const mentionedJids = (contextInfo.mentionedJid || []).map(normalizeJid); + const mentioned = mentionedJids.some(jid => botIds.has(jid)); + const quotedParticipant = normalizeJid(contextInfo.participant || ''); + const isReplyToBot = Boolean(quotedParticipant && botIds.has(quotedParticipant)); + return { + messageId: msg.key.id || '', + chatId, + senderId, + senderName: msg.pushName || '', + isGroup, + body, + hasMedia: Boolean(mediaPath), + mediaUrls: mediaPath ? [mediaPath] : [], + mediaType: mediaInfo?.type || '', + mime: mediaInfo?.block?.mimetype || '', + quotedMessageId: contextInfo.stanzaId || '', + quotedParticipant, + mentioned, + isReplyToBot, + fromOwner: Boolean(fromOwner), + }; +} + +async function startSocket() { + const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); + const { version } = await fetchLatestBaileysVersion(); + sock = makeWASocket({ + version, + auth: state, + logger, + printQRInTerminal: false, + browser: ['Flocks', 'Chrome', '120.0'], + syncFullHistory: false, + markOnlineOnConnect: false, + getMessage: async (key) => messageStore.get(key?.id) || { conversation: '' }, + }); + + sock.ev.on('creds.update', saveCreds); + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + if (qr) { + emitPairEvent({ event: 'qr', qr }); + if (!PAIR_JSON) console.log(qr); + } + if (connection === 'close') { + const reason = new Boom(lastDisconnect?.error)?.output?.statusCode; + connectionState = 'disconnected'; + if (reason === DisconnectReason.loggedOut) { + emitPairEvent({ event: 'error', error: 'logged_out', reason }); + process.exit(1); + } + emitPairEvent({ event: 'disconnected', reason }); + setTimeout(startSocket, reason === 515 ? 1000 : 3000); + } else if (connection === 'open') { + connectionState = 'connected'; + emitPairEvent({ + event: 'connected', + user: sock?.user ? { id: sock.user.id || null, name: sock.user.name || sock.user.verifiedName || null } : null, + }); + if (PAIR_ONLY) setTimeout(() => process.exit(0), 1500); + } + }); + + sock.ev.on('messages.upsert', async ({ messages, type }) => { + if (type !== 'notify' && type !== 'append') return; + for (const msg of messages || []) { + if (!msg?.message) continue; + const chatId = normalizeJid(msg.key.remoteJid); + const senderId = normalizeJid(msg.key.participant || chatId); + const isGroup = chatId.endsWith('@g.us'); + const fromMe = Boolean(msg.key.fromMe); + + if (fromMe) { + if (recentlySent.has(msg.key.id)) continue; + if (MODE === 'bot') continue; + const selfIds = new Set([ + normalizeJid(sock?.user?.id).replace(/:\d+@/, '@'), + normalizeJid(sock?.user?.lid).replace(/:\d+@/, '@'), + ].filter(Boolean)); + if (!selfIds.has(chatId)) continue; + } else if (MODE === 'self-chat') { + continue; + } + + const content = getMessageContent(msg); + const body = extractText(content); + const mediaInfo = detectMediaBlock(content); + const mediaPath = mediaInfo ? await cacheInboundMedia(msg, mediaInfo) : ''; + if (!body && !mediaPath) continue; + const event = buildBridgeEvent({ + msg, + chatId, + senderId, + isGroup, + body, + mediaInfo, + mediaPath, + fromOwner: fromMe, + }); + messageStore.set(msg.key.id, msg); + messageQueue.push(event); + if (messageQueue.length > 1000) messageQueue.shift(); + } + }); +} + +function validateHost(req, res, next) { + const raw = String(req.headers.host || '').trim(); + const host = raw.includes(':') ? raw.slice(0, raw.lastIndexOf(':')) : raw; + const normalized = host.replace(/^\[|\]$/g, '').toLowerCase(); + if (!['localhost', '127.0.0.1', '::1'].includes(normalized)) { + return res.status(400).json({ error: 'Invalid Host header' }); + } + return next(); +} + +function validateToken(req, res, next) { + const auth = String(req.headers.authorization || ''); + const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''; + const token = String(req.headers['x-flocks-bridge-token'] || bearer); + if (!BRIDGE_TOKEN || token !== BRIDGE_TOKEN) { + return res.status(401).json({ error: 'Unauthorized' }); + } + return next(); +} + +function isAllowedMediaPath(filePath) { + if (ALLOWED_MEDIA_ROOTS.length === 0) return false; + const resolved = path.resolve(filePath); + return ALLOWED_MEDIA_ROOTS.some(root => resolved === root || resolved.startsWith(`${root}${path.sep}`)); +} + +function inferMediaPayload(filePath, mediaType, caption) { + const buffer = readFileSync(filePath); + const ext = path.extname(filePath).slice(1).toLowerCase(); + const type = mediaType || ( + ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext) ? 'image' + : ['mp4', 'mov', 'mkv', 'webm'].includes(ext) ? 'video' + : ['ogg', 'opus', 'mp3', 'wav', 'm4a'].includes(ext) ? 'audio' + : 'document' + ); + if (type === 'image') return { image: buffer, caption: caption || undefined }; + if (type === 'video') return { video: buffer, caption: caption || undefined }; + if (type === 'audio') { + let audioBuffer = buffer; + let audioExt = ext; + let tmpPath = null; + if (!['ogg', 'opus'].includes(ext)) { + tmpPath = path.join(tmpdir(), `flocks_voice_${randomBytes(6).toString('hex')}.ogg`); + try { + execFileSync('ffmpeg', ['-y', '-i', filePath, '-ar', '48000', '-ac', '1', '-c:a', 'libopus', tmpPath], { timeout: 30000, stdio: 'pipe' }); + audioBuffer = readFileSync(tmpPath); + audioExt = 'ogg'; + } catch { + audioBuffer = buffer; + } finally { + try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch {} + } + } + return { audio: audioBuffer, mimetype: audioExt === 'ogg' || audioExt === 'opus' ? 'audio/ogg; codecs=opus' : 'audio/mpeg', ptt: audioExt === 'ogg' || audioExt === 'opus' }; + } + return { + document: buffer, + fileName: path.basename(filePath), + mimetype: 'application/octet-stream', + caption: caption || undefined, + }; +} + +if (PAIR_ONLY) { + emitPairEvent({ event: 'started', session: SESSION_DIR }); + setTimeout(() => { + emitPairEvent({ event: 'error', error: 'pairing_timeout' }); + process.exit(2); + }, PAIR_TIMEOUT_MS); + await startSocket(); +} else { + await startSocket(); + const app = express(); + app.use(express.json({ limit: '2mb' })); + app.use(validateHost); + app.use(validateToken); + + app.get('/messages', (_req, res) => { + const messages = messageQueue.splice(0, messageQueue.length); + res.json(messages); + }); + + app.post('/send', async (req, res) => { + if (!sock || connectionState !== 'connected') return res.status(503).json({ error: 'Not connected to WhatsApp' }); + const { chatId, message, replyTo } = req.body || {}; + if (!chatId || !message) return res.status(400).json({ error: 'chatId and message are required' }); + try { + const text = REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : String(message); + const chunks = splitLongMessage(text); + const messageIds = []; + for (let i = 0; i < chunks.length; i += 1) { + const quoted = replyTo && i === 0 ? messageStore.get(replyTo) : null; + const options = quoted ? { quoted } : {}; + const sent = await sendWithTimeout(chatId, { text: chunks[i] }, options); + rememberSent(sent?.key?.id); + if (sent?.key?.id) messageIds.push(sent.key.id); + if (i < chunks.length - 1) await sleep(CHUNK_DELAY_MS); + } + res.json({ success: true, messageId: messageIds[messageIds.length - 1] || '', messageIds }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + app.post('/send-media', async (req, res) => { + if (!sock || connectionState !== 'connected') return res.status(503).json({ error: 'Not connected to WhatsApp' }); + const { chatId, filePath, mediaType, caption } = req.body || {}; + if (!chatId || !filePath) return res.status(400).json({ error: 'chatId and filePath are required' }); + if (!existsSync(filePath)) return res.status(404).json({ error: `File not found: ${filePath}` }); + if (!isAllowedMediaPath(filePath)) return res.status(403).json({ error: `File path is not allowed: ${filePath}` }); + try { + const payload = inferMediaPayload(filePath, mediaType, caption); + const sent = await sendWithTimeout(chatId, payload); + rememberSent(sent?.key?.id); + res.json({ success: true, messageId: sent?.key?.id || '' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + app.get('/health', (_req, res) => { + res.json({ + status: connectionState, + queueLength: messageQueue.length, + uptime: process.uptime(), + scriptHash, + sessionPath: path.resolve(SESSION_DIR), + mediaDir: path.resolve(MEDIA_DIR), + mode: MODE, + configHash: CONFIG_HASH, + tokenHash, + }); + }); + + app.listen(PORT, '127.0.0.1', () => { + console.log(`[whatsapp-bridge] listening on 127.0.0.1:${PORT}`); + }); +} diff --git a/flocks/channel/builtin/whatsapp/bridge/package-lock.json b/flocks/channel/builtin/whatsapp/bridge/package-lock.json new file mode 100644 index 000000000..b4fc7d4b6 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/bridge/package-lock.json @@ -0,0 +1,2184 @@ +{ + "name": "flocks-whatsapp-bridge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flocks-whatsapp-bridge", + "version": "1.0.0", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@whiskeysockets/baileys": "7.0.0-rc13", + "express": "^4.21.2", + "pino": "^9.6.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/node-cache": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", + "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", + "license": "MIT", + "dependencies": { + "cacheable": "^2.3.1", + "hookified": "^1.14.0", + "keyv": "^5.5.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/inflate/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@tokenizer/inflate/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@whiskeysockets/baileys": { + "version": "7.0.0-rc13", + "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc13.tgz", + "integrity": "sha512-8JPc8gaaCRykkjW2jxLGQ7/RZGrc7awO7WU+QJocf58eSUI9jAdcuYLynzhAbyU4UWvJJsHImZ+5E/JaZj5ypA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cacheable/node-cache": "^1.4.0", + "@hapi/boom": "^9.1.3", + "async-mutex": "^0.5.0", + "libsignal": "^6.0.0", + "lru-cache": "^11.1.0", + "music-metadata": "^11.12.3", + "p-queue": "^9.0.0", + "pino": "^9.6", + "protobufjs": "^7.5.6", + "whatsapp-rust-bridge": "0.5.4", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "audio-decode": "^2.1.3", + "jimp": "^1.6.1", + "link-preview-js": "^3.0.0", + "sharp": "*" + }, + "peerDependenciesMeta": { + "audio-decode": { + "optional": true + }, + "jimp": { + "optional": true + }, + "link-preview-js": { + "optional": true + } + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/@hapi/boom": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", + "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/libsignal": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/libsignal/-/libsignal-6.0.0.tgz", + "integrity": "sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==", + "license": "GPL-3.0", + "dependencies": { + "curve25519-js": "^0.0.4", + "protobufjs": "^7.5.5" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz", + "integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/music-metadata": { + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.13.0.tgz", + "integrity": "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.2", + "@tokenizer/token": "^0.3.0", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "file-type": "^21.3.4", + "media-typer": "^2.0.0", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/music-metadata/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/music-metadata/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/music-metadata/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/p-queue": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.1.tgz", + "integrity": "sha512-POWdiIPmsUPGwb4FeQ4OBg46aqmcInSWe45CKDsGHiOBiVQM9chqfQTuqhuTzcg2Vz9faTI65at0KkVyVEiCHw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/whatsapp-rust-bridge": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.4.tgz", + "integrity": "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==", + "license": "MIT" + }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/flocks/channel/builtin/whatsapp/bridge/package.json b/flocks/channel/builtin/whatsapp/bridge/package.json new file mode 100644 index 000000000..ffb826619 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/bridge/package.json @@ -0,0 +1,20 @@ +{ + "name": "flocks-whatsapp-bridge", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Local WhatsApp bridge for Flocks using Baileys", + "scripts": { + "start": "node bridge.js" + }, + "dependencies": { + "@hapi/boom": "^10.0.1", + "@whiskeysockets/baileys": "7.0.0-rc13", + "express": "^4.21.2", + "pino": "^9.6.0" + }, + "overrides": { + "protobufjs": "^7.5.5" + } +} + diff --git a/flocks/channel/builtin/whatsapp/bridge_runtime.py b/flocks/channel/builtin/whatsapp/bridge_runtime.py new file mode 100644 index 000000000..f15d22eb6 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/bridge_runtime.py @@ -0,0 +1,80 @@ +"""Runtime helpers for the bundled WhatsApp bridge.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import shlex +import subprocess +from pathlib import Path +from typing import Any + +from .config import coerce_int + +NODE_USE_BUNDLED_CA_OPTION = "--use-bundled-ca" + + +def file_hash(path: Path) -> str: + try: + return hashlib.sha256(path.read_bytes()).hexdigest()[:16] + except OSError: + return "" + + +def config_hash(values: dict[str, Any]) -> str: + payload = json.dumps(values, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +def append_node_options(env: dict[str, str], *options: str) -> None: + raw = str(env.get("NODE_OPTIONS") or "").strip() + try: + current = shlex.split(raw) if raw else [] + except ValueError: + current = raw.split() + + additions = [option for option in options if option and option not in current] + if not additions: + return + env["NODE_OPTIONS"] = " ".join([part for part in [raw, *additions] if part]).strip() + + +async def ensure_bridge_deps(bridge_dir: Path) -> None: + package_json = bridge_dir / "package.json" + if not package_json.exists(): + raise RuntimeError(f"WhatsApp bridge package.json not found: {package_json}") + node_modules = bridge_dir / "node_modules" + stamp = node_modules / ".flocks-pkg-hash" + pkg_hash = file_hash(package_json) + deps_fresh = False + if node_modules.exists(): + try: + deps_fresh = stamp.read_text(encoding="utf-8").strip() == pkg_hash and bool(pkg_hash) + except OSError: + deps_fresh = False + if deps_fresh: + return + + from .config import find_executable + + npm = find_executable("npm") + if not npm: + raise RuntimeError("npm is required to install WhatsApp bridge dependencies") + install_cmd = [npm, "ci", "--silent"] if (bridge_dir / "package-lock.json").exists() else [npm, "install", "--silent"] + result = await asyncio.to_thread( + subprocess.run, + install_cmd, + cwd=str(bridge_dir), + capture_output=True, + text=True, + timeout=coerce_int(os.getenv("FLOCKS_WHATSAPP_NPM_INSTALL_TIMEOUT"), 300), + ) + if result.returncode != 0: + raise RuntimeError(f"WhatsApp bridge npm install failed: {result.stderr or result.stdout}") + if pkg_hash: + try: + stamp.write_text(pkg_hash, encoding="utf-8") + except OSError: + pass diff --git a/flocks/channel/builtin/whatsapp/channel.py b/flocks/channel/builtin/whatsapp/channel.py new file mode 100644 index 000000000..aefdcb029 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/channel.py @@ -0,0 +1,638 @@ +"""WhatsApp ChannelPlugin implementation.""" + +from __future__ import annotations + +import asyncio +import json +import os +import signal +import secrets +import subprocess +import time +from pathlib import Path +from typing import Any, Awaitable, Callable, Optional +from urllib.parse import unquote, urlparse + +import aiohttp + +from flocks.channel.base import ( + ChannelCapabilities, + ChannelMeta, + ChannelPlugin, + ChatType, + DeliveryResult, + InboundMessage, + OutboundContext, +) +from flocks.utils.log import Log + +from .config import ( + DEFAULT_BRIDGE_PORT, + DEFAULT_MESSAGE_LIMIT, + DEFAULT_SEND_CHUNK_DELAY_MS, + DEFAULT_SEND_TIMEOUT_MS, + DEFAULT_TEXT_BATCH_DELAY_SECONDS, + VALID_DM_POLICIES, + VALID_GROUP_POLICIES, + VALID_GROUP_TRIGGERS, + VALID_MODES, + coerce_float, + coerce_int, + coerce_list, + coerce_str, + default_bridge_dir, + default_media_cache_dir, + default_session_path, + find_executable, + format_env_list, + matches_identifier, + parse_target, +) +from .bridge_runtime import NODE_USE_BUNDLED_CA_OPTION, append_node_options, config_hash, ensure_bridge_deps, file_hash +from .format import format_for_whatsapp +from .inbound import build_inbound_message + +log = Log.create(service="channel.whatsapp") + + +def _pid_exists(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def _path_from_media_url(media_url: str) -> Optional[Path]: + parsed = urlparse(media_url) + if parsed.scheme == "file": + return Path(unquote(parsed.path)) + if parsed.scheme in {"", None}: + return Path(media_url) + return None + + +def _media_type_for_path(path: Path) -> str: + suffix = path.suffix.lower() + if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif"}: + return "image" + if suffix in {".mp4", ".mov", ".mkv", ".webm"}: + return "video" + if suffix in {".ogg", ".opus", ".mp3", ".wav", ".m4a"}: + return "audio" + return "document" + + +def _raw_reply_id(reply_to_id: Optional[str]) -> Optional[str]: + if not reply_to_id: + return None + value = coerce_str(reply_to_id) + if "@" in value and ":" in value: + return value.rsplit(":", 1)[-1] or value + return value + + +class WhatsAppChannel(ChannelPlugin): + """WhatsApp personal-account channel via a local Baileys bridge.""" + + def __init__(self) -> None: + super().__init__() + self._account_id = "default" + self._bridge_port = DEFAULT_BRIDGE_PORT + self._bridge_dir = default_bridge_dir() + self._session_path = default_session_path() + self._media_cache_dir = default_media_cache_dir() + self._mode = "bot" + self._dm_policy = "allowlist" + self._group_policy = "disabled" + self._group_trigger = "mention" + self._allow_from: list[str] = [] + self._group_allow_from: list[str] = [] + self._text_batch_delay = DEFAULT_TEXT_BATCH_DELAY_SECONDS + self._send_chunk_delay_ms = DEFAULT_SEND_CHUNK_DELAY_MS + self._send_timeout_ms = DEFAULT_SEND_TIMEOUT_MS + self._reply_prefix = "" + self._http: Optional[aiohttp.ClientSession] = None + self._bridge_token = secrets.token_urlsafe(32) + self._bridge_process: Optional[subprocess.Popen] = None + self._poll_task: Optional[asyncio.Task] = None + self._bridge_log_fh = None + self._bridge_log_path: Optional[Path] = None + self._pending_text: dict[str, InboundMessage] = {} + self._pending_tasks: dict[str, asyncio.Task] = {} + self._shutting_down = False + + def meta(self) -> ChannelMeta: + return ChannelMeta( + id="whatsapp", + label="WhatsApp", + aliases=["wa"], + order=45, + ) + + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=[ChatType.DIRECT, ChatType.GROUP], + media=True, + threads=False, + reactions=False, + edit=False, + rich_text=True, + ) + + def validate_config(self, config: dict) -> Optional[str]: + mode = coerce_str(config.get("mode") or "bot") + if mode not in VALID_MODES: + return "WhatsApp mode must be 'bot' or 'self-chat'" + dm_policy = coerce_str(config.get("dmPolicy") or "allowlist") + if dm_policy not in VALID_DM_POLICIES: + return "WhatsApp dmPolicy must be 'open', 'allowlist', or 'disabled'" + group_policy = coerce_str(config.get("groupPolicy") or "disabled") + if group_policy not in VALID_GROUP_POLICIES: + return "WhatsApp groupPolicy must be 'open', 'allowlist', or 'disabled'" + group_trigger = coerce_str(config.get("groupTrigger") or "mention") + if group_trigger not in VALID_GROUP_TRIGGERS: + return "WhatsApp groupTrigger must be 'mention' or 'all'" + bridge_port = coerce_int(config.get("bridgePort"), DEFAULT_BRIDGE_PORT) + if bridge_port <= 0 or bridge_port > 65535: + return "WhatsApp bridgePort must be between 1 and 65535" + if not find_executable("node"): + return "WhatsApp channel requires Node.js" + session_path = Path(coerce_str(config.get("sessionPath")) or default_session_path()).expanduser() + if not (session_path / "creds.json").exists(): + return "WhatsApp is not paired yet. Use QR pairing before enabling the channel." + return None + + @property + def text_chunk_limit(self) -> int: + return DEFAULT_MESSAGE_LIMIT + + @property + def rate_limit(self) -> tuple[float, int]: + return (3.0, 2) + + def format_message(self, text: str, format_hint: str = "markdown") -> str: + if format_hint == "plain": + return text + return format_for_whatsapp(text) + + def normalize_target(self, raw: str) -> Optional[str]: + target = parse_target(raw) + return target or None + + def target_hint(self) -> str: + return " / @s.whatsapp.net / @g.us" + + async def start( + self, + config: dict, + on_message: Callable[[InboundMessage], Awaitable[None]], + abort_event: Optional[asyncio.Event] = None, + ) -> None: + self._config = config + self._on_message = on_message + self._load_config(config) + self._shutting_down = False + + await self._ensure_bridge_deps() + await self._start_bridge() + self._http = aiohttp.ClientSession() + self.mark_connected() + log.info("whatsapp.connected", { + "bridge_port": self._bridge_port, + "session_path": str(self._session_path), + }) + + _abort = abort_event if abort_event is not None else asyncio.Event() + self._poll_task = asyncio.create_task(self._poll_messages(_abort)) + abort_task = asyncio.create_task(_abort.wait()) + try: + done, _pending = await asyncio.wait( + {abort_task, self._poll_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if self._poll_task in done: + exc = self._poll_task.exception() + if exc: + raise exc + finally: + abort_task.cancel() + await asyncio.gather(abort_task, return_exceptions=True) + await self.stop() + + async def stop(self) -> None: + self._shutting_down = True + for task in list(self._pending_tasks.values()): + task.cancel() + if self._pending_tasks: + await asyncio.gather(*self._pending_tasks.values(), return_exceptions=True) + self._pending_tasks.clear() + self._pending_text.clear() + + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + await asyncio.gather(self._poll_task, return_exceptions=True) + self._poll_task = None + + if self._http is not None: + await self._http.close() + self._http = None + + if self._bridge_process is not None: + proc = self._bridge_process + if proc.poll() is None: + try: + if os.name == "nt": + proc.terminate() + else: + os.killpg(proc.pid, signal.SIGTERM) + await asyncio.to_thread(proc.wait, 5) + except Exception: + try: + proc.kill() + except Exception: + pass + self._bridge_process = None + + if self._bridge_log_fh: + try: + self._bridge_log_fh.close() + except Exception: + pass + self._bridge_log_fh = None + self.mark_disconnected() + + async def send_text(self, ctx: OutboundContext) -> DeliveryResult: + if self._http is None: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error="WhatsApp bridge is not connected", + retryable=True, + ) + chat_id = parse_target(ctx.to) + if not chat_id: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error="Invalid WhatsApp target", + ) + + try: + async with self._http.post( + self._url("/send"), + json={ + "chatId": chat_id, + "message": ctx.text, + "replyTo": _raw_reply_id(ctx.reply_to_id), + }, + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=max(self._send_timeout_ms / 1000, 1)), + ) as resp: + data = await resp.json(content_type=None) + if resp.status >= 400 or data.get("success") is False: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error=str(data.get("error") or f"HTTP {resp.status}"), + retryable=resp.status >= 500, + ) + except Exception as exc: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error=f"WhatsApp send failed: {exc}", + retryable=True, + ) + + self.record_message() + return DeliveryResult( + channel_id="whatsapp", + message_id=coerce_str(data.get("messageId")), + chat_id=chat_id, + success=True, + ) + + async def send_media(self, ctx: OutboundContext) -> DeliveryResult: + if not ctx.media_url: + return await self.send_text(ctx) + if self._http is None: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error="WhatsApp bridge is not connected", + retryable=True, + ) + chat_id = parse_target(ctx.to) + path = _path_from_media_url(ctx.media_url) + if not chat_id: + return DeliveryResult(channel_id="whatsapp", message_id="", success=False, error="Invalid WhatsApp target") + if path is None or not path.is_file(): + return DeliveryResult(channel_id="whatsapp", message_id="", success=False, error="WhatsApp media must be a local file") + + try: + async with self._http.post( + self._url("/send-media"), + json={ + "chatId": chat_id, + "filePath": str(path), + "mediaType": _media_type_for_path(path), + "caption": ctx.text or "", + "replyTo": _raw_reply_id(ctx.reply_to_id), + }, + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=max(self._send_timeout_ms / 1000, 1)), + ) as resp: + data = await resp.json(content_type=None) + if resp.status >= 400 or data.get("success") is False: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error=str(data.get("error") or f"HTTP {resp.status}"), + retryable=resp.status >= 500, + ) + except Exception as exc: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error=f"WhatsApp send_media failed: {exc}", + retryable=True, + ) + + self.record_message() + return DeliveryResult( + channel_id="whatsapp", + message_id=coerce_str(data.get("messageId")), + chat_id=chat_id, + success=True, + ) + + def _load_config(self, config: dict) -> None: + self._account_id = coerce_str(config.get("accountId")) or "default" + self._bridge_port = coerce_int(config.get("bridgePort"), DEFAULT_BRIDGE_PORT) + self._bridge_dir = Path(coerce_str(config.get("bridgeDir")) or default_bridge_dir()).expanduser() + self._session_path = Path(coerce_str(config.get("sessionPath")) or default_session_path()).expanduser() + self._media_cache_dir = Path(coerce_str(config.get("mediaCacheDir")) or default_media_cache_dir()).expanduser() + self._mode = coerce_str(config.get("mode") or "bot") + self._dm_policy = coerce_str(config.get("dmPolicy") or "allowlist") + self._group_policy = coerce_str(config.get("groupPolicy") or "disabled") + self._group_trigger = coerce_str(config.get("groupTrigger") or "mention") + self._allow_from = coerce_list(config.get("allowFrom")) + self._group_allow_from = coerce_list(config.get("groupAllowFrom")) + self._text_batch_delay = coerce_float(config.get("textBatchDelaySeconds"), DEFAULT_TEXT_BATCH_DELAY_SECONDS) + self._send_chunk_delay_ms = coerce_int(config.get("sendChunkDelayMs"), DEFAULT_SEND_CHUNK_DELAY_MS) + self._send_timeout_ms = coerce_int(config.get("sendTimeoutMs"), DEFAULT_SEND_TIMEOUT_MS) + self._reply_prefix = coerce_str(config.get("replyPrefix")) + + async def _ensure_bridge_deps(self) -> None: + await ensure_bridge_deps(self._bridge_dir) + + async def _start_bridge(self) -> None: + node = find_executable("node") + if not node: + raise RuntimeError("Node.js is required for WhatsApp channel") + script = self._bridge_dir / "bridge.js" + if not script.exists(): + raise RuntimeError(f"WhatsApp bridge script not found: {script}") + self._session_path.mkdir(parents=True, exist_ok=True) + self._media_cache_dir.mkdir(parents=True, exist_ok=True) + + if await self._reuse_or_stop_existing_bridge(script): + return + + self._bridge_log_path = self._session_path.parent / "bridge.log" + self._bridge_log_path.parent.mkdir(parents=True, exist_ok=True) + self._bridge_log_fh = open(self._bridge_log_path, "a", encoding="utf-8") + env = os.environ.copy() + append_node_options(env, NODE_USE_BUNDLED_CA_OPTION) + env["FLOCKS_WHATSAPP_MEDIA_DIR"] = str(self._media_cache_dir) + env["FLOCKS_WHATSAPP_BRIDGE_TOKEN"] = self._bridge_token + env["FLOCKS_WHATSAPP_CONFIG_HASH"] = self._bridge_config_hash() + env["FLOCKS_WHATSAPP_ALLOWED_MEDIA_ROOTS"] = os.pathsep.join(self._allowed_media_roots()) + env["FLOCKS_WHATSAPP_MODE"] = self._mode + env["FLOCKS_WHATSAPP_DM_POLICY"] = self._dm_policy + env["FLOCKS_WHATSAPP_ALLOWED_USERS"] = format_env_list(self._allow_from) + env["FLOCKS_WHATSAPP_REPLY_PREFIX"] = self._reply_prefix + env["FLOCKS_WHATSAPP_CHUNK_DELAY_MS"] = str(self._send_chunk_delay_ms) + env["FLOCKS_WHATSAPP_SEND_TIMEOUT_MS"] = str(self._send_timeout_ms) + + self._bridge_process = subprocess.Popen( + [ + node, + str(script), + "--port", + str(self._bridge_port), + "--session", + str(self._session_path), + ], + cwd=str(self._bridge_dir), + stdout=self._bridge_log_fh, + stderr=self._bridge_log_fh, + start_new_session=(os.name != "nt"), + env=env, + ) + await self._wait_for_bridge(script) + + async def _reuse_or_stop_existing_bridge(self, script: Path) -> bool: + try: + async with aiohttp.ClientSession() as session: + async with session.get( + self._url("/health"), + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=2), + ) as resp: + data = await resp.json(content_type=None) + if resp.status == 200 and data.get("status") == "connected" and self._bridge_identity_matches(data, script): + self._bridge_process = None + return True + if resp.status == 200 and data.get("scriptHash") == file_hash(script): + if coerce_str(data.get("sessionPath")) != str(self._session_path): + raise RuntimeError( + f"WhatsApp bridge port {self._bridge_port} is already used by another session" + ) + except RuntimeError: + raise + except Exception: + pass + pid_file = self._session_path / "bridge.pid" + try: + pid = int(pid_file.read_text(encoding="utf-8").strip().splitlines()[0]) + if _pid_exists(pid): + os.kill(pid, signal.SIGTERM) + await asyncio.sleep(1) + except Exception: + pass + return False + + async def _wait_for_bridge(self, script: Path) -> None: + deadline = time.monotonic() + 30 + last_status = "unknown" + while time.monotonic() < deadline: + if self._bridge_process is not None and self._bridge_process.poll() is not None: + raise RuntimeError( + f"WhatsApp bridge exited early with code {self._bridge_process.returncode}; log: {self._bridge_log_path}" + ) + try: + async with aiohttp.ClientSession() as session: + async with session.get( + self._url("/health"), + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=2), + ) as resp: + data = await resp.json(content_type=None) + last_status = coerce_str(data.get("status")) or last_status + if resp.status == 200 and self._bridge_identity_matches(data, script): + if data.get("status") == "connected": + if self._bridge_process is not None: + try: + (self._session_path / "bridge.pid").write_text( + str(self._bridge_process.pid), + encoding="utf-8", + ) + except OSError: + pass + return + except Exception: + pass + await asyncio.sleep(1) + raise RuntimeError(f"WhatsApp bridge did not connect in time (last status: {last_status}); log: {self._bridge_log_path}") + + async def _poll_messages(self, abort_event: asyncio.Event) -> None: + consecutive_failures = 0 + while not abort_event.is_set() and not self._shutting_down: + if self._http is None: + return + if self._bridge_process is not None and self._bridge_process.poll() is not None: + raise RuntimeError(f"WhatsApp bridge exited with code {self._bridge_process.returncode}") + try: + async with self._http.get( + self._url("/messages"), + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=35), + ) as resp: + if resp.status != 200: + raise RuntimeError(f"WhatsApp bridge /messages returned HTTP {resp.status}") + events = await resp.json(content_type=None) + consecutive_failures = 0 + if isinstance(events, list): + for event in events: + await self._handle_bridge_event(event) + except asyncio.CancelledError: + break + except Exception as exc: + consecutive_failures += 1 + log.warning("whatsapp.poll.error", {"error": str(exc)}) + if consecutive_failures >= 3: + self.mark_disconnected(str(exc)) + raise RuntimeError(f"WhatsApp bridge polling failed {consecutive_failures} times: {exc}") from exc + try: + await asyncio.wait_for(abort_event.wait(), timeout=5) + except asyncio.TimeoutError: + pass + + async def _handle_bridge_event(self, event: Any) -> None: + if not isinstance(event, dict): + return + inbound = build_inbound_message(event, self._account_id) + if inbound is None or not self._is_allowed(inbound): + return + if inbound.chat_type == ChatType.GROUP and not self._passes_group_trigger(inbound): + return + await self._enqueue_or_dispatch(inbound) + + def _is_allowed(self, msg: InboundMessage) -> bool: + if msg.chat_type == ChatType.DIRECT: + if self._dm_policy == "disabled": + return False + if self._dm_policy == "open": + return True + return matches_identifier(msg.sender_id, self._allow_from) + if self._group_policy == "disabled": + return False + if self._group_policy == "open": + return True + return matches_identifier(msg.chat_id, self._group_allow_from) + + def _passes_group_trigger(self, msg: InboundMessage) -> bool: + if self._group_trigger == "all": + return True + return msg.mentioned + + async def _enqueue_or_dispatch(self, msg: InboundMessage) -> None: + if msg.media_url or self._text_batch_delay <= 0: + if self._on_message: + await self._on_message(msg) + return + key = f"{msg.account_id}:{msg.chat_id}" + existing = self._pending_text.get(key) + if existing is None: + self._pending_text[key] = msg + else: + existing.text = f"{existing.text}\n{msg.text}".strip() + existing.message_id = msg.message_id + existing.raw = msg.raw + prior = self._pending_tasks.get(key) + if prior and not prior.done(): + prior.cancel() + self._pending_tasks[key] = asyncio.create_task(self._flush_text_batch(key)) + + async def _flush_text_batch(self, key: str) -> None: + current = asyncio.current_task() + try: + await asyncio.sleep(self._text_batch_delay) + msg = self._pending_text.pop(key, None) + if msg and self._on_message: + await self._on_message(msg) + except asyncio.CancelledError: + pass + finally: + if self._pending_tasks.get(key) is current: + self._pending_tasks.pop(key, None) + + def _url(self, path: str) -> str: + return f"http://127.0.0.1:{self._bridge_port}{path}" + + def _bridge_headers(self) -> dict[str, str]: + return {"X-Flocks-Bridge-Token": self._bridge_token} + + def _bridge_config_hash(self) -> str: + return config_hash({ + "sessionPath": str(self._session_path), + "mediaDir": str(self._media_cache_dir), + "mode": self._mode, + "replyPrefix": self._reply_prefix, + "sendChunkDelayMs": self._send_chunk_delay_ms, + "sendTimeoutMs": self._send_timeout_ms, + }) + + def _bridge_identity_matches(self, data: dict[str, Any], script: Path) -> bool: + return ( + data.get("scriptHash") == file_hash(script) + and coerce_str(data.get("sessionPath")) == str(self._session_path) + and coerce_str(data.get("mediaDir")) == str(self._media_cache_dir) + and coerce_str(data.get("mode")) == self._mode + and coerce_str(data.get("configHash")) == self._bridge_config_hash() + ) + + def _allowed_media_roots(self) -> list[str]: + roots = [ + self._media_cache_dir, + Path.home() / ".flocks" / "workspace", + Path(os.getenv("TMPDIR") or "/tmp"), + ] + extra = coerce_list(self._config.get("mediaAllowedRoots") if hasattr(self, "_config") else None) + roots.extend(Path(item).expanduser() for item in extra) + resolved: list[str] = [] + for root in roots: + try: + resolved.append(str(root.resolve())) + except OSError: + resolved.append(str(root.expanduser())) + return list(dict.fromkeys(resolved)) diff --git a/flocks/channel/builtin/whatsapp/config.py b/flocks/channel/builtin/whatsapp/config.py new file mode 100644 index 000000000..f9af7441f --- /dev/null +++ b/flocks/channel/builtin/whatsapp/config.py @@ -0,0 +1,127 @@ +"""Configuration helpers for the WhatsApp channel.""" + +from __future__ import annotations + +import os +import re +import shutil +from pathlib import Path +from typing import Any, Optional + +DEFAULT_BRIDGE_PORT = 3100 +DEFAULT_TEXT_BATCH_DELAY_SECONDS = 3.0 +DEFAULT_SEND_CHUNK_DELAY_MS = 300 +DEFAULT_MESSAGE_LIMIT = 4096 +DEFAULT_SEND_TIMEOUT_MS = 60000 + +VALID_MODES = {"bot", "self-chat"} +VALID_DM_POLICIES = {"open", "allowlist", "disabled"} +VALID_GROUP_POLICIES = {"open", "allowlist", "disabled"} +VALID_GROUP_TRIGGERS = {"mention", "all"} + +_PHONE_RE = re.compile(r"^\+?\d{6,20}$") +_JID_RE = re.compile(r"^[\w.+-]+@(?:s\.whatsapp\.net|g\.us|lid|broadcast|newsletter)$") + + +def coerce_str(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def coerce_int(value: Any, default: int) -> int: + if isinstance(value, bool): + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed + + +def coerce_float(value: Any, default: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return default + if parsed < 0: + return default + return parsed + + +def coerce_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [part.strip() for part in str(value).split(",") if part.strip()] + + +def default_state_dir() -> Path: + raw = os.getenv("FLOCKS_WHATSAPP_DATA_DIR") + if raw: + return Path(raw).expanduser() + return Path.home() / ".flocks" / "workspace" / "channels" / "whatsapp" + + +def default_session_path() -> Path: + return default_state_dir() / "session" + + +def default_media_cache_dir() -> Path: + return default_state_dir() / "media" + + +def default_bridge_dir() -> Path: + return Path(__file__).resolve().parent / "bridge" + + +def find_executable(name: str) -> Optional[str]: + return shutil.which(name) + + +def normalize_jid(value: str) -> str: + raw = coerce_str(value) + if not raw: + return "" + if ":" in raw and "@" in raw: + raw = re.sub(r":\d+@", "@", raw, count=1) + if _JID_RE.fullmatch(raw): + return raw + phone = raw.lstrip("+").replace(" ", "") + if _PHONE_RE.fullmatch(phone): + return f"{phone}@s.whatsapp.net" + return raw + + +def strip_jid(value: str) -> str: + raw = normalize_jid(value) + if "@" in raw: + return raw.split("@", 1)[0].split(":", 1)[0] + return raw.lstrip("+") + + +def parse_target(raw: str) -> str: + value = coerce_str(raw) + for prefix in ("whatsapp:", "wa:", "user:", "group:"): + if value.lower().startswith(prefix): + value = value[len(prefix):].strip() + break + return normalize_jid(value) + + +def matches_identifier(candidate: str, allowed: list[str]) -> bool: + if not allowed: + return False + if "*" in allowed: + return True + aliases = {candidate, normalize_jid(candidate), strip_jid(candidate)} + for entry in allowed: + aliases_for_entry = {entry, normalize_jid(entry), strip_jid(entry)} + if aliases & aliases_for_entry: + return True + return False + + +def format_env_list(items: list[str]) -> str: + return ",".join(item for item in items if item) diff --git a/flocks/channel/builtin/whatsapp/format.py b/flocks/channel/builtin/whatsapp/format.py new file mode 100644 index 000000000..8570d81b5 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/format.py @@ -0,0 +1,23 @@ +"""WhatsApp text formatting helpers.""" + +from __future__ import annotations + +import re + + +_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)") + + +def format_for_whatsapp(text: str) -> str: + """Convert generic Markdown into WhatsApp-friendly plain markdown. + + WhatsApp supports lightweight emphasis but not Markdown links. Keeping + code fences intact gives readable monospace blocks on most clients. + """ + if not text: + return "" + out = _LINK_RE.sub(r"\1: \2", text) + out = re.sub(r"(? tuple[Optional[str], Optional[str]]: + urls = event.get("mediaUrls") + if isinstance(urls, list) and urls: + url = coerce_str(urls[0]) + if url: + return url, coerce_str(event.get("mime")) or None + url = coerce_str(event.get("mediaUrl")) + if url: + return url, coerce_str(event.get("mime")) or None + return None, None + + +def _is_group_jid(jid: str) -> bool: + return jid.endswith("@g.us") + + +def _resolve_mentioned(event: dict[str, Any], text: str) -> tuple[bool, str]: + if event.get("mentioned") is True: + mention_text = coerce_str(event.get("mentionText")) or text + return True, mention_text.strip() + if event.get("isGroup") and event.get("isReplyToBot"): + return True, text.strip() + return False, "" + + +def build_inbound_message(event: dict[str, Any], account_id: str = "default") -> Optional[InboundMessage]: + chat_id = normalize_jid(coerce_str(event.get("chatId"))) + sender_id = normalize_jid(coerce_str(event.get("senderId")) or chat_id) + message_id = coerce_str(event.get("messageId")) + if not chat_id or not message_id: + return None + + text = coerce_str(event.get("body") or event.get("text")) + media_url, media_mime = _first_media(event) + if not text and not media_url: + return None + + is_group = bool(event.get("isGroup")) or _is_group_jid(chat_id) + chat_type = ChatType.GROUP if is_group else ChatType.DIRECT + sender_name = coerce_str(event.get("senderName")) or None + + mentioned, mention_text = _resolve_mentioned(event, text) + + return InboundMessage( + channel_id="whatsapp", + account_id=account_id, + message_id=f"{chat_id}:{message_id}", + sender_id=strip_jid(sender_id) or sender_id, + sender_name=sender_name, + chat_id=chat_id, + chat_type=chat_type, + text=text, + media_url=media_url, + media_mime=media_mime, + reply_to_id=coerce_str(event.get("quotedMessageId")) or None, + thread_id=None, + mentioned=mentioned, + mention_text=mention_text, + raw=event, + ) + diff --git a/flocks/channel/builtin/whatsapp/pairing.py b/flocks/channel/builtin/whatsapp/pairing.py new file mode 100644 index 000000000..b58b10040 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/pairing.py @@ -0,0 +1,212 @@ +"""QR pairing helpers for the WhatsApp channel.""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from flocks.utils.log import Log + +from .bridge_runtime import NODE_USE_BUNDLED_CA_OPTION, append_node_options, ensure_bridge_deps +from .config import default_bridge_dir, default_session_path, find_executable + +log = Log.create(service="channel.whatsapp.pairing") + + +@dataclass +class PairingSession: + id: str + session_path: Path + process: asyncio.subprocess.Process + qr: Optional[str] = None + status: str = "starting" + error: Optional[str] = None + user: Optional[dict] = None + created_at: float = field(default_factory=time.time) + finished_at: Optional[float] = None + _reader_task: Optional[asyncio.Task] = field(default=None, repr=False) + + +_sessions: dict[str, PairingSession] = {} +_active_session_paths: set[str] = set() +_active_session_paths_lock = asyncio.Lock() + + +def _session_key(path: Path) -> str: + try: + return str(path.resolve()) + except OSError: + return str(path.expanduser()) + + +def _pid_exists(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def _cleanup_pairings(ttl_seconds: int = 600) -> None: + now = time.time() + stale = [ + pairing_id + for pairing_id, pairing in _sessions.items() + if pairing.finished_at is not None and now - pairing.finished_at > ttl_seconds + ] + for pairing_id in stale: + _sessions.pop(pairing_id, None) + + +def _release_session(pairing: PairingSession) -> None: + _active_session_paths.discard(_session_key(pairing.session_path)) + pairing.finished_at = pairing.finished_at or time.time() + + +def _backup_session_dir(session_path: Path) -> Optional[Path]: + if not session_path.exists() or not any(session_path.iterdir()): + return None + backup_path = session_path.with_name(f"{session_path.name}.backup.{int(time.time())}.{uuid.uuid4().hex[:8]}") + shutil.move(str(session_path), str(backup_path)) + log.info("whatsapp.pairing.session_backed_up", { + "session_path": str(session_path), + "backup_path": str(backup_path), + }) + return backup_path + + +async def _read_pair_output(pairing: PairingSession) -> None: + assert pairing.process.stdout is not None + while True: + line = await pairing.process.stdout.readline() + if not line: + break + raw = line.decode("utf-8", errors="replace").strip() + if not raw: + continue + try: + event = json.loads(raw) + except json.JSONDecodeError: + continue + name = str(event.get("event") or "") + if name == "qr": + pairing.qr = str(event.get("qr") or "") + pairing.status = "qr" + elif name == "connected": + pairing.user = event.get("user") if isinstance(event.get("user"), dict) else None + pairing.status = "connected" + elif name == "error": + pairing.error = str(event.get("error") or "WhatsApp pairing failed") + pairing.status = "error" + elif name == "disconnected" and pairing.status not in {"qr", "connected"}: + pairing.status = "starting" + + code = await pairing.process.wait() + if pairing.status == "connected" and code == 0: + pairing.status = "complete" + elif pairing.status not in {"complete", "connected", "error", "cancelled"}: + pairing.status = "error" + pairing.error = f"WhatsApp pairing bridge exited with code {code}" + _release_session(pairing) + + +async def start_pairing( + *, + session_path: Optional[str] = None, + bridge_dir: Optional[str] = None, + reset_session: bool = False, +) -> PairingSession: + _cleanup_pairings() + node = find_executable("node") + if not node: + raise RuntimeError("Node.js is required for WhatsApp QR pairing") + + bridge_root = Path(bridge_dir).expanduser() if bridge_dir else default_bridge_dir() + script = bridge_root / "bridge.js" + if not script.exists(): + raise RuntimeError(f"WhatsApp bridge script not found: {script}") + + sess = Path(session_path).expanduser() if session_path else default_session_path() + key = _session_key(sess) + + async with _active_session_paths_lock: + if key in _active_session_paths: + raise RuntimeError("WhatsApp pairing is already running for this session") + _active_session_paths.add(key) + + try: + await ensure_bridge_deps(bridge_root) + + pid_file = sess / "bridge.pid" + try: + pid = int(pid_file.read_text(encoding="utf-8").strip().splitlines()[0]) + if _pid_exists(pid): + raise RuntimeError("WhatsApp channel is already running for this session; stop it before pairing") + except FileNotFoundError: + pass + except ValueError: + pass + + if reset_session: + _backup_session_dir(sess) + sess.mkdir(parents=True, exist_ok=True) + + pairing_id = uuid.uuid4().hex + env = os.environ.copy() + append_node_options(env, NODE_USE_BUNDLED_CA_OPTION) + env.setdefault("FLOCKS_WHATSAPP_PAIR_TIMEOUT_MS", "120000") + + proc = await asyncio.create_subprocess_exec( + node, + str(script), + "--pair-only", + "--pair-json", + "--session", + str(sess), + cwd=str(bridge_root), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=env, + ) + except Exception: + _active_session_paths.discard(key) + raise + + pairing = PairingSession( + id=pairing_id, + session_path=sess, + process=proc, + ) + pairing._reader_task = asyncio.create_task(_read_pair_output(pairing)) + _sessions[pairing_id] = pairing + log.info("whatsapp.pairing.started", {"pairing_id": pairing_id}) + return pairing + + +def get_pairing(pairing_id: str) -> Optional[PairingSession]: + _cleanup_pairings() + return _sessions.get(pairing_id) + + +async def cancel_pairing(pairing_id: str) -> bool: + pairing = _sessions.pop(pairing_id, None) + if pairing is None: + return False + pairing.status = "cancelled" + _release_session(pairing) + if pairing.process.returncode is None: + pairing.process.terminate() + try: + await asyncio.wait_for(pairing.process.wait(), timeout=5) + except asyncio.TimeoutError: + pairing.process.kill() + if pairing._reader_task: + pairing._reader_task.cancel() + return True diff --git a/flocks/channel/inbound/dispatcher.py b/flocks/channel/inbound/dispatcher.py index 37f38a242..0effd28ce 100644 --- a/flocks/channel/inbound/dispatcher.py +++ b/flocks/channel/inbound/dispatcher.py @@ -135,6 +135,17 @@ def _evict_expired(self, now: float) -> None: # Allowlist / authorisation check # ===================================================================== +def _matches_allow_from(msg: InboundMessage, allow_from: list[str]) -> bool: + if msg.channel_id == "whatsapp": + try: + from flocks.channel.builtin.whatsapp.config import matches_identifier + + return matches_identifier(msg.sender_id, allow_from) + except Exception: + log.warning("allowlist.whatsapp_match_failed", {"sender": msg.sender_id}) + return msg.sender_id in allow_from + + def _check_allowlist(msg: InboundMessage, config: ChannelConfig) -> bool: """Return True if the message is allowed through. @@ -156,12 +167,12 @@ def _check_allowlist(msg: InboundMessage, config: ChannelConfig) -> bool: if not allow_from: log.debug("allowlist.dm_blocked_no_list", {"sender": msg.sender_id}) return False - return msg.sender_id in allow_from + return _matches_allow_from(msg, allow_from) if dm_policy == "pairing": return True return True - if allow_from and msg.sender_id not in allow_from: + if allow_from and not _matches_allow_from(msg, allow_from): log.debug("allowlist.group_blocked", { "sender": msg.sender_id, "chat_id": msg.chat_id, }) diff --git a/flocks/channel/inbound/session_binding.py b/flocks/channel/inbound/session_binding.py index 906f913c1..c9e49cd2b 100644 --- a/flocks/channel/inbound/session_binding.py +++ b/flocks/channel/inbound/session_binding.py @@ -135,6 +135,9 @@ async def _get_db() -> aiosqlite.Connection: """ global _db_conn, _db_ready, _db_owner_pid + from flocks.storage.storage import Storage + + await Storage._ensure_init() current_pid = os.getpid() if ( _db_conn is not None @@ -169,7 +172,6 @@ async def _get_db() -> aiosqlite.Connection: _db_ready = False _db_owner_pid = None - from flocks.storage.storage import Storage db_path = Storage.get_db_path() db_path.parent.mkdir(parents=True, exist_ok=True) @@ -230,17 +232,28 @@ async def _migrate_legacy_binding_agent_ids(db: aiosqlite.Connection) -> None: }) -async def close_binding_db() -> None: - """Close the persistent connection (call during shutdown).""" +async def _close_binding_db_locked(*, suppress_errors: bool) -> None: + """Invalidate and close ``_db_conn`` while ``_init_lock`` is held.""" + global _db_conn, _db_ready, _db_owner_pid - if _db_conn is not None: + + conn = _db_conn + _db_conn = None + _db_ready = False + _db_owner_pid = None + if conn is not None: try: - await _db_conn.close() + await conn.close() except Exception: - pass - _db_conn = None - _db_ready = False - _db_owner_pid = None + if not suppress_errors: + raise + + +async def close_binding_db() -> None: + """Close the persistent connection (call during shutdown).""" + + async with _init_lock: + await _close_binding_db_locked(suppress_errors=True) class SessionBindingService: diff --git a/flocks/channel/registry.py b/flocks/channel/registry.py index c782f937a..e8ae74aa7 100644 --- a/flocks/channel/registry.py +++ b/flocks/channel/registry.py @@ -94,10 +94,12 @@ def _register_builtin_channels(self) -> None: from flocks.channel.builtin.feishu.channel import FeishuChannel from flocks.channel.builtin.telegram.channel import TelegramChannel from flocks.channel.builtin.wecom.channel import WeComChannel + from flocks.channel.builtin.whatsapp.channel import WhatsAppChannel from flocks.channel.builtin.weixin.channel import WeixinChannel self.register(FeishuChannel()) self.register(WeComChannel()) self.register(TelegramChannel()) + self.register(WhatsAppChannel()) self.register(DingTalkChannel()) self.register(WeixinChannel()) diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index cc4ce8d44..e75fb8d1a 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -1042,17 +1042,6 @@ def _is_running_status_response(response: httpx.Response) -> bool: return isinstance(payload, dict) and payload.get("status") == "running" -def _is_healthy_status_response(response: httpx.Response) -> bool: - """Return True when the backend health endpoint reports healthy.""" - if response.status_code != 200: - return False - try: - payload = response.json() - except ValueError: - return False - return isinstance(payload, dict) and payload.get("status") == "healthy" - - def wait_for_http( urls: Sequence[str], name: str, @@ -1086,10 +1075,6 @@ def print(self, *args, **_kwargs) -> None: sys.stdout.flush() -def _backend_health_url(host: str, port: int) -> str: - return f"http://{_format_host_for_url(access_host(host))}:{port}/api/health" - - def _terminate_process( process: subprocess.Popen | None, name: str, diff --git a/flocks/cli/service_process.py b/flocks/cli/service_process.py index d6407417e..a64d82810 100644 --- a/flocks/cli/service_process.py +++ b/flocks/cli/service_process.py @@ -7,10 +7,6 @@ from dataclasses import dataclass from typing import Protocol -import httpx - -from flocks.cli.service_config import ServiceConfig - @dataclass(frozen=True) class ServiceProbeResult: @@ -59,29 +55,7 @@ def probe(self, process: subprocess.Popen | None, host: str, port: int) -> Servi ) if not tcp_port_accepts_connections(host, port): return ServiceProbeResult(healthy=False, reason=f"port {port} is not listening", restart=True) - - from flocks.cli.service_manager import _backend_health_url, _is_healthy_status_response, backend_access_base_url - - url = _backend_health_url(host, port) - try: - with httpx.Client(timeout=2.0, trust_env=False) as client: - response = client.get(url) - root_response = client.get( - backend_access_base_url(ServiceConfig(backend_host=host, backend_port=port)), - headers={"Accept": "text/html"}, - ) - healthy = _is_healthy_status_response(response) and _is_static_webui_response(root_response) - reason = f"health status={response.status_code}, root status={root_response.status_code}" - except Exception as exc: - healthy = False - reason = f"health failed: {exc}" - return ServiceProbeResult(healthy=healthy, reason=reason) - - -def _is_static_webui_response(response: httpx.Response) -> bool: - """Return True only when the unified service serves the SPA index.""" - content_type = response.headers.get("content-type", "").lower() - return response.status_code == 200 and "text/html" in content_type + return ServiceProbeResult(healthy=True, reason="liveness check passed") def tcp_port_accepts_connections(host: str, port: int) -> bool: diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index bb08cb066..d82e001f5 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -5,6 +5,7 @@ import json import platform import re +import threading from functools import lru_cache from pathlib import Path from typing import Any, Iterable, Optional @@ -31,6 +32,7 @@ _TOOL_TYPE_DIRS = frozenset({"api", "device", "python", "mcp", "generated"}) _SKIP_PLUGIN_DIRS = frozenset({"__pycache__"}) _PATH_SIGNATURE_MISSING = -1 +_CATALOG_ENTRIES_LOCK = threading.Lock() def _read_json(path: Path) -> dict: @@ -594,6 +596,86 @@ def _bundled_tool_roots() -> dict[tuple[PluginType, str], Path]: } +def _installed_plugins_cache_key() -> tuple[tuple[str, int, int], ...]: + signature: list[tuple[str, int, int]] = [_path_signature(local._record_path())] + + for plugin_type in ("skill", "agent", "workflow", "webui", "component"): + for scope in ("global", "project"): + base = local.install_root(plugin_type, scope) + signature.append(_path_signature(base)) + if not base.is_dir(): + continue + for child in sorted(base.iterdir(), key=lambda item: item.name): + if not child.is_dir(): + continue + signature.append(_path_signature(child)) + signature.extend(_plugin_manifest_signature(plugin_type, child)) + + for scope in ("global", "project"): + base = local.install_root("tool", scope) + signature.append(_path_signature(base)) + if not base.is_dir(): + continue + for directory in _iter_tool_plugin_dirs(base): + signature.append(_path_signature(directory)) + signature.extend(_plugin_manifest_signature("tool", directory)) + + return tuple(signature) + + +def _catalog_entries_cache_key() -> tuple[tuple[str, int, int], ...]: + root = get_bundled_hub_root() + return ( + _path_signature(root / "index.json"), + _path_signature(root / "taxonomy.json"), + *_installed_plugins_cache_key(), + *_system_plugin_roots_cache_key(), + *_bundled_tool_roots_cache_key(), + ) + + +@lru_cache(maxsize=8) +def _cached_catalog_entries( + _signature: tuple[tuple[str, int, int], ...], +) -> tuple[HubCatalogEntry, ...]: + records = local.load_installed_records() + inferred_installs = local.infer_local_installs() + entries = [ + _entry_from_index(item, records, inferred_installs) + for item in load_index().plugins + ] + seen: set[tuple[PluginType, str]] = {(entry.type, entry.id) for entry in entries} + for (system_type, system_id), root in _system_plugin_roots().items(): + if (system_type, system_id) in seen: + continue + manifest = _manifest_for_system_root(system_type, system_id, root) + if manifest: + entries.append(_entry_from_system_manifest(manifest, root)) + seen.add((system_type, system_id)) + + # FlocksHub-bundled tool plugins. The runtime ``ToolRegistry`` only + # discovers tools that have been installed into ``/tools``, + # so the catalog needs an explicit pass to surface bundled-but-not- + # yet-installed plugins like ``onesig_v2_5_3_D20250710``. These show + # up as ``state="available"`` until the user installs them, at which + # point the standard ``records``/``inferred_installs`` flow upgrades + # them to ``state="installed"`` (see :func:`_entry_from_bundled_tool`). + for (bundled_type, bundled_id), root in _bundled_tool_roots().items(): + if (bundled_type, bundled_id) in seen: + continue + manifest = _manifest_for_system_root(bundled_type, bundled_id, root) + if manifest: + entries.append(_entry_from_bundled_tool(manifest, root, records, inferred_installs)) + seen.add((bundled_type, bundled_id)) + return tuple(entries) + + +def _catalog_entries_snapshot() -> tuple[HubCatalogEntry, ...]: + signature = _catalog_entries_cache_key() + with _CATALOG_ENTRIES_LOCK: + return _cached_catalog_entries(signature) + + def clear_catalog_caches() -> None: """Clear Hub filesystem discovery caches after installs or explicit refreshes.""" load_index.cache_clear() @@ -601,6 +683,7 @@ def clear_catalog_caches() -> None: _manifest_path_lookup.cache_clear() _cached_system_plugin_roots.cache_clear() _cached_bundled_tool_roots.cache_clear() + _cached_catalog_entries.cache_clear() def system_plugin_root(plugin_type: PluginType, plugin_id: str) -> Optional[Path]: @@ -868,7 +951,8 @@ def _contains_any(values: Iterable[str], selected: Optional[list[str]]) -> bool: return any(item.lower() in value_set for item in selected) -def list_catalog( +def filter_catalog_entries( + entries: Iterable[HubCatalogEntry], *, plugin_type: Optional[PluginType] = None, category: Optional[list[str]] = None, @@ -879,35 +963,6 @@ def list_catalog( risk: Optional[list[str]] = None, q: Optional[str] = None, ) -> list[HubCatalogEntry]: - records = local.load_installed_records() - inferred_installs = local.infer_local_installs() - entries = [ - _entry_from_index(item, records, inferred_installs) - for item in load_index().plugins - ] - seen: set[tuple[PluginType, str]] = {(entry.type, entry.id) for entry in entries} - for (system_type, system_id), root in _system_plugin_roots().items(): - if (system_type, system_id) in seen: - continue - manifest = _manifest_for_system_root(system_type, system_id, root) - if manifest: - entries.append(_entry_from_system_manifest(manifest, root)) - seen.add((system_type, system_id)) - - # FlocksHub-bundled tool plugins. The runtime ``ToolRegistry`` only - # discovers tools that have been installed into ``/tools``, - # so the catalog needs an explicit pass to surface bundled-but-not- - # yet-installed plugins like ``onesig_v2_5_3_D20250710``. These show - # up as ``state="available"`` until the user installs them, at which - # point the standard ``records``/``inferred_installs`` flow upgrades - # them to ``state="installed"`` (see :func:`_entry_from_bundled_tool`). - for (bundled_type, bundled_id), root in _bundled_tool_roots().items(): - if (bundled_type, bundled_id) in seen: - continue - manifest = _manifest_for_system_root(bundled_type, bundled_id, root) - if manifest: - entries.append(_entry_from_bundled_tool(manifest, root, records, inferred_installs)) - seen.add((bundled_type, bundled_id)) query = (q or "").strip().lower() def keep(entry: HubCatalogEntry) -> bool: @@ -944,6 +999,30 @@ def keep(entry: HubCatalogEntry) -> bool: return [entry for entry in entries if keep(entry)] +def list_catalog( + *, + plugin_type: Optional[PluginType] = None, + category: Optional[list[str]] = None, + tags: Optional[list[str]] = None, + use_cases: Optional[list[str]] = None, + state: Optional[list[str]] = None, + trust: Optional[list[str]] = None, + risk: Optional[list[str]] = None, + q: Optional[str] = None, +) -> list[HubCatalogEntry]: + return filter_catalog_entries( + _catalog_entries_snapshot(), + plugin_type=plugin_type, + category=category, + tags=tags, + use_cases=use_cases, + state=state, + trust=trust, + risk=risk, + q=q, + ) + + def category_counts() -> dict: taxonomy = load_taxonomy().model_dump(mode="json") entries = list_catalog() diff --git a/flocks/mcp/__init__.py b/flocks/mcp/__init__.py index 3472e96f6..87e90c48c 100644 --- a/flocks/mcp/__init__.py +++ b/flocks/mcp/__init__.py @@ -87,6 +87,12 @@ async def status(cls) -> Dict[str, McpStatusInfo]: """ manager = get_manager() return await manager.status() + + @classmethod + def status_snapshot(cls) -> tuple[Dict[str, McpStatusInfo], bool]: + """Return current MCP status without starting uninitialized servers.""" + manager = get_manager() + return manager.status_snapshot() @classmethod async def get_server_info(cls, name: str) -> Optional[McpServerInfo]: diff --git a/flocks/mcp/server.py b/flocks/mcp/server.py index 6ef4eaa63..b7a20cd10 100644 --- a/flocks/mcp/server.py +++ b/flocks/mcp/server.py @@ -331,6 +331,10 @@ async def status(self) -> Dict[str, McpStatusInfo]: return self._status.copy() + def status_snapshot(self) -> tuple[Dict[str, McpStatusInfo], bool]: + """Return current status without triggering MCP initialization.""" + return self._status.copy(), self._initialized + async def get_server_info(self, name: str) -> Optional[McpServerInfo]: """ Get detailed server information diff --git a/flocks/permission/next.py b/flocks/permission/next.py index 5fc7c041f..18bfc51af 100644 --- a/flocks/permission/next.py +++ b/flocks/permission/next.py @@ -238,10 +238,12 @@ async def _apply_reply_without_future( cls._permanent_rules[permission] = "deny" await cls._persist_permanent_rule(permission, "deny") return - if reply == "allow_session": + if reply in {"allow_session", "deny_session"}: if resolved_session_id not in cls._session_permissions: cls._session_permissions[resolved_session_id] = {} - cls._session_permissions[resolved_session_id][permission] = "allow" + cls._session_permissions[resolved_session_id][permission] = ( + "allow" if reply == "allow_session" else "deny" + ) await cls._persist_session_rules(resolved_session_id) @classmethod @@ -376,11 +378,14 @@ async def ask( cls._permanent_rules[permission] = "deny" cls._schedule_persist(cls._persist_permanent_rule(permission, "deny")) raise DeniedError([]) - if reply == "allow_session": + if reply in {"allow_session", "deny_session"}: if session_id not in cls._session_permissions: cls._session_permissions[session_id] = {} - cls._session_permissions[session_id][permission] = "allow" + action = "allow" if reply == "allow_session" else "deny" + cls._session_permissions[session_id][permission] = action cls._schedule_persist(cls._persist_session_rules(session_id)) + if action == "deny": + raise DeniedError([]) return raise PermissionError(f"Unknown permission reply: {reply}") diff --git a/flocks/plugin/loader.py b/flocks/plugin/loader.py index b0144a178..d0bbf59ea 100644 --- a/flocks/plugin/loader.py +++ b/flocks/plugin/loader.py @@ -147,8 +147,8 @@ class ExtensionPoint: subdir: str """Subdirectory under the plugin root, e.g. ``"agents"``.""" - consumer: Callable[[List[Any], str], None] - """Callback ``(items, source_path) -> None`` that receives validated items.""" + consumer: Callable[[List[Any], str], Optional[List[str]]] + """Callback receiving validated items and optionally returning errors.""" item_type: Optional[type] = None """If set, only items that are ``isinstance(item, item_type)`` are kept.""" @@ -255,7 +255,7 @@ def load_extension( project_dir: Optional[Path] = None, *, load_entry_points: bool = False, - ) -> None: + ) -> List[str]: """Load one registered extension point using normal plugin scan rules. This is the scoped counterpart to :meth:`load_all`. It scans the same @@ -269,16 +269,20 @@ def load_extension( ext = cls._extension_points.get(attr_name) if ext is None: log.warn("plugin.ext_point.not_found", {"attr": attr_name}) - return + return [f"extension point not found: {attr_name}"] + + errors: List[str] = [] cls._load_extension_point( ext, extra_sources=extra_sources, project_dir=project_dir or Path.cwd(), log_scope="load_extension", + errors=errors, ) if load_entry_points: - cls._load_entry_points() + cls._load_entry_points(errors=errors) + return errors @classmethod def load_for_extension( @@ -299,9 +303,12 @@ def load_for_extension( collected: List[Any] = [] original_consumer = ext.consumer - def _collecting_consumer(items: List[Any], source: str) -> None: + def _collecting_consumer( + items: List[Any], + source: str, + ) -> Optional[List[str]]: collected.extend(items) - original_consumer(items, source) + return original_consumer(items, source) ext.consumer = _collecting_consumer ext._seen_keys = set() @@ -352,6 +359,7 @@ def _load_extension_point( extra_sources: Optional[List[str]], project_dir: Path, log_scope: str, + errors: Optional[List[str]] = None, ) -> None: """Scan and load one registered extension point.""" if ext.load_once and ext._loaded: @@ -382,7 +390,7 @@ def _load_extension_point( "files": [Path(s).name for s in default_sources], }, ) - cls._load_sources_for_ext(ext, default_sources, subdir_path) + cls._load_sources_for_ext(ext, default_sources, subdir_path, errors=errors) # 2. Project-level plugin subdirectory (/.flocks/plugins/{subdir}/) project_subdir_path = project_plugin_root / ext.subdir @@ -402,17 +410,22 @@ def _load_extension_point( "files": [Path(s).name for s in project_sources], }, ) - cls._load_sources_for_ext(ext, project_sources, project_subdir_path) + cls._load_sources_for_ext( + ext, + project_sources, + project_subdir_path, + errors=errors, + ) # 3. Explicit sources from cfg.plugin if extra_sources: - cls._load_sources_for_ext(ext, extra_sources, project_dir) + cls._load_sources_for_ext(ext, extra_sources, project_dir, errors=errors) if ext.load_once: ext._loaded = True @classmethod - def _load_entry_points(cls) -> None: + def _load_entry_points(cls, errors: Optional[List[str]] = None) -> None: """ Load installed package entry-points under ``flocks.plugins``. @@ -425,6 +438,8 @@ def _load_entry_points(cls) -> None: eps = importlib.metadata.entry_points().select(group=group) except Exception as e: log.debug("plugin.entrypoints.scan_failed", {"group": group, "error": str(e)}) + if errors is not None: + errors.append(f"entry point scan: {type(e).__name__}: {e}") return for ep in eps: @@ -432,10 +447,14 @@ def _load_entry_points(cls) -> None: target = ep.load() except Exception as e: log.warning("plugin.entrypoint.load_failed", {"name": ep.name, "error": str(e)}) + if errors is not None: + errors.append(f"entry point {ep.name}: {e}") continue if not callable(target): log.warning("plugin.entrypoint.not_callable", {"name": ep.name}) + if errors is not None: + errors.append(f"entry point {ep.name}: target is not callable") continue try: @@ -447,6 +466,8 @@ def _load_entry_points(cls) -> None: log.info("plugin.entrypoint.loaded", {"name": ep.name, "group": group}) except Exception as e: log.warning("plugin.entrypoint.invoke_failed", {"name": ep.name, "error": str(e)}) + if errors is not None: + errors.append(f"entry point {ep.name}: {e}") @classmethod def _load_sources_for_ext( @@ -454,12 +475,13 @@ def _load_sources_for_ext( ext: ExtensionPoint, sources: List[str], base_dir: Path, + errors: Optional[List[str]] = None, ) -> None: """Load each source module and dispatch matching items to *ext.consumer*.""" for source in sources: source_path = Path(source) if source_path.suffix in (".yaml", ".yml"): - cls._load_yaml_source(ext, source_path) + cls._load_yaml_source(ext, source_path, errors=errors) continue try: @@ -473,6 +495,8 @@ def _load_sources_for_ext( "type": type(e).__name__, }, ) + if errors is not None: + errors.append(f"{source}: {type(e).__name__}: {e}") continue raw = getattr(module, ext.attr_name, None) @@ -487,11 +511,29 @@ def _load_sources_for_ext( "attr": ext.attr_name, }, ) + if errors is not None: + errors.append(f"{source}: {ext.attr_name} must be a list") continue - items = cls._validate_and_dedup(ext, list(raw), source) + items = cls._validate_and_dedup(ext, list(raw), source, errors=errors) if items: - ext.consumer(items, source) + try: + consumer_errors = ext.consumer(items, source) + except Exception as e: + log.error( + "plugin.consumer_failed", + { + "source": source, + "attr": ext.attr_name, + "error": str(e), + }, + ) + if errors is None: + raise + errors.append(f"{source}: consumer: {type(e).__name__}: {e}") + continue + if errors is not None and consumer_errors: + errors.extend(f"{source}: {error}" for error in consumer_errors) log.info( "plugin.dispatched", { @@ -502,7 +544,12 @@ def _load_sources_for_ext( ) @classmethod - def _load_yaml_source(cls, ext: ExtensionPoint, yaml_path: Path) -> None: + def _load_yaml_source( + cls, + ext: ExtensionPoint, + yaml_path: Path, + errors: Optional[List[str]] = None, + ) -> None: """Load a YAML config file and dispatch via the extension point's factory.""" if ext.yaml_item_factory is None: log.warn( @@ -513,27 +560,60 @@ def _load_yaml_source(cls, ext: ExtensionPoint, yaml_path: Path) -> None: "hint": f"Extension point '{ext.attr_name}' has no yaml_item_factory; skipping YAML file", }, ) + if errors is not None: + errors.append(f"{yaml_path}: YAML is not supported for {ext.attr_name}") return try: raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) except Exception as e: log.error("plugin.yaml_parse_failed", {"path": str(yaml_path), "error": str(e)}) + if errors is not None: + errors.append(f"{yaml_path}: {e}") return if not isinstance(raw, dict): log.warn("plugin.yaml_invalid", {"path": str(yaml_path), "hint": "Expected a YAML mapping"}) + if errors is not None: + errors.append(f"{yaml_path}: expected a YAML mapping") return try: item = ext.yaml_item_factory(raw, yaml_path) except Exception as e: log.error("plugin.yaml_factory_failed", {"path": str(yaml_path), "error": str(e)}) + if errors is not None: + errors.append(f"{yaml_path}: {e}") return - items = cls._validate_and_dedup(ext, [item], str(yaml_path)) + items = cls._validate_and_dedup( + ext, + [item], + str(yaml_path), + errors=errors, + ) if items: - ext.consumer(items, str(yaml_path)) + try: + consumer_errors = ext.consumer(items, str(yaml_path)) + except Exception as e: + log.error( + "plugin.consumer_failed", + { + "source": str(yaml_path), + "attr": ext.attr_name, + "error": str(e), + }, + ) + if errors is None: + raise + errors.append( + f"{yaml_path}: consumer: {type(e).__name__}: {e}" + ) + return + if errors is not None and consumer_errors: + errors.extend( + f"{yaml_path}: {error}" for error in consumer_errors + ) log.debug( "plugin.yaml_dispatched", { @@ -549,20 +629,27 @@ def _validate_and_dedup( ext: ExtensionPoint, raw_items: List[Any], source: str, + errors: Optional[List[str]] = None, ) -> List[Any]: """Type-check and deduplicate items for an extension point.""" if ext.item_type is not None: valid = [it for it in raw_items if isinstance(it, ext.item_type)] if len(valid) < len(raw_items): + invalid_count = len(raw_items) - len(valid) log.warn( "plugin.invalid_entries", { "source": source, "attr": ext.attr_name, "expected_type": ext.item_type.__name__, - "invalid_count": len(raw_items) - len(valid), + "invalid_count": invalid_count, }, ) + if errors is not None: + errors.append( + f"{source}: {invalid_count} invalid {ext.attr_name} " + f"entries; expected {ext.item_type.__name__}" + ) else: valid = raw_items diff --git a/flocks/server/app.py b/flocks/server/app.py index f557c79e8..78271d130 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -11,13 +11,15 @@ from dataclasses import dataclass from types import SimpleNamespace from pathlib import Path -from typing import Any, Callable, Optional +from typing import Any, AsyncGenerator, Callable, Optional from contextlib import asynccontextmanager from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError +from starlette.datastructures import MutableHeaders from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.types import ASGIApp, Message, Receive, Scope, Send from flocks.utils.log import Log, LogLevel from flocks.config.config import Config @@ -814,132 +816,277 @@ async def __call__(self, scope, receive, send): await self._inner(scope, receive, send) -@app.middleware("http") -async def security_headers_middleware(request: Request, call_next): - """Attach baseline browser security headers to every HTTP response.""" - response = await call_next(request) - for name, value in _SECURITY_HEADERS.items(): - response.headers.setdefault(name, value) - return response +class _SecurityHeadersMiddleware: + """Attach baseline browser security headers without spawning request tasks.""" + def __init__(self, app: ASGIApp) -> None: + self.app = app -@app.middleware("http") -async def instance_context_middleware(request: Request, call_next): - """ - Provide Instance context for all requests (except global routes) - - Middleware that wraps all routes with Instance.provide(). - Gets directory from: - 1. Query parameter 'directory' - 2. Header 'x-flocks-directory' - 3. Falls back to current working directory - """ - import os - from urllib.parse import unquote - from flocks.project.instance import Instance - from flocks.project.bootstrap import instance_bootstrap - - # Skip instance context for global routes, static files, and simple endpoints - skip_prefixes = { - "/global", "/docs", "/redoc", "/openapi.json", "/health", - "/path", "/permission", "/question", "/tui", - } - - if any(request.url.path.startswith(prefix) for prefix in skip_prefixes): - return await call_next(request) - - # Get directory from query param, header, or use cwd - # Support both x-flocks-directory (native) and x-flocks-directory (TUI compatibility) - directory = request.query_params.get("directory") - if not directory: - directory = request.headers.get("x-flocks-directory") - if not directory: - directory = request.headers.get("x-flocks-directory") - if not directory: - directory = os.getcwd() - - # Decode URL-encoded directory - try: - directory = unquote(directory) - except Exception: - pass # Use original value if decode fails - - # Provide instance context for the request - async def handle_request(): - return await call_next(request) - - return await Instance.provide( - directory=directory, - init=instance_bootstrap, - fn=handle_request + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + async def send_with_security_headers(message: Message) -> None: + if message["type"] == "http.response.start": + headers = MutableHeaders(scope=message) + for name, value in _SECURITY_HEADERS.items(): + headers.setdefault(name, value) + await send(message) + + await self.app(scope, receive, send_with_security_headers) + + +app.add_middleware(_SecurityHeadersMiddleware) + + +class _InstanceContextMiddleware: + """Provide the project instance context without buffering the response body.""" + + _SKIP_PREFIXES = ( + "/global", + "/docs", + "/redoc", + "/openapi.json", + "/health", + "/path", + "/permission", + "/question", + "/tui", ) + def __init__(self, app: ASGIApp) -> None: + self.app = app -# Request Logging Middleware -@app.middleware("http") -async def log_requests(request: Request, call_next): - """Log one completion line for useful requests; suppress successful polling noise.""" - path = request.url.path - started_at = time.monotonic() + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return - try: - response = await call_next(request) - except Exception as exc: - duration_ms = int((time.monotonic() - started_at) * 1000) - log.error("request.error", { - "method": request.method, - "path": path, - "duration": duration_ms, - "error": str(exc), - }) - raise + path = scope.get("path", "") + if path.startswith(self._SKIP_PREFIXES): + await self.app(scope, receive, send) + return - if _should_log_request(path, response.status_code): - duration_ms = int((time.monotonic() - started_at) * 1000) - log.info("request.complete", { - "method": request.method, - "path": path, - "status": response.status_code, - "duration": duration_ms, - }) + from urllib.parse import unquote + from flocks.project.instance import Instance + from flocks.project.bootstrap import instance_bootstrap - return response + request = Request(scope, receive=receive) + directory = request.query_params.get("directory") + if not directory: + directory = request.headers.get("x-flocks-directory") + if not directory: + directory = os.getcwd() + try: + directory = unquote(directory) + except Exception: + pass -@app.middleware("http") -async def auth_guard_middleware(request: Request, call_next): - """Guard requests with local account auth, except public endpoints.""" - try: - await _run_http_middleware_hooks(request, {"stage": "before_auth"}) - _blocked, token, _user = await apply_auth_for_request(request) - except StarletteHTTPException as exc: - return JSONResponse( - status_code=exc.status_code, - content={"error": "AuthError", "message": exc.detail}, - ) - except Exception as exc: - log.error("auth.middleware.unexpected", { - "path": request.url.path, - "error": repr(exc), - }) - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"error": "InternalError", "message": "鉴权处理异常,请稍后重试"}, + async def handle_request() -> None: + await self.app(scope, receive, send) + + await Instance.provide( + directory=directory, + init=instance_bootstrap, + fn=handle_request, ) - try: - return await call_next(request) - finally: - clear_auth_context(token) + +app.add_middleware(_InstanceContextMiddleware) + + +class _RequestLoggingMiddleware: + """Log response start without retaining an extra task for streaming bodies.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + method = scope.get("method", "") + started_at = time.monotonic() + response_started = False + + async def send_with_request_log(message: Message) -> None: + nonlocal response_started + if message["type"] == "http.response.start" and not response_started: + response_started = True + status_code = int(message["status"]) + if _should_log_request(path, status_code): + duration_ms = int((time.monotonic() - started_at) * 1000) + log.info( + "request.complete", + { + "method": method, + "path": path, + "status": status_code, + "duration": duration_ms, + }, + ) + await send(message) + + try: + await self.app(scope, receive, send_with_request_log) + except Exception as exc: + if not response_started: + duration_ms = int((time.monotonic() - started_at) * 1000) + log.error( + "request.error", + { + "method": method, + "path": path, + "duration": duration_ms, + "error": str(exc), + }, + ) + raise + + +app.add_middleware(_RequestLoggingMiddleware) + + +class _HookRequest(Request): + """Preserve request-receive semantics while auth hooks inspect the body. + + A body read is replayed once to the endpoint, a fully consumed stream is + replayed as an empty request, and a partially consumed stream continues + from upstream. Once a disconnect is observed, downstream reads keep seeing + the disconnect instead of polling upstream again. + """ + + def __init__(self, scope: Scope, receive: Receive) -> None: + self._flocks_upstream_receive = receive + self._flocks_request_complete = False + self._flocks_disconnected = False + self._flocks_cached_body: Optional[bytes] = None + self._flocks_stream_consumed = False + super().__init__(scope, receive=self._receive_for_hook) + + async def _receive_for_hook(self) -> Message: + message = await self._flocks_upstream_receive() + if message["type"] == "http.request": + if not message.get("more_body", False): + self._flocks_request_complete = True + elif message["type"] == "http.disconnect": + self._flocks_disconnected = True + return message + + async def body(self) -> bytes: + body = await super().body() + self._flocks_cached_body = body + return body + + async def stream(self) -> AsyncGenerator[bytes, None]: + async for chunk in super().stream(): + if self._flocks_request_complete: + self._flocks_stream_consumed = True + yield chunk + self._flocks_stream_consumed = True + + def downstream_receive(self) -> Receive: + """Return the receive callable that preserves the hook's body reads.""" + has_replay = self._flocks_cached_body is not None or self._flocks_stream_consumed + if not has_replay and not self._flocks_disconnected: + return self._flocks_upstream_receive + + replay_pending = has_replay + replay_body = self._flocks_cached_body if self._flocks_cached_body is not None else b"" + + async def receive_for_downstream() -> Message: + nonlocal replay_pending + if replay_pending: + replay_pending = False + return { + "type": "http.request", + "body": replay_body, + "more_body": False, + } + if self._flocks_disconnected: + return {"type": "http.disconnect"} + + message = await self._flocks_upstream_receive() + if message["type"] == "http.disconnect": + self._flocks_disconnected = True + return message + if has_replay: + raise RuntimeError(f"Unexpected message received: {message['type']}") + return message + + return receive_for_downstream + + +class _AuthGuardMiddleware: + """Bind request auth context in the same ASGI task as the endpoint.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = _HookRequest(scope, receive) + try: + await _run_http_middleware_hooks(request, {"stage": "before_auth"}) + _blocked, token, _user = await apply_auth_for_request(request) + except StarletteHTTPException as exc: + response = JSONResponse( + status_code=exc.status_code, + content={"error": "AuthError", "message": exc.detail}, + ) + await response(scope, receive, send) + return + except Exception as exc: + log.error( + "auth.middleware.unexpected", + { + "path": request.url.path, + "error": repr(exc), + }, + ) + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"error": "InternalError", "message": "鉴权处理异常,请稍后重试"}, + ) + await response(scope, receive, send) + return + + try: + await self.app(scope, request.downstream_receive(), send) + finally: + clear_auth_context(token) + + +app.add_middleware(_AuthGuardMiddleware) + + +class _StaticWebUIMiddleware: + """Serve the SPA shell before auth without a BaseHTTPMiddleware task.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + static_response = await maybe_serve_static_webui(request) + if static_response is not None: + await static_response(scope, receive, send) + return + await self.app(scope, receive, send) -@app.middleware("http") -async def static_webui_middleware(request: Request, call_next): - """Serve the SPA shell before auth for browser navigations.""" - static_response = await maybe_serve_static_webui(request) - if static_response is not None: - return static_response - return await call_next(request) +app.add_middleware(_StaticWebUIMiddleware) # Error Handlers @@ -1059,6 +1206,8 @@ async def general_exception_handler(request: Request, exc: Exception): from flocks.server.routes.update import router as update_router # Log viewing from flocks.server.routes.logs import router as logs_router +from flocks.server.routes.monitoring import router as monitoring_router +from flocks.server.routes.stats import router as stats_router from flocks.server.routes.auth import router as auth_router from flocks.server.routes.admin_users import router as admin_users_router from flocks.server.routes.notifications import router as notifications_router @@ -1120,6 +1269,8 @@ async def general_exception_handler(request: Request, exc: Exception): app.include_router(update_router, prefix="/api/update", tags=["Update"]) # Log viewing routes app.include_router(logs_router, prefix="/api/logs", tags=["Logs"]) +app.include_router(stats_router, prefix="/api/stats", tags=["Stats"]) +app.include_router(monitoring_router, prefix="/api/monitoring", tags=["Monitoring"]) app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(admin_users_router, prefix="/api/admin", tags=["Admin"]) app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) @@ -1182,6 +1333,7 @@ async def general_exception_handler(request: Request, exc: Exception): # Permission routes (/permission) app.include_router(permission_router, prefix="/permission", tags=["Permission"]) +app.include_router(permission_router, prefix="/api/permission", tags=["Permission"]) # Question routes (/question) app.include_router(question_router, prefix="/question", tags=["Question"]) diff --git a/flocks/server/routes/channel.py b/flocks/server/routes/channel.py index 518e3fcbd..02349aaff 100644 --- a/flocks/server/routes/channel.py +++ b/flocks/server/routes/channel.py @@ -380,6 +380,85 @@ async def weixin_qr_login_status(qrcode: str, baseUrl: Optional[str] = None): raise HTTPException(status_code=502, detail=str(exc)) +# --------------------------------------------------------------------------- +# WhatsApp QR pairing +# --------------------------------------------------------------------------- + +class WhatsAppPairStartRequest(BaseModel): + sessionPath: Optional[str] = None + bridgeDir: Optional[str] = None + resetSession: bool = False + + +@router.post("/whatsapp/pair/start") +async def whatsapp_pair_start(req: WhatsAppPairStartRequest): + """Start a WhatsApp Web QR pairing process. + + The helper starts the bundled Node bridge in pair-only mode and returns a + pairing_id. The frontend polls /whatsapp/pair/{pairing_id}/status until a + QR code is available or pairing completes. + """ + from flocks.channel.builtin.whatsapp.pairing import start_pairing + + try: + pairing = await start_pairing( + session_path=req.sessionPath, + bridge_dir=req.bridgeDir, + reset_session=req.resetSession, + ) + return { + "ok": True, + "pairing_id": pairing.id, + "status": pairing.status, + "session_path": str(pairing.session_path), + } + except Exception as exc: + log.error("whatsapp.pair.start_failed", {"error": str(exc)}) + raise HTTPException(status_code=502, detail=str(exc)) + + +@router.get("/whatsapp/session-status") +async def whatsapp_session_status(sessionPath: Optional[str] = None): + """Return whether the configured WhatsApp session contains credentials.""" + from pathlib import Path + + from flocks.channel.builtin.whatsapp.config import default_session_path + + path = Path(sessionPath).expanduser() if sessionPath else default_session_path() + return { + "session_path": str(path), + "paired": (path / "creds.json").exists(), + } + + +@router.get("/whatsapp/pair/{pairing_id}/status") +async def whatsapp_pair_status(pairing_id: str): + from flocks.channel.builtin.whatsapp.pairing import get_pairing + + pairing = get_pairing(pairing_id) + if pairing is None: + raise HTTPException(status_code=404, detail="WhatsApp pairing session not found") + return { + "ok": True, + "pairing_id": pairing.id, + "status": pairing.status, + "qr": pairing.qr, + "error": pairing.error, + "user": pairing.user, + "session_path": str(pairing.session_path), + } + + +@router.post("/whatsapp/pair/{pairing_id}/cancel") +async def whatsapp_pair_cancel(pairing_id: str): + from flocks.channel.builtin.whatsapp.pairing import cancel_pairing + + cancelled = await cancel_pairing(pairing_id) + if not cancelled: + raise HTTPException(status_code=404, detail="WhatsApp pairing session not found") + return {"ok": True, "pairing_id": pairing_id} + + # --------------------------------------------------------------------------- # Telegram pairing # --------------------------------------------------------------------------- diff --git a/flocks/server/routes/event.py b/flocks/server/routes/event.py index b5b5b5350..9f427cb67 100644 --- a/flocks/server/routes/event.py +++ b/flocks/server/routes/event.py @@ -14,15 +14,21 @@ import asyncio import json import os -from typing import AsyncGenerator, Optional +from collections import deque +from typing import AsyncGenerator, Optional, TYPE_CHECKING, cast from datetime import datetime from fastapi import APIRouter, Request from fastapi.responses import StreamingResponse +from flocks.server.auth import require_user from flocks.utils.log import Log from flocks.utils.id import Identifier +if TYPE_CHECKING: + from flocks.auth.context import AuthUser + from flocks.session.session import SessionInfo + router = APIRouter() log = Log.create(service="event-routes") @@ -33,12 +39,184 @@ "context.", "permission.", ) +EVENT_QUEUE_MAXSIZE = max(100, int(os.getenv("FLOCKS_EVENT_QUEUE_MAXSIZE", "1000"))) +EVENT_QUEUE_DROP_TO = max(0, min( + EVENT_QUEUE_MAXSIZE - 1, + int(os.getenv("FLOCKS_EVENT_QUEUE_DROP_TO", str(EVENT_QUEUE_MAXSIZE // 2))), +)) +EVENT_QUEUE_COALESCE_AT = max(1, min( + EVENT_QUEUE_MAXSIZE, + int(os.getenv("FLOCKS_EVENT_QUEUE_COALESCE_AT", str(min(EVENT_QUEUE_MAXSIZE, 64)))), +)) # Current directory context for SSE events _current_directory: str = os.getcwd() +def _event_session_id(event: dict) -> Optional[str]: + """Extract the session id from the event shapes emitted by the runtime.""" + properties = event.get("properties") + if not isinstance(properties, dict): + return None + + for key in ("sessionID", "session_id"): + value = properties.get(key) + if isinstance(value, str) and value: + return value + + for container_name in ("part", "info"): + container = properties.get(container_name) + if not isinstance(container, dict): + continue + for key in ("sessionID", "session_id"): + value = container.get(key) + if isinstance(value, str) and value: + return value + + event_type = event.get("type") + runtime_type = properties.get("runtimeType") + semantic_type = runtime_type if event_type == "runtime.event" else event_type + if isinstance(semantic_type, str) and semantic_type.startswith("session."): + value = properties.get("id") + if isinstance(value, str) and value: + return value + info = properties.get("info") + if isinstance(info, dict): + value = info.get("id") + if isinstance(value, str) and value: + return value + return None + + +async def _get_event_session(session_id: str) -> Optional["SessionInfo"]: + """Load a session without inheriting the publisher's auth context.""" + from flocks.auth.context import reset_current_auth_user, set_current_auth_user + from flocks.session.session import Session, SessionInfo + from flocks.storage.storage import Storage + + token = set_current_auth_user(None) + try: + session = await Session.get_by_id(session_id) + if session is not None: + return session + + cached_sessions = getattr(Session, "_all_sessions_cache", None) or [] + cached = next((item for item in cached_sessions if item.id == session_id), None) + if cached is not None: + return cached + + for key in await Storage.list_keys(prefix="session:"): + if key.endswith(f":{session_id}"): + return await Storage.get(key, SessionInfo) + return None + finally: + reset_current_auth_user(token) + + +def _snapshot_key(event: dict) -> Optional[tuple[str, str, str]]: + """Return the identity of a coalescible accumulated-text snapshot.""" + if event.get("type") != "message.part.updated": + return None + properties = event.get("properties") + part = properties.get("part") if isinstance(properties, dict) else None + if not isinstance(part, dict) or part.get("type") not in {"text", "reasoning", "thinking"}: + return None + session_id = part.get("sessionID") + message_id = part.get("messageID") + part_id = part.get("id") + if not isinstance(session_id, str) or not session_id: + return None + if not isinstance(message_id, str) or not message_id: + return None + if not isinstance(part_id, str) or not part_id: + return None + return session_id, message_id, part_id + + +def _merge_snapshots(previous: dict, current: dict) -> dict: + """Keep the latest full part while preserving all unconsumed deltas.""" + previous_properties = previous.get("properties") + current_properties = current.get("properties") + if not isinstance(previous_properties, dict) or not isinstance(current_properties, dict): + return current + + previous_delta = previous_properties.get("delta") + current_delta = current_properties.get("delta") + if not isinstance(previous_delta, str): + return current + + merged_properties = dict(current_properties) + merged_properties["delta"] = previous_delta + (current_delta if isinstance(current_delta, str) else "") + return {**current, "properties": merged_properties} + + +class EventQueue(asyncio.Queue[dict]): + """Queue with slow-client compaction for accumulated part snapshots.""" + + def __init__(self, maxsize: int = 0) -> None: + super().__init__(maxsize=maxsize) + self._pending_drop_count = 0 + + def _pending_events(self) -> deque[dict]: + return cast(deque[dict], self.__dict__["_queue"]) + + def coalesce_tail_snapshot(self, event: dict) -> bool: + """Merge only with the immediately preceding snapshot. + + Control events form an ordering barrier: snapshots on opposite sides of + one must remain separate even while a client is backpressured. + """ + key = _snapshot_key(event) + pending = self._pending_events() + if key is None or not pending: + return False + queued = pending[-1] + if _snapshot_key(queued) != key: + return False + pending[-1] = _merge_snapshots(queued, event) + return True + + def drop_snapshots_until(self, target_size: int) -> int: + pending = self._pending_events() + dropped = 0 + while len(pending) > target_size: + snapshot_index = next( + (index for index, queued in enumerate(pending) if _snapshot_key(queued) is not None), + None, + ) + if snapshot_index is None: + break + del pending[snapshot_index] + dropped += 1 + return dropped + + def report_dropped(self, count: int) -> None: + """Emit a recovery marker now or defer it until capacity is freed.""" + if count <= 0: + return + self._pending_drop_count += count + self._flush_drop_marker() + + def _flush_drop_marker(self) -> None: + if self._pending_drop_count <= 0 or self.full(): + return + dropped = self._pending_drop_count + self._pending_drop_count = 0 + super().put_nowait(create_event("server.events_dropped", { + "dropped": dropped, + "reason": "client_backpressure", + })) + + async def get(self) -> dict: + event = await super().get() + # A queue containing only control events has no safe item to evict for + # a marker. Once the client consumes one, append the deferred marker + # without sacrificing those control events. + self._flush_drop_marker() + return event + + def set_event_directory(directory: str): """Set the current directory for SSE events""" global _current_directory @@ -56,9 +234,29 @@ class EventBroadcaster: _instance: Optional["EventBroadcaster"] = None - def __init__(self): - self._clients: list[asyncio.Queue] = [] + def __init__( + self, + queue_maxsize: int = EVENT_QUEUE_MAXSIZE, + queue_drop_to: Optional[int] = None, + queue_coalesce_at: Optional[int] = None, + ) -> None: + self._clients: dict[EventQueue, Optional["AuthUser"]] = {} self._lock = asyncio.Lock() + self._queue_maxsize = max(1, queue_maxsize) + self._queue_drop_to = max( + 0, + min( + self._queue_maxsize - 1, + EVENT_QUEUE_DROP_TO if queue_drop_to is None else queue_drop_to, + ), + ) + self._queue_coalesce_at = max( + 1, + min( + self._queue_maxsize, + EVENT_QUEUE_COALESCE_AT if queue_coalesce_at is None else queue_coalesce_at, + ), + ) @classmethod def get(cls) -> "EventBroadcaster": @@ -67,42 +265,127 @@ def get(cls) -> "EventBroadcaster": cls._instance = EventBroadcaster() return cls._instance - async def subscribe(self) -> asyncio.Queue: + async def subscribe(self, user: Optional["AuthUser"] = None) -> EventQueue: """Subscribe a new client""" - queue: asyncio.Queue = asyncio.Queue() + queue = EventQueue(maxsize=self._queue_maxsize) async with self._lock: - self._clients.append(queue) + self._clients[queue] = user return queue - async def unsubscribe(self, queue: asyncio.Queue): + async def unsubscribe(self, queue: EventQueue) -> None: """Unsubscribe a client""" async with self._lock: - if queue in self._clients: - self._clients.remove(queue) + self._clients.pop(queue, None) - async def publish(self, event: dict): + async def publish(self, event: dict) -> None: """Publish event to all clients""" async with self._lock: - for queue in self._clients: + clients = list(self._clients.items()) + + session = None + session_id = _event_session_id(event) + if session_id: + try: + session = await _get_event_session(session_id) + except Exception as exc: + log.warning("event.session_access_lookup_failed", { + "session_id": session_id, + "event_type": event.get("type"), + "error": str(exc), + }) + return + if session is None: + log.debug("event.session_access_missing", { + "session_id": session_id, + "event_type": event.get("type"), + }) + return + + if session is not None: + from flocks.session.policy import SessionPolicy + + for queue, user in clients: + if session is not None and user is not None and not SessionPolicy.can_read(session, user): + continue + self._publish_to_queue(queue, event) + + def _publish_to_queue(self, queue: EventQueue, event: dict) -> None: + # Preserve the event cadence for healthy clients. Once a subscriber is + # materially behind, compact only adjacent snapshots for the same part; + # this bounds accumulated full-text payloads without crossing control + # events or changing their relative order. + if ( + queue.qsize() >= self._queue_coalesce_at + and queue.coalesce_tail_snapshot(event) + ): + return + + try: + queue.put_nowait(event) + return + except asyncio.QueueFull: + pass + + is_snapshot = _snapshot_key(event) is not None + marker_target = min(self._queue_drop_to, max(0, self._queue_maxsize - 2)) + dropped = queue.drop_snapshots_until(marker_target) + + if is_snapshot and queue.qsize() > marker_target: + # A text snapshot must never evict permission/question/session + # control events. If removing older snapshots only leaves room for + # the recovery marker, keep that marker and discard this ordinary + # snapshot so the client knows it must reconcile over REST. + # Include the incoming snapshot itself. If the queue consists only + # of control events, defer the marker until the client frees one + # slot rather than evicting a permission/question/session event. + dropped += 1 + queue.report_dropped(dropped) + log.debug("event.queue.snapshot_dropped", { + "queue_size": queue.qsize(), + "queue_maxsize": self._queue_maxsize, + "event_type": event.get("type"), + }) + return + else: + while queue.qsize() > marker_target: try: - await queue.put(event) - except Exception: - pass # Ignore errors for disconnected clients + queue.get_nowait() + dropped += 1 + except asyncio.QueueEmpty: + break + + reported_drops = 0 + if dropped > 0 and queue.qsize() <= self._queue_maxsize - 2: + queue.report_dropped(dropped) + reported_drops = dropped + + try: + queue.put_nowait(event) + except asyncio.QueueFull: + dropped += 1 + + if dropped > reported_drops: + queue.report_dropped(dropped - reported_drops) + + if dropped > 0: + log.debug("event.queue.overflow", { + "dropped": dropped, + "queue_size": queue.qsize(), + "queue_maxsize": self._queue_maxsize, + "event_type": event.get("type"), + }) @property def client_count(self) -> int: """Number of connected clients""" return len(self._clients) - async def shutdown(self): + async def shutdown(self) -> None: """Notify all clients that the server is shutting down, then clear.""" shutdown_event = create_event("server.shutting_down", {}) async with self._lock: for queue in self._clients: - try: - await queue.put(shutdown_event) - except Exception: - pass + self._publish_to_queue(queue, shutdown_event) self._clients.clear() log.info("event.broadcaster.shutdown", {"clients_notified": True}) @@ -186,7 +469,7 @@ async def publish_runtime_event(runtime_type: str, properties: dict = None, dire async def sse_generator( - queue: asyncio.Queue, + queue: EventQueue, request: Request, directory: str = None, ) -> AsyncGenerator[str, None]: @@ -238,7 +521,8 @@ async def subscribe_events(request: Request): Returns: StreamingResponse with SSE events """ - queue = await EventBroadcaster.get().subscribe() + user = require_user(request) + queue = await EventBroadcaster.get().subscribe(user) log.info("event.subscribe", { "clients": EventBroadcaster.get().client_count, diff --git a/flocks/server/routes/global_.py b/flocks/server/routes/global_.py index 75dfc45a1..afe3fd518 100644 --- a/flocks/server/routes/global_.py +++ b/flocks/server/routes/global_.py @@ -18,6 +18,7 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel +from flocks.server.auth import require_user from flocks.utils.log import Log from flocks.server.routes.event import EventBroadcaster, sse_generator @@ -54,7 +55,8 @@ async def get_global_events(request: Request): This is the main event endpoint that Flocks TUI uses. """ - queue = await EventBroadcaster.get().subscribe() + user = require_user(request) + queue = await EventBroadcaster.get().subscribe(user) log.info("global.event.subscribe", { "clients": EventBroadcaster.get().client_count, diff --git a/flocks/server/routes/hub.py b/flocks/server/routes/hub.py index ba37eef83..92da6378d 100644 --- a/flocks/server/routes/hub.py +++ b/flocks/server/routes/hub.py @@ -3,13 +3,21 @@ from __future__ import annotations import asyncio -from typing import Optional +from typing import Optional, Union -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from flocks.hub.catalog import category_counts, clear_catalog_caches, legacy_removed_plugin_message, list_catalog, load_manifest +from flocks.hub.catalog import ( + category_counts, + clear_catalog_caches, + filter_catalog_entries, + legacy_removed_plugin_message, + list_catalog, + load_manifest, + load_taxonomy, +) from flocks.hub.files import file_tree, read_file_content from flocks.hub.installer import install_plugin, uninstall_plugin, update_plugin from flocks.hub.models import ( @@ -21,6 +29,7 @@ InstalledPluginRecord, PluginType, ) +from flocks.server.auth import require_admin from flocks.utils.log import Log @@ -32,6 +41,24 @@ class HubInstallRequest(BaseModel): scope: str = Field(default="global", description="'global' only") +class HubCatalogFacets(BaseModel): + type: dict[str, int] = Field(default_factory=dict) + category: dict[str, int] = Field(default_factory=dict) + tags: dict[str, int] = Field(default_factory=dict) + useCases: dict[str, int] = Field(default_factory=dict) + state: dict[str, int] = Field(default_factory=dict) + trust: dict[str, int] = Field(default_factory=dict) + riskLevel: dict[str, int] = Field(default_factory=dict) + + +class HubCatalogPageResponse(BaseModel): + items: list[HubCatalogEntry] = Field(default_factory=list) + total: int = Field(0) + offset: int = Field(0) + limit: int = Field(25) + facets: HubCatalogFacets = Field(default_factory=HubCatalogFacets) + + def _split_csv(value: Optional[str | list[str]]) -> Optional[list[str]]: if value is None: return None @@ -59,7 +86,46 @@ def _clear_hub_runtime_caches() -> None: pass -@router.get("/hub/catalog", response_model=list[HubCatalogEntry]) +def _count_hub_catalog_facet( + items: list[HubCatalogEntry], + attribute: str, +) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + values = getattr(item, attribute) + if isinstance(values, str): + values = [values] + for value in values: + counts[value] = counts.get(value, 0) + 1 + return counts + + +def _build_hub_catalog_facets_for_filters( + all_entries: list[HubCatalogEntry], + filters: dict[str, object], +) -> HubCatalogFacets: + """Build each facet with every active filter except that facet itself.""" + dimensions = { + "type": ("plugin_type", "type"), + "category": ("category", "category"), + "tags": ("tags", "tags"), + "useCases": ("use_cases", "useCases"), + "state": ("state", "state"), + "trust": ("trust", "trust"), + "riskLevel": ("risk", "riskLevel"), + } + counts: dict[str, dict[str, int]] = {} + for response_field, (query_field, item_attribute) in dimensions.items(): + facet_filters = dict(filters) + facet_filters[query_field] = None + counts[response_field] = _count_hub_catalog_facet( + filter_catalog_entries(all_entries, **facet_filters), + item_attribute, + ) + return HubCatalogFacets(**counts) + + +@router.get("/hub/catalog", response_model=Union[list[HubCatalogEntry], HubCatalogPageResponse]) async def hub_catalog( type: Optional[PluginType] = Query(default=None), # noqa: A002 - API field name category: Optional[str] = None, @@ -69,22 +135,45 @@ async def hub_catalog( trust: Optional[str] = None, risk: Optional[str] = None, q: Optional[str] = None, + offset: int = Query(0, ge=0), + limit: Optional[int] = Query(default=None, ge=1, le=200), ): - return await asyncio.to_thread( - list_catalog, - plugin_type=type, - category=_split_csv(category), - tags=_split_csv(tags), - use_cases=_split_csv(useCases), - state=_split_csv(state), - trust=_split_csv(trust), - risk=_split_csv(risk), - q=q, + filters: dict[str, object] = { + "plugin_type": type, + "category": _split_csv(category), + "tags": _split_csv(tags), + "use_cases": _split_csv(useCases), + "state": _split_csv(state), + "trust": _split_csv(trust), + "risk": _split_csv(risk), + "q": q, + } + if limit is None and offset == 0: + return await asyncio.to_thread(list_catalog, **filters) + + def load_page() -> tuple[list[HubCatalogEntry], HubCatalogFacets]: + all_entries = list_catalog() + entries = filter_catalog_entries(all_entries, **filters) + facets = _build_hub_catalog_facets_for_filters(all_entries, filters) + return entries, facets + + entries, facets = await asyncio.to_thread(load_page) + + page_limit = limit or 25 + total = len(entries) + return HubCatalogPageResponse( + items=entries[offset:offset + page_limit], + total=total, + offset=offset, + limit=page_limit, + facets=facets, ) @router.get("/hub/categories") -async def hub_categories(): +async def hub_categories(include_counts: bool = Query(True)): + if not include_counts: + return load_taxonomy().model_dump(mode="json") return await asyncio.to_thread(category_counts) @@ -120,7 +209,12 @@ async def hub_plugin_file_content(plugin_type: PluginType, plugin_id: str, path: @router.post("/hub/plugins/{plugin_type}/{plugin_id}/install", response_model=InstalledPluginRecord) -async def hub_install_plugin(plugin_type: PluginType, plugin_id: str, req: HubInstallRequest = HubInstallRequest()): +async def hub_install_plugin( + plugin_type: PluginType, + plugin_id: str, + req: HubInstallRequest = HubInstallRequest(), + _admin: object = Depends(require_admin), +): _guard_legacy_removed_plugin(plugin_type, plugin_id) try: return await install_plugin(plugin_type, plugin_id, scope=req.scope) @@ -130,7 +224,12 @@ async def hub_install_plugin(plugin_type: PluginType, plugin_id: str, req: HubIn @router.post("/hub/plugins/{plugin_type}/{plugin_id}/install/stream") -async def hub_install_plugin_stream(plugin_type: PluginType, plugin_id: str, req: HubInstallRequest = HubInstallRequest()): +async def hub_install_plugin_stream( + plugin_type: PluginType, + plugin_id: str, + req: HubInstallRequest = HubInstallRequest(), + _admin: object = Depends(require_admin), +): _guard_legacy_removed_plugin(plugin_type, plugin_id) if plugin_type != "component": raise HTTPException(status_code=400, detail="Streaming install progress is only supported for components.") @@ -179,7 +278,12 @@ async def run_install() -> None: @router.post("/hub/plugins/{plugin_type}/{plugin_id}/update", response_model=InstalledPluginRecord) -async def hub_update_plugin(plugin_type: PluginType, plugin_id: str, req: HubInstallRequest = HubInstallRequest()): +async def hub_update_plugin( + plugin_type: PluginType, + plugin_id: str, + req: HubInstallRequest = HubInstallRequest(), + _admin: object = Depends(require_admin), +): _guard_legacy_removed_plugin(plugin_type, plugin_id) try: return await update_plugin(plugin_type, plugin_id, scope=req.scope) @@ -189,7 +293,11 @@ async def hub_update_plugin(plugin_type: PluginType, plugin_id: str, req: HubIns @router.delete("/hub/plugins/{plugin_type}/{plugin_id}") -async def hub_uninstall_plugin(plugin_type: PluginType, plugin_id: str): +async def hub_uninstall_plugin( + plugin_type: PluginType, + plugin_id: str, + _admin: object = Depends(require_admin), +): _guard_legacy_removed_plugin(plugin_type, plugin_id) try: removed = await uninstall_plugin(plugin_type, plugin_id) @@ -199,6 +307,6 @@ async def hub_uninstall_plugin(plugin_type: PluginType, plugin_id: str): @router.post("/hub/refresh") -async def hub_refresh(): +async def hub_refresh(_admin: object = Depends(require_admin)): _clear_hub_runtime_caches() return {"count": len(await asyncio.to_thread(list_catalog))} diff --git a/flocks/server/routes/monitoring.py b/flocks/server/routes/monitoring.py new file mode 100644 index 000000000..9a43dcdfc --- /dev/null +++ b/flocks/server/routes/monitoring.py @@ -0,0 +1,225 @@ +""" +Lightweight WebUI monitoring routes. + +These endpoints back the existing Monitoring page without forcing the page to +poll heavier session/tool list APIs. Detailed historical sampling can be added +later without changing the frontend contract. +""" + +from __future__ import annotations + +import time +import threading +from typing import Any, Literal + +from fastapi import APIRouter, Query +from pydantic import BaseModel, Field + +from flocks.agent import registry as agent_registry +from flocks.mcp import MCP +from flocks.session.session import Session +from flocks.utils.log import Log +from flocks.utils.monitor import get_monitor + +router = APIRouter() +log = Log.create(service="monitoring-routes") +_started_at = time.monotonic() + + +class SystemStatusResponse(BaseModel): + status: Literal["healthy", "degraded", "down"] + uptime: int + activeSessions: int | None + activeAgents: int | None + mcpServers: dict[str, str] + timestamp: int + + +class MetricsSnapshotResponse(BaseModel): + timestamp: int + messageRate: float | None = Field( + default=None, + description="Messages per minute. None because message throughput is not collected yet.", + ) + toolCallRate: float | None = Field( + default=None, + description="Parsed tool calls per minute.", + ) + errorRate: float | None = Field( + default=None, + description="System-wide error rate. None until a global error metric is available.", + ) + toolParseFailureRate: float | None = Field( + default=None, + description="Failed tool-call parses per minute.", + ) + avgResponseTime: float | None = Field( + default=None, + description="Average response time in milliseconds. None until latency is collected.", + ) + activeRequests: int | None = Field( + default=None, + description="Active request count. None until request concurrency is collected.", + ) + + +class PerformanceDataResponse(BaseModel): + category: Literal["llm", "tool", "api"] + name: str + avgDuration: float + p50: float | None = None + p95: float | None = None + p99: float | None = None + count: int + errors: int + + +def _timestamp_ms() -> int: + return int(time.time() * 1000) + + +async def _active_session_count() -> int | None: + cached_sessions = getattr(Session, "_all_sessions_cache", None) + if cached_sessions is None: + return None + return sum(1 for session in cached_sessions if getattr(session, "status", None) == "active") + + +async def _active_agent_count() -> int | None: + agents = getattr(agent_registry, "_agents_ref", None) + if agents is None: + return None + return sum(1 for agent in agents.values() if not getattr(agent, "hidden", False)) + + +async def _mcp_server_statuses() -> tuple[dict[str, str], bool]: + try: + statuses, initialized = MCP.status_snapshot() + except Exception as exc: + log.warning("monitoring.mcp.status_failed", {"error": str(exc)}) + return {}, False + return ({ + name: str(info.status.value if hasattr(info.status, "value") else info.status) + for name, info in statuses.items() + }, initialized) + + +_metrics_sample_lock = threading.Lock() +_METRICS_RATE_WINDOW_SECONDS = 60.0 +_metrics_rate_state: tuple[ + float, + float, + float, + float | None, + float | None, +] | None = None + + +def _metrics_clock() -> float: + return time.monotonic() + + +def _sample_tool_rates(total_calls: float, failed_calls: float) -> tuple[float | None, float | None]: + """Return a stable process-wide rate snapshot for the current time window.""" + global _metrics_rate_state + + now = _metrics_clock() + with _metrics_sample_lock: + state = _metrics_rate_state + if state is None: + _metrics_rate_state = (now, total_calls, failed_calls, None, None) + return None, None + + sampled_at, previous_calls, previous_failures, call_rate, failure_rate = state + elapsed_seconds = now - sampled_at + counters_reset = total_calls < previous_calls or failed_calls < previous_failures + if counters_reset: + _metrics_rate_state = (now, total_calls, failed_calls, None, None) + return None, None + if elapsed_seconds < _METRICS_RATE_WINDOW_SECONDS: + return call_rate, failure_rate + + elapsed_minutes = elapsed_seconds / 60 + call_rate = (total_calls - previous_calls) / elapsed_minutes + failure_rate = (failed_calls - previous_failures) / elapsed_minutes + _metrics_rate_state = (now, total_calls, failed_calls, call_rate, failure_rate) + return call_rate, failure_rate + + +@router.get("/status", response_model=SystemStatusResponse) +async def get_status() -> SystemStatusResponse: + mcp_servers, mcp_status_available = await _mcp_server_statuses() + unhealthy_mcp = any( + status.strip().lower() not in {"connected", "disabled"} + for status in mcp_servers.values() + ) + return SystemStatusResponse( + status="degraded" if not mcp_status_available or unhealthy_mcp else "healthy", + uptime=max(0, int(time.monotonic() - _started_at)), + activeSessions=await _active_session_count(), + activeAgents=await _active_agent_count(), + mcpServers=mcp_servers, + timestamp=_timestamp_ms(), + ) + + +@router.get("/metrics", response_model=MetricsSnapshotResponse) +async def get_metrics() -> MetricsSnapshotResponse: + metrics = get_monitor().get_metrics().get("global", {}) + total_calls = float(metrics.get("total_calls") or 0) + failed_calls = float(metrics.get("failed_parses") or 0) + tool_call_rate, tool_parse_failure_rate = _sample_tool_rates(total_calls, failed_calls) + return MetricsSnapshotResponse( + timestamp=_timestamp_ms(), + messageRate=None, + toolCallRate=tool_call_rate, + errorRate=None, + toolParseFailureRate=tool_parse_failure_rate, + avgResponseTime=None, + activeRequests=None, + ) + + +@router.get("/metrics/history") +async def get_metrics_history( + duration: int = Query(300, ge=1), + interval: int = Query(5, ge=1), +) -> dict[str, Any]: + return {"duration": duration, "interval": interval, "items": []} + + +@router.get("/performance", response_model=list[PerformanceDataResponse]) +async def get_performance( + category: Literal["llm", "tool", "api"] | None = None, +) -> list[PerformanceDataResponse]: + return [] + + +@router.get("/performance/llm", response_model=list[PerformanceDataResponse]) +async def get_llm_performance() -> list[PerformanceDataResponse]: + return [] + + +@router.get("/performance/tool", response_model=list[PerformanceDataResponse]) +async def get_tool_performance() -> list[PerformanceDataResponse]: + return [] + + +@router.get("/api-stats") +async def get_api_stats() -> dict[str, Any]: + return {"items": []} + + +@router.get("/api-stats/history") +async def get_api_stats_history(duration: int = Query(300, ge=1)) -> dict[str, Any]: + return {"duration": duration, "items": []} + + +@router.get("/events") +async def get_events( + level: Literal["info", "warn", "error"] | None = None, + service: str | None = None, + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> list[dict[str, Any]]: + return [] diff --git a/flocks/server/routes/permission.py b/flocks/server/routes/permission.py index 4162c7d9b..073652b27 100644 --- a/flocks/server/routes/permission.py +++ b/flocks/server/routes/permission.py @@ -11,9 +11,13 @@ from typing import Dict, List, Any, Optional from datetime import datetime -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from flocks.auth.context import AuthUser +from flocks.server.auth import require_user +from flocks.session.policy import SessionPolicy +from flocks.session.session import Session from flocks.utils.log import Log from flocks.utils.id import Identifier from flocks.permission.next import PermissionNext @@ -119,21 +123,49 @@ async def remove_permission(permission_id: str) -> bool: return True +async def _can_access_permission_session( + session_id: str, + user: AuthUser, + *, + write: bool, +) -> bool: + session = await Session.get_by_id(session_id) + if session is None: + return False + return SessionPolicy.can_write(session, user) if write else SessionPolicy.can_read(session, user) + + +async def _require_permission_session_access( + session_id: str, + user: AuthUser, + *, + write: bool, +) -> None: + if not await _can_access_permission_session(session_id, user, write=write): + detail = "Only the session owner can reply to permissions" if write else "Permission not found" + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + + @router.get( "", response_model=List[PermissionInfo], summary="List permissions", description="Get all pending permission requests" ) -async def list_permissions() -> List[PermissionInfo]: +async def list_permissions( + user: AuthUser = Depends(require_user), +) -> List[PermissionInfo]: """ List all pending permission requests. Flocks TUI uses this to display permission prompts. """ pending_infos = await PermissionNext.list_pending_infos() - return [ - PermissionInfo( + result: List[PermissionInfo] = [] + for info in pending_infos: + if not await _can_access_permission_session(info.session_id, user, write=False): + continue + result.append(PermissionInfo( id=info.id, sessionID=info.session_id, messageID=info.metadata.get("messageID", ""), @@ -143,9 +175,8 @@ async def list_permissions() -> List[PermissionInfo]: always=info.always, metadata=info.metadata, time=info.time, - ) - for info in pending_infos - ] + )) + return result @router.get( @@ -154,11 +185,15 @@ async def list_permissions() -> List[PermissionInfo]: summary="Get permission", description="Get a specific permission request" ) -async def get_permission_by_id(permission_id: str) -> PermissionInfo: +async def get_permission_by_id( + permission_id: str, + user: AuthUser = Depends(require_user), +) -> PermissionInfo: """Get a specific permission request""" info = await PermissionNext.get_pending_info(permission_id) if not info: raise HTTPException(status_code=404, detail="Permission not found") + await _require_permission_session_access(info.session_id, user, write=False) return PermissionInfo( id=info.id, sessionID=info.session_id, @@ -180,6 +215,7 @@ async def get_permission_by_id(permission_id: str) -> PermissionInfo: async def reply_permission( permission_id: str, request: PermissionReplyRequest, + user: AuthUser = Depends(require_user), ) -> Dict[str, bool]: """ Reply to a permission request. @@ -194,6 +230,7 @@ async def reply_permission( info = await PermissionNext.get_pending_info(permission_id) if not info: raise HTTPException(status_code=404, detail="Permission not found") + await _require_permission_session_access(info.session_id, user, write=True) log.info("permission.reply", { "id": permission_id, @@ -201,7 +238,13 @@ async def reply_permission( "always": request.always, }) - reply = "always" if request.allow and request.always else "allow" if request.allow else "never" if request.always else "deny" + if request.always: + if SessionPolicy.is_admin(user): + reply = "always" if request.allow else "never" + else: + reply = "allow_session" if request.allow else "deny_session" + else: + reply = "allow" if request.allow else "deny" await PermissionNext.reply(permission_id, reply, session_id=info.session_id) return {"success": True} diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index 19c8e8397..79e4d7e37 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -6,16 +6,20 @@ temperature, tool_call, limit, etc. """ -import time +import asyncio +import json import re +import threading +import time from pathlib import Path -from typing import List, Optional, Dict, Any -from fastapi import APIRouter, Body, HTTPException, Query, status +from typing import Any, Dict, List, Optional +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from pydantic import BaseModel, Field, ConfigDict from flocks.utils.log import Log from flocks.provider.provider import Provider, ModelInfo as ProviderModelInfo from flocks.security.secrets import SecretManager +from flocks.server.auth import require_admin from flocks.config.config import Config from flocks.config.config_writer import ConfigWriter from flocks.storage.storage import Storage @@ -42,6 +46,62 @@ router = APIRouter() log = Log.create(service="provider-routes") +_api_service_summary_metadata_cache_lock = threading.Lock() +_api_service_summary_metadata_cache: Dict[str, tuple[tuple[Any, ...], Optional[Dict[str, Any]]]] = {} +_provider_initialization_lock = asyncio.Lock() +_provider_initialization_task: asyncio.Task[None] | None = None +_dynamic_provider_load_tasks: set[asyncio.Task[None]] = set() + + +async def _run_provider_initialization() -> None: + async with _provider_initialization_lock: + if Provider._initialized: + return + await asyncio.to_thread(Provider._ensure_initialized) + + +async def _ensure_provider_initialized() -> None: + """Run synchronous provider discovery without blocking the event loop.""" + global _provider_initialization_task + + if Provider._initialized: + return + + task = _provider_initialization_task + if task is None or task.done(): + task = asyncio.create_task(_run_provider_initialization()) + _provider_initialization_task = task + + def clear_completed(completed: asyncio.Task[None]) -> None: + global _provider_initialization_task + if _provider_initialization_task is completed: + _provider_initialization_task = None + if not completed.cancelled(): + completed.exception() + + task.add_done_callback(clear_completed) + + await asyncio.shield(task) + + +async def _run_dynamic_provider_load() -> None: + async with _provider_initialization_lock: + await asyncio.to_thread(Provider._load_dynamic_providers) + + +async def _load_dynamic_providers() -> None: + """Queue one serialized config scan per trigger without blocking the event loop.""" + task = asyncio.create_task(_run_dynamic_provider_load()) + _dynamic_provider_load_tasks.add(task) + + def clear_completed(completed: asyncio.Task[None]) -> None: + _dynamic_provider_load_tasks.discard(completed) + if not completed.cancelled(): + completed.exception() + + task.add_done_callback(clear_completed) + + await asyncio.shield(task) def _load_provider_yaml_metadata(provider_id: str) -> Optional[Dict[str, Any]]: @@ -71,6 +131,118 @@ def _load_api_service_metadata_data(provider_id: str) -> Optional[Dict[str, Any] return merged or None +def _clear_api_service_summary_metadata_cache(provider_id: Optional[str] = None) -> None: + with _api_service_summary_metadata_cache_lock: + if provider_id is None: + _api_service_summary_metadata_cache.clear() + else: + _api_service_summary_metadata_cache.pop(provider_id, None) + + +def _provider_yaml_cache_key(provider_id: str) -> tuple[str, int]: + descriptor = _find_api_service_descriptor(provider_id) + if descriptor is None: + return "", 0 + try: + return str(descriptor.provider_yaml), descriptor.provider_yaml.stat().st_mtime_ns + except OSError: + return str(descriptor.provider_yaml), 0 + + +def _legacy_metadata_cache_key(provider_id: str) -> tuple[str, int]: + meta_file = api_service_schema_helpers._LEGACY_METADATA_DIR / f"{provider_id}.json" + if not meta_file.is_file(): + return "", 0 + try: + return str(meta_file), meta_file.stat().st_mtime_ns + except OSError: + return str(meta_file), 0 + + +def _api_service_summary_metadata_cache_key(provider_id: str) -> tuple[Any, ...]: + config_data = ConfigWriter.get_api_service_raw(provider_id) or {} + return ( + json.dumps(config_data, sort_keys=True, default=str), + _legacy_metadata_cache_key(provider_id), + _provider_yaml_cache_key(provider_id), + ) + + +def _find_api_service_descriptor(provider_id: str): + from flocks.config.api_versioning import discover_api_service_descriptors + + return next( + ( + d + for d in discover_api_service_descriptors() + if provider_id in (d.storage_key, d.service_id) + ), + None, + ) + + +def _load_provider_yaml_summary_metadata(provider_id: str) -> Optional[Dict[str, Any]]: + descriptor = _find_api_service_descriptor(provider_id) + if descriptor is None: + return None + try: + prov = api_service_schema_helpers.yaml.safe_load( + descriptor.provider_yaml.read_text(encoding="utf-8") + ) + except Exception as e: + log.debug("api_service.summary_metadata.provider_yaml_failed", { + "provider_id": provider_id, + "path": str(descriptor.provider_yaml), + "error": str(e), + }) + return None + if not isinstance(prov, dict): + return None + return { + "name": prov.get("name", provider_id), + "service_id": prov.get("service_id", provider_id), + "version": extract_provider_version(prov), + "description": prov.get("description"), + "description_cn": prov.get("description_cn"), + "defaults": prov.get("defaults", {}), + "integration_type": prov.get("integration_type"), + "vendor": prov.get("vendor"), + } + + +def _load_api_service_summary_metadata_data(provider_id: str) -> Optional[Dict[str, Any]]: + """Load only metadata needed by the API service list endpoint.""" + cache_key = _api_service_summary_metadata_cache_key(provider_id) + with _api_service_summary_metadata_cache_lock: + cached = _api_service_summary_metadata_cache.get(provider_id) + if cached and cached[0] == cache_key: + return dict(cached[1]) if isinstance(cached[1], dict) else cached[1] + + merged: Dict[str, Any] = {} + config_data = ConfigWriter.get_api_service_raw(provider_id) + if isinstance(config_data, dict): + merged.update(config_data) + + meta_file = api_service_schema_helpers._LEGACY_METADATA_DIR / f"{provider_id}.json" + if meta_file.is_file(): + with open(meta_file, "r", encoding="utf-8") as f: + metadata_data = api_service_schema_helpers.json.load(f) + if isinstance(metadata_data, dict): + merged = {**metadata_data, **merged} + + yaml_data = _load_provider_yaml_summary_metadata(provider_id) + if isinstance(yaml_data, dict): + merged = {**yaml_data, **merged} + + result = merged or None + with _api_service_summary_metadata_cache_lock: + _api_service_summary_metadata_cache[provider_id] = ( + cache_key, + dict(result) if isinstance(result, dict) else result, + ) + return result + + _EMAIL_KEY_PAIR_PATTERN = re.compile( r"([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\s*:\s*([^\s]+)" ) @@ -353,8 +525,9 @@ async def list_providers() -> ProviderListResponse: List of providers with their models """ try: - # Ensure providers are initialized - Provider._ensure_initialized() + # Initial discovery imports provider modules and uses a threading lock. + # Keep that synchronous work off the server event loop. + await _ensure_provider_initialized() # Get config for enabled/disabled providers try: @@ -545,7 +718,8 @@ async def get_all_provider_auth() -> Dict[str, List[Any]]: ) async def oauth_authorize( provider_id: str, - method: int = 0 + method: int = 0, + _admin: object = Depends(require_admin), ) -> Optional[Dict[str, Any]]: """ Initiate OAuth authorization @@ -568,7 +742,8 @@ async def oauth_authorize( async def oauth_callback( provider_id: str, method: int, - code: Optional[str] = None + code: Optional[str] = None, + _admin: object = Depends(require_admin), ) -> bool: """ Handle OAuth callback @@ -598,6 +773,7 @@ async def list_api_services_route(): async def update_api_service_route( provider_id: str, request: Dict[str, Any] = Body(...), + _admin: object = Depends(require_admin), ): return await update_api_service(provider_id, APIServiceUpdateRequest.model_validate(request)) @@ -608,7 +784,10 @@ async def update_api_service_route( summary="Delete API service", description="Delete an API service configuration and its stored credential." ) -async def delete_api_service_route(provider_id: str): +async def delete_api_service_route( + provider_id: str, + _admin: object = Depends(require_admin), +): return await delete_api_service(provider_id) @@ -629,7 +808,7 @@ async def get_provider(provider_id: str) -> ProviderInfo: Provider information """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if not provider: @@ -686,7 +865,7 @@ async def list_models(provider_id: str) -> List[Dict[str, Any]]: List of models in Flocks-compatible format """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() if provider_id not in ConfigWriter.list_provider_ids(): return [] config = await Config.get() @@ -719,7 +898,11 @@ class ProviderConfigRequest(BaseModel): summary="Configure provider", description="Configure provider with API key and settings" ) -async def configure_provider(provider_id: str, config: ProviderConfigRequest) -> ProviderInfo: +async def configure_provider( + provider_id: str, + config: ProviderConfigRequest, + _admin: object = Depends(require_admin), +) -> ProviderInfo: """ Configure provider @@ -731,7 +914,7 @@ async def configure_provider(provider_id: str, config: ProviderConfigRequest) -> Updated provider information """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if not provider: @@ -770,7 +953,10 @@ async def configure_provider(provider_id: str, config: ProviderConfigRequest) -> summary="Test provider", description="Test provider connection" ) -async def test_provider(provider_id: str) -> Dict[str, Any]: +async def test_provider( + provider_id: str, + _admin: object = Depends(require_admin), +) -> Dict[str, Any]: """ Test provider connection @@ -781,7 +967,7 @@ async def test_provider(provider_id: str) -> Dict[str, Any]: Test result """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if not provider: @@ -825,14 +1011,18 @@ async def test_provider(provider_id: str) -> Dict[str, Any]: summary="Update provider", description="Update provider configuration" ) -async def update_provider(provider_id: str, config: ProviderConfigRequest) -> ProviderInfo: +async def update_provider( + provider_id: str, + config: ProviderConfigRequest, + _admin: object = Depends(require_admin), +) -> ProviderInfo: """ Update provider configuration Updates the provider's API key, base URL, and custom settings. """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if not provider: @@ -857,7 +1047,6 @@ async def update_provider(provider_id: str, config: ProviderConfigRequest) -> Pr config_key = f"provider_config/{provider_id}" await Storage.write(config_key, { "provider_id": provider_id, - "api_key": config.api_key, "base_url": config.base_url, "custom_settings": config.custom_settings, }) @@ -989,6 +1178,16 @@ def _set_api_service_tools_enabled(provider_id: str, enabled: bool) -> int: matched_tools = _get_api_service_tool_infos(provider_id) for tool_info in matched_tools: tool_info.enabled = enabled + if matched_tools: + try: + from flocks.server.routes.tool import _invalidate_tool_summary_cache + + _invalidate_tool_summary_cache() + except Exception as e: + log.debug("tool.summary_cache.invalidate_failed", { + "provider_id": provider_id, + "error": str(e), + }) return len(matched_tools) @@ -1031,7 +1230,7 @@ def _build_api_service_summary( raw_statuses: Dict[str, Any], ) -> APIServiceSummary: """Build API service summary used by the Tool API page.""" - meta = _load_api_service_metadata_data(provider_id) or {} + meta = _load_api_service_summary_metadata_data(provider_id) or {} enabled = _get_api_service_enabled(provider_id) cached_status = raw_statuses.get(provider_id) or {} matched_tools = _get_api_service_tool_infos(provider_id) @@ -1177,6 +1376,7 @@ async def update_api_service(provider_id: str, request: APIServiceUpdateRequest) if request.verify_ssl is not None: existing["verify_ssl"] = request.verify_ssl ConfigWriter.set_api_service(provider_id, existing) + _clear_api_service_summary_metadata_cache(provider_id) matched_count = _set_api_service_tools_enabled(provider_id, request.enabled) @@ -1306,6 +1506,7 @@ async def delete_api_service(provider_id: str) -> Dict[str, Any]: backing_plugin = _find_user_installed_tool_plugin_for(provider_id) removed_config = ConfigWriter.remove_api_service(provider_id) + _clear_api_service_summary_metadata_cache(provider_id) secrets = get_secret_manager() deleted_secret = False @@ -1461,7 +1662,10 @@ class ProviderCredentialResponse(BaseModel): summary="Get provider credentials (masked)", description="Get masked credential information for a registered LLM provider." ) -async def get_provider_credentials(provider_id: str): +async def get_provider_credentials( + provider_id: str, + _admin: object = Depends(require_admin), +): """Get credential info for an LLM provider (_llm_key convention). - api_key: from .secret.json (_llm_key first, legacy _api_key fallback) @@ -1503,10 +1707,10 @@ async def get_provider_credentials(provider_id: str): return ProviderCredentialResponse( secret_id=secret_id if api_key else None, - api_key=ui_api_key, + api_key=None, api_key_masked=( - SecretManager.mask(api_key) - if api_key and not is_placeholder + SecretManager.mask(ui_api_key) + if ui_api_key else None ), base_url=base_url, @@ -1523,7 +1727,11 @@ async def get_provider_credentials(provider_id: str): summary="Set provider credentials", description="Set authentication credentials for a provider or API service." ) -async def set_provider_credentials(provider_id: str, request: ProviderCredentialRequest): +async def set_provider_credentials( + provider_id: str, + request: ProviderCredentialRequest, + _admin: object = Depends(require_admin), +): """Set credentials for a provider. - api_key → .secret.json @@ -1537,37 +1745,66 @@ async def set_provider_credentials(provider_id: str, request: ProviderCredential try: # OpenAI-compatible endpoints (e.g. internal LLM gateways without auth) # and user-defined custom-* providers may legitimately have no API key. - # For other providers (OpenAI, Anthropic, etc.) the key is still required. + # For an already configured provider, an omitted/blank key means "keep + # the existing secret" so callers can safely update only the base URL + # or model selection without receiving the original key back first. api_key_optional = ( provider_id == "openai-compatible" or provider_id.startswith("custom-") ) + secrets = get_secret_manager() + secret_id = request.secret_id or f"{provider_id}_llm_key" effective_api_key = (request.api_key or "").strip() + preserve_existing_secret = False + if not effective_api_key: - if not api_key_optional: - raise HTTPException(status_code=400, detail="API key required") - # Persist a sentinel value so downstream OpenAI SDK clients (which - # require a non-empty key argument) keep working transparently. - # The GET endpoint masks this back to ``None`` for the UI. - effective_api_key = _NO_API_KEY_PLACEHOLDER + existing_secret_ids = [secret_id] + legacy_secret_id = f"{provider_id}_api_key" + if request.secret_id is None and legacy_secret_id not in existing_secret_ids: + existing_secret_ids.append(legacy_secret_id) + + for candidate in existing_secret_ids: + existing_value = secrets.get(candidate) + if isinstance(existing_value, str) and existing_value.strip(): + secret_id = candidate + effective_api_key = existing_value.strip() + preserve_existing_secret = True + break - secrets = get_secret_manager() + if not effective_api_key: + inline_api_key = _get_inline_provider_api_key(provider_id) + if inline_api_key: + effective_api_key = inline_api_key + preserve_existing_secret = True + + if not effective_api_key: + if not api_key_optional: + raise HTTPException(status_code=400, detail="API key required") + # Persist a sentinel value so downstream OpenAI SDK clients + # (which require a non-empty key argument) keep working. + effective_api_key = _NO_API_KEY_PLACEHOLDER # 1. Save API key to .secret.json using _llm_key convention for LLM providers - secret_id = request.secret_id or f"{provider_id}_llm_key" - secrets.set(secret_id, effective_api_key) - if _is_placeholder_api_key(effective_api_key): - masked = _NO_API_KEY_LOG_MARKER - elif len(effective_api_key) > 8: - masked = f"{effective_api_key[:4]}***{effective_api_key[-4:]}" + if preserve_existing_secret: + log.info("provider.credentials.preserved", { + "provider_id": provider_id, + "secret_id": secret_id, + "base_url": request.base_url, + }) else: - masked = "***" - log.info("provider.credentials.saving", { - "provider_id": provider_id, - "secret_id": secret_id, - "api_key_masked": masked, - "api_key_optional": api_key_optional, - "base_url": request.base_url, - }) + secrets.set(secret_id, effective_api_key) + if _is_placeholder_api_key(effective_api_key): + masked = _NO_API_KEY_LOG_MARKER + elif len(effective_api_key) > 8: + masked = f"{effective_api_key[:4]}***{effective_api_key[-4:]}" + else: + masked = "***" + log.info("provider.credentials.saving", { + "provider_id": provider_id, + "secret_id": secret_id, + "api_key_masked": masked, + "api_key_optional": api_key_optional, + "base_url": request.base_url, + }) # 2. Ensure provider entry exists in flocks.json and update base_url / name raw_provider = ConfigWriter.get_provider_raw(provider_id) @@ -1577,10 +1814,13 @@ async def set_provider_credentials(provider_id: str, request: ProviderCredential ConfigWriter.update_provider_field( provider_id, "options.baseURL", request.base_url ) - # Ensure apiKey reference is set - ConfigWriter.update_provider_field( - provider_id, "options.apiKey", f"{{secret:{provider_id}_llm_key}}" - ) + if not preserve_existing_secret: + # A newly supplied key uses the canonical secret reference. + # When preserving a stored or inline key, leave its existing + # config reference untouched. + ConfigWriter.update_provider_field( + provider_id, "options.apiKey", f"{{secret:{secret_id}}}" + ) if request.provider_name: ConfigWriter.update_provider_field( provider_id, "name", request.provider_name @@ -1633,7 +1873,7 @@ async def set_provider_credentials(provider_id: str, request: ProviderCredential ConfigWriter.add_provider(provider_id, config_dict) # 3. Configure the provider runtime so is_configured() reflects the change - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if provider: # Preserve the existing base_url from flocks.json when not explicitly provided, @@ -1683,7 +1923,10 @@ async def set_provider_credentials(provider_id: str, request: ProviderCredential summary="Delete provider credentials", description="Delete stored credentials for a provider or API service." ) -async def delete_provider_credentials(provider_id: str): +async def delete_provider_credentials( + provider_id: str, + _admin: object = Depends(require_admin), +): """Delete a provider: remove from flocks.json and .secret.json.""" from flocks.security import get_secret_manager @@ -1706,7 +1949,7 @@ async def delete_provider_credentials(provider_id: str): raise HTTPException(status_code=404, detail="No credentials found for this provider") # 4. Clear provider runtime config - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if provider: provider._config = None @@ -1738,7 +1981,10 @@ async def delete_provider_credentials(provider_id: str): summary="Get API service credentials (masked)", description="Get masked credential information for an API service (tool integrations)." ) -async def get_service_credentials(provider_id: str): +async def get_service_credentials( + provider_id: str, + _admin: object = Depends(require_admin), +): """Get credential info for an API service (_api_key convention). Reads the secret_id from flocks.json api_services.{id}.secret first, @@ -1795,15 +2041,30 @@ async def get_service_credentials(provider_id: str): field_values["api_key"] = split_api_key field_values["secret"] = split_secret + sensitive_field_names = { + field.key + for field in schema + if field.sensitive or field.storage == "secret" + } + sensitive_field_names.update(secret_ids) + safe_field_values = { + field_name: ( + SecretManager.mask(value) + if value and field_name in sensitive_field_names + else value + ) + for field_name, value in field_values.items() + } + return ProviderCredentialResponse( secret_id=secret_ids.get("api_key"), - api_key=field_values.get("api_key"), + api_key=None, api_key_masked=SecretManager.mask(field_values["api_key"]) if field_values.get("api_key") else None, - secret=field_values.get("secret"), + secret=None, secret_masked=SecretManager.mask(field_values["secret"]) if field_values.get("secret") else None, base_url=field_values.get("base_url"), username=field_values.get("username"), - fields=field_values or None, + fields=safe_field_values or None, secret_ids=secret_ids or None, has_credential=bool(any(value for value in field_values.values())), ) @@ -1818,7 +2079,11 @@ async def get_service_credentials(provider_id: str): summary="Set API service credentials", description="Set authentication credentials for an API service." ) -async def set_service_credentials(provider_id: str, request: ProviderCredentialRequest): +async def set_service_credentials( + provider_id: str, + request: ProviderCredentialRequest, + _admin: object = Depends(require_admin), +): """Set credentials for an API service (_api_key convention). Saves to .secret.json AND writes the secret reference into flocks.json @@ -1935,6 +2200,7 @@ async def set_service_credentials(provider_id: str, request: ProviderCredentialR if field.key == "base_url": existing.pop("baseUrl", None) ConfigWriter.set_api_service(provider_id, existing) + _clear_api_service_summary_metadata_cache(provider_id) log.info( "service.credentials.set", @@ -1971,7 +2237,11 @@ class TestCredentialRequest(BaseModel): summary="Test provider credentials", description="Test if the stored credentials are valid by making a real chat API call." ) -async def test_provider_credentials(provider_id: str, body: Optional[TestCredentialRequest] = None): +async def test_provider_credentials( + provider_id: str, + body: Optional[TestCredentialRequest] = None, + _admin: object = Depends(require_admin), +): """Test credentials for a provider or API service by making a real API call""" from flocks.security import get_secret_manager @@ -2013,12 +2283,12 @@ async def test_provider_credentials(provider_id: str, body: Optional[TestCredent await _save_api_service_status_if_configured(provider_id, response) return response - Provider._ensure_initialized() + await _ensure_provider_initialized() # Re-run dynamic provider loading in case this provider was created after # the server started (e.g. user just added azure-openai via the UI). # _load_dynamic_providers skips already-registered providers, so it is safe # to call multiple times. - Provider._load_dynamic_providers() + await _load_dynamic_providers() # Apply config to ensure _config_models (user-defined models) are loaded config = await Config.get() await Provider.apply_config(config, provider_id=provider_id) @@ -2588,7 +2858,9 @@ async def get_api_services_status(): summary="Refresh API service connectivity status", description="Manually trigger a refresh of all API service connectivity statuses." ) -async def refresh_api_services_status(): +async def refresh_api_services_status( + _admin: object = Depends(require_admin), +): """Refresh all API service connectivity statuses and cache them.""" try: await Storage.init() diff --git a/flocks/server/routes/question.py b/flocks/server/routes/question.py index 4696cb722..ef33b301a 100644 --- a/flocks/server/routes/question.py +++ b/flocks/server/routes/question.py @@ -9,9 +9,13 @@ from typing import Dict, List, Any, Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from flocks.auth.context import AuthUser +from flocks.server.auth import require_user +from flocks.session.policy import SessionPolicy +from flocks.session.session import Session from flocks.utils.log import Log @@ -76,6 +80,21 @@ def clear_request_state(request_id: str) -> None: _request_rejected.discard(request_id) +async def _require_question_session_access( + session_id: str, + user: AuthUser, + *, + write: bool, +) -> None: + session = await Session.get_by_id(session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + allowed = SessionPolicy.can_write(session, user) if write else SessionPolicy.can_read(session, user) + if not allowed: + detail = "Only the session owner can answer questions" if write else "Session access denied" + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + + async def reject_session_questions(session_id: str) -> int: """Reject all pending question requests for a session. @@ -126,7 +145,11 @@ def has_pending_questions(session_id: str) -> bool: summary="List pending questions for a session", description="Return all unanswered Question requests for the given session", ) -async def get_pending_question_requests(session_id: str) -> List[Dict[str, Any]]: +async def get_pending_question_requests( + session_id: str, + user: AuthUser = Depends(require_user), +) -> List[Dict[str, Any]]: + await _require_question_session_access(session_id, user, write=False) return list_question_requests(session_id) @@ -138,9 +161,16 @@ async def get_pending_question_requests(session_id: str) -> List[Dict[str, Any]] async def reply_question_request( request_id: str, request: QuestionRequestReply, + user: AuthUser = Depends(require_user), ) -> Dict[str, Any]: - if request_id not in _question_requests: + question_request = _question_requests.get(request_id) + if question_request is None: raise HTTPException(status_code=404, detail="Question request not found") + await _require_question_session_access( + question_request.get("sessionID", ""), + user, + write=True, + ) log.info("question.request.reply", { "request_id": request_id, @@ -150,7 +180,6 @@ async def reply_question_request( try: from flocks.server.routes.event import publish_event - question_request = _question_requests[request_id] await publish_event("question.replied", { "sessionID": question_request.get("sessionID", ""), "requestID": request_id, @@ -159,7 +188,7 @@ async def reply_question_request( except Exception as e: log.error("question.replied.event.failed", {"error": str(e)}) - del _question_requests[request_id] + _question_requests.pop(request_id, None) return {"success": True} @@ -168,16 +197,24 @@ async def reply_question_request( summary="Reject question request", description="Reject a QuestionRequest (user doesn't want to answer)", ) -async def reject_question_request(request_id: str) -> Dict[str, bool]: - if request_id not in _question_requests: +async def reject_question_request( + request_id: str, + user: AuthUser = Depends(require_user), +) -> Dict[str, bool]: + question_request = _question_requests.get(request_id) + if question_request is None: raise HTTPException(status_code=404, detail="Question request not found") + await _require_question_session_access( + question_request.get("sessionID", ""), + user, + write=True, + ) log.info("question.request.reject", {"request_id": request_id}) _request_rejected.add(request_id) try: from flocks.server.routes.event import publish_event - question_request = _question_requests[request_id] await publish_event("question.rejected", { "sessionID": question_request.get("sessionID", ""), "requestID": request_id, @@ -185,7 +222,7 @@ async def reject_question_request(request_id: str) -> Dict[str, bool]: except Exception as e: log.error("question.rejected.event.failed", {"error": str(e)}) - del _question_requests[request_id] + _question_requests.pop(request_id, None) return {"success": True} diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index 7d7858b2d..c68ab65b8 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -37,6 +37,10 @@ DEFAULT_AGENT = "rex" DEFAULT_MESSAGE_PAGE_LIMIT = 50 _DESCENDANT_ABORT_SCAN_LIMIT = 3 +_CONTEXT_USAGE_CACHE_TTL_SECONDS = 5.0 +_context_usage_cache: Dict[Tuple[str, int], Tuple[float, ContextUsageSnapshot]] = {} +_context_usage_inflight: Dict[Tuple[str, int], asyncio.Task[ContextUsageSnapshot]] = {} +_context_usage_cache_lock = asyncio.Lock() # File extensions that are safe to persist when materialising data-URL uploads. # Intentionally narrow: any extension outside this set is rejected to prevent @@ -45,6 +49,59 @@ # ".exe"). _UPLOAD_SAFE_EXTS = frozenset({"png", "jpg", "jpeg", "gif", "webp", "bmp", "pdf"}) + +def _context_usage_cache_key(session_id: str, session: SessionModel) -> Tuple[str, int]: + return session_id, int(getattr(session.time, "updated", 0) or 0) + + +async def _build_context_usage_for_cache( + key: Tuple[str, int], + session_id: str, + *, + session: SessionModel, +) -> ContextUsageSnapshot: + current_task = asyncio.current_task() + try: + snapshot = await build_context_usage_snapshot(session_id, session=session) + async with _context_usage_cache_lock: + if _context_usage_inflight.get(key) is not current_task: + return snapshot + for cache_key in [item for item in _context_usage_cache if item[0] == session_id and item[1] < key[1]]: + _context_usage_cache.pop(cache_key, None) + has_newer_cache = any(item[0] == session_id and item[1] > key[1] for item in _context_usage_cache) + if not has_newer_cache: + _context_usage_cache[key] = ( + time.monotonic() + _CONTEXT_USAGE_CACHE_TTL_SECONDS, + snapshot.model_copy(deep=True), + ) + return snapshot + finally: + async with _context_usage_cache_lock: + if _context_usage_inflight.get(key) is current_task: + _context_usage_inflight.pop(key, None) + + +async def _cached_context_usage_snapshot( + session_id: str, + *, + session: SessionModel, +) -> ContextUsageSnapshot: + key = _context_usage_cache_key(session_id, session) + now = time.monotonic() + + async with _context_usage_cache_lock: + cached = _context_usage_cache.get(key) + if cached and cached[0] > now: + return cached[1].model_copy(deep=True) + + task = _context_usage_inflight.get(key) + if task is None: + task = asyncio.create_task(_build_context_usage_for_cache(key, session_id, session=session)) + _context_usage_inflight[key] = task + + snapshot = await asyncio.shield(task) + return snapshot.model_copy(deep=True) + # ============================================================================= # Request/Response Models - API Compatible (camelCase) # ============================================================================= @@ -597,7 +654,7 @@ async def get_session_context_usage(sessionID: str, request: Request) -> Context detail=f"Session {sessionID} not found", ) _require_session_read_access(session, current_user) - snapshot = await build_context_usage_snapshot(sessionID, session=session) + snapshot = await _cached_context_usage_snapshot(sessionID, session=session) log_route_timing(log, "session.context_usage.complete", started_at=started_at, extra={ "sessionID": sessionID, "source": snapshot.source, diff --git a/flocks/server/routes/stats.py b/flocks/server/routes/stats.py new file mode 100644 index 000000000..af2e74766 --- /dev/null +++ b/flocks/server/routes/stats.py @@ -0,0 +1,150 @@ +""" +Lightweight WebUI summary stats. + +The home page needs counts, not the full agent/workflow/skill/tool payloads. +Keeping this aggregation on the server avoids serialising and parsing large +lists on every first paint. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable, Literal + +from fastapi import APIRouter +from pydantic import BaseModel + +from flocks.agent.registry import Agent +from flocks.provider.provider import Provider +from flocks.server.routes.provider import list_providers +from flocks.server.routes.workflow import _list_workflows_from_fs, _migrate_storage_to_filesystem +from flocks.skill.skill import Skill +from flocks.task.manager import TaskManager +from flocks.tool.registry import ToolRegistry +from flocks.utils.log import Log + +router = APIRouter() +log = Log.create(service="stats-routes") + + +class CountResponse(BaseModel): + total: int + + +class TaskStatsResponse(BaseModel): + week: int + scheduledActive: int + + +class SystemHealthResponse(BaseModel): + status: Literal["healthy", "warning", "error"] + message: str + + +class SystemStatsResponse(BaseModel): + tasks: TaskStatsResponse + agents: CountResponse + workflows: CountResponse + skills: CountResponse + tools: CountResponse + models: CountResponse + system: SystemHealthResponse + + +async def _safe_count(name: str, loader: Callable[[], Awaitable[int]], failures: list[str]) -> int: + try: + return await loader() + except Exception as exc: + failures.append(name) + log.warning("stats.summary.source_failed", {"source": name, "error": str(exc)}) + return 0 + + +def _should_count_agent(agent: Any) -> bool: + if getattr(agent, "hidden", False): + return False + if getattr(agent, "mode", None) == "primary": + return True + tags = getattr(agent, "tags", None) + return not isinstance(tags, list) or "system" not in tags + + +async def _task_dashboard() -> dict[str, Any]: + return await TaskManager.dashboard() + + +async def _safe_dashboard(failures: list[str]) -> dict[str, Any]: + try: + return await _task_dashboard() + except Exception as exc: + failures.append("tasks") + log.warning("stats.summary.source_failed", {"source": "tasks", "error": str(exc)}) + return {} + + +async def _count_agents() -> int: + agents = await Agent.list() + return sum(1 for agent in agents if _should_count_agent(agent)) + + +async def _count_workflows() -> int: + await _migrate_storage_to_filesystem() + return await asyncio.to_thread(lambda: len(_list_workflows_from_fs())) + + +async def _count_skills() -> int: + skills = await Skill.all() + return sum( + 1 + for skill in skills + if getattr(skill, "category", None) != "system" + ) + + +async def _count_tools() -> int: + await ToolRegistry.init_async() + return len(ToolRegistry.all_tool_ids()) + + +async def _count_models() -> int: + await asyncio.to_thread(Provider._ensure_initialized) + providers = await list_providers() + connected = set(providers.connected or []) + return sum( + len(provider.models or {}) + for provider in providers.all + if provider.id in connected + ) + + +@router.get("/summary", response_model=SystemStatsResponse) +async def get_system_stats_summary() -> SystemStatsResponse: + failures: list[str] = [] + + # Agent and Skill cold loading now wait on their registries without + # blocking the event loop, so all independent summary sources can stay + # concurrent instead of paying a separate registry warm-up phase. + dashboard, agents, workflows, skills, tools, models = await asyncio.gather( + _safe_dashboard(failures), + _safe_count("agents", _count_agents, failures), + _safe_count("workflows", _count_workflows, failures), + _safe_count("skills", _count_skills, failures), + _safe_count("tools", _count_tools, failures), + _safe_count("models", _count_models, failures), + ) + + system_status: Literal["healthy", "warning"] = "warning" if failures else "healthy" + message = "部分统计加载失败" if failures else "所有服务运行正常" + + return SystemStatsResponse( + tasks=TaskStatsResponse( + week=int(dashboard.get("completed_week") or 0) + int(dashboard.get("failed_week") or 0), + scheduledActive=int(dashboard.get("scheduled_active") or 0), + ), + agents=CountResponse(total=agents), + workflows=CountResponse(total=workflows), + skills=CountResponse(total=skills), + tools=CountResponse(total=tools), + models=CountResponse(total=models), + system=SystemHealthResponse(status=system_status, message=message), + ) diff --git a/flocks/server/routes/tool.py b/flocks/server/routes/tool.py index 658bc0897..e217cbf3f 100644 --- a/flocks/server/routes/tool.py +++ b/flocks/server/routes/tool.py @@ -3,8 +3,10 @@ """ import asyncio +from dataclasses import dataclass +import threading import time -from typing import Annotated, List, Optional, Dict, Any +from typing import Annotated, List, Optional, Dict, Any, Literal, Sequence from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field @@ -15,6 +17,7 @@ from flocks.permission.next import DeniedError, PermissionNext from flocks.tool.registry import ( ToolRegistry, + ToolRefreshError, ToolInfo, ToolSchema, ToolResult, @@ -39,6 +42,7 @@ class ToolInfoResponse(BaseModel): source_name: Optional[str] = Field(None, description="Source detail, e.g. MCP server name or API module name") vendor: Optional[str] = Field(None, description="Manufacturer key for device tools (e.g. threatbook, qianxin, sangfor, qingteng)") parameters: List[Dict[str, Any]] = Field(default_factory=list, description="Tool parameters") + parameters_count: int = Field(0, description="Number of tool parameters") enabled: bool = Field(True, description="Effective enabled state (overlay applied, ANDed with API service flag)") enabled_default: bool = Field(True, description="Factory default from the YAML/registration source (no overlay)") enabled_customized: bool = Field(False, description="True if a user setting is recorded in flocks.json tool_settings") @@ -116,6 +120,24 @@ class BatchExecuteResponse(BaseModel): results: List[ToolExecuteResponse] = Field(..., description="Execution results") +class ToolListFacets(BaseModel): + """Facet counts for server-side tool list filtering.""" + category: Dict[str, int] = Field(default_factory=dict) + source: Dict[str, int] = Field(default_factory=dict) + source_groups: Dict[str, int] = Field(default_factory=dict) + source_name: Dict[str, int] = Field(default_factory=dict) + enabled: Dict[str, int] = Field(default_factory=dict) + + +class ToolListPageResponse(BaseModel): + """Paginated tool list response.""" + items: List[ToolInfoResponse] = Field(default_factory=list) + total: int = Field(0) + offset: int = Field(0) + limit: int = Field(25) + facets: ToolListFacets = Field(default_factory=ToolListFacets) + + # Helper: determine tool source _BUILTIN_CATEGORIES = { @@ -133,6 +155,63 @@ class BatchExecuteResponse(BaseModel): ) +@dataclass(frozen=True) +class ToolListIndexItem: + name: str + description: str + description_cn: Optional[str] + category: str + source: str + source_name: Optional[str] + vendor: Optional[str] + parameters_count: int + enabled: bool + enabled_default: bool + enabled_customized: bool + requires_confirmation: bool + + +_TOOL_SUMMARY_CACHE_TTL_SECONDS = 5.0 +_tool_summary_cache_lock = threading.Lock() +_tool_summary_cache_key: tuple[int, tuple[str, ...]] | None = None +_tool_summary_cache_expires_at = 0.0 +_tool_summary_cache_items: tuple[ToolListIndexItem, ...] = () + + +def _invalidate_tool_summary_cache() -> None: + """Clear the lightweight list snapshot used by ``GET /api/tools/page``.""" + global _tool_summary_cache_key, _tool_summary_cache_expires_at, _tool_summary_cache_items + with _tool_summary_cache_lock: + _tool_summary_cache_key = None + _tool_summary_cache_expires_at = 0.0 + _tool_summary_cache_items = () + + +def _tool_summary_cache_current_key() -> tuple[int, tuple[str, ...]]: + return ToolRegistry.snapshot_identity() + + +def _get_tool_summary_items() -> List[ToolListIndexItem]: + """Return cached, lightweight tool summaries for list filtering.""" + global _tool_summary_cache_key, _tool_summary_cache_expires_at, _tool_summary_cache_items + + now = time.monotonic() + cache_key = _tool_summary_cache_current_key() + with _tool_summary_cache_lock: + if ( + _tool_summary_cache_items + and _tool_summary_cache_key == cache_key + and now < _tool_summary_cache_expires_at + ): + return list(_tool_summary_cache_items) + + items = tuple(_build_tool_index_item(t) for t in ToolRegistry.list_tools()) + _tool_summary_cache_key = cache_key + _tool_summary_cache_expires_at = now + _TOOL_SUMMARY_CACHE_TTL_SECONDS + _tool_summary_cache_items = items + return list(items) + + def _get_tool_source(tool_info: ToolInfo) -> tuple: """ Determine tool source type and source name. @@ -175,12 +254,13 @@ def _get_tool_source(tool_info: ToolInfo) -> tuple: return "custom", None -def _build_tool_response(t: ToolInfo) -> ToolInfoResponse: +def _build_tool_response(t: ToolInfo, *, include_parameters: bool = True) -> ToolInfoResponse: """Build ToolInfoResponse with source info and overlay metadata.""" source, source_name = _get_tool_source(t) setting = ConfigWriter.get_tool_setting(t.name) or {} customized = "enabled" in setting enabled_default = _get_default_enabled(t) + parameters = [p.model_dump() for p in t.parameters] if include_parameters else [] return ToolInfoResponse( name=t.name, description=t.description, @@ -189,14 +269,142 @@ def _build_tool_response(t: ToolInfo) -> ToolInfoResponse: source=source, source_name=source_name, vendor=t.vendor, - parameters=[p.model_dump() for p in t.parameters], - enabled=_get_effective_tool_enabled(t), + parameters=parameters, + parameters_count=len(t.parameters), + enabled=_get_effective_tool_enabled(t, source=source, source_name=source_name), enabled_default=enabled_default, enabled_customized=customized, requires_confirmation=t.requires_confirmation, ) +def _build_tool_index_item(t: ToolInfo) -> ToolListIndexItem: + """Build the lightweight index row used by the paged list endpoint.""" + source, source_name = _get_tool_source(t) + setting = ConfigWriter.get_tool_setting(t.name) or {} + return ToolListIndexItem( + name=t.name, + description=t.description, + description_cn=t.description_cn, + category=t.category.value, + source=source, + source_name=source_name, + vendor=t.vendor, + parameters_count=len(t.parameters), + enabled=_get_effective_tool_enabled(t, source=source, source_name=source_name), + enabled_default=_get_default_enabled(t), + enabled_customized="enabled" in setting, + requires_confirmation=t.requires_confirmation, + ) + + +def _tool_index_item_to_response(item: ToolListIndexItem) -> ToolInfoResponse: + return ToolInfoResponse( + name=item.name, + description=item.description, + description_cn=item.description_cn, + category=item.category, + source=item.source, + source_name=item.source_name, + vendor=item.vendor, + parameters=[], + parameters_count=item.parameters_count, + enabled=item.enabled, + enabled_default=item.enabled_default, + enabled_customized=item.enabled_customized, + requires_confirmation=item.requires_confirmation, + ) + + +def _split_csv_filter(value: Optional[str]) -> Optional[set[str]]: + if value is None: + return None + parts = {part.strip() for part in value.split(",") if part and part.strip()} + return parts or None + + +def _matches_tool_query(tool: ToolInfoResponse | ToolListIndexItem, query: str) -> bool: + if not query: + return True + haystack = " ".join([ + tool.name, + tool.description, + tool.description_cn or "", + tool.category, + tool.source, + tool.source_name or "", + tool.vendor or "", + ]).lower() + return query in haystack + + +def _build_tool_facets(items: Sequence[ToolInfoResponse | ToolListIndexItem]) -> ToolListFacets: + facets = ToolListFacets() + for item in items: + facets.category[item.category] = facets.category.get(item.category, 0) + 1 + facets.source[item.source] = facets.source.get(item.source, 0) + 1 + source_name = item.source_name or "Flocks" + facets.source_name[source_name] = facets.source_name.get(source_name, 0) + 1 + enabled_key = str(item.enabled).lower() + facets.enabled[enabled_key] = facets.enabled.get(enabled_key, 0) + 1 + return facets + + +def _build_source_group_counts( + items: Sequence[ToolInfoResponse | ToolListIndexItem], +) -> Dict[str, int]: + groups: Dict[str, set[str]] = {} + for item in items: + if not item.source_name: + continue + groups.setdefault(item.source, set()).add(item.source_name) + return {source: len(source_names) for source, source_names in groups.items()} + + +def _sort_tool_items( + items: Sequence[ToolInfoResponse | ToolListIndexItem], + sort_by: Literal["category", "source", "source_name", "enabled", "name"], + sort_dir: Literal["asc", "desc"], +) -> List[ToolInfoResponse | ToolListIndexItem]: + reverse = sort_dir == "desc" + + def sort_key(item: ToolInfoResponse): + if sort_by == "enabled": + return 0 if item.enabled else 1 + if sort_by == "source_name": + return (item.source_name or "Flocks").lower() + return getattr(item, sort_by) + + return sorted(items, key=sort_key, reverse=reverse) + + +def _filter_tool_items( + items: Sequence[ToolInfoResponse | ToolListIndexItem], + *, + category_filter: Optional[set[str]], + source_filter: Optional[set[str]], + source_name_filter: Optional[set[str]], + enabled_filter: Optional[set[str]], + query: str, + include_category: bool = True, + include_source: bool = True, + include_source_name: bool = True, + include_enabled: bool = True, +) -> List[ToolInfoResponse | ToolListIndexItem]: + result = list(items) + if include_category and category_filter: + result = [tool for tool in result if tool.category in category_filter] + if include_source and source_filter: + result = [tool for tool in result if tool.source in source_filter] + if include_source_name and source_name_filter: + result = [tool for tool in result if (tool.source_name or "Flocks") in source_name_filter] + if include_enabled and enabled_filter: + result = [tool for tool in result if str(tool.enabled).lower() in enabled_filter] + if query: + result = [tool for tool in result if _matches_tool_query(tool, query)] + return result + + def _requires_session_backed_context(tool_info: ToolInfo) -> bool: """Return True when a tool must be anchored to a real session/message context.""" source, _ = _get_tool_source(tool_info) @@ -413,9 +621,15 @@ def _service_allows_enable(t: ToolInfo) -> bool: return bool(svc.get("enabled", False)) -def _get_effective_tool_enabled(tool_info: ToolInfo) -> bool: +def _get_effective_tool_enabled( + tool_info: ToolInfo, + *, + source: Optional[str] = None, + source_name: Optional[str] = None, +) -> bool: """Compute tool enabled state without mutating the registry object.""" - source, source_name = _get_tool_source(tool_info) + if source is None: + source, source_name = _get_tool_source(tool_info) if source not in ("api", "device") or not source_name: return tool_info.enabled from flocks.server.routes.provider import _get_api_service_enabled @@ -505,6 +719,112 @@ async def list_tools( return result +@router.get( + "/page", + response_model=ToolListPageResponse, + summary="List tools with server-side pagination", +) +async def list_tools_page( + category: Optional[str] = None, + source: Optional[str] = None, + source_name: Optional[str] = None, + enabled: Optional[str] = None, + q: Optional[str] = None, + sort_by: Literal["category", "source", "source_name", "enabled", "name"] = "source", + sort_dir: Literal["asc", "desc"] = "asc", + offset: int = Query(0, ge=0), + limit: int = Query(25, ge=1, le=200), +): + """ + List tools with server-side search/filter/sort/pagination. + + The paged list omits full parameter definitions and exposes + `parameters_count`; call `GET /api/tools/{tool_name}` for details. + """ + started_at = time.perf_counter() + await ToolRegistry.init_async() + + category_filter = _split_csv_filter(category) + source_filter = _split_csv_filter(source) + source_name_filter = _split_csv_filter(source_name) + enabled_filter = _split_csv_filter(enabled) + query = (q or "").strip().lower() + + all_items = _get_tool_summary_items() + + result = _filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + ) + source_facet_items = _filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + include_source=False, + ) + facets = ToolListFacets( + category=_build_tool_facets(_filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + include_category=False, + )).category, + source=_build_tool_facets(source_facet_items).source, + source_groups=_build_source_group_counts(source_facet_items), + source_name=_build_tool_facets(_filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + include_source_name=False, + )).source_name, + enabled=_build_tool_facets(_filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + include_enabled=False, + )).enabled, + ) + result = _sort_tool_items(result, sort_by, sort_dir) + total = len(result) + items = [ + _tool_index_item_to_response(item) if isinstance(item, ToolListIndexItem) else item + for item in result[offset:offset + limit] + ] + + log_route_timing(log, "tools.list_page.complete", started_at=started_at, extra={ + "count": len(items), + "total": total, + "category": category, + "source": source, + "q": q, + "offset": offset, + "limit": limit, + }) + return ToolListPageResponse( + items=items, + total=total, + offset=offset, + limit=limit, + facets=facets, + ) + + @router.get( "/{tool_name}", response_model=ToolInfoResponse, @@ -617,10 +937,12 @@ async def update_tool( # The in-memory ToolInfo.enabled reflects global state. Per-device # enabled=True is not a supported override; switch-on means clear the # per-device disable and follow the global tool setting. + _invalidate_tool_summary_cache() return _build_tool_response(tool.info) # --- Global mode (original behaviour) --- _set_global_tool_enabled(tool, desired) + _invalidate_tool_summary_cache() return _build_tool_response(tool.info) @@ -651,6 +973,7 @@ async def reset_tool_setting(tool_name: str, _admin: object = Depends(require_ad default = _get_default_enabled(tool.info) new_enabled = default and _service_allows_enable(tool.info) tool.info.enabled = new_enabled + _invalidate_tool_summary_cache() log.info("tool.setting.reset", { "name": tool_name, @@ -816,9 +1139,11 @@ async def execute_batch(request: BatchExecuteRequest): class RefreshResponse(BaseModel): """Tool refresh response""" - status: str = Field(..., description="Operation status") + status: Literal["success", "partial", "error"] = Field(..., description="Operation status") tool_count: int = Field(..., description="Total registered tool count after refresh") message: str = Field("", description="Human-readable summary") + stages: Dict[str, Literal["success", "error"]] = Field(default_factory=dict) + errors: List[str] = Field(default_factory=list) @router.post( @@ -837,38 +1162,49 @@ async def refresh_tools(_admin: object = Depends(require_admin)): ToolRegistry.init() errors: list[str] = [] + stages: dict[str, Literal["success", "error"]] = {} - # 1. Reload generated tools (generated/) - try: - ToolRegistry.refresh_dynamic_tools() - except Exception as e: - log.error("tools.refresh.dynamic_error", {"error": str(e)}) - errors.append(f"dynamic: {e}") - - # 2. Reload plugin tools (api/, python/) — unregisters stale entries first - try: - ToolRegistry.refresh_plugin_tools() - except Exception as e: - log.error("tools.refresh.plugin_error", {"error": str(e)}) - errors.append(f"plugin: {e}") - + for stage, refresh in ( + ("dynamic", ToolRegistry.refresh_dynamic_tools), + ("plugin", ToolRegistry.refresh_plugin_tools), + ): + try: + refresh() + stages[stage] = "success" + except ToolRefreshError as exc: + stages[stage] = "error" + stage_errors = [f"{stage}: {error}" for error in exc.errors] + errors.extend(stage_errors) + log.error(f"tools.refresh.{stage}_error", {"errors": exc.errors}) + except Exception as exc: + stages[stage] = "error" + errors.append(f"{stage}: {exc}") + log.error(f"tools.refresh.{stage}_error", {"error": str(exc)}) + + _invalidate_tool_summary_cache() tool_count = len(ToolRegistry.all_tool_ids()) log_route_timing(log, "tools.refresh.done", started_at=started_at, extra={ "tool_count": tool_count, "errors": len(errors), }) - if errors: - return RefreshResponse( - status="partial", - tool_count=tool_count, - message=f"Refreshed with {len(errors)} error(s): {'; '.join(errors)}", - ) + failed_stages = sum(status == "error" for status in stages.values()) + if failed_stages == 0: + outcome: Literal["success", "partial", "error"] = "success" + message = f"All tools refreshed successfully ({tool_count} tools registered)" + elif failed_stages == len(stages): + outcome = "error" + message = f"Tool refresh failed: {'; '.join(errors)}" + else: + outcome = "partial" + message = f"Tool refresh completed with errors: {'; '.join(errors)}" return RefreshResponse( - status="success", + status=outcome, tool_count=tool_count, - message=f"All tools refreshed successfully ({tool_count} tools registered)", + message=message, + stages=stages, + errors=errors, ) @@ -1082,6 +1418,7 @@ async def create_tool(request: CreateToolRequest, _admin: object = Depends(requi ToolRegistry.register(tool) if tool.info.name not in ToolRegistry._plugin_tool_names: ToolRegistry._plugin_tool_names.append(tool.info.name) + _invalidate_tool_summary_cache() except Exception as e: log.error("tool.create.register_error", {"error": str(e), "name": request.name}) raise HTTPException( @@ -1155,12 +1492,14 @@ async def update_plugin_tool(name: str, request: UpdateToolRequest, _admin: obje if not tool.info.source: tool.info.source = "plugin_yaml" ToolRegistry.register(tool) + _invalidate_tool_summary_cache() return _build_tool_response(tool.info) except Exception as e: log.error("tool.update.reload_error", {"error": str(e), "name": name}) existing = ToolRegistry.get(name) if existing: + _invalidate_tool_summary_cache() return _build_tool_response(existing.info) raise HTTPException(status_code=500, detail="Tool updated but reload failed") @@ -1200,12 +1539,38 @@ async def delete_tool(name: str, _admin: object = Depends(require_admin)): detail=f"Plugin tool not found: {name}", ) - # Refresh plugin tools so stale decorator-registered python tools are removed too. - ToolRegistry.refresh_plugin_tools() + # The file deletion is already committed at this point. Keep the Hub + # installation record consistent even when the in-memory refresh fails. + cleanup_errors: List[str] = [] + try: + ToolRegistry.refresh_plugin_tools() + except Exception as e: + refresh_error = str(e) + cleanup_errors.append(f"registry refresh: {refresh_error}") + log.warning("tool.delete.refresh_failed", { + "error": refresh_error, + "name": name, + }) + _invalidate_tool_summary_cache() from flocks.hub import local as hub_local - hub_local.remove_installed_record("tool", name) + try: + hub_local.remove_installed_record("tool", name) + except Exception as e: + hub_error = str(e) + cleanup_errors.append(f"Hub record cleanup: {hub_error}") + log.warning("tool.delete.hub_cleanup_failed", { + "error": hub_error, + "name": name, + }) + + if cleanup_errors: + return { + "status": "partial", + "message": f"Tool {name} deleted, but cleanup was incomplete", + "errors": cleanup_errors, + } return {"status": "success", "message": f"Tool {name} deleted"} @@ -1239,6 +1604,7 @@ async def reload_tool(name: str, _admin: object = Depends(require_admin)): if not tool.info.source: tool.info.source = "plugin_yaml" ToolRegistry.register(tool) + _invalidate_tool_summary_cache() log.info("tool.reloaded", {"name": name}) return _build_tool_response(tool.info) except Exception as e: diff --git a/flocks/server/routes/update.py b/flocks/server/routes/update.py index 9836dfe0b..d559fbe72 100644 --- a/flocks/server/routes/update.py +++ b/flocks/server/routes/update.py @@ -4,7 +4,8 @@ import asyncio import json -from typing import Literal +import time +from typing import Annotated, Literal from fastapi import APIRouter, Query, Request from fastapi.responses import StreamingResponse @@ -17,6 +18,72 @@ router = APIRouter() log = Log.create(service="update-routes") +_UPDATE_CHECK_CACHE_TTL_SECONDS = 600.0 +_UPDATE_CHECK_ERROR_CACHE_TTL_SECONDS = 60.0 +_update_check_cache: dict[tuple[str, str], tuple[float, VersionInfo]] = {} +_update_check_inflight: dict[tuple[str, str], asyncio.Task[VersionInfo]] = {} +_update_check_lock = asyncio.Lock() + + +def _update_cache_key(locale: str | None, edition: str) -> tuple[str, str]: + return (locale or "", edition) + + +def clear_update_check_cache() -> None: + _update_check_cache.clear() + _update_check_inflight.clear() + + +async def _run_update_check_for_cache( + key: tuple[str, str], + *, + locale: str | None, + edition: Literal["flocks", "flockspro"], +) -> VersionInfo: + current_task = asyncio.current_task() + try: + info = await check_update(locale=locale, force_console_manifest=(edition == "flockspro")) + ttl = _UPDATE_CHECK_ERROR_CACHE_TTL_SECONDS if info.error else _UPDATE_CHECK_CACHE_TTL_SECONDS + async with _update_check_lock: + if _update_check_inflight.get(key) is current_task: + _update_check_cache[key] = (time.monotonic() + ttl, info.model_copy(deep=True)) + return info + finally: + async with _update_check_lock: + if _update_check_inflight.get(key) is current_task: + _update_check_inflight.pop(key, None) + + +async def _check_update_cached( + *, + locale: str | None, + edition: Literal["flocks", "flockspro"], + force: bool, +) -> VersionInfo: + key = _update_cache_key(locale, edition) + now = time.monotonic() + task: asyncio.Task[VersionInfo] + async with _update_check_lock: + if not force: + cached = _update_check_cache.get(key) + if cached and cached[0] > now: + return cached[1].model_copy(deep=True) + + # A forced check bypasses only the completed-result cache. Reuse any + # same-key request already reaching the upstream service so concurrent + # manual refreshes cannot fan out into an update-check storm. + existing_task = _update_check_inflight.get(key) + if existing_task is None: + task = asyncio.create_task( + _run_update_check_for_cache(key, locale=locale, edition=edition) + ) + _update_check_inflight[key] = task + else: + task = existing_task + + info = await asyncio.shield(task) + return info.model_copy(deep=True) + @router.get( "/check", @@ -33,10 +100,13 @@ async def check_version( default="flocks", description="Version channel to check. flockspro checks the Console Pro bundle manifest.", ), + force: Annotated[bool, Query( + description="Bypass the short server-side cache for an explicit manual check.", + )] = False, ) -> VersionInfo: if edition == "flockspro": require_admin(request) - return await check_update(locale=locale, force_console_manifest=(edition == "flockspro")) + return await _check_update_cached(locale=locale, edition=edition, force=force) @router.post( diff --git a/flocks/server/static_webui.py b/flocks/server/static_webui.py index dede61320..20c70a0d6 100644 --- a/flocks/server/static_webui.py +++ b/flocks/server/static_webui.py @@ -14,6 +14,12 @@ _ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" _STATIC_CACHE_CONTROL = "no-cache" _FINGERPRINT_RE = re.compile(r"(?:^|[.-])[0-9a-f]{8,}(?:[.-]|$)", re.IGNORECASE) +_STATIC_MEDIA_TYPES = { + ".css": "text/css", + ".js": "application/javascript", + ".mjs": "application/javascript", + ".wasm": "application/wasm", +} _PROTECTED_PREFIXES = ( "/api", "/event", @@ -107,7 +113,7 @@ def _resolve_existing_static_file(dist_dir: Path, path: str) -> Path | None: def _file_response(path: Path, *, cache_control: str) -> FileResponse: headers = {"Cache-Control": cache_control} - return FileResponse(path, headers=headers) + return FileResponse(path, headers=headers, media_type=_STATIC_MEDIA_TYPES.get(path.suffix.lower())) def _cache_control_for_file(path: str, file_path: Path) -> str: diff --git a/flocks/skill/skill.py b/flocks/skill/skill.py index 2bdb8f3cc..db5f2e1e5 100644 --- a/flocks/skill/skill.py +++ b/flocks/skill/skill.py @@ -6,6 +6,7 @@ """ import json +import asyncio import os import glob import re @@ -204,6 +205,45 @@ class Skill: """ _cache: Optional[Dict[str, SkillInfo]] = None + _cache_lock = threading.Lock() + _cache_generation = 0 + _cache_loads: Dict[int, threading.Event] = {} + + @classmethod + def _all_sync(cls) -> List[SkillInfo]: + """Discover skills once per cache generation without stale refills.""" + while True: + with cls._cache_lock: + if cls._cache is not None: + return list(cls._cache.values()) + generation = cls._cache_generation + completion = cls._cache_loads.get(generation) + should_discover = completion is None + if completion is None: + completion = threading.Event() + cls._cache_loads[generation] = completion + + if not should_discover: + completion.wait() + continue + + try: + discovered = cls._discover() + except BaseException: + with cls._cache_lock: + cls._cache_loads.pop(generation, None) + completion.set() + raise + + with cls._cache_lock: + if generation == cls._cache_generation and cls._cache is None: + cls._cache = discovered + cls._cache_loads.pop(generation, None) + completion.set() + if generation == cls._cache_generation and cls._cache is not None: + return list(cls._cache.values()) + # The cache was invalidated while discovery was running. Repeat + # against the new generation instead of publishing stale data. @classmethod def _parse_skill_md(cls, filepath: str, source: Optional[str] = None) -> Optional[SkillInfo]: @@ -460,10 +500,7 @@ async def all(cls) -> List[SkillInfo]: Returns: List of all discovered skills """ - if cls._cache is None: - cls._cache = cls._discover() - - return list(cls._cache.values()) + return await asyncio.to_thread(cls._all_sync) @classmethod async def list_enabled(cls) -> List[SkillInfo]: @@ -495,10 +532,8 @@ async def get(cls, name: str) -> Optional[SkillInfo]: Returns: SkillInfo or None if not found """ - if cls._cache is None: - cls._cache = cls._discover() - - skill = cls._cache.get(name) + skills = await cls.all() + skill = next((item for item in skills if item.name == name), None) if not skill: return None return skill @@ -506,7 +541,9 @@ async def get(cls, name: str) -> Optional[SkillInfo]: @classmethod def clear_cache(cls) -> None: """Clear the skill cache (for testing or forced refresh)""" - cls._cache = None + with cls._cache_lock: + cls._cache = None + cls._cache_generation += 1 log.info("skill.cache.cleared") @classmethod diff --git a/flocks/storage/storage.py b/flocks/storage/storage.py index 4a1b1ed41..4bf9124f6 100644 --- a/flocks/storage/storage.py +++ b/flocks/storage/storage.py @@ -8,6 +8,7 @@ import os import shutil import subprocess +import uuid from contextlib import asynccontextmanager from pathlib import Path @@ -71,6 +72,7 @@ class Storage: _log = Log.create(service="storage") _db_path: Optional[Path] = None _initialized = False + _db_identity: Optional[Tuple[int, int]] = None # PID of the process that called ``init()``. Used by ``_ensure_init`` to # detect ``fork()`` (uvicorn ``--reload`` / multiprocessing workers) and # re-initialise per-process state so the parent's open SQLite file @@ -149,6 +151,34 @@ def get_db_path(cls) -> Path: data_dir = Config.get_data_path() return data_dir / "flocks.db" + @staticmethod + def _file_identity(db_path: Path) -> Optional[Tuple[int, int]]: + """Return the filesystem identity used to detect an online DB replacement.""" + + try: + stat_result = db_path.stat() + except OSError: + return None + return stat_result.st_dev, stat_result.st_ino + + @classmethod + def _assert_active_db_identity(cls, db_path: Path) -> None: + """Reject direct connections after the active primary DB was moved or replaced.""" + + if ( + not cls._initialized + or cls._init_pid != os.getpid() + or cls._db_path is None + or cls._db_identity is None + or db_path.resolve() != cls._db_path.resolve() + ): + return + if cls._file_identity(db_path) != cls._db_identity: + raise StorageError( + f"The active SQLite database changed on disk: {db_path}. " + "Refusing to open a connection to a different file identity; restart Flocks." + ) + @classmethod def get_workflow_db_path(cls) -> Path: """Return the SQLite database path for workflow-domain KV data.""" @@ -264,9 +294,10 @@ def _quarantine_corrupt_db(cls, db_path: Path) -> Optional[Path]: Returns the new location of the main file so callers can surface it in logs or recovery instructions. Returns ``None`` when there was - nothing to quarantine (no main file present), or when the rename - failed — in which case the caller must propagate the original error - because we cannot safely recreate the file in place. + nothing to quarantine (no main file present), or when the main file + and its data-bearing WAL cannot be kept together. In that case the + caller must propagate the original error because recovery without the + matching WAL could silently omit committed transactions. """ db_path = Path(db_path) if not db_path.exists(): @@ -276,12 +307,18 @@ def _quarantine_corrupt_db(cls, db_path: Path) -> Optional[Path]: timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S") suffix = f".corrupt.{timestamp}" + sidecar_suffixes = ("-wal", "-shm") new_main = db_path.with_name(db_path.name + suffix) # Avoid collision if multiple corruptions happen within the same - # second — fall back to a counter suffix. + # second — fall back to a counter suffix. Reserve the paired WAL/SHM + # names as well so every quarantined main file keeps the exact sidecar + # names SQLite expects: ``-wal`` and ``-shm``. counter = 1 - while new_main.exists(): + while new_main.exists() or any( + new_main.with_name(new_main.name + sidecar_suffix).exists() + for sidecar_suffix in sidecar_suffixes + ): new_main = db_path.with_name(f"{db_path.name}{suffix}.{counter}") counter += 1 @@ -297,20 +334,34 @@ def _quarantine_corrupt_db(cls, db_path: Path) -> Optional[Path]: ) return None - for sidecar_name in (f"{db_path.name}-wal", f"{db_path.name}-shm"): - side_path = db_path.with_name(sidecar_name) + for sidecar_suffix in sidecar_suffixes: + side_path = db_path.with_name(db_path.name + sidecar_suffix) if not side_path.exists(): continue try: - side_path.rename(side_path.with_name(sidecar_name + suffix)) + side_path.rename(new_main.with_name(new_main.name + sidecar_suffix)) except OSError as exc: - cls._log.warn( + log_method = cls._log.error if sidecar_suffix == "-wal" else cls._log.warn + log_method( "storage.quarantine.sidecar_rename_failed", { "path": str(side_path), "error": str(exc), }, ) + if sidecar_suffix == "-wal": + try: + new_main.rename(db_path) + except OSError as rollback_exc: + cls._log.error( + "storage.quarantine.rollback_failed", + { + "original_path": str(db_path), + "quarantined_path": str(new_main), + "error": str(rollback_exc), + }, + ) + return None cls._log.error( "storage.corruption.quarantined", @@ -355,13 +406,8 @@ async def _assert_integrity_check_ok(cls, db_path: Path) -> None: @classmethod def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> Optional[Path]: - """Try SQLite's lightweight `.recover` and install the recovered DB. + """Recover from an isolated copy without allowing SQLite to mutate evidence.""" - This intentionally avoids the heavier raw-page/WAL reconstruction script. - It handles the common case where SQLite can still scan a malformed DB - enough to emit recoverable SQL. Failure is non-fatal; callers fall back - to bootstrapping an empty database. - """ sqlite_bin = shutil.which("sqlite3") if sqlite_bin is None: cls._log.warn( @@ -374,6 +420,67 @@ def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> ) return None + working_path = quarantined_path.with_name( + f".{quarantined_path.name}.recovery-source-{uuid.uuid4().hex}" + ) + working_files = [ + working_path, + working_path.with_name(working_path.name + "-wal"), + working_path.with_name(working_path.name + "-shm"), + working_path.with_name(working_path.name + "-journal"), + ] + try: + shutil.copy2(quarantined_path, working_path) + working_path.chmod(0o600) + for suffix in ("-wal", "-shm"): + evidence_sidecar = quarantined_path.with_name(quarantined_path.name + suffix) + if not evidence_sidecar.exists(): + continue + working_sidecar = working_path.with_name(working_path.name + suffix) + shutil.copy2(evidence_sidecar, working_sidecar) + working_sidecar.chmod(0o600) + return cls._try_sqlite_recover_working_copy_sync( + working_path, + quarantined_path, + target_path, + sqlite_bin, + ) + except OSError as exc: + cls._log.warn( + "storage.corruption.recovery.failed", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "stage": "copy_evidence", + "error": str(exc), + }, + ) + return None + finally: + for path in working_files: + try: + path.unlink(missing_ok=True) + except OSError as exc: + cls._log.warn( + "storage.corruption.recovery.temp_cleanup_failed", + {"path": str(path), "error": str(exc)}, + ) + + @classmethod + def _try_sqlite_recover_working_copy_sync( + cls, + working_path: Path, + quarantined_path: Path, + target_path: Path, + sqlite_bin: str, + ) -> Optional[Path]: + """Try SQLite's lightweight `.recover` and install the recovered DB. + + This intentionally avoids the heavier raw-page/WAL reconstruction script. + It handles the common case where SQLite can still scan a malformed DB + enough to emit recoverable SQL. Failure is non-fatal; callers fall back + to bootstrapping an empty database. + """ recovered_path = target_path.with_name(target_path.name + ".recovered") sql_path = target_path.with_name(target_path.name + ".recover.sql") for path in (recovered_path, sql_path): @@ -383,8 +490,13 @@ def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> pass try: + lost_and_found_table = f"flocks_recovery_lost_{uuid.uuid4().hex}" completed = subprocess.run( - [sqlite_bin, str(quarantined_path), ".recover"], + [ + sqlite_bin, + str(working_path), + f".recover --lost-and-found {lost_and_found_table}", + ], check=False, capture_output=True, text=True, @@ -493,6 +605,42 @@ def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> ) return target_path + @staticmethod + async def _wait_for_recovery_worker_on_cancel( + worker: "asyncio.Task[Optional[Path]]", + ) -> Optional[Path]: + """Do not let a cancelled coroutine leave a recovery thread mutating files.""" + + try: + return await asyncio.shield(worker) + except asyncio.CancelledError as cancelled: + while not worker.done(): + try: + await asyncio.shield(worker) + except asyncio.CancelledError: + continue + except BaseException: + break + if worker.done() and not worker.cancelled(): + try: + worker.result() + except BaseException: + pass + raise cancelled + + @classmethod + async def _run_sqlite_recover_async( + cls, + quarantined_path: Path, + target_path: Path, + ) -> Optional[Path]: + """Run blocking sqlite recovery without outliving a cancelled startup task.""" + + worker = asyncio.create_task( + asyncio.to_thread(cls._try_sqlite_recover_sync, quarantined_path, target_path) + ) + return await cls._wait_for_recovery_worker_on_cancel(worker) + @classmethod async def recover_corrupt_db( cls, @@ -515,6 +663,26 @@ async def recover_corrupt_db( if generation is not None and generation != cls._corruption_recovery_generation: return False + is_primary_db = cls._db_path == db_path or db_path.name == "flocks.db" + is_live_process = ( + cls._initialized + and cls._init_pid is not None + and cls._init_pid == os.getpid() + ) + if is_primary_db and is_live_process: + cls._log.error( + "storage.corruption.online_recovery_refused", + { + "db_path": str(db_path), + "action": action, + "hint": "Restart Flocks so recovery runs before request connections open.", + }, + ) + raise StorageError( + "Refusing to replace the primary SQLite database while Flocks is running; " + "restart Flocks to recover it safely." + ) from exc + cls._log.error( "storage.corruption.detected", { @@ -527,15 +695,16 @@ async def recover_corrupt_db( quarantined = cls._quarantine_corrupt_db(db_path) if quarantined is None: raise exc - await asyncio.to_thread(cls._try_sqlite_recover_sync, quarantined, db_path) + await cls._run_sqlite_recover_async(quarantined, db_path) if cls._db_path == db_path: cls._initialized = False cls._init_pid = None + cls._db_identity = None if reinitialize is not None: await reinitialize() - elif cls._db_path == db_path or db_path.name == "flocks.db": + elif is_primary_db: await cls.init(db_path) cls._corruption_recovery_generation += 1 @@ -557,19 +726,29 @@ async def _run_with_corruption_recovery( db_path: Path, action: str, ) -> R: - generation = cls._corruption_recovery_generation + """Run a request operation without replacing its SQLite file in-place. + + Runtime corruption is deliberately deferred to the next process start, + where no request handlers or persistent connections can still reference + the old inode. Primary-DB online recovery is explicitly refused. + """ + try: return await operation() except Exception as exc: if not cls._is_db_corruption_error(exc): raise - await cls.recover_corrupt_db( - db_path, - action=action, - exc=exc, - generation=generation, + cls._log.error( + "storage.corruption.deferred_to_restart", + { + "db_path": str(db_path), + "action": action, + "error": str(exc), + "error_type": type(exc).__name__, + "hint": "Restart Flocks to run startup database recovery safely.", + }, ) - return await operation() + raise @classmethod def _is_sqlite_busy_error(cls, exc: Exception) -> bool: @@ -666,9 +845,11 @@ async def _run_write_with_retry( async def connect(cls, db_path: Optional[Path] = None) -> AsyncIterator[aiosqlite.Connection]: """Open a configured async SQLite connection for the active storage DB.""" target = Path(db_path) if db_path is not None else cls.get_db_path() + cls._assert_active_db_identity(target) target.parent.mkdir(parents=True, exist_ok=True) conn = await aiosqlite.connect(target, timeout=cls._sqlite_timeout_s) try: + cls._assert_active_db_identity(target) await cls.configure_connection(conn) yield conn finally: @@ -678,10 +859,16 @@ async def connect(cls, db_path: Optional[Path] = None) -> AsyncIterator[aiosqlit def connect_sync(cls, db_path: Optional[Path] = None) -> sqlite3.Connection: """Open a configured sync SQLite connection for the active storage DB.""" target = Path(db_path) if db_path is not None else cls.get_db_path() + cls._assert_active_db_identity(target) target.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(target, timeout=cls._sqlite_timeout_s) - conn.row_factory = sqlite3.Row - return cls.configure_sync_connection(conn) + try: + cls._assert_active_db_identity(target) + conn.row_factory = sqlite3.Row + return cls.configure_sync_connection(conn) + except BaseException: + conn.close() + raise @classmethod def register_ddl(cls, ddl: DDLScript) -> None: @@ -815,13 +1002,59 @@ async def init(cls, db_path: Optional[Path] = None) -> None: db_path = data_dir / "flocks.db" db_path = Path(db_path) + current_pid = os.getpid() + same_process = cls._init_pid is None or cls._init_pid == current_pid + if cls._initialized and same_process and cls._db_path is not None: + if cls._db_path != db_path: + # An explicit path switch is allowed for tests and embedded + # callers, but the process-owned persistent binding handle + # must not silently remain attached to the previous DB. + from flocks.channel.inbound.session_binding import close_binding_db + + await close_binding_db() + cls._initialized = False + cls._init_pid = None + cls._db_identity = None + elif not db_path.exists(): + from flocks.channel.inbound.session_binding import close_binding_db + + await close_binding_db() + cls._log.error( + "storage.live_path_disappeared", + { + "db_path": str(db_path), + "hint": "Restart Flocks after restoring or recovering the database file.", + }, + ) + raise StorageError( + f"The active SQLite database disappeared: {db_path}. " + "Refusing to create a second database while existing handles may reference " + "the old file; restart Flocks." + ) + elif ( + cls._db_identity is not None + and cls._file_identity(db_path) != cls._db_identity + ): + from flocks.channel.inbound.session_binding import close_binding_db + + await close_binding_db() + cls._log.error( + "storage.live_path_replaced", + { + "db_path": str(db_path), + "hint": "Restart Flocks; never replace SQLite files while it is running.", + }, + ) + raise StorageError( + f"The active SQLite database was replaced on disk: {db_path}. " + "Refusing to mix connections to different file identities; restart Flocks." + ) + # Tests and short-lived processes may initialize Storage against a # temporary database that later disappears. We also force a # reinit after ``fork()`` (detected via PID mismatch) to avoid the # child silently reusing the parent's open SQLite handle. - current_pid = os.getpid() same_path = cls._db_path == db_path and db_path.exists() - same_process = cls._init_pid is None or cls._init_pid == current_pid if cls._initialized and same_path and same_process: return @@ -879,7 +1112,7 @@ async def init(cls, db_path: Optional[Path] = None) -> None: quarantined = cls._quarantine_corrupt_db(cls._db_path) if quarantined is None: raise - await asyncio.to_thread(cls._try_sqlite_recover_sync, quarantined, cls._db_path) + await cls._run_sqlite_recover_async(quarantined, cls._db_path) await cls._bootstrap_schema() # Drain any residual WAL frames left by the previous process so the @@ -893,6 +1126,7 @@ async def init(cls, db_path: Optional[Path] = None) -> None: cls._log.warn("storage.startup_checkpoint.failed", {"error": str(exc)}) cls._init_pid = os.getpid() + cls._db_identity = cls._file_identity(cls._db_path) cls._initialized = True cls._log.info( "storage.initialized", @@ -979,6 +1213,19 @@ async def shutdown(cls) -> None: """ if not cls._initialized: return + if ( + cls._db_path is not None + and cls._db_identity is not None + and cls._file_identity(cls._db_path) != cls._db_identity + ): + cls._log.warn( + "storage.shutdown.checkpoint.skipped_replaced_path", + {"db_path": str(cls._db_path)}, + ) + cls._initialized = False + cls._init_pid = None + cls._db_identity = None + return attempts = max(int(cls._shutdown_checkpoint_attempts), 1) backoff = max(float(cls._shutdown_checkpoint_backoff_s), 0.0) @@ -1046,6 +1293,7 @@ async def shutdown(cls) -> None: finally: cls._initialized = False cls._init_pid = None + cls._db_identity = None @classmethod async def _bootstrap_schema(cls) -> None: @@ -1197,8 +1445,20 @@ async def _ensure_init(cls) -> None: ) cls._initialized = False cls._init_pid = None + cls._db_identity = None - if not cls._initialized or cls._db_path is None or not cls._db_path.exists(): + identity_changed = ( + cls._initialized + and cls._db_path is not None + and cls._db_identity is not None + and cls._file_identity(cls._db_path) != cls._db_identity + ) + if ( + not cls._initialized + or cls._db_path is None + or not cls._db_path.exists() + or identity_changed + ): await cls.init(cls._db_path) @classmethod diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index 0f11995e7..d348a8387 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -25,6 +25,15 @@ log = Log.create(service="tool-registry") +class ToolRefreshError(RuntimeError): + """A refresh stage failed and the previous registry state was restored.""" + + def __init__(self, stage: str, errors: List[str]): + self.stage = stage + self.errors = list(errors) + super().__init__(f"{stage}: {'; '.join(self.errors)}") + + class ToolCategory(str, Enum): """Tool categories""" FILE = "file" @@ -602,6 +611,7 @@ class ToolRegistry: _failure_state: Dict[str, Dict[str, Any]] = {} _failure_disable_threshold: int = 3 _init_lock = threading.Lock() + _refresh_lock = threading.RLock() _initializing_thread_id: Optional[int] = None # Snapshot of every tool's factory-default ``enabled`` flag — captured @@ -646,8 +656,9 @@ def register(cls, tool: Tool) -> None: # ``refresh_plugin_tools`` cycle — must refresh the snapshot so # ``enabled_default`` / ``reset`` reflect the current source of # truth instead of the first value ever observed. - cls._enabled_defaults[tool.info.name] = bool(tool.info.enabled) - cls._tools[tool.info.name] = tool + with cls._refresh_lock: + cls._enabled_defaults[tool.info.name] = bool(tool.info.enabled) + cls._tools[tool.info.name] = tool log.debug("tool.registered", { "name": tool.info.name, "category": tool.info.category.value, @@ -660,7 +671,8 @@ def revision(cls) -> int: The revision is bumped when plugin or dynamic tools are reloaded so long-lived session caches can detect toolset changes. """ - return cls._revision + with cls._refresh_lock: + return cls._revision @classmethod def _bump_revision(cls, reason: str) -> None: @@ -728,7 +740,8 @@ def decorator(func: ToolHandler) -> ToolHandler: @classmethod def unregister(cls, name: str) -> bool: """Unregister a tool by name. Returns True if the tool was found and removed.""" - removed = cls._tools.pop(name, None) + with cls._refresh_lock: + removed = cls._tools.pop(name, None) if removed: log.debug("tool.unregistered", {"name": name}) return removed is not None @@ -743,18 +756,18 @@ def _ensure_initialized(cls) -> None: def get(cls, name: str) -> Optional[Tool]: """Get a tool by name""" cls._ensure_initialized() - return cls._tools.get(name) + with cls._refresh_lock: + return cls._tools.get(name) @classmethod def list_tools(cls, category: Optional[ToolCategory] = None) -> List[ToolInfo]: """List all registered tools, optionally filtered by category""" cls._ensure_initialized() - tools = list(cls._tools.values()) - - if category: - tools = [t for t in tools if t.info.category == category] - - return [t.info for t in tools] + with cls._refresh_lock: + tools = list(cls._tools.values()) + if category: + tools = [t for t in tools if t.info.category == category] + return [t.info for t in tools] @classmethod def get_schema(cls, name: str) -> Optional[ToolSchema]: @@ -983,13 +996,25 @@ async def execute_batch( def all_tool_ids(cls) -> List[str]: """Get all registered tool IDs""" cls._ensure_initialized() - return list(cls._tools.keys()) + with cls._refresh_lock: + return list(cls._tools.keys()) + + @classmethod + def snapshot_identity(cls) -> tuple[int, tuple[str, ...]]: + """Return a revision/tool-id identity from one consistent registry state.""" + cls._ensure_initialized() + with cls._refresh_lock: + return cls._revision, tuple(cls._tools.keys()) @classmethod def get_dynamic_tools_by_module(cls) -> Dict[str, List[str]]: """Return a copy of the dynamic-module → tool-names mapping.""" cls._ensure_initialized() - return dict(cls._dynamic_tools_by_module) + with cls._refresh_lock: + return { + module_name: list(tool_names) + for module_name, tool_names in cls._dynamic_tools_by_module.items() + } @classmethod def get_api_service_ids(cls) -> set: @@ -1039,7 +1064,7 @@ async def init_async(cls) -> None: await asyncio.to_thread(cls.init) @classmethod - def _load_plugin_tools(cls) -> None: + def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> None: """Load plugin tools from both user-level and project-level plugin dirs on init. Without this, YAML/Python plugin tools only appear after an explicit @@ -1057,9 +1082,13 @@ def _load_plugin_tools(cls) -> None: try: from flocks.plugin import PluginLoader - PluginLoader.load_extension("TOOLS", load_entry_points=True) + load_errors = PluginLoader.load_extension("TOOLS", load_entry_points=True) + if errors is not None: + errors.extend(load_errors) except Exception as e: log.warn("tool_registry.plugin_load_failed", {"error": str(e)}) + if errors is not None: + errors.append(f"plugin loader: {e}") after = set(cls._tools.keys()) new_plugin_tools = sorted(after - before) python_tool_sources: Dict[str, Path] = {} @@ -1102,6 +1131,8 @@ def _load_plugin_tools(cls) -> None: except ValueError: tool.info.native = True cls._plugin_tool_names = sorted(set(new_plugin_tools) | python_plugin_names) + if errors: + return cls._bootstrap_user_api_services() # Defence-in-depth: ``register()`` is the canonical writer for # ``_enabled_defaults`` but this catches any tool that landed in @@ -1265,7 +1296,8 @@ def get_default_enabled(cls, name: str) -> Optional[bool]: (e.g. dynamic tools registered after init). Callers should fall back to the live ``ToolInfo.enabled`` in that case. """ - return cls._enabled_defaults.get(name) + with cls._refresh_lock: + return cls._enabled_defaults.get(name) @classmethod def _apply_tool_settings(cls) -> None: @@ -1351,8 +1383,9 @@ def _is_native_source(source: str) -> bool: except ValueError: return True # Under /.flocks/plugins/ or elsewhere → project-level → native - def _consume_tools(items: list, source: str) -> None: + def _consume_tools(items: list, source: str) -> Optional[List[str]]: is_native = _is_native_source(source) + errors: List[str] = [] for spec in items: # YAML factory produces Tool instances directly if isinstance(spec, Tool): @@ -1380,6 +1413,7 @@ def _consume_tools(items: list, source: str) -> None: if not isinstance(spec, dict): log.warn("plugin.tool.invalid_spec", {"source": source}) + errors.append("tool definition must be a Tool or mapping") continue name = spec.get("name") handler = spec.get("handler") @@ -1388,6 +1422,7 @@ def _consume_tools(items: list, source: str) -> None: "source": source, "spec_keys": list(spec.keys()), }) + errors.append("tool definition requires name and handler") continue existing = cls._tools.get(name) if existing is not None: @@ -1411,8 +1446,19 @@ def _consume_tools(items: list, source: str) -> None: log.warn("plugin.tool.handler_not_found", { "source": source, "handler": spec.get("handler"), }) + errors.append( + f"tool {name}: handler {spec.get('handler')!r} not found" + ) continue + if not callable(handler): + log.warn("plugin.tool.handler_not_callable", { + "source": source, + "name": name, + }) + errors.append(f"tool {name}: handler must be callable") + continue + params = [ ToolParameter(**p) if isinstance(p, dict) else p for p in spec.get("parameters", []) @@ -1429,6 +1475,7 @@ def _consume_tools(items: list, source: str) -> None: native=is_native, ) cls.register(Tool(info=info, handler=handler)) + return errors def _dedup_key(item: Any) -> str: if isinstance(item, Tool): @@ -1572,18 +1619,68 @@ def refresh_plugin_tools(cls) -> List[str]: correctly removed from the registry. """ cls._ensure_initialized() - cls._unregister_plugin_tools() - cls._load_plugin_tools() - cls._bump_revision("plugin_refresh") - return cls.all_tool_ids() + with cls._refresh_lock: + tools_before = cls._tools.copy() + defaults_before = cls._enabled_defaults.copy() + plugin_names_before = list(cls._plugin_tool_names) + load_errors: List[str] = [] + + try: + cls._unregister_plugin_tools() + cls._load_plugin_tools(load_errors) + except Exception: + cls._tools.clear() + cls._tools.update(tools_before) + cls._enabled_defaults.clear() + cls._enabled_defaults.update(defaults_before) + cls._plugin_tool_names = plugin_names_before + raise + if load_errors: + cls._tools.clear() + cls._tools.update(tools_before) + cls._enabled_defaults.clear() + cls._enabled_defaults.update(defaults_before) + cls._plugin_tool_names = plugin_names_before + raise ToolRefreshError("plugin", load_errors) + + cls._bump_revision("plugin_refresh") + return cls.all_tool_ids() @classmethod def refresh_dynamic_tools(cls) -> List[str]: """Reload dynamically generated tools and return tool ids.""" cls._ensure_initialized() - cls._register_dynamic_tools() - cls._bump_revision("dynamic_refresh") - return cls.all_tool_ids() + with cls._refresh_lock: + tools_before = cls._tools.copy() + defaults_before = cls._enabled_defaults.copy() + dynamic_modules_before = cls._dynamic_modules.copy() + dynamic_tools_before = { + name: list(tool_names) + for name, tool_names in cls._dynamic_tools_by_module.items() + } + load_errors: List[str] = [] + + try: + cls._register_dynamic_tools(load_errors) + except Exception: + cls._tools.clear() + cls._tools.update(tools_before) + cls._enabled_defaults.clear() + cls._enabled_defaults.update(defaults_before) + cls._dynamic_modules = dynamic_modules_before + cls._dynamic_tools_by_module = dynamic_tools_before + raise + if load_errors: + cls._tools.clear() + cls._tools.update(tools_before) + cls._enabled_defaults.clear() + cls._enabled_defaults.update(defaults_before) + cls._dynamic_modules = dynamic_modules_before + cls._dynamic_tools_by_module = dynamic_tools_before + raise ToolRefreshError("dynamic", load_errors) + + cls._bump_revision("dynamic_refresh") + return cls.all_tool_ids() @classmethod def _reset_failure_state(cls, tool_name: str) -> None: @@ -1717,7 +1814,7 @@ def _unregister_dynamic_tools(cls, module_name: str) -> None: }) @classmethod - def _register_dynamic_tools(cls) -> None: + def _register_dynamic_tools(cls, errors: Optional[List[str]] = None) -> None: """Register dynamically generated tools by importing modules.""" modules = cls._discover_dynamic_modules() @@ -1743,6 +1840,8 @@ def _register_dynamic_tools(cls) -> None: "module": module_name, "error": str(e), }) + if errors is not None: + errors.append(f"{path}: {e}") continue after = set(cls._tools.keys()) diff --git a/pyproject.toml b/pyproject.toml index e68732a07..88e6ae933 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,11 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["flocks"] +artifacts = [ + "flocks/channel/builtin/whatsapp/bridge/*.js", + "flocks/channel/builtin/whatsapp/bridge/package.json", + "flocks/channel/builtin/whatsapp/bridge/package-lock.json", +] [tool.ruff] line-length = 120 diff --git a/scripts/recover_raw_flocks_db.py b/scripts/recover_raw_flocks_db.py index b77088240..846ef56b2 100644 --- a/scripts/recover_raw_flocks_db.py +++ b/scripts/recover_raw_flocks_db.py @@ -1,4 +1,8 @@ -"""One-click recovery for a damaged Flocks SQLite database.""" +"""Offline recovery for a damaged Flocks SQLite database. + +The helper writes recovery artifacts only. It never deletes SQLite sidecars or +installs a recovered database over the live Flocks store. +""" from __future__ import annotations @@ -9,13 +13,19 @@ import struct import subprocess import shutil +import uuid +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import Dict, Sequence +from dotenv import load_dotenv + WAL_MAGIC = 0x377F0682 +WAL_MAGIC_BIG_ENDIAN_CHECKSUM = 0x377F0683 +WAL_MAGIC_VALUES = {WAL_MAGIC, WAL_MAGIC_BIG_ENDIAN_CHECKSUM} WAL_VERSION = 3007000 -COMMON_SQLITE_PAGE_SIZES = (4096, 8192, 2048, 1024, 16384, 32768, 65536) +COMMON_SQLITE_PAGE_SIZES = (4096, 8192, 2048, 1024, 512, 16384, 32768, 65536) STORAGE_DDL = """ CREATE TABLE IF NOT EXISTS storage ( @@ -276,12 +286,76 @@ class RecoveryArtifacts: extracted_db: Path recovered_db: Path summary_path: Path + lost_and_found_table: str pagesize: int wal_frames: int wal_final_db_pages: int copied_rows: Dict[str, int] +def _cleanup_temporary_sqlite_files(path: Path) -> None: + """Remove only temporary files created by the current recovery operation.""" + + for candidate in ( + path, + path.with_name(f"{path.name}-journal"), + path.with_name(f"{path.name}-wal"), + path.with_name(f"{path.name}-shm"), + ): + candidate.unlink(missing_ok=True) + + +def _publish_file_exclusive(temporary_path: Path, destination: Path) -> None: + """Atomically publish a sibling temporary file without replacing a destination.""" + + destination.parent.mkdir(parents=True, exist_ok=True) + if os.name == "nt": + try: + os.rename(temporary_path, destination) + except FileExistsError: + raise FileExistsError(f"Refusing to overwrite existing recovery file: {destination}") + return + try: + os.link(temporary_path, destination, follow_symlinks=False) + except FileExistsError: + raise FileExistsError(f"Refusing to overwrite existing recovery file: {destination}") + temporary_path.unlink() + + +@contextmanager +def _atomic_output_path(destination: Path): + """Yield a sibling temporary path and publish it only after successful completion.""" + + destination.parent.mkdir(parents=True, exist_ok=True) + temporary_path = destination.with_name(f".{destination.name}.tmp-{uuid.uuid4().hex}") + try: + yield temporary_path + if not temporary_path.is_file(): + raise RuntimeError(f"Recovery output was not created: {temporary_path}") + unexpected_sidecars = [ + candidate + for candidate in ( + temporary_path.with_name(f"{temporary_path.name}-journal"), + temporary_path.with_name(f"{temporary_path.name}-wal"), + temporary_path.with_name(f"{temporary_path.name}-shm"), + ) + if candidate.exists() + ] + if unexpected_sidecars: + raise RuntimeError( + "Recovery staging file still has SQLite sidecars: " + + ", ".join(str(path) for path in unexpected_sidecars) + ) + # Windows maps fsync() to _commit(), which requires a writable file + # descriptor. ``r+b`` works on every supported platform and keeps the + # durability step consistent before the no-replace publish. + with temporary_path.open("r+b") as handle: + os.fsync(handle.fileno()) + _publish_file_exclusive(temporary_path, destination) + finally: + _cleanup_temporary_sqlite_files(temporary_path) + + def _quote_identifier(value: str) -> str: return '"' + value.replace('"', '""') + '"' @@ -294,44 +368,100 @@ def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool: return row is not None -def _table_columns(conn: sqlite3.Connection, table_name: str) -> list[str]: - rows = conn.execute(f"PRAGMA table_info({_quote_identifier(table_name)})").fetchall() - return [str(row[1]) for row in rows] +def _wal_checksum( + data: bytes, + *, + byte_order: str, + state: tuple[int, int] = (0, 0), +) -> tuple[int, int]: + """Return SQLite's rolling WAL checksum for an 8-byte-aligned payload.""" + + if len(data) % 8 != 0: + raise ValueError("WAL checksum input must be aligned to 8 bytes.") + words = struct.unpack(f"{byte_order}{len(data) // 4}I", data) + checksum_1, checksum_2 = state + for index in range(0, len(words), 2): + checksum_1 = (checksum_1 + words[index] + checksum_2) & 0xFFFFFFFF + checksum_2 = (checksum_2 + words[index + 1] + checksum_1) & 0xFFFFFFFF + return checksum_1, checksum_2 def _parse_wal_frames(wal_bytes: bytes) -> tuple[int, int, Dict[int, bytes], int]: - """Return WAL page size, final db pages, latest page map, and frame count.""" + """Return committed, checksum-valid WAL pages through the last transaction.""" if len(wal_bytes) < 32: raise ValueError("WAL file is too small to contain a valid header.") - magic, version, pagesize, _, _, _, _, _ = struct.unpack(">8I", wal_bytes[:32]) - if magic != WAL_MAGIC: + magic, version, pagesize, _, salt_1, salt_2, stored_1, stored_2 = struct.unpack( + ">8I", wal_bytes[:32] + ) + if magic not in WAL_MAGIC_VALUES: raise ValueError(f"Unexpected WAL magic: 0x{magic:08x}") if version != WAL_VERSION: raise ValueError(f"Unexpected WAL version: {version}") - if pagesize <= 0: + if pagesize < 512 or pagesize > 65536 or pagesize & (pagesize - 1): raise ValueError(f"Invalid WAL pagesize: {pagesize}") + byte_order = "<" if magic == WAL_MAGIC else ">" + checksum = _wal_checksum(wal_bytes[:24], byte_order=byte_order) + if checksum != (stored_1, stored_2): + raise ValueError("WAL header checksum is invalid.") + frame_size = 24 + pagesize payload = len(wal_bytes) - 32 - if payload < 0 or payload % frame_size != 0: - raise ValueError("WAL payload is not aligned to complete frames.") - - frame_count = payload // frame_size - latest_pages: Dict[int, bytes] = {} + available_frames = payload // frame_size + valid_frames: list[tuple[int, bytes]] = [] + last_commit_frame_count = 0 final_db_pages = 0 - for frame_index in range(frame_count): + for frame_index in range(available_frames): offset = 32 + frame_index * frame_size - page_no, db_page_count, *_ = struct.unpack(">6I", wal_bytes[offset : offset + 24]) - latest_pages[page_no] = wal_bytes[offset + 24 : offset + 24 + pagesize] + frame_header = wal_bytes[offset : offset + 24] + page = wal_bytes[offset + 24 : offset + 24 + pagesize] + page_no, db_page_count, frame_salt_1, frame_salt_2, frame_sum_1, frame_sum_2 = ( + struct.unpack(">6I", frame_header) + ) + if page_no == 0 or (frame_salt_1, frame_salt_2) != (salt_1, salt_2): + break + + next_checksum = _wal_checksum( + frame_header[:8], + byte_order=byte_order, + state=checksum, + ) + next_checksum = _wal_checksum( + page, + byte_order=byte_order, + state=next_checksum, + ) + if next_checksum != (frame_sum_1, frame_sum_2): + break + + checksum = next_checksum + valid_frames.append((page_no, page)) if db_page_count: final_db_pages = db_page_count + last_commit_frame_count = len(valid_frames) - if final_db_pages <= 0: + if final_db_pages <= 0 or last_commit_frame_count == 0: raise ValueError("WAL does not contain any committed frames.") - return pagesize, final_db_pages, latest_pages, frame_count + latest_pages: Dict[int, bytes] = {} + for page_no, page in valid_frames[:last_commit_frame_count]: + latest_pages[page_no] = page + + return pagesize, final_db_pages, latest_pages, last_commit_frame_count + + +def _read_header_pagesize(raw_bytes: bytes) -> int | None: + """Return a valid SQLite page size from the database header, if present.""" + + if len(raw_bytes) < 100 or not raw_bytes.startswith(b"SQLite format 3\x00"): + return None + encoded = int.from_bytes(raw_bytes[16:18], "big") + pagesize = 65536 if encoded == 1 else encoded + if pagesize < 512 or pagesize > 65536 or pagesize & (pagesize - 1): + return None + return pagesize def _guess_raw_pagesize(raw_bytes: bytes) -> int: @@ -363,14 +493,14 @@ def _build_synthetic_page1(pagesize: int, total_pages: int) -> bytes: page1[40:44] = (1).to_bytes(4, "big") page1[44:48] = (4).to_bytes(4, "big") page1[56:60] = (1).to_bytes(4, "big") - page1[96] = 0x0D - page1[97] = 0x00 - page1[98:100] = (0).to_bytes(2, "big") + page1[92:96] = (1).to_bytes(4, "big") + # Page 1's b-tree header begins after the 100-byte database header. + page1[100] = 0x0D + page1[101:103] = (0).to_bytes(2, "big") + page1[103:105] = (0).to_bytes(2, "big") cell_content_area = 0 if pagesize == 65536 else pagesize - page1[100:102] = cell_content_area.to_bytes(2, "big") - page1[102] = 0 - page1[103] = 0 - page1[104:108] = (0).to_bytes(4, "big") + page1[105:107] = cell_content_area.to_bytes(2, "big") + page1[107] = 0 return bytes(page1) @@ -387,43 +517,49 @@ def reconstruct_sqlite_candidate( if wal_path is not None: wal_bytes = wal_path.read_bytes() pagesize, final_db_pages, latest_pages, frame_count = _parse_wal_frames(wal_bytes) + header_pagesize = _read_header_pagesize(raw_bytes) + if header_pagesize is not None and header_pagesize != pagesize: + raise ValueError( + "WAL page size does not match the SQLite database header: " + f"{pagesize} != {header_pagesize}." + ) if len(raw_bytes) % pagesize != 0: raise ValueError( f"Raw file size {len(raw_bytes)} is not aligned to WAL pagesize {pagesize}." ) else: - pagesize = _guess_raw_pagesize(raw_bytes) + pagesize = _read_header_pagesize(raw_bytes) or _guess_raw_pagesize(raw_bytes) final_db_pages = len(raw_bytes) // pagesize latest_pages = {} frame_count = 0 - output_path.parent.mkdir(parents=True, exist_ok=True) - with output_path.open("wb") as handle: - if wal_path is None and not raw_has_header: - handle.write(_build_synthetic_page1(pagesize, final_db_pages)) - start_page = 2 - else: - start_page = 1 - - if wal_path is None and raw_has_header: - handle.write(raw_bytes) - return { - "pagesize": pagesize, - "wal_frames": 0, - "wal_final_db_pages": final_db_pages, - "wal_pages_used": 0, - } - - for page_no in range(1, final_db_pages + 1): - if wal_path is None and page_no < start_page: - continue - if page_no in latest_pages: - handle.write(latest_pages[page_no]) - continue - - offset = (page_no - 1) * pagesize - page = raw_bytes[offset : offset + pagesize] - handle.write(page if len(page) == pagesize else (b"\x00" * pagesize)) + with _atomic_output_path(output_path) as temporary_path: + with temporary_path.open("xb") as handle: + if wal_path is None and not raw_has_header: + handle.write(_build_synthetic_page1(pagesize, final_db_pages)) + start_page = 2 + else: + start_page = 1 + + if wal_path is None and raw_has_header: + handle.write(raw_bytes) + return { + "pagesize": pagesize, + "wal_frames": 0, + "wal_final_db_pages": final_db_pages, + "wal_pages_used": 0, + } + + for page_no in range(1, final_db_pages + 1): + if wal_path is None and page_no < start_page: + continue + if page_no in latest_pages: + handle.write(latest_pages[page_no]) + continue + + offset = (page_no - 1) * pagesize + page = raw_bytes[offset : offset + pagesize] + handle.write(page if len(page) == pagesize else (b"\x00" * pagesize)) return { "pagesize": pagesize, @@ -433,254 +569,199 @@ def reconstruct_sqlite_candidate( } -def _run_sqlite_recover(candidate_db: Path, recover_sql_path: Path) -> None: +def _run_sqlite_recover( + candidate_db: Path, + recover_sql_path: Path, + *, + lost_and_found_table: str, +) -> None: """Write `sqlite3 .recover` output to a SQL file.""" completed = subprocess.run( - ["sqlite3", str(candidate_db), ".recover"], + [ + "sqlite3", + str(candidate_db), + f".recover --lost-and-found {lost_and_found_table}", + ], check=False, capture_output=True, text=True, encoding="utf-8", ) - recover_sql_path.write_text(completed.stdout or "", encoding="utf-8") if completed.returncode != 0 and not completed.stdout.strip(): stderr = completed.stderr.strip() or completed.stdout.strip() raise RuntimeError(f"sqlite3 .recover failed: {stderr or completed.returncode}") + with _atomic_output_path(recover_sql_path) as temporary_path: + temporary_path.write_text(completed.stdout or "", encoding="utf-8") def _materialize_recovered_sql(recover_sql_path: Path, extracted_db_path: Path) -> None: """Execute recovered SQL into a scratch SQLite database.""" - if extracted_db_path.exists(): - extracted_db_path.unlink() - - completed = subprocess.run( - ["sqlite3", str(extracted_db_path)], - input=recover_sql_path.read_text(encoding="utf-8"), - check=False, - capture_output=True, - text=True, - encoding="utf-8", - ) - if completed.returncode != 0: - stderr = completed.stderr.strip() or completed.stdout.strip() - raise RuntimeError(f"Failed to materialize recovered SQL: {stderr}") - - -def _ensure_recovered_schema(output_db_path: Path) -> None: - """Create the normalized target schema.""" - - if output_db_path.exists(): - output_db_path.unlink() - - output_db_path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(output_db_path) - try: - conn.executescript(STORAGE_DDL) - conn.executescript(USAGE_RECORDS_DDL) - conn.executescript(TASKS_DDL) - conn.executescript(CHANNEL_BINDINGS_DDL) - for stmt in (*USAGE_INDEX_STMTS, *TASK_INDEX_STMTS): - conn.execute(stmt) - conn.commit() - finally: - conn.close() - + with _atomic_output_path(extracted_db_path) as temporary_path: + completed = subprocess.run( + ["sqlite3", str(temporary_path)], + input=recover_sql_path.read_text(encoding="utf-8"), + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + if completed.returncode != 0: + stderr = completed.stderr.strip() or completed.stdout.strip() + raise RuntimeError(f"Failed to materialize recovered SQL: {stderr}") -def _insert_rows( - target_conn: sqlite3.Connection, - table_name: str, - columns: Sequence[str], - rows: Sequence[Sequence[object]], -) -> int: - """Insert rows into a table with INSERT OR REPLACE.""" - - if not rows: - return 0 - - quoted_columns = ", ".join(_quote_identifier(column) for column in columns) - placeholders = ", ".join("?" for _ in columns) - target_conn.executemany( - ( - f"INSERT OR REPLACE INTO {_quote_identifier(table_name)} " - f"({quoted_columns}) VALUES ({placeholders})" - ), - rows, - ) - return len(rows) +def _schema_manifest(conn: sqlite3.Connection) -> dict[tuple[str, str], tuple[str, str | None]]: + """Return logical schema objects, excluding SQLite-managed internal objects.""" -def _copy_table_rows( - source_conn: sqlite3.Connection, - target_conn: sqlite3.Connection, - table_name: str, -) -> int: - """Copy shared columns from one table to another.""" - - if not _table_exists(source_conn, table_name) or not _table_exists(target_conn, table_name): - return 0 - - source_columns = _table_columns(source_conn, table_name) - target_columns = set(_table_columns(target_conn, table_name)) - copy_columns = [column for column in source_columns if column in target_columns] - if not copy_columns: - return 0 - - quoted_columns = ", ".join(_quote_identifier(column) for column in copy_columns) - rows = source_conn.execute( - f"SELECT {quoted_columns} FROM {_quote_identifier(table_name)}" + rows = conn.execute( + """ + SELECT type, name, tbl_name, sql + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type, name + """ ).fetchall() - if not rows: - return 0 - - return _insert_rows(target_conn, table_name, copy_columns, rows) + return { + (str(row[0]), str(row[1])): (str(row[2]), None if row[3] is None else str(row[3])) + for row in rows + } -def _copy_lost_and_found_rows( +def _validate_recovered_db( source_conn: sqlite3.Connection, target_conn: sqlite3.Connection, - table_name: str, -) -> int: - """Recover business rows from `.recover` lost_and_found output.""" - - if not _table_exists(source_conn, "lost_and_found"): - return 0 - - if table_name == "storage": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4 - FROM lost_and_found - WHERE nfield = 5 - AND c0 NOT IN ('table', 'index') - AND typeof(c0) = 'text' - AND typeof(c1) = 'text' - AND typeof(c2) = 'text' - AND typeof(c3) = 'text' - AND typeof(c4) = 'text' - """ - ).fetchall() - if ":" in str(row[0]) or "/" in str(row[0]) - ] - return _insert_rows( - target_conn, - "storage", - ("key", "value", "type", "created_at", "updated_at"), - rows, +) -> None: + """Fail closed if the published backup changed recovered business data.""" + + source_manifest = _schema_manifest(source_conn) + target_manifest = _schema_manifest(target_conn) + if target_manifest != source_manifest: + changed_keys = sorted( + key + for key in source_manifest.keys() | target_manifest.keys() + if source_manifest.get(key) != target_manifest.get(key) + ) + changed = changed_keys[0] if changed_keys else ("schema", "unknown") + raise sqlite3.DatabaseError( + f"Recovered DB schema object was not preserved: {changed[0]} {changed[1]}" ) - if table_name == "usage_records": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, - c10, c11, c12, c13, c14, c15, c16, c17, c18, c19 - FROM lost_and_found - WHERE nfield = 20 - AND typeof(c0) = 'text' - AND typeof(c1) = 'text' - AND typeof(c2) = 'text' - """ - ).fetchall() - ] - return _insert_rows(target_conn, "usage_records", USAGE_RECORD_COLUMNS, rows) - - if table_name == "task_schedulers": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, - c10, c11, c12, c13, c14, c15, c16, c17, c18, c19, c20 - FROM lost_and_found - WHERE nfield = 21 - AND typeof(c0) = 'text' - """ - ).fetchall() - if str(row[0]).startswith("tsk_") - ] - return _insert_rows(target_conn, "task_schedulers", TASK_SCHEDULER_COLUMNS, rows) - - if table_name == "task_executions": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, - c10, c11, c12, c13, c14, c15, c16, c17, c18, c19, - c20, c21, c22, c23 - FROM lost_and_found - WHERE nfield = 24 - AND typeof(c0) = 'text' - """ - ).fetchall() - if str(row[0]).startswith("txe_") - ] - return _insert_rows(target_conn, "task_executions", TASK_EXECUTION_COLUMNS, rows) - - if table_name == "task_execution_queue_refs": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4 - FROM lost_and_found - WHERE nfield = 5 - AND typeof(c0) = 'text' - AND typeof(c1) = 'text' - AND typeof(c2) = 'text' - AND typeof(c3) = 'text' - """ - ).fetchall() - if str(row[0]).startswith("tqref_") - ] - return _insert_rows(target_conn, "task_execution_queue_refs", QUEUE_REF_COLUMNS, rows) - - if table_name == "channel_bindings": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4, c5, c6, c7, c8, c9 - FROM lost_and_found - WHERE nfield = 10 - AND typeof(c0) = 'text' - """ - ).fetchall() - if str(row[0]).startswith("chb_") - ] - return _insert_rows(target_conn, "channel_bindings", CHANNEL_BINDING_COLUMNS, rows) + source_tables = { + name for object_type, name in source_manifest if object_type == "table" + } + virtual_tables = { + name + for (object_type, name), (_, sql) in source_manifest.items() + if object_type == "table" + and sql is not None + and sql.lstrip().upper().startswith("CREATE VIRTUAL TABLE") + } + missing_row = object() + for table_name in sorted(source_tables - virtual_tables): + quoted_table = _quote_identifier(table_name) + table_sql = source_manifest[("table", table_name)][1] or "" + column_names = { + str(row[1]).casefold() + for row in source_conn.execute(f"PRAGMA table_info({quoted_table})").fetchall() + } + rowid_alias = next( + ( + alias + for alias in ("rowid", "_rowid_", "oid") + if alias.casefold() not in column_names + ), + None, + ) + include_rowid = rowid_alias is not None and "WITHOUT ROWID" not in table_sql.upper() + projection = f"{_quote_identifier(rowid_alias)}, *" if include_rowid else "*" + try: + source_rows = source_conn.execute(f"SELECT {projection} FROM {quoted_table}") + target_rows = target_conn.execute(f"SELECT {projection} FROM {quoted_table}") + row_number = 0 + while True: + source_row = source_rows.fetchone() + target_row = target_rows.fetchone() + if source_row is None and target_row is None: + break + row_number += 1 + source_value = missing_row if source_row is None else tuple(source_row) + target_value = missing_row if target_row is None else tuple(target_row) + if source_value != target_value: + raise sqlite3.DatabaseError( + f"Recovered DB row content changed for {table_name} at row {row_number}" + ) + except sqlite3.DatabaseError: + raise + except sqlite3.Error as exc: + raise sqlite3.DatabaseError( + f"Recovered DB table could not be compared: {table_name}: {exc}" + ) from exc + + for pragma_name in ("application_id", "user_version"): + source_value = source_conn.execute(f"PRAGMA {pragma_name}").fetchone()[0] + target_value = target_conn.execute(f"PRAGMA {pragma_name}").fetchone()[0] + if target_value != source_value: + raise sqlite3.DatabaseError( + f"Recovered DB PRAGMA {pragma_name} changed: {source_value} -> {target_value}" + ) - return 0 + integrity_rows = target_conn.execute("PRAGMA integrity_check").fetchall() + if integrity_rows != [("ok",)]: + detail = "; ".join(str(row[0]) for row in integrity_rows) + raise sqlite3.DatabaseError(f"Recovered DB integrity check failed: {detail}") + foreign_key_rows = target_conn.execute("PRAGMA foreign_key_check").fetchall() + if foreign_key_rows: + raise sqlite3.IntegrityError( + f"Recovered DB foreign key check failed: {foreign_key_rows[0]}" + ) def build_normalized_recovery_db( extracted_db_path: Path, output_db_path: Path, + *, + lost_and_found_table: str = "lost_and_found", ) -> Dict[str, int]: - """Copy supported recovered tables into a clean output database.""" + """Publish an exact recovered backup and retain unresolved rows for inspection. - _ensure_recovered_schema(output_db_path) + ``sqlite3 .recover`` stores rows it cannot attribute confidently in its + lost-and-found table. Guessing their destination from field count or value + prefixes can silently inject another table's data into Flocks, so this + function deliberately leaves those rows untouched. + """ copied_rows: Dict[str, int] = {} - source_conn = sqlite3.connect(extracted_db_path) - target_conn = sqlite3.connect(output_db_path) - try: - for table_name in SUPPORTED_COPY_TABLES: - direct_rows = _copy_table_rows(source_conn, target_conn, table_name) - if direct_rows == 0: - _copy_lost_and_found_rows(source_conn, target_conn, table_name) - target_conn.commit() - copied_rows[table_name] = target_conn.execute( - f"SELECT COUNT(*) FROM {_quote_identifier(table_name)}" - ).fetchone()[0] - finally: - source_conn.close() - target_conn.close() + with _atomic_output_path(output_db_path) as temporary_path: + source_conn = sqlite3.connect(extracted_db_path) + target_conn = sqlite3.connect(temporary_path) + try: + source_conn.backup(target_conn) + finally: + source_conn.close() + target_conn.close() + + source_conn = sqlite3.connect(extracted_db_path) + target_conn = sqlite3.connect(temporary_path) + try: + journal_mode = target_conn.execute("PRAGMA journal_mode=DELETE").fetchone()[0] + if str(journal_mode).lower() != "delete": + raise sqlite3.DatabaseError( + f"Could not switch recovered DB to DELETE journal mode: {journal_mode}" + ) + for table_name in SUPPORTED_COPY_TABLES: + copied_rows[table_name] = ( + target_conn.execute( + f"SELECT COUNT(*) FROM {_quote_identifier(table_name)}" + ).fetchone()[0] + if _table_exists(target_conn, table_name) + else 0 + ) + _validate_recovered_db(source_conn, target_conn) + finally: + source_conn.close() + target_conn.close() return copied_rows @@ -695,6 +776,7 @@ def _render_summary(artifacts: RecoveryArtifacts) -> str: f"extracted_db={artifacts.extracted_db}", f"recovered_db={artifacts.recovered_db}", f"summary_path={artifacts.summary_path}", + f"lost_and_found_table={artifacts.lost_and_found_table}", f"pagesize={artifacts.pagesize}", f"wal_frames={artifacts.wal_frames}", f"wal_final_db_pages={artifacts.wal_final_db_pages}", @@ -704,6 +786,34 @@ def _render_summary(artifacts: RecoveryArtifacts) -> str: return "\n".join(lines) + "\n" +def _recovery_artifact_paths( + recovery_dir: Path, + prefix: str, +) -> tuple[Path, Path, Path, Path, Path]: + """Return every file path written by one recovery run.""" + + return ( + recovery_dir / f"{prefix}.candidate.db", + recovery_dir / f"{prefix}.recover.sql", + recovery_dir / f"{prefix}.extracted.db", + recovery_dir / f"{prefix}.db", + recovery_dir / f"{prefix}.summary.txt", + ) + + +def _recovery_sqlite_sidecar_paths( + artifact_paths: tuple[Path, Path, Path, Path, Path], +) -> tuple[Path, ...]: + """Return sidecar paths SQLite may create while processing DB artifacts.""" + + database_paths = (artifact_paths[0], artifact_paths[2], artifact_paths[3]) + return tuple( + database_path.with_name(f"{database_path.name}{suffix}") + for database_path in database_paths + for suffix in ("-journal", "-wal", "-shm") + ) + + def recover_raw_storage_db( raw_path: Path, wal_path: Path | None, @@ -713,17 +823,32 @@ def recover_raw_storage_db( ) -> RecoveryArtifacts: """Recover a damaged raw SQLite file into a normalized database.""" + candidate_db, recover_sql, extracted_db, recovered_db, summary_path = _recovery_artifact_paths( + recovery_dir, + prefix, + ) + _validate_recovery_plan( + raw_path=raw_path, + wal_path=wal_path, + recovery_dir=recovery_dir, + output_db=recovered_db, + prefix=prefix, + ) recovery_dir.mkdir(parents=True, exist_ok=True) - candidate_db = recovery_dir / f"{prefix}.candidate.db" - recover_sql = recovery_dir / f"{prefix}.recover.sql" - extracted_db = recovery_dir / f"{prefix}.extracted.db" - recovered_db = recovery_dir / f"{prefix}.db" - summary_path = recovery_dir / f"{prefix}.summary.txt" + lost_and_found_table = f"flocks_recovery_lost_{uuid.uuid4().hex}" candidate_stats = reconstruct_sqlite_candidate(raw_path, wal_path, candidate_db) - _run_sqlite_recover(candidate_db, recover_sql) + _run_sqlite_recover( + candidate_db, + recover_sql, + lost_and_found_table=lost_and_found_table, + ) _materialize_recovered_sql(recover_sql, extracted_db) - copied_rows = build_normalized_recovery_db(extracted_db, recovered_db) + copied_rows = build_normalized_recovery_db( + extracted_db, + recovered_db, + lost_and_found_table=lost_and_found_table, + ) artifacts = RecoveryArtifacts( recovery_dir=recovery_dir, @@ -732,12 +857,14 @@ def recover_raw_storage_db( extracted_db=extracted_db, recovered_db=recovered_db, summary_path=summary_path, + lost_and_found_table=lost_and_found_table, pagesize=candidate_stats["pagesize"], wal_frames=candidate_stats["wal_frames"], wal_final_db_pages=candidate_stats["wal_final_db_pages"], copied_rows=copied_rows, ) - summary_path.write_text(_render_summary(artifacts), encoding="utf-8") + with _atomic_output_path(summary_path) as temporary_path: + temporary_path.write_text(_render_summary(artifacts), encoding="utf-8") return artifacts @@ -760,45 +887,196 @@ def _resolve_raw_path(args: argparse.Namespace) -> Path: def _detect_wal_path(raw_path: Path) -> Path | None: """Try to find a matching WAL file next to the damaged DB.""" - bases = {raw_path.name, raw_path.stem} - suffixes = {"", raw_path.suffix} + candidates: list[Path] = [] seen: set[Path] = set() - for base in bases: - for suffix in suffixes: + def add_candidate(name: str) -> None: + candidate = raw_path.with_name(name) + if candidate not in seen: + seen.add(candidate) + candidates.append(candidate) + + add_candidate(f"{raw_path.name}-wal") + add_candidate(f"{raw_path.name}.wal") + + marker = ".corrupt." + if marker in raw_path.name: + original_name, quarantine_suffix = raw_path.name.split(marker, 1) + add_candidate(f"{original_name}-wal{marker}{quarantine_suffix}") + suffix_base, separator, counter = quarantine_suffix.rpartition(".") + if separator and counter.isdigit(): + add_candidate(f"{original_name}-wal{marker}{suffix_base}") + + for base in (raw_path.stem, raw_path.name): + for suffix in ("", raw_path.suffix): for wal_suffix in ("-wal", ".wal"): - candidate = raw_path.with_name(f"{base}{wal_suffix}{suffix}") - if candidate in seen: - continue - seen.add(candidate) - if candidate.exists(): - return candidate.resolve() + add_candidate(f"{base}{wal_suffix}{suffix}") + + for candidate in candidates: + # A clean ``wal_checkpoint(TRUNCATE)`` commonly leaves a zero-length + # WAL beside a complete main database. Auto-detection should treat it + # as no pending WAL; an explicitly supplied ``--wal`` remains strict. + if candidate.is_file() and candidate.stat().st_size > 0: + return candidate.resolve() return None def _default_artifacts_dir(raw_path: Path) -> Path: - """Create the default workspace output directory for recovery artifacts.""" + """Return the default workspace output directory for recovery artifacts.""" - workspace_dir = Path(os.getenv("FLOCKS_WORKSPACE_DIR", Path.home() / ".flocks" / "workspace")) + workspace_value = _getenv_case_insensitive("FLOCKS_WORKSPACE_DIR") + workspace_dir = Path( + workspace_value + if workspace_value is not None + else Path.home() / ".flocks" / "workspace" + ) today = dt.date.today().isoformat() run_name = _sanitize_name(raw_path.name) - output_dir = workspace_dir / "outputs" / today / f"db-recovery-{run_name}" - output_dir.mkdir(parents=True, exist_ok=True) - return output_dir + run_id = uuid.uuid4().hex + return workspace_dir / "outputs" / today / f"db-recovery-{run_name}-{run_id}" + + +def _getenv_case_insensitive(name: str) -> str | None: + """Read a Flocks setting with the same case-insensitive key semantics as BaseSettings.""" + + result: str | None = None + for key, value in os.environ.items(): + if key.lower() == name.lower(): + result = value + return result + + +def _resolve_live_data_dir() -> Path: + """Resolve the live Flocks data directory without importing the application.""" + + data_dir = _getenv_case_insensitive("FLOCKS_DATA_DIR") + if data_dir is not None: + return Path(data_dir).resolve() + + xdg_data = _getenv_case_insensitive("XDG_DATA_HOME") + if xdg_data: + return (Path(xdg_data) / "flocks").resolve() + + flocks_root = _getenv_case_insensitive("FLOCKS_ROOT") + if flocks_root: + return (Path(flocks_root) / "data").resolve() + + return (Path.home() / ".flocks" / "data").resolve() + + +def _same_file_or_path(first: Path, second: Path) -> bool: + """Return whether two paths resolve to the same path or existing inode.""" + + if first.resolve() == second.resolve(): + return True + try: + return first.samefile(second) + except OSError: + return False + + +def _path_collision_key(path: Path) -> str: + """Return a conservative path key for case-insensitive filesystems.""" + + return os.path.normcase(str(path.resolve())).casefold() + +def _is_within(path: Path, directory: Path) -> bool: + """Return whether a resolved path is inside a resolved directory.""" -def _cleanup_live_sqlite_sidecars() -> list[Path]: - """Delete stale live WAL/SHM sidecar files under `~/.flocks/data/`.""" + path_key = _path_collision_key(path) + directory_key = _path_collision_key(directory).rstrip(os.sep) + return path_key == directory_key or path_key.startswith(f"{directory_key}{os.sep}") - data_dir = Path(os.getenv("FLOCKS_DATA_DIR", Path.home() / ".flocks" / "data")) - removed: list[Path] = [] - for name in ("flocks.db-shm", "flocks.db-wal"): - candidate = data_dir / name - if candidate.exists(): - candidate.unlink() - removed.append(candidate) - return removed + +def _validate_recovery_plan( + *, + raw_path: Path, + wal_path: Path | None, + recovery_dir: Path, + output_db: Path, + prefix: str, +) -> None: + """Reject recovery plans that could mutate live data or source evidence.""" + + live_data_dir = _resolve_live_data_dir() + live_db = live_data_dir / "flocks.db" + live_sidecars = (live_data_dir / "flocks.db-wal", live_data_dir / "flocks.db-shm") + artifact_paths = _recovery_artifact_paths(recovery_dir, prefix) + sqlite_sidecar_paths = _recovery_sqlite_sidecar_paths(artifact_paths) + recovered_db = artifact_paths[3] + + if _same_file_or_path(raw_path, live_db): + raise ValueError( + "Refusing to recover the live Flocks database directly. " + "Stop Flocks, copy the database and matching WAL to a separate path, " + "then recover that copy." + ) + + if wal_path is not None and any( + _same_file_or_path(wal_path, live_sidecar) for live_sidecar in live_sidecars + ): + raise ValueError( + "Refusing to read a live Flocks SQLite sidecar. " + "Stop Flocks and copy the database with its matching WAL before recovery." + ) + + output_key = _path_collision_key(output_db) + recovered_key = _path_collision_key(recovered_db) + if output_db.resolve() != recovered_db.resolve() and ( + output_key == recovered_key or _same_file_or_path(output_db, recovered_db) + ): + raise ValueError( + "Recovery output has an ambiguous case-insensitive collision with the recovered DB: " + f"{output_db}" + ) + + intermediate_paths = ( + *(path for path in artifact_paths if path != recovered_db), + *sqlite_sidecar_paths, + ) + colliding_artifact = next( + ( + artifact_path + for artifact_path in intermediate_paths + if ( + output_key == _path_collision_key(artifact_path) + or _same_file_or_path(output_db, artifact_path) + ) + ), + None, + ) + if colliding_artifact is not None: + raise ValueError( + f"Recovery output collides with an intermediate artifact: {colliding_artifact}" + ) + + write_paths = [*artifact_paths, *sqlite_sidecar_paths, output_db] + + source_paths = [raw_path] + if wal_path is not None: + source_paths.append(wal_path) + for write_path in write_paths: + if any(_same_file_or_path(write_path, source_path) for source_path in source_paths): + raise ValueError( + f"Refusing to overwrite recovery source evidence: {write_path}" + ) + + if any(_same_file_or_path(write_path, live_db) for write_path in write_paths): + raise ValueError( + "Refusing to write recovery output to the live Flocks database. " + "Generate a recovery artifact, stop Flocks, then install it explicitly." + ) + + for write_path in write_paths: + if _is_within(write_path, live_data_dir): + raise ValueError( + "Refusing to write recovery files inside the live Flocks data directory: " + f"{write_path}" + ) + if write_path.exists() or write_path.is_symlink(): + raise FileExistsError(f"Refusing to overwrite existing recovery file: {write_path}") def _resolve_output_paths(raw_path: Path, args: argparse.Namespace) -> tuple[Path, Path, str]: @@ -819,18 +1097,26 @@ def _resolve_output_paths(raw_path: Path, args: argparse.Namespace) -> tuple[Pat ) output_db = artifacts_dir / f"{_sanitize_name(raw_path.name)}.recovered.db" - prefix = args.prefix or output_db.stem + prefix = _sanitize_name(args.prefix or output_db.stem) return artifacts_dir, output_db, prefix +def _copy_file_exclusive(source: Path, destination: Path) -> None: + """Copy a file without ever replacing a destination created after validation.""" + + with _atomic_output_path(destination) as temporary_path: + with source.open("rb") as source_handle, temporary_path.open("xb") as destination_handle: + shutil.copyfileobj(source_handle, destination_handle) + + def build_parser() -> argparse.ArgumentParser: """Build the CLI parser.""" parser = argparse.ArgumentParser( description=( - "Recover a damaged Flocks SQLite DB in one command. The script can " + "Recover an offline copy of a damaged Flocks SQLite DB. The script can " "auto-detect a sibling WAL file and writes the repaired DB plus " - "intermediate artifacts." + "intermediate artifacts without modifying the live store." ) ) parser.add_argument( @@ -855,7 +1141,7 @@ def build_parser() -> argparse.ArgumentParser: "--output", type=Path, default=None, - help="Optional final output DB file path.", + help="Optional new output DB path. Existing files and the live Flocks DB are rejected.", ) parser.add_argument( "--artifacts-dir", @@ -881,14 +1167,31 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: """Run recovery and print artifact locations.""" + # Match the normal ``flocks`` CLI: environment variables already exported + # by the operator win, and missing values are filled from the current + # project's .env before resolving the protected live data directory. + current_env = Path.cwd() / ".env" + if current_env.is_file(): + load_dotenv(current_env) + project_env = Path(__file__).resolve().parent.parent / ".env" + if project_env.is_file() and project_env != current_env: + load_dotenv(project_env) args = build_parser().parse_args(argv) - removed_sidecars = _cleanup_live_sqlite_sidecars() raw_path = _resolve_raw_path(args) if not raw_path.exists(): raise FileNotFoundError(f"Damaged DB file does not exist: {raw_path}") wal_path = args.wal.expanduser().resolve() if args.wal is not None else _detect_wal_path(raw_path) + if wal_path is not None and not wal_path.is_file(): + raise FileNotFoundError(f"WAL file does not exist or is not a file: {wal_path}") artifacts_dir, output_db, prefix = _resolve_output_paths(raw_path, args) + _validate_recovery_plan( + raw_path=raw_path, + wal_path=wal_path, + recovery_dir=artifacts_dir, + output_db=output_db, + prefix=prefix, + ) artifacts = recover_raw_storage_db( raw_path=raw_path, @@ -897,20 +1200,14 @@ def main(argv: Sequence[str] | None = None) -> int: prefix=prefix, ) - if artifacts.recovered_db != output_db: - output_db.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(artifacts.recovered_db, output_db) + if not _same_file_or_path(artifacts.recovered_db, output_db): + _copy_file_exclusive(artifacts.recovered_db, output_db) print(f"input_db={raw_path}") print(f"wal_path={wal_path if wal_path is not None else 'none'}") - print( - "removed_sidecars=" - + ( - ",".join(str(path) for path in removed_sidecars) - if removed_sidecars - else "none" - ) - ) + # Preserve the original machine-readable field for callers while making + # the non-destructive behavior explicit. + print("removed_sidecars=none") print(f"artifacts_dir={artifacts_dir}") print(f"recovered_db={output_db}") print(f"summary_path={artifacts.summary_path}") @@ -920,4 +1217,3 @@ def main(argv: Sequence[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/tests/channel/test_channel.py b/tests/channel/test_channel.py index 065325060..74f6ed77a 100644 --- a/tests/channel/test_channel.py +++ b/tests/channel/test_channel.py @@ -1059,9 +1059,9 @@ def test_max_size_eviction(self): # ===================================================================== class TestCheckAllowlist: - def _make_msg(self, *, chat_type=ChatType.DIRECT, sender_id="u1"): + def _make_msg(self, *, chat_type=ChatType.DIRECT, sender_id="u1", channel_id="test"): return InboundMessage( - channel_id="test", account_id="acc", message_id="m1", + channel_id=channel_id, account_id="acc", message_id="m1", sender_id=sender_id, chat_type=chat_type, ) @@ -1097,6 +1097,18 @@ def test_group_no_allow_from_passes(self): msg = self._make_msg(chat_type=ChatType.GROUP) assert _check_allowlist(msg, cfg) is True + def test_whatsapp_dm_allowlist_matches_lid_alias(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(dm_policy="allowlist", allow_from=["25482795991095@lid"]) + msg = self._make_msg(channel_id="whatsapp", sender_id="25482795991095") + assert _check_allowlist(msg, cfg) is True + + def test_whatsapp_group_allowlist_matches_lid_alias(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(allow_from=["25482795991095@lid"]) + msg = self._make_msg(channel_id="whatsapp", chat_type=ChatType.GROUP, sender_id="25482795991095") + assert _check_allowlist(msg, cfg) is True + class TestFeishuGroupContext: def test_group_override_can_disable_top_level_context_cache(self): diff --git a/tests/channel/test_whatsapp.py b/tests/channel/test_whatsapp.py new file mode 100644 index 000000000..ae7366db7 --- /dev/null +++ b/tests/channel/test_whatsapp.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from flocks.channel.base import ChatType, OutboundContext +from flocks.channel.builtin.whatsapp.channel import WhatsAppChannel +from flocks.channel.builtin.whatsapp.config import ( + matches_identifier, + normalize_jid, + parse_target, + strip_jid, +) +from flocks.channel.builtin.whatsapp.bridge_runtime import NODE_USE_BUNDLED_CA_OPTION, append_node_options +from flocks.channel.builtin.whatsapp.inbound import build_inbound_message +from flocks.channel.builtin.whatsapp import pairing +from flocks.channel.registry import ChannelRegistry + + +def test_whatsapp_channel_metadata_and_capabilities(): + channel = WhatsAppChannel() + + assert channel.meta().id == "whatsapp" + assert "wa" in channel.meta().aliases + assert channel.capabilities().media is True + assert channel.capabilities().chat_types == [ChatType.DIRECT, ChatType.GROUP] + + +def test_registry_registers_builtin_whatsapp_channel(): + registry = ChannelRegistry() + registry.init() + + plugin = registry.get("whatsapp") + assert plugin is not None + assert plugin.meta().label == "WhatsApp" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("+15551234567", "15551234567@s.whatsapp.net"), + ("15551234567", "15551234567@s.whatsapp.net"), + ("120363001234@g.us", "120363001234@g.us"), + ("abc123@lid", "abc123@lid"), + ("whatsapp:+15551234567", "15551234567@s.whatsapp.net"), + ("15551234567:12@s.whatsapp.net", "15551234567@s.whatsapp.net"), + ], +) +def test_normalize_and_parse_targets(raw: str, expected: str): + assert parse_target(raw) == expected + assert normalize_jid(raw.replace("whatsapp:", "")) == expected + + +def test_strip_jid_and_allowlist_alias_matching(): + assert strip_jid("15551234567@s.whatsapp.net") == "15551234567" + assert matches_identifier("15551234567@s.whatsapp.net", ["+15551234567"]) + assert matches_identifier("abc123@lid", ["abc123"]) + assert matches_identifier("120363001234@g.us", ["120363001234@g.us"]) + assert not matches_identifier("15550000000@s.whatsapp.net", ["15551234567"]) + + +def test_build_inbound_dm_message(): + msg = build_inbound_message({ + "messageId": "m1", + "chatId": "15551234567@s.whatsapp.net", + "senderId": "15551234567@s.whatsapp.net", + "senderName": "Alice", + "body": "hello", + "isGroup": False, + }) + + assert msg is not None + assert msg.channel_id == "whatsapp" + assert msg.chat_type == ChatType.DIRECT + assert msg.sender_id == "15551234567" + assert msg.chat_id == "15551234567@s.whatsapp.net" + assert msg.text == "hello" + assert msg.message_id == "15551234567@s.whatsapp.net:m1" + + +def test_build_inbound_group_mention_message(): + msg = build_inbound_message({ + "messageId": "m2", + "chatId": "120363001234@g.us", + "senderId": "15551234567@s.whatsapp.net", + "body": "@bot investigate this", + "isGroup": True, + "mentioned": True, + "mentionText": "investigate this", + "quotedMessageId": "q1", + }) + + assert msg is not None + assert msg.chat_type == ChatType.GROUP + assert msg.mentioned is True + assert msg.mention_text == "investigate this" + assert msg.reply_to_id == "q1" + + +def test_build_inbound_media_message(tmp_path: Path): + media = tmp_path / "image.jpg" + media.write_bytes(b"jpg") + + msg = build_inbound_message({ + "messageId": "m3", + "chatId": "15551234567@s.whatsapp.net", + "senderId": "15551234567@s.whatsapp.net", + "body": "", + "mediaUrls": [str(media)], + "mime": "image/jpeg", + }) + + assert msg is not None + assert msg.media_url == str(media) + assert msg.media_mime == "image/jpeg" + + +def test_validate_config_requires_valid_mode_and_pairing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + channel = WhatsAppChannel() + monkeypatch.setattr("flocks.channel.builtin.whatsapp.channel.find_executable", lambda name: "/usr/bin/node") + + assert channel.validate_config({"mode": "bad"}) == "WhatsApp mode must be 'bot' or 'self-chat'" + + session = tmp_path / "session" + session.mkdir() + assert "not paired" in (channel.validate_config({"sessionPath": str(session)}) or "") + + (session / "creds.json").write_text("{}", encoding="utf-8") + assert channel.validate_config({"sessionPath": str(session), "bridgePort": 3100}) is None + + +@pytest.mark.asyncio +async def test_send_text_posts_to_bridge(monkeypatch: pytest.MonkeyPatch): + channel = WhatsAppChannel() + channel._http = _FakeSession({"success": True, "messageId": "wa1"}) # type: ignore[attr-defined] + channel._send_timeout_ms = 1000 # type: ignore[attr-defined] + channel._bridge_port = 3100 # type: ignore[attr-defined] + + result = await channel.send_text( + OutboundContext(channel_id="whatsapp", to="+15551234567", text="hello", reply_to_id="q1") + ) + + assert result.success is True + assert result.message_id == "wa1" + assert result.chat_id == "15551234567@s.whatsapp.net" + assert channel._http.calls == [ # type: ignore[attr-defined] + ( + "http://127.0.0.1:3100/send", + { + "chatId": "15551234567@s.whatsapp.net", + "message": "hello", + "replyTo": "q1", + }, + {"X-Flocks-Bridge-Token": channel._bridge_token}, # type: ignore[attr-defined] + ) + ] + + +@pytest.mark.asyncio +async def test_send_text_strips_composite_reply_id(): + channel = WhatsAppChannel() + channel._http = _FakeSession({"success": True, "messageId": "wa1"}) # type: ignore[attr-defined] + channel._send_timeout_ms = 1000 # type: ignore[attr-defined] + channel._bridge_port = 3100 # type: ignore[attr-defined] + + result = await channel.send_text( + OutboundContext( + channel_id="whatsapp", + to="+15551234567", + text="hello", + reply_to_id="15551234567@s.whatsapp.net:q1", + ) + ) + + assert result.success is True + assert channel._http.calls[0][1]["replyTo"] == "q1" # type: ignore[attr-defined] + + +def test_bridge_identity_requires_matching_session_and_config(tmp_path: Path): + channel = WhatsAppChannel() + channel._session_path = tmp_path / "session" # type: ignore[attr-defined] + channel._media_cache_dir = tmp_path / "media" # type: ignore[attr-defined] + channel._mode = "bot" # type: ignore[attr-defined] + channel._reply_prefix = "" # type: ignore[attr-defined] + channel._send_chunk_delay_ms = 300 # type: ignore[attr-defined] + channel._send_timeout_ms = 60000 # type: ignore[attr-defined] + script = tmp_path / "bridge.js" + script.write_text("console.log('bridge')", encoding="utf-8") + + matching = { + "scriptHash": "placeholder", + "sessionPath": str(channel._session_path), # type: ignore[attr-defined] + "mediaDir": str(channel._media_cache_dir), # type: ignore[attr-defined] + "mode": "bot", + "configHash": channel._bridge_config_hash(), # type: ignore[attr-defined] + } + from flocks.channel.builtin.whatsapp.bridge_runtime import file_hash + + matching["scriptHash"] = file_hash(script) + assert channel._bridge_identity_matches(matching, script) is True # type: ignore[attr-defined] + + mismatched = {**matching, "sessionPath": str(tmp_path / "other")} + assert channel._bridge_identity_matches(mismatched, script) is False # type: ignore[attr-defined] + + +def test_append_node_options_preserves_existing_and_dedupes(): + env = {"NODE_OPTIONS": "--trace-warnings"} + append_node_options(env, NODE_USE_BUNDLED_CA_OPTION) + + assert env["NODE_OPTIONS"] == "--trace-warnings --use-bundled-ca" + + append_node_options(env, NODE_USE_BUNDLED_CA_OPTION) + + assert env["NODE_OPTIONS"] == "--trace-warnings --use-bundled-ca" + + +@pytest.mark.asyncio +async def test_channel_bridge_process_uses_bundled_ca_node_option( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("console.log('bridge')", encoding="utf-8") + + channel = WhatsAppChannel() + channel._bridge_dir = bridge_dir # type: ignore[attr-defined] + channel._session_path = tmp_path / "session" # type: ignore[attr-defined] + channel._media_cache_dir = tmp_path / "media" # type: ignore[attr-defined] + captured_env: dict[str, str] = {} + + async def fake_reuse(script: Path) -> bool: + return False + + async def fake_wait(script: Path) -> None: + return None + + class FakePopen: + def __init__(self, *args, **kwargs): + captured_env.update(kwargs.get("env") or {}) + + monkeypatch.setenv("NODE_OPTIONS", "--trace-warnings") + monkeypatch.setattr(channel, "_reuse_or_stop_existing_bridge", fake_reuse) + monkeypatch.setattr(channel, "_wait_for_bridge", fake_wait) + monkeypatch.setattr("flocks.channel.builtin.whatsapp.channel.find_executable", lambda name: "/usr/bin/node") + monkeypatch.setattr("flocks.channel.builtin.whatsapp.channel.subprocess.Popen", FakePopen) + + await channel._start_bridge() # type: ignore[attr-defined] + if channel._bridge_log_fh: # type: ignore[attr-defined] + channel._bridge_log_fh.close() # type: ignore[attr-defined] + + assert captured_env["NODE_OPTIONS"] == "--trace-warnings --use-bundled-ca" + + +@pytest.mark.asyncio +async def test_pairing_rejects_running_session_after_dependency_bootstrap( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("console.log('bridge')", encoding="utf-8") + session = tmp_path / "session" + session.mkdir() + (session / "bridge.pid").write_text(str(__import__("os").getpid()), encoding="utf-8") + calls: list[Path] = [] + + async def fake_ensure(path: Path) -> None: + calls.append(path) + + monkeypatch.setattr(pairing, "find_executable", lambda name: "/usr/bin/node") + monkeypatch.setattr(pairing, "ensure_bridge_deps", fake_ensure) + + with pytest.raises(RuntimeError, match="already running"): + await pairing.start_pairing(session_path=str(session), bridge_dir=str(bridge_dir)) + + assert calls == [bridge_dir] + + +@pytest.mark.asyncio +async def test_pairing_rejects_concurrent_same_session_before_dependency_bootstrap( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("console.log('bridge')", encoding="utf-8") + session = tmp_path / "session" + entered_ensure = asyncio.Event() + release_ensure = asyncio.Event() + + class FakeStream: + async def readline(self) -> bytes: + return b"" + + class FakeProcess: + stdout = FakeStream() + returncode = 0 + + async def wait(self) -> int: + return 0 + + def terminate(self) -> None: + pass + + def kill(self) -> None: + pass + + async def fake_create_subprocess_exec(*args, **kwargs): + return FakeProcess() + + async def fake_ensure(path: Path) -> None: + entered_ensure.set() + await release_ensure.wait() + + monkeypatch.setattr(pairing, "find_executable", lambda name: "/usr/bin/node") + monkeypatch.setattr(pairing, "ensure_bridge_deps", fake_ensure) + monkeypatch.setattr(pairing.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + first = asyncio.create_task(pairing.start_pairing(session_path=str(session), bridge_dir=str(bridge_dir))) + await entered_ensure.wait() + + with pytest.raises(RuntimeError, match="already running"): + await pairing.start_pairing(session_path=str(session), bridge_dir=str(bridge_dir)) + + release_ensure.set() + session_info = await first + await pairing.cancel_pairing(session_info.id) + + +@pytest.mark.asyncio +async def test_pairing_reset_session_backs_up_existing_credentials( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("console.log('bridge')", encoding="utf-8") + session = tmp_path / "session" + session.mkdir() + (session / "creds.json").write_text("old", encoding="utf-8") + captured_env: dict[str, str] = {} + + class FakeStream: + async def readline(self) -> bytes: + return b"" + + class FakeProcess: + stdout = FakeStream() + returncode = 0 + + async def wait(self) -> int: + return 0 + + def terminate(self) -> None: + pass + + def kill(self) -> None: + pass + + async def fake_create_subprocess_exec(*args, **kwargs): + captured_env.update(kwargs.get("env") or {}) + return FakeProcess() + + async def fake_ensure(path: Path) -> None: + pass + + monkeypatch.setattr(pairing, "find_executable", lambda name: "/usr/bin/node") + monkeypatch.setattr(pairing, "ensure_bridge_deps", fake_ensure) + monkeypatch.setattr(pairing.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setenv("NODE_OPTIONS", "--trace-warnings") + + session_info = await pairing.start_pairing( + session_path=str(session), + bridge_dir=str(bridge_dir), + reset_session=True, + ) + + backups = list(tmp_path.glob("session.backup.*")) + assert len(backups) == 1 + assert (backups[0] / "creds.json").read_text(encoding="utf-8") == "old" + assert session.exists() + assert not (session / "creds.json").exists() + assert captured_env["NODE_OPTIONS"] == "--trace-warnings --use-bundled-ca" + + await pairing.cancel_pairing(session_info.id) + + +class _FakeResponse: + def __init__(self, payload: dict, status: int = 200): + self._payload = payload + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def json(self, content_type=None): + return self._payload + + +class _FakeSession: + def __init__(self, payload: dict): + self.payload = payload + self.calls: list[tuple[str, dict, dict | None]] = [] + + def post(self, url: str, *, json: dict, headers=None, timeout=None): + self.calls.append((url, json, headers)) + return _FakeResponse(self.payload) diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index cef7885e4..d69f3f960 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -1522,70 +1522,37 @@ def test_supervisor_recovers_backend_when_port_disappears(monkeypatch, tmp_path: assert daemon.backend.pid == 333 -def test_supervisor_waits_for_second_backend_health_failure(monkeypatch, tmp_path: Path) -> None: +def test_supervisor_liveness_probe_keeps_backend_healthy(monkeypatch, tmp_path: Path) -> None: + """Liveness-only probe: process alive + TCP port open = healthy, no HTTP check needed.""" paths = _make_runtime_paths(tmp_path) calls: list[str] = [] monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) daemon = service_supervisor.SupervisorDaemon( service_manager.ServiceConfig(backend_port=9995, frontend_port=9996), - failure_threshold=2, ) daemon.paths = paths daemon.backend.process = _fake_process(111, ["backend"]) - class FakeClient: - def __init__(self, *_args, **_kwargs) -> None: - pass - - def __enter__(self): - return self - - def __exit__(self, *_args) -> None: - return None - - def get(self, _url, **_kwargs): - return httpx.Response(503, json={"status": "unhealthy"}) - - monkeypatch.setattr(service_process.httpx, "Client", FakeClient) monkeypatch.setattr(service_process, "tcp_port_accepts_connections", lambda *_args: True) monkeypatch.setattr(service_manager, "_terminate_process", lambda _process, name, _console: calls.append(f"stop:{name}")) - monkeypatch.setattr( - service_manager, - "_start_backend_process", - lambda *_args, **_kwargs: calls.append("start:backend") or _fake_process(333, ["backend-new"]), - ) daemon.tick() assert calls == [] - assert daemon.backend.state == "degraded" + assert daemon.backend.state == "healthy" daemon.tick() - assert calls == ["stop:后端", "start:backend"] - - -def test_backend_probe_rejects_api_root_when_static_webui_missing(monkeypatch) -> None: - class FakeClient: - def __init__(self, *_args, **_kwargs) -> None: - pass - - def __enter__(self): - return self - - def __exit__(self, *_args) -> None: - return None + assert calls == [] + assert daemon.backend.state == "healthy" - def get(self, url, **_kwargs): - if str(url).endswith("/api/health"): - return httpx.Response(200, json={"status": "healthy"}) - return httpx.Response(200, json={"status": "running"}) +def test_backend_probe_liveness_only_accepts_alive_process(monkeypatch) -> None: + """Liveness-only probe: process alive + TCP port open = healthy regardless of HTTP response.""" monkeypatch.setattr(service_process, "tcp_port_accepts_connections", lambda *_args: True) - monkeypatch.setattr(service_process.httpx, "Client", FakeClient) result = service_process.BackendProcessAdapter().probe(_fake_process(111, ["backend"]), "127.0.0.1", 5173) - assert result.healthy is False - assert result.reason == "health status=200, root status=200" + assert result.healthy is True + assert result.reason == "liveness check passed" def test_supervisor_reports_webui_as_static_endpoint(monkeypatch, tmp_path: Path) -> None: diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index 4921d8930..f26ec863e 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -75,6 +75,30 @@ def test_bundled_hub_catalog_loads(): assert {entry.type for entry in entries} >= {"skill", "agent", "tool", "device", "workflow", "webui", "component"} +def test_hub_catalog_snapshot_reuses_manifest_parse_for_counts(monkeypatch: pytest.MonkeyPatch): + from flocks.hub import catalog as catalog_module + + catalog_module.clear_catalog_caches() + original_read_yaml = catalog_module._read_yaml + calls = 0 + + def counted_read_yaml(path: Path): + nonlocal calls + calls += 1 + return original_read_yaml(path) + + monkeypatch.setattr(catalog_module, "_read_yaml", counted_read_yaml) + + assert catalog_module.list_catalog() + initial_calls = calls + assert initial_calls > 0 + + catalog_module.category_counts() + catalog_module.list_catalog(plugin_type="device") + + assert calls == initial_calls + + def test_workflow_catalog_exposes_chinese_names(): entries = {entry.id: entry for entry in list_catalog(plugin_type="workflow")} @@ -590,15 +614,36 @@ async def test_catalog_clears_stale_skill_record_after_external_delete(isolated_ def test_hub_routes_cover_catalog_files_install_and_uninstall(isolated_hub_env): + from flocks.auth.context import AuthUser + from flocks.server.auth import require_admin from flocks.server.routes.hub import router app = FastAPI() + app.dependency_overrides[require_admin] = lambda: AuthUser( + id="hub-test-admin", + username="hub-test-admin", + role="admin", + status="active", + must_reset_password=False, + ) app.include_router(router, prefix="/api") client = TestClient(app, raise_server_exceptions=True) catalog = client.get("/api/hub/catalog").json() assert any(item["id"] == "ndr-alert-analysis" for item in catalog) + catalog_page = client.get("/api/hub/catalog", params={"limit": 1, "offset": 0}).json() + assert isinstance(catalog_page, dict) + assert len(catalog_page["items"]) == 1 + assert catalog_page["total"] == len(catalog) + assert catalog_page["limit"] == 1 + assert catalog_page["facets"]["type"] + assert catalog_page["facets"]["state"] + + taxonomy = client.get("/api/hub/categories", params={"include_counts": False}).json() + assert taxonomy["tags"] + assert "counts" not in taxonomy + detail = client.get("/api/hub/plugins/skill/ndr-alert-analysis").json() assert detail["id"] == "ndr-alert-analysis" @@ -631,6 +676,68 @@ def test_hub_routes_cover_catalog_files_install_and_uninstall(isolated_hub_env): assert any(item["id"] == "ndr-alert-analysis" for item in available_catalog) +def test_hub_paginated_facets_exclude_their_own_filter(): + from flocks.hub.models import HubCatalogEntry + from flocks.server.routes import hub as hub_routes + + entries = [ + HubCatalogEntry( + id="skill-installed", + type="skill", + name="Installed skill", + category="security", + tags=["triage"], + useCases=["incident-response"], + trust="official", + riskLevel="low", + state="installed", + manifestPath="skills/installed.json", + ), + HubCatalogEntry( + id="agent-installed", + type="agent", + name="Installed agent", + category="security", + tags=["triage"], + useCases=["incident-response"], + trust="official", + riskLevel="low", + state="installed", + manifestPath="agents/installed.json", + ), + HubCatalogEntry( + id="skill-available", + type="skill", + name="Available skill", + category="productivity", + tags=["automation"], + useCases=["operations"], + trust="community", + riskLevel="medium", + state="available", + manifestPath="skills/available.json", + ), + ] + + facets = hub_routes._build_hub_catalog_facets_for_filters( + entries, + { + "plugin_type": "skill", + "category": None, + "tags": None, + "use_cases": None, + "state": ["installed"], + "trust": None, + "risk": None, + "q": None, + }, + ) + + assert facets.type == {"skill": 1, "agent": 1} + assert facets.state == {"installed": 1, "available": 1} + assert facets.category == {"security": 1} + + def test_hub_refresh_clears_catalog_and_device_template_caches(monkeypatch): from flocks.server.routes import hub as hub_routes @@ -647,6 +754,8 @@ def test_hub_refresh_clears_catalog_and_device_template_caches(monkeypatch): def test_hub_component_install_stream_reports_child_progress(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + from flocks.auth.context import AuthUser + from flocks.server.auth import require_admin from flocks.server.routes.hub import router async def noop_refresh(_plugin_type): @@ -656,6 +765,13 @@ async def noop_refresh(_plugin_type): _patch_webui_bundle_build(monkeypatch) app = FastAPI() + app.dependency_overrides[require_admin] = lambda: AuthUser( + id="hub-test-admin", + username="hub-test-admin", + role="admin", + status="active", + must_reset_password=False, + ) app.include_router(router, prefix="/api") client = TestClient(app, raise_server_exceptions=True) diff --git a/tests/mcp/test_mcp_server.py b/tests/mcp/test_mcp_server.py index 1ea0264ea..b89a6b26d 100644 --- a/tests/mcp/test_mcp_server.py +++ b/tests/mcp/test_mcp_server.py @@ -43,6 +43,19 @@ async def list_resources(self) -> list: return [] +def test_status_snapshot_is_read_only_and_does_not_initialize(): + manager = McpServerManager() + status = SimpleNamespace(status=McpStatus.DISCONNECTED) + manager._status["demo"] = status + + snapshot, initialized = manager.status_snapshot() + + assert initialized is False + assert snapshot == {"demo": status} + snapshot.clear() + assert manager._status == {"demo": status} + + @pytest.mark.asyncio async def test_connect_and_register_accepts_legacy_env_alias(monkeypatch: pytest.MonkeyPatch): captured: dict[str, object] = {} diff --git a/tests/permission/test_permission_next.py b/tests/permission/test_permission_next.py index 9e8c5d5f9..e0fba7b67 100644 --- a/tests/permission/test_permission_next.py +++ b/tests/permission/test_permission_next.py @@ -72,6 +72,26 @@ async def test_reply_persists_session_rule_without_in_memory_future(permission_s assert await Storage.get(f"{PermissionNext._SESSION_PREFIX}{request.session_id}") == {"write": "allow"} +@pytest.mark.asyncio +async def test_reply_persists_session_denial_without_in_memory_future(permission_storage) -> None: + request = PermissionRequestInfo( + id="per_testsessiondeny000000001", + sessionID="ses_testsessiondeny00000001", + permission="bash", + patterns=["*"], + ) + await Storage.set( + f"{PermissionNext._PENDING_PREFIX}{request.id}", + request.model_dump(by_alias=True), + "permission_pending", + ) + + await PermissionNext.reply(request.id, "deny_session", session_id=request.session_id) + + assert PermissionNext._session_permissions[request.session_id]["bash"] == "deny" + assert await Storage.get(f"{PermissionNext._SESSION_PREFIX}{request.session_id}") == {"bash": "deny"} + + @pytest.mark.asyncio @pytest.mark.parametrize("reply", ["allow", "once"]) async def test_reply_unblocks_waiting_request_via_persisted_reply_when_memory_future_missing( @@ -130,6 +150,32 @@ async def test_reply_denies_waiting_request_via_persisted_reply_when_memory_futu assert await Storage.get(f"{PermissionNext._REPLY_PREFIX}{request_id}") is None +@pytest.mark.asyncio +async def test_session_denial_applies_to_active_request_without_global_rule(permission_storage) -> None: + request_id = "per_waiting_session_deny" + session_id = "ses_waiting_session_deny" + ask_task = asyncio.create_task( + PermissionNext.ask( + session_id=session_id, + permission="bash", + patterns=["*"], + ruleset=[], + request_id=request_id, + ) + ) + + while request_id not in PermissionNext._pending: + await asyncio.sleep(0) + + await PermissionNext.reply(request_id, "deny_session", session_id=session_id) + + with pytest.raises(DeniedError): + await asyncio.wait_for(ask_task, timeout=2) + + assert PermissionNext._session_permissions[session_id]["bash"] == "deny" + assert "bash" not in PermissionNext._permanent_rules + + @pytest.mark.asyncio async def test_state_load_retries_after_transient_storage_failure(permission_storage, monkeypatch: pytest.MonkeyPatch) -> None: await Storage.set(f"{PermissionNext._PERMANENT_PREFIX}bash", "allow", "permission_rule") diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 1e75807f4..95758cfd7 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -259,6 +259,96 @@ def test_load_extension_scans_only_requested_extension(self, tmp_path: Path): ] assert tool_items == [] + def test_load_extension_reports_bad_sources_without_dropping_valid_ones(self, tmp_path: Path): + tools_dir = tmp_path / "tools" + _write_plugin(tools_dir, "good.py", 'TOOLS = [{"name": "working"}]\n') + _write_plugin(tools_dir, "broken.py", "TOOLS = [\n") + collected = [] + + PluginLoader._plugin_root = tmp_path + PluginLoader.register_extension_point(ExtensionPoint( + attr_name="TOOLS", + subdir="tools", + consumer=lambda items, _source: collected.extend(items), + item_type=dict, + dedup_key=lambda item: item["name"], + )) + + errors = PluginLoader.load_extension("TOOLS", project_dir=tmp_path) + + assert [item["name"] for item in collected] == ["working"] + assert len(errors) == 1 + assert "broken.py" in errors[0] + + def test_load_extension_reports_filtered_invalid_entries(self, tmp_path: Path): + tools_dir = tmp_path / "tools" + _write_plugin( + tools_dir, + "mixed.py", + 'TOOLS = [{"name": "working"}, "invalid"]\n', + ) + collected = [] + + PluginLoader._plugin_root = tmp_path + PluginLoader.register_extension_point(ExtensionPoint( + attr_name="TOOLS", + subdir="tools", + consumer=lambda items, _source: collected.extend(items), + item_type=dict, + dedup_key=lambda item: item["name"], + )) + + errors = PluginLoader.load_extension("TOOLS", project_dir=tmp_path) + + assert [item["name"] for item in collected] == ["working"] + assert len(errors) == 1 + assert "1 invalid TOOLS entries" in errors[0] + + def test_load_extension_propagates_consumer_errors(self, tmp_path: Path): + tools_dir = tmp_path / "tools" + _write_plugin(tools_dir, "invalid.py", 'TOOLS = [{"name": "bad"}]\n') + + PluginLoader._plugin_root = tmp_path + PluginLoader.register_extension_point(ExtensionPoint( + attr_name="TOOLS", + subdir="tools", + consumer=lambda _items, _source: ["tool definition rejected"], + item_type=dict, + )) + + errors = PluginLoader.load_extension("TOOLS", project_dir=tmp_path) + + assert len(errors) == 1 + assert "invalid.py: tool definition rejected" in errors[0] + + def test_load_extension_reports_entry_point_scan_failure( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ): + PluginLoader._plugin_root = tmp_path + PluginLoader.register_extension_point(ExtensionPoint( + attr_name="TOOLS", + subdir="tools", + consumer=lambda _items, _source: None, + )) + + def _failed_scan(): + raise RuntimeError("metadata unavailable") + + monkeypatch.setattr( + "flocks.plugin.loader.importlib.metadata.entry_points", + _failed_scan, + ) + + errors = PluginLoader.load_extension( + "TOOLS", + project_dir=tmp_path, + load_entry_points=True, + ) + + assert errors == ["entry point scan: RuntimeError: metadata unavailable"] + def test_load_extension_can_load_legacy_entry_points(self, tmp_path: Path, monkeypatch): """Scoped loads opt in to the legacy flocks.plugins entry-point group.""" loaded = [] diff --git a/tests/provider/test_api_service_credentials.py b/tests/provider/test_api_service_credentials.py index d6013be8c..e42620b24 100644 --- a/tests/provider/test_api_service_credentials.py +++ b/tests/provider/test_api_service_credentials.py @@ -2,6 +2,8 @@ import pytest +from flocks.security.secrets import SecretManager + class TestAPIServiceCredentials: @pytest.mark.asyncio @@ -25,7 +27,9 @@ async def test_get_service_credentials_returns_base_url_and_username(self): result = await get_service_credentials("skyeye_api") assert result.secret_id == "skyeye_api_key" - assert result.api_key == "skyeye-login-key" + assert result.api_key is None + assert result.api_key_masked + assert result.api_key_masked != "skyeye-login-key" assert result.base_url == "https://skyeye-domain/skyeye" assert result.username == "skyeye" assert result.has_credential is True @@ -160,8 +164,10 @@ async def test_get_service_credentials_returns_tdp_secret(self): result = await get_service_credentials("tdp_api") assert result.secret_id == "tdp_api_key" - assert result.api_key == "tdp-api-key" - assert result.secret == "tdp-secret" + assert result.api_key is None + assert result.api_key_masked != "tdp-api-key" + assert result.secret is None + assert result.secret_masked != "tdp-secret" assert result.base_url == "https://tdp.example.com" assert result.has_credential is True @@ -231,8 +237,10 @@ async def test_get_service_credentials_splits_legacy_onesec_combined_secret(self result = await get_service_credentials("onesec_api") assert result.secret_id == "onesec_credentials" - assert result.api_key == "onesec-api-key" - assert result.secret == "onesec-secret" + assert result.api_key is None + assert result.api_key_masked != "onesec-api-key" + assert result.secret is None + assert result.secret_masked != "onesec-secret" assert result.base_url == "https://console.onesec.net" assert result.has_credential is True @@ -320,7 +328,7 @@ async def test_get_service_credentials_returns_dynamic_qingteng_fields(self): assert result.fields == { "base_url": "https://qt.example.com:8443/openapi", "username": "alice", - "password": "qt-secret", + "password": SecretManager.mask("qt-secret"), } assert result.secret_ids == {"password": "qingteng_password"} assert result.has_credential is True diff --git a/tests/provider/test_api_service_management.py b/tests/provider/test_api_service_management.py index 1b90da15e..4647fb8df 100644 --- a/tests/provider/test_api_service_management.py +++ b/tests/provider/test_api_service_management.py @@ -33,7 +33,7 @@ async def test_list_api_services_returns_enabled_state_and_bilingual_description "threatbook_api": {"enabled": False}, }.get(service_id, {})), patch( - "flocks.server.routes.provider._load_api_service_metadata_data", + "flocks.server.routes.provider._load_api_service_summary_metadata_data", side_effect=lambda service_id: metadata_by_id[service_id], ), patch( @@ -66,6 +66,35 @@ async def test_list_api_services_returns_enabled_state_and_bilingual_description assert result[1].status == "disabled" assert result[1].tool_count == 2 + def test_api_service_summary_uses_lightweight_metadata_loader(self): + from flocks.server.routes import provider as provider_routes + + with ( + patch( + "flocks.server.routes.provider._load_api_service_summary_metadata_data", + return_value={ + "name": "Example API", + "description": "Summary metadata only", + "version": "1.2.3", + }, + ) as mock_summary_loader, + patch( + "flocks.server.routes.provider._load_api_service_metadata_data", + side_effect=AssertionError("list summaries should not load full API metadata"), + ), + patch("flocks.server.routes.provider._get_api_service_enabled", return_value=True), + patch("flocks.server.routes.provider._get_api_service_tool_infos", return_value=[]), + patch("flocks.server.routes.provider._is_api_service_builtin", return_value=False), + patch("flocks.config.config_writer.ConfigWriter.get_api_service_raw", return_value={}), + ): + summary = provider_routes._build_api_service_summary("example_api", {}) + + mock_summary_loader.assert_called_once_with("example_api") + assert summary.id == "example_api" + assert summary.name == "Example API" + assert summary.version == "1.2.3" + assert summary.tool_count == 0 + @pytest.mark.asyncio async def test_update_api_service_persists_enabled_flag_and_updates_status_cache(self): from flocks.server.routes.provider import ( @@ -394,6 +423,58 @@ def test_load_api_service_metadata_uses_config_writer_raw_data(self): assert metadata["base_url"] == "https://api.example.test" assert metadata["enabled"] is False + def test_load_api_service_summary_metadata_reuses_cached_yaml(self, tmp_path, monkeypatch): + from flocks.server.routes import provider as provider_routes + + provider_routes._clear_api_service_summary_metadata_cache() + monkeypatch.setattr(provider_routes.api_service_schema_helpers, "_LEGACY_METADATA_DIR", tmp_path) + try: + with ( + patch( + "flocks.config.config_writer.ConfigWriter.get_api_service_raw", + return_value={"enabled": True}, + ), + patch( + "flocks.server.routes.provider._provider_yaml_cache_key", + return_value=("/plugins/example/_provider.yaml", 123), + ), + patch( + "flocks.server.routes.provider._load_provider_yaml_summary_metadata", + return_value={ + "name": "Example Service", + "description": "Loaded from provider yaml", + }, + ) as mock_yaml_loader, + ): + first = provider_routes._load_api_service_summary_metadata_data("example_service") + second = provider_routes._load_api_service_summary_metadata_data("example_service") + + mock_yaml_loader.assert_called_once_with("example_service") + assert first == second + assert first["name"] == "Example Service" + assert first["enabled"] is True + finally: + provider_routes._clear_api_service_summary_metadata_cache() + + def test_summary_descriptor_lookup_uses_cached_discovery(self, monkeypatch): + from flocks.server.routes import provider as provider_routes + + calls = [] + + def discover_api_service_descriptors(*, refresh=False): + calls.append(refresh) + if refresh: + raise AssertionError("summary lookup should not refresh descriptor discovery") + return [] + + monkeypatch.setattr( + "flocks.config.api_versioning.discover_api_service_descriptors", + discover_api_service_descriptors, + ) + + assert provider_routes._find_api_service_descriptor("missing_service") is None + assert calls == [False] + def test_load_provider_yaml_metadata_from_project_plugins(self, tmp_path, monkeypatch): from flocks.server.routes.provider import _load_provider_yaml_metadata diff --git a/tests/scripts/test_recover_raw_flocks_db.py b/tests/scripts/test_recover_raw_flocks_db.py new file mode 100644 index 000000000..b53fdeaec --- /dev/null +++ b/tests/scripts/test_recover_raw_flocks_db.py @@ -0,0 +1,1187 @@ +"""Safety tests for the offline SQLite recovery helper.""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import sqlite3 +import struct +import sys +from pathlib import Path + +import pytest + + +def load_module(): + module_path = Path(__file__).resolve().parents[2] / "scripts" / "recover_raw_flocks_db.py" + module_name = "recover_raw_flocks_db_test_module" + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +recover_raw_flocks_db = load_module() + + +def _wal_checksum(data, *, byte_order, state=(0, 0)): + assert len(data) % 8 == 0 + words = struct.unpack(f"{byte_order}{len(data) // 4}I", data) + s0, s1 = state + for index in range(0, len(words), 2): + s0 = (s0 + words[index] + s1) & 0xFFFFFFFF + s1 = (s1 + words[index + 1] + s0) & 0xFFFFFFFF + return s0, s1 + + +def _build_wal(frames, *, page_size=1024, magic=None): + magic = magic or recover_raw_flocks_db.WAL_MAGIC + byte_order = "<" if magic == 0x377F0682 else ">" + salt1, salt2 = 0x12345678, 0x90ABCDEF + header_prefix = struct.pack( + ">6I", + magic, + recover_raw_flocks_db.WAL_VERSION, + page_size, + 0, + salt1, + salt2, + ) + checksum = _wal_checksum(header_prefix, byte_order=byte_order) + wal = bytearray(header_prefix + struct.pack(">2I", *checksum)) + for page_no, db_page_count, fill_byte in frames: + page = bytes([fill_byte]) * page_size + frame_prefix = struct.pack(">4I", page_no, db_page_count, salt1, salt2) + checksum = _wal_checksum(frame_prefix[:8], byte_order=byte_order, state=checksum) + checksum = _wal_checksum(page, byte_order=byte_order, state=checksum) + wal.extend(frame_prefix + struct.pack(">2I", *checksum) + page) + return bytes(wal) + + +def test_default_artifacts_dir_is_unique_and_not_created(tmp_path, monkeypatch): + workspace_dir = tmp_path / "workspace" + raw_path = tmp_path / "damaged.db" + monkeypatch.setenv("FLOCKS_WORKSPACE_DIR", str(workspace_dir)) + + first = recover_raw_flocks_db._default_artifacts_dir(raw_path) + second = recover_raw_flocks_db._default_artifacts_dir(raw_path) + + assert first != second + assert first.parent == second.parent + assert first.exists() is False + assert second.exists() is False + + +def test_empty_data_dir_env_matches_runtime_current_directory(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("FLOCKS_DATA_DIR", "") + + assert recover_raw_flocks_db._resolve_live_data_dir() == tmp_path.resolve() + + +def test_lowercase_data_dir_env_matches_runtime(tmp_path, monkeypatch): + data_dir = tmp_path / "lowercase-live-data" + monkeypatch.delenv("FLOCKS_DATA_DIR", raising=False) + monkeypatch.setenv("flocks_data_dir", str(data_dir)) + + assert recover_raw_flocks_db._resolve_live_data_dir() == data_dir.resolve() + + +@pytest.mark.skipif(os.name == "nt", reason="Windows environment keys are case-insensitive") +def test_data_dir_case_variant_priority_matches_runtime(tmp_path, monkeypatch): + from flocks.config.config import GlobalConfig + + uppercase_dir = tmp_path / "uppercase" + lowercase_dir = tmp_path / "lowercase" + monkeypatch.delenv("FLOCKS_DATA_DIR", raising=False) + monkeypatch.delenv("flocks_data_dir", raising=False) + monkeypatch.setenv("FLOCKS_DATA_DIR", str(uppercase_dir)) + monkeypatch.setenv("flocks_data_dir", str(lowercase_dir)) + + assert recover_raw_flocks_db._resolve_live_data_dir() == GlobalConfig().data_dir.resolve() + + +def test_cli_loads_dotenv_before_protecting_live_data_dir(tmp_path, monkeypatch): + live_data = tmp_path / "configured-live-data" + live_data.mkdir() + live_db = live_data / "flocks.db" + live_db.write_bytes(b"live") + (tmp_path / ".env").write_text( + f"FLOCKS_DATA_DIR={live_data}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("FLOCKS_DATA_DIR", raising=False) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.main([str(live_db)]) + + +def test_missing_input_does_not_touch_live_sidecars(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + wal_path = data_dir / "flocks.db-wal" + shm_path = data_dir / "flocks.db-shm" + wal_path.write_bytes(b"live-wal") + shm_path.write_bytes(b"live-shm") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(FileNotFoundError): + recover_raw_flocks_db.main([str(tmp_path / "missing.db")]) + + assert wal_path.read_bytes() == b"live-wal" + assert shm_path.read_bytes() == b"live-shm" + + +def test_recovery_cli_keeps_live_wal_usable(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + db_path = data_dir / "flocks.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + holder = sqlite3.connect(db_path) + try: + assert holder.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" + holder.execute("CREATE TABLE live_rows(value INTEGER)") + holder.execute("INSERT INTO live_rows VALUES (1)") + holder.commit() + assert db_path.with_name("flocks.db-wal").exists() + assert db_path.with_name("flocks.db-shm").exists() + + with pytest.raises(FileNotFoundError): + recover_raw_flocks_db.main([str(tmp_path / "missing.db")]) + + holder.execute("INSERT INTO live_rows VALUES (2)") + holder.commit() + with sqlite3.connect(db_path) as probe: + assert probe.execute("SELECT value FROM live_rows ORDER BY value").fetchall() == [ + (1,), + (2,), + ] + finally: + holder.close() + + +def test_rejects_live_database_as_output_before_recovery(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + recovery_called = False + + def fail_if_called(*_args, **_kwargs): + nonlocal recovery_called + recovery_called = True + raise AssertionError("recovery must not start for an unsafe output path") + + monkeypatch.setattr(recover_raw_flocks_db, "recover_raw_storage_db", fail_if_called) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.main([str(damaged_db), "--output", str(live_db)]) + + assert recovery_called is False + assert live_db.read_bytes() == b"live-db" + + +def test_rejects_live_database_as_input_before_writing_artifacts(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + workspace_dir = tmp_path / "workspace" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + monkeypatch.setenv("FLOCKS_WORKSPACE_DIR", str(workspace_dir)) + + with pytest.raises(ValueError, match="recover the live Flocks database"): + recover_raw_flocks_db.main([str(live_db)]) + + assert live_db.read_bytes() == b"live-db" + assert workspace_dir.exists() is False + + +def test_rejects_artifact_plan_that_writes_live_database(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + recovery_called = False + + def fail_if_called(*_args, **_kwargs): + nonlocal recovery_called + recovery_called = True + raise AssertionError("recovery must not start for an unsafe artifact path") + + monkeypatch.setattr(recover_raw_flocks_db, "recover_raw_storage_db", fail_if_called) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(data_dir), + "--prefix", + "flocks", + ] + ) + + assert recovery_called is False + assert live_db.read_bytes() == b"live-db" + + +@pytest.mark.parametrize("prefix, live_name", [("tasks", "tasks.db"), ("workflow", "workflow.db")]) +def test_rejects_artifacts_anywhere_in_live_data_dir( + tmp_path, + monkeypatch, + prefix, + live_name, +): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / live_name + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + monkeypatch.setattr( + recover_raw_flocks_db, + "recover_raw_storage_db", + lambda *_args, **_kwargs: pytest.fail("unsafe recovery plan must be rejected"), + ) + + with pytest.raises(ValueError, match="live Flocks data directory"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(data_dir), + "--prefix", + prefix, + ] + ) + + assert live_db.read_bytes() == b"live-db" + + +def test_rejects_existing_artifact_before_recovery(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir() + candidate_db = artifacts_dir / "safe.candidate.db" + candidate_db.write_bytes(b"existing-evidence") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(FileExistsError, match="existing recovery file"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert candidate_db.read_bytes() == b"existing-evidence" + + +@pytest.mark.parametrize("suffix", ["-journal", "-wal", "-shm"]) +def test_rejects_existing_sqlite_sidecar_before_recovery(tmp_path, monkeypatch, suffix): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir() + sidecar_path = artifacts_dir / f"safe.db{suffix}" + sidecar_path.write_bytes(b"existing-sidecar") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(FileExistsError, match="existing recovery file"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert sidecar_path.read_bytes() == b"existing-sidecar" + + +def test_rejects_hardlinked_artifact_to_live_database(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir() + os.link(live_db, artifacts_dir / "safe.candidate.db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert live_db.read_bytes() == b"live-db" + + +def test_direct_recovery_call_rejects_live_data_directory(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.recover_raw_storage_db( + damaged_db, + None, + data_dir, + prefix="flocks", + ) + + assert live_db.read_bytes() == b"live-db" + + +def test_rejects_output_collision_with_intermediate_artifact(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="collides with an intermediate artifact"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(artifacts_dir / "safe.summary.txt"), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert artifacts_dir.exists() is False + + +def test_rejects_output_collision_with_sqlite_sidecar(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="collides with an intermediate artifact"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(artifacts_dir / "safe.db-journal"), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert artifacts_dir.exists() is False + + +def test_rejects_case_insensitive_output_collision(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="ambiguous case-insensitive collision"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(artifacts_dir / "SAFE.db"), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert artifacts_dir.exists() is False + + +def test_rejects_wal_path_that_collides_with_artifact(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir() + wal_path = artifacts_dir / "safe.candidate.db" + wal_path.write_bytes(b"wal-evidence") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="source evidence"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--wal", + str(wal_path), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert wal_path.read_bytes() == b"wal-evidence" + + +def test_rejects_live_wal_as_recovery_input(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_wal = data_dir / "flocks.db-wal" + live_wal.write_bytes(b"live-wal") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="live Flocks SQLite sidecar"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--wal", + str(live_wal), + "--artifacts-dir", + str(tmp_path / "artifacts"), + ] + ) + + assert live_wal.read_bytes() == b"live-wal" + + +def test_detects_storage_quarantine_wal_name(tmp_path): + raw_path = tmp_path / "flocks.db.corrupt.20260710T083229" + wal_path = tmp_path / "flocks.db-wal.corrupt.20260710T083229" + raw_path.write_bytes(b"raw") + wal_path.write_bytes(b"wal") + + assert recover_raw_flocks_db._detect_wal_path(raw_path) == wal_path.resolve() + + +def test_auto_detect_ignores_empty_checkpointed_wal(tmp_path): + raw_path = tmp_path / "flocks.db" + raw_path.write_bytes(b"main") + raw_path.with_name("flocks.db-wal").touch() + + assert recover_raw_flocks_db._detect_wal_path(raw_path) is None + + +@pytest.mark.parametrize("magic", [0x377F0682, 0x377F0683]) +def test_wal_parser_discards_frames_after_last_commit(magic): + wal_bytes = _build_wal( + [ + (1, 1, ord("A")), + (1, 0, ord("B")), + ], + magic=magic, + ) + + page_size, final_pages, latest_pages, frame_count = recover_raw_flocks_db._parse_wal_frames( + wal_bytes + ) + + assert page_size == 1024 + assert final_pages == 1 + assert frame_count == 1 + assert latest_pages[1] == b"A" * 1024 + + +def test_wal_parser_rejects_invalid_header_checksum(): + wal_bytes = bytearray(_build_wal([(1, 1, ord("A"))])) + wal_bytes[24] ^= 0xFF + + with pytest.raises(ValueError, match="header checksum"): + recover_raw_flocks_db._parse_wal_frames(bytes(wal_bytes)) + + +def test_real_wal_snapshot_does_not_replay_uncommitted_spill(tmp_path): + live_db = tmp_path / "live.db" + source = sqlite3.connect(live_db) + try: + assert source.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" + source.execute("PRAGMA wal_autocheckpoint=0") + source.execute("PRAGMA cache_size=5") + source.execute("CREATE TABLE records(id INTEGER PRIMARY KEY, value BLOB)") + source.executemany( + "INSERT INTO records(value) VALUES (?)", + [(b"a" * 3500,) for _ in range(300)], + ) + source.commit() + + source.execute("BEGIN IMMEDIATE") + source.executemany( + "UPDATE records SET value=? WHERE id=?", + [(b"b" * 3500, row_id) for row_id in range(1, 301)], + ) + + raw_copy = tmp_path / "offline.db" + wal_copy = tmp_path / "offline.db-wal" + shutil.copy2(live_db, raw_copy) + shutil.copy2(live_db.with_name(f"{live_db.name}-wal"), wal_copy) + finally: + source.rollback() + source.close() + + wal_bytes = wal_copy.read_bytes() + page_size = int.from_bytes(wal_bytes[8:12], "big") + physical_frames = (len(wal_bytes) - 32) // (24 + page_size) + candidate_db = tmp_path / "candidate.db" + stats = recover_raw_flocks_db.reconstruct_sqlite_candidate( + raw_copy, + wal_copy, + candidate_db, + ) + + assert stats["wal_frames"] < physical_frames + with sqlite3.connect(candidate_db) as db: + assert db.execute("SELECT DISTINCT substr(value, 1, 1) FROM records").fetchall() == [ + (b"a",) + ] + + +def test_reconstruct_uses_valid_512_byte_header_page_size(tmp_path): + source_db = tmp_path / "page-512.db" + with sqlite3.connect(source_db) as db: + db.execute("PRAGMA page_size=512") + db.execute("VACUUM") + db.execute("CREATE TABLE probe(value TEXT)") + db.execute("INSERT INTO probe VALUES ('ok')") + db.commit() + assert db.execute("PRAGMA page_size").fetchone()[0] == 512 + + candidate_db = tmp_path / "candidate.db" + stats = recover_raw_flocks_db.reconstruct_sqlite_candidate(source_db, None, candidate_db) + + assert stats["pagesize"] == 512 + with sqlite3.connect(candidate_db) as db: + assert db.execute("SELECT value FROM probe").fetchone() == ("ok",) + + +def test_synthetic_page_one_is_a_valid_empty_sqlite_database(tmp_path): + candidate = tmp_path / "synthetic.db" + candidate.write_bytes(recover_raw_flocks_db._build_synthetic_page1(4096, 1)) + + with sqlite3.connect(candidate) as db: + assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",) + assert db.execute("SELECT COUNT(*) FROM sqlite_schema").fetchone() == (0,) + + +def test_reconstruct_rejects_wal_with_different_header_page_size(tmp_path): + source_db = tmp_path / "page-4096.db" + with sqlite3.connect(source_db) as db: + db.execute("PRAGMA page_size=4096") + db.execute("VACUUM") + db.execute("CREATE TABLE probe(value TEXT)") + db.execute("INSERT INTO probe VALUES ('main-db')") + db.commit() + assert source_db.stat().st_size % 1024 == 0 + + unrelated_wal = tmp_path / "unrelated.db-wal" + unrelated_wal.write_bytes(_build_wal([(1, 1, ord("W"))], page_size=1024)) + + with pytest.raises(ValueError, match="does not match"): + recover_raw_flocks_db.reconstruct_sqlite_candidate( + source_db, + unrelated_wal, + tmp_path / "candidate.db", + ) + + +def test_successful_cli_path_preserves_sidecars_and_compatibility_output( + tmp_path, + monkeypatch, + capsys, +): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + wal_path = data_dir / "flocks.db-wal" + shm_path = data_dir / "flocks.db-shm" + wal_path.write_bytes(b"live-wal") + shm_path.write_bytes(b"live-shm") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + output_db = tmp_path / "recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + def fake_recovery(raw_path, wal_path, recovery_dir, *, prefix): + assert raw_path == damaged_db + assert wal_path is None + recovery_dir.mkdir(parents=True) + artifact_paths = recover_raw_flocks_db._recovery_artifact_paths(recovery_dir, prefix) + candidate_db, recover_sql, extracted_db, recovered_db, summary_path = artifact_paths + recovered_db.write_bytes(b"recovered-db") + summary_path.write_text("recovery=ok\n", encoding="utf-8") + return recover_raw_flocks_db.RecoveryArtifacts( + recovery_dir=recovery_dir, + candidate_db=candidate_db, + recover_sql=recover_sql, + extracted_db=extracted_db, + recovered_db=recovered_db, + summary_path=summary_path, + lost_and_found_table="lost_and_found", + pagesize=4096, + wal_frames=0, + wal_final_db_pages=0, + copied_rows={}, + ) + + monkeypatch.setattr(recover_raw_flocks_db, "recover_raw_storage_db", fake_recovery) + + assert ( + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(output_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + == 0 + ) + + assert output_db.read_bytes() == b"recovered-db" + assert wal_path.read_bytes() == b"live-wal" + assert shm_path.read_bytes() == b"live-shm" + assert "removed_sidecars=none\n" in capsys.readouterr().out + + +def test_real_recovery_preserves_auth_and_custom_tables(tmp_path, monkeypatch): + if shutil.which("sqlite3") is None: + pytest.skip("sqlite3 CLI is required for the recovery integration test") + + data_dir = tmp_path / "live-data" + data_dir.mkdir() + source_db = tmp_path / "source.db" + with sqlite3.connect(source_db) as db: + db.executescript( + """ + CREATE TABLE storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO storage VALUES ('probe', '{}', 'json', 'now', 'now'); + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'active', + must_reset_password INTEGER NOT NULL DEFAULT 0, + tenant_ids TEXT NOT NULL DEFAULT '[]', + asset_groups TEXT NOT NULL DEFAULT '[]', + temp_password_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT + ); + INSERT INTO users VALUES ( + 'u1', 'admin', 'hash', 'admin', 'active', 0, '[]', '[]', NULL, 'now', 'now', NULL + ); + CREATE TABLE user_sessions ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO user_sessions VALUES ('session-1', 'u1', 'later', 'now', 'now'); + CREATE TABLE custom_records (id INTEGER PRIMARY KEY, value TEXT NOT NULL); + CREATE INDEX idx_custom_records_value ON custom_records(value); + CREATE VIEW custom_record_values AS SELECT value FROM custom_records; + CREATE TABLE custom_audit (record_id INTEGER NOT NULL, value TEXT NOT NULL); + CREATE TRIGGER custom_records_audit + AFTER INSERT ON custom_records + BEGIN + INSERT INTO custom_audit VALUES (NEW.id, NEW.value); + END; + INSERT INTO custom_records(value) VALUES ('preserved'); + CREATE TABLE lost_and_found (id INTEGER PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO lost_and_found(value) VALUES ('business-table'); + PRAGMA user_version=7; + PRAGMA application_id=4242; + """ + ) + db.commit() + + artifacts_dir = tmp_path / "artifacts" + output_db = tmp_path / "recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + assert ( + recover_raw_flocks_db.main( + [ + str(source_db), + "--output", + str(output_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "real", + ] + ) + == 0 + ) + + with sqlite3.connect(output_db) as db: + assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",) + assert db.execute("SELECT username FROM users").fetchone() == ("admin",) + assert db.execute("SELECT session_id FROM user_sessions").fetchone() == ("session-1",) + assert db.execute("SELECT value FROM custom_records").fetchone() == ("preserved",) + assert db.execute("SELECT value FROM custom_record_values").fetchone() == ("preserved",) + assert db.execute("SELECT value FROM lost_and_found").fetchone() == ("business-table",) + assert db.execute( + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_custom_records_value'" + ).fetchone() == (1,) + db.execute("INSERT INTO custom_records(value) VALUES ('after-recovery')") + db.commit() + assert db.execute( + "SELECT value FROM custom_audit WHERE value='after-recovery'" + ).fetchone() == ("after-recovery",) + assert db.execute("PRAGMA user_version").fetchone() == (7,) + assert db.execute("PRAGMA application_id").fetchone() == (4242,) + + +def test_real_wal_recovery_preserves_committed_row(tmp_path, monkeypatch): + if shutil.which("sqlite3") is None: + pytest.skip("sqlite3 CLI is required for the recovery integration test") + + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = tmp_path / "snapshot-source.db" + source = sqlite3.connect(live_db) + try: + assert source.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" + source.execute("PRAGMA wal_autocheckpoint=0") + source.executescript( + """ + CREATE TABLE storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE wal_business_records (id INTEGER PRIMARY KEY, value TEXT NOT NULL); + """ + ) + source.commit() + assert source.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()[0] == 0 + source.execute("INSERT INTO storage VALUES ('wal-row', '{}', 'json', 'now', 'now')") + source.execute("INSERT INTO wal_business_records(value) VALUES ('wal-only')") + source.commit() + + raw_copy = tmp_path / "offline-copy.db" + wal_copy = tmp_path / "offline-copy.db-wal" + shutil.copy2(live_db, raw_copy) + shutil.copy2(live_db.with_name(f"{live_db.name}-wal"), wal_copy) + main_only_copy = tmp_path / "main-only.db" + shutil.copy2(live_db, main_only_copy) + with sqlite3.connect(main_only_copy) as main_only: + assert main_only.execute("SELECT COUNT(*) FROM wal_business_records").fetchone() == ( + 0, + ) + + output_db = tmp_path / "wal-recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + assert ( + recover_raw_flocks_db.main( + [ + str(raw_copy), + "--wal", + str(wal_copy), + "--output", + str(output_db), + "--artifacts-dir", + str(tmp_path / "wal-artifacts"), + "--prefix", + "wal", + ] + ) + == 0 + ) + finally: + source.close() + + with sqlite3.connect(output_db) as db: + assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",) + assert db.execute("SELECT key FROM storage WHERE key='wal-row'").fetchone() == ( + "wal-row", + ) + assert db.execute("SELECT value FROM wal_business_records").fetchone() == ("wal-only",) + + +def test_real_recovery_preserves_fts_virtual_table(tmp_path, monkeypatch): + if shutil.which("sqlite3") is None: + pytest.skip("sqlite3 CLI is required for the recovery integration test") + + source_db = tmp_path / "fts-source.db" + with sqlite3.connect(source_db) as db: + try: + db.execute("CREATE VIRTUAL TABLE memory_search USING fts5(content)") + except sqlite3.OperationalError: + pytest.skip("SQLite was built without FTS5") + db.execute("INSERT INTO memory_search(content) VALUES ('recoverable memory')") + db.commit() + + data_dir = tmp_path / "live-data" + data_dir.mkdir() + output_db = tmp_path / "fts-recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + assert ( + recover_raw_flocks_db.main( + [ + str(source_db), + "--output", + str(output_db), + "--artifacts-dir", + str(tmp_path / "fts-artifacts"), + "--prefix", + "fts", + ] + ) + == 0 + ) + + with sqlite3.connect(output_db) as db: + assert db.execute( + "SELECT content FROM memory_search WHERE memory_search MATCH 'recoverable'" + ).fetchone() == ("recoverable memory",) + + +def test_failed_normalized_build_does_not_publish_output(tmp_path, monkeypatch): + extracted_db = tmp_path / "extracted.db" + with sqlite3.connect(extracted_db) as db: + db.executescript(recover_raw_flocks_db.STORAGE_DDL) + db.commit() + + def fail_validation(*_args, **_kwargs): + raise RuntimeError("injected normalized build failure") + + monkeypatch.setattr( + recover_raw_flocks_db, + "_validate_recovered_db", + fail_validation, + ) + output_db = tmp_path / "recovered.db" + + with pytest.raises(RuntimeError, match="injected normalized build failure"): + recover_raw_flocks_db.build_normalized_recovery_db(extracted_db, output_db) + + assert output_db.exists() is False + + +def test_unattributed_lost_rows_are_retained_without_guessing_destination(tmp_path): + extracted_db = tmp_path / "extracted.db" + lost_table = "flocks_recovery_lost_test" + with sqlite3.connect(extracted_db) as db: + db.executescript(recover_raw_flocks_db.STORAGE_DDL) + db.execute( + "INSERT INTO storage VALUES (?, ?, ?, ?, ?)", + ("session:existing", '"direct"', "json", "now", "now"), + ) + db.executescript( + """ + CREATE TABLE storage_audit(key TEXT NOT NULL); + CREATE TRIGGER storage_insert_audit + AFTER INSERT ON storage + BEGIN + INSERT INTO storage_audit VALUES (NEW.key); + END; + """ + ) + lost_columns = ", ".join(f"c{index} TEXT" for index in range(24)) + db.execute(f"CREATE TABLE {lost_table} (nfield INTEGER, {lost_columns})") + placeholders = ", ".join("?" for _ in range(25)) + db.executemany( + f"INSERT INTO {lost_table} VALUES ({placeholders})", + [ + (6, "ignored", "x", "x", "x", "x", *(None for _ in range(19))), + ( + 5, + "session:existing", + '"lost"', + "json", + "old", + "old", + *(None for _ in range(19)), + ), + ( + 5, + "session:new", + '"recovered"', + "json", + "old", + "old", + *(None for _ in range(19)), + ), + ], + ) + db.commit() + + output_db = tmp_path / "recovered.db" + counts = recover_raw_flocks_db.build_normalized_recovery_db( + extracted_db, + output_db, + lost_and_found_table=lost_table, + ) + + assert counts["storage"] == 1 + with sqlite3.connect(output_db) as db: + assert db.execute( + "SELECT value FROM storage WHERE key='session:existing'" + ).fetchone() == ('"direct"',) + assert db.execute("SELECT value FROM storage WHERE key='session:new'").fetchone() is None + assert db.execute(f"SELECT COUNT(*) FROM {lost_table}").fetchone() == (3,) + assert db.execute("SELECT COUNT(*) FROM storage_audit").fetchone() == (0,) + db.execute( + "INSERT INTO storage VALUES (?, ?, ?, ?, ?)", + ("session:after", '"normal"', "json", "now", "now"), + ) + db.commit() + assert db.execute("SELECT key FROM storage_audit").fetchone() == ("session:after",) + + +def test_exact_backup_accepts_legacy_schema_and_narrow_lost_table(tmp_path): + """Current schema assumptions must not block an otherwise valid recovered DB.""" + extracted_db = tmp_path / "legacy-extracted.db" + lost_table = "flocks_recovery_lost_narrow" + orphan_table = "flocks_recovery_lost_orphan" + with sqlite3.connect(extracted_db) as db: + db.execute( + "CREATE TABLE usage_records(id TEXT PRIMARY KEY, legacy_value TEXT NOT NULL)" + ) + db.execute("INSERT INTO usage_records VALUES ('legacy-1', 'preserve')") + db.execute( + f"CREATE TABLE {lost_table} " + "(rootpgno INTEGER, pgno INTEGER, nfield INTEGER, c0 TEXT, c1 TEXT, c2 TEXT, c3 TEXT, c4 TEXT)" + ) + db.execute( + f"INSERT INTO {lost_table} VALUES (1, 2, 5, ?, ?, ?, ?, ?)", + ("https://example.test/path", "v", "t", "created", "updated"), + ) + orphan_columns = ", ".join(f"c{index} TEXT" for index in range(24)) + db.execute( + f"CREATE TABLE {orphan_table} " + f"(rootpgno INTEGER, pgno INTEGER, nfield INTEGER, {orphan_columns})" + ) + orphan_values = ( + 5, + 6, + 24, + "txe_orphan", + "missing_scheduler", + *(None for _ in range(22)), + ) + db.execute( + f"INSERT INTO {orphan_table} VALUES ({', '.join('?' for _ in orphan_values)})", + orphan_values, + ) + db.commit() + + output_db = tmp_path / "legacy-recovered.db" + counts = recover_raw_flocks_db.build_normalized_recovery_db( + extracted_db, + output_db, + lost_and_found_table=lost_table, + ) + + assert counts["usage_records"] == 1 + assert counts["storage"] == 0 + with sqlite3.connect(output_db) as db: + assert db.execute("PRAGMA table_info(usage_records)").fetchall() == [ + (0, "id", "TEXT", 0, None, 1), + (1, "legacy_value", "TEXT", 1, None, 0), + ] + assert db.execute("SELECT * FROM usage_records").fetchone() == ( + "legacy-1", + "preserve", + ) + assert db.execute(f"SELECT COUNT(*) FROM {lost_table}").fetchone() == (1,) + assert db.execute(f"SELECT COUNT(*) FROM {orphan_table}").fetchone() == (1,) + assert not db.execute( + "SELECT 1 FROM sqlite_schema WHERE name='task_executions'" + ).fetchone() + assert not db.execute( + "SELECT 1 FROM sqlite_schema WHERE name='idx_usage_provider'" + ).fetchone() + + +@pytest.mark.parametrize( + "mutation", + ["delete_row", "update_row", "rowid_change", "drop_view"], +) +def test_recovered_db_validator_fails_on_unknown_data_or_schema_loss(tmp_path, mutation): + source_db = tmp_path / "source.db" + target_db = tmp_path / "target.db" + with sqlite3.connect(source_db) as source: + source.executescript(recover_raw_flocks_db.STORAGE_DDL) + source.executescript( + """ + CREATE TABLE plugin_records(id INTEGER PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO plugin_records(value) VALUES ('preserve-me'); + CREATE TABLE plugin_heap(value TEXT NOT NULL); + INSERT INTO plugin_heap(value) VALUES ('same-visible-value'); + CREATE VIEW plugin_record_values AS SELECT value FROM plugin_records; + """ + ) + source.commit() + with sqlite3.connect(target_db) as target: + source.backup(target) + + with sqlite3.connect(target_db) as target: + if mutation == "delete_row": + target.execute("DELETE FROM plugin_records") + elif mutation == "update_row": + target.execute("UPDATE plugin_records SET value='changed'") + elif mutation == "rowid_change": + target.execute("DELETE FROM plugin_heap") + target.execute( + "INSERT INTO plugin_heap(rowid, value) VALUES (99, 'same-visible-value')" + ) + else: + target.execute("DROP VIEW plugin_record_values") + target.commit() + + with sqlite3.connect(source_db) as source, sqlite3.connect(target_db) as target: + with pytest.raises(sqlite3.DatabaseError): + recover_raw_flocks_db._validate_recovered_db(source, target) + + +def test_validator_skips_unavailable_virtual_table_module(tmp_path): + source_db = tmp_path / "source.db" + target_db = tmp_path / "target.db" + with sqlite3.connect(source_db) as source: + source.execute("CREATE TABLE plugin_records(id INTEGER PRIMARY KEY, value TEXT)") + source.execute("INSERT INTO plugin_records(value) VALUES ('preserved')") + source.execute("PRAGMA writable_schema=ON") + source.execute( + "INSERT INTO sqlite_schema(type, name, tbl_name, rootpage, sql) " + "VALUES ('table', 'plugin_vtab', 'plugin_vtab', 0, " + "'CREATE VIRTUAL TABLE plugin_vtab USING unavailable_module(value)')" + ) + source.execute("PRAGMA writable_schema=OFF") + source.commit() + with sqlite3.connect(target_db) as target: + source.backup(target) + + with sqlite3.connect(source_db) as source, sqlite3.connect(target_db) as target: + recover_raw_flocks_db._validate_recovered_db(source, target) + + +def test_failed_final_copy_does_not_publish_output(tmp_path, monkeypatch): + source = tmp_path / "source.db" + source.write_bytes(b"recovered-content") + output = tmp_path / "output.db" + + def fail_copy(_source, destination): + destination.write(b"part") + raise OSError("injected copy failure") + + monkeypatch.setattr(recover_raw_flocks_db.shutil, "copyfileobj", fail_copy) + + with pytest.raises(OSError, match="injected copy failure"): + recover_raw_flocks_db._copy_file_exclusive(source, output) + + assert output.exists() is False + + +def test_output_created_after_validation_is_not_overwritten(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + output_db = tmp_path / "recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + def fake_recovery(raw_path, wal_path, recovery_dir, *, prefix): + assert raw_path == damaged_db + assert wal_path is None + recovery_dir.mkdir(parents=True) + artifact_paths = recover_raw_flocks_db._recovery_artifact_paths(recovery_dir, prefix) + candidate_db, recover_sql, extracted_db, recovered_db, summary_path = artifact_paths + recovered_db.write_bytes(b"recovered-db") + summary_path.write_text("recovery=ok\n", encoding="utf-8") + output_db.write_bytes(b"late-output") + return recover_raw_flocks_db.RecoveryArtifacts( + recovery_dir=recovery_dir, + candidate_db=candidate_db, + recover_sql=recover_sql, + extracted_db=extracted_db, + recovered_db=recovered_db, + summary_path=summary_path, + lost_and_found_table="lost_and_found", + pagesize=4096, + wal_frames=0, + wal_final_db_pages=0, + copied_rows={}, + ) + + monkeypatch.setattr(recover_raw_flocks_db, "recover_raw_storage_db", fake_recovery) + + with pytest.raises(FileExistsError): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(output_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert output_db.read_bytes() == b"late-output" diff --git a/tests/server/routes/test_event_routes.py b/tests/server/routes/test_event_routes.py new file mode 100644 index 000000000..35da794a2 --- /dev/null +++ b/tests/server/routes/test_event_routes.py @@ -0,0 +1,340 @@ +import json +from unittest.mock import AsyncMock + +import pytest +from starlette.requests import Request + +from flocks.auth.context import AuthUser +from flocks.server.routes import event as event_routes +from flocks.server.routes.event import EventBroadcaster +from flocks.session.session import SessionInfo + + +def _snapshot(text: str, delta: str, part_type: str = "text") -> dict: + return { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "messageID": "message-1", + "sessionID": "session-1", + "type": part_type, + "text": text, + }, + "delta": delta, + }, + } + + +@pytest.mark.asyncio +async def test_event_broadcaster_compacts_overflowing_client_queue(): + broadcaster = EventBroadcaster(queue_maxsize=3, queue_drop_to=1) + queue = await broadcaster.subscribe() + + for index in range(5): + await broadcaster.publish({ + "type": "message.part.updated", + "properties": {"index": index}, + }) + + assert queue.qsize() <= 3 + + events = [] + while not queue.empty(): + events.append(queue.get_nowait()) + + assert any(event["type"] == "server.events_dropped" for event in events) + assert events[-1] == { + "type": "message.part.updated", + "properties": {"index": 4}, + } + + +@pytest.mark.asyncio +async def test_event_broadcaster_shutdown_does_not_block_on_full_queue(): + broadcaster = EventBroadcaster(queue_maxsize=2, queue_drop_to=0) + queue = await broadcaster.subscribe() + + await broadcaster.publish({"type": "event.one", "properties": {}}) + await broadcaster.publish({"type": "event.two", "properties": {}}) + await broadcaster.shutdown() + + events = [] + while not queue.empty(): + events.append(queue.get_nowait()) + + assert broadcaster.client_count == 0 + assert any(event["type"] == "server.shutting_down" for event in events) + + +@pytest.mark.asyncio +async def test_event_broadcaster_keeps_latest_event_when_drop_target_is_max_minus_one(): + broadcaster = EventBroadcaster(queue_maxsize=3, queue_drop_to=2) + queue = await broadcaster.subscribe() + + for event_type in ("event.one", "event.two", "event.three", "event.latest"): + await broadcaster.publish({"type": event_type, "properties": {}}) + + events = [] + while not queue.empty(): + events.append(queue.get_nowait()) + + assert events[-1]["type"] == "event.latest" + assert any(event["type"] == "server.events_dropped" for event in events) + + +@pytest.mark.asyncio +async def test_event_broadcaster_preserves_low_backlog_snapshot_cadence(): + broadcaster = EventBroadcaster(queue_maxsize=10, queue_coalesce_at=3) + queue = await broadcaster.subscribe() + first = _snapshot("hello", "hello") + latest = _snapshot("hello world", " world") + + broadcaster._publish_to_queue(queue, first) + broadcaster._publish_to_queue(queue, latest) + + assert queue.qsize() == 2 + assert queue.get_nowait() == first + assert queue.get_nowait() == latest + + +@pytest.mark.parametrize("part_type", ["text", "reasoning"]) +@pytest.mark.asyncio +async def test_event_broadcaster_coalesces_adjacent_snapshots_at_high_water(part_type: str): + broadcaster = EventBroadcaster(queue_maxsize=10, queue_coalesce_at=3) + queue = await broadcaster.subscribe() + snapshots = [ + _snapshot("h", "h", part_type), + _snapshot("he", "e", part_type), + _snapshot("hel", "l", part_type), + _snapshot("hello", "lo", part_type), + ] + + for snapshot in snapshots: + broadcaster._publish_to_queue(queue, snapshot) + + assert queue.qsize() == 3 + assert queue.get_nowait() == snapshots[0] + assert queue.get_nowait() == snapshots[1] + merged = queue.get_nowait() + assert merged["properties"]["part"]["text"] == "hello" + assert merged["properties"]["delta"] == "llo" + + +@pytest.mark.asyncio +async def test_event_broadcaster_does_not_coalesce_across_control_events(): + broadcaster = EventBroadcaster(queue_maxsize=10, queue_coalesce_at=2) + queue = await broadcaster.subscribe() + first = _snapshot("hello", "hello") + control = {"type": "question.asked", "properties": {"requestID": "question-1"}} + latest = _snapshot("hello world", " world") + + broadcaster._publish_to_queue(queue, first) + broadcaster._publish_to_queue(queue, control) + broadcaster._publish_to_queue(queue, latest) + + assert queue.qsize() == 3 + assert queue.get_nowait() == first + assert queue.get_nowait() == control + assert queue.get_nowait() == latest + + +@pytest.mark.asyncio +async def test_message_finish_is_an_ordering_barrier_at_high_water(): + broadcaster = EventBroadcaster(queue_maxsize=10, queue_coalesce_at=2) + queue = await broadcaster.subscribe() + first = _snapshot("hello", "hello") + finish = { + "type": "message.updated", + "properties": { + "info": { + "id": "message-1", + "sessionID": "session-1", + "finish": "stop", + }, + }, + } + later = _snapshot("hello world", " world") + + broadcaster._publish_to_queue(queue, first) + broadcaster._publish_to_queue(queue, finish) + broadcaster._publish_to_queue(queue, later) + + assert queue.qsize() == 3 + assert queue.get_nowait() == first + assert queue.get_nowait() == finish + assert queue.get_nowait() == later + + +@pytest.mark.asyncio +async def test_high_backlog_snapshot_memory_remains_bounded(): + broadcaster = EventBroadcaster(queue_maxsize=1000, queue_coalesce_at=4) + queue = await broadcaster.subscribe() + text = "" + + for _ in range(1000): + delta = "x" * 100 + text += delta + broadcaster._publish_to_queue(queue, _snapshot(text, delta)) + + assert queue.qsize() == 4 + payloads = [queue.get_nowait() for _ in range(queue.qsize())] + assert "".join(payload["properties"]["delta"] for payload in payloads) == text + assert payloads[-1]["properties"]["part"]["text"] == text + assert len(json.dumps(payloads)) < 250_000 + + +@pytest.mark.asyncio +async def test_text_snapshot_does_not_evict_control_events(): + broadcaster = EventBroadcaster(queue_maxsize=3, queue_drop_to=1) + queue = await broadcaster.subscribe() + controls = [ + {"type": "permission.request", "properties": {"requestID": "permission-1"}}, + {"type": "question.asked", "properties": {"id": "question-1"}}, + ] + for event in controls: + broadcaster._publish_to_queue(queue, event) + + def _snapshot(part_id: str) -> dict: + return { + "type": "message.part.updated", + "properties": { + "part": { + "id": part_id, + "messageID": "message-1", + "sessionID": "session-1", + "type": "text", + "text": part_id, + }, + "delta": part_id, + }, + } + + broadcaster._publish_to_queue(queue, _snapshot("part-old")) + broadcaster._publish_to_queue(queue, _snapshot("part-latest")) + + events = [] + while not queue.empty(): + events.append(queue.get_nowait()) + assert [event["type"] for event in events[:2]] == [ + "permission.request", + "question.asked", + ] + assert events[-1]["type"] == "server.events_dropped" + + +@pytest.mark.asyncio +async def test_snapshot_dropped_behind_full_control_queue_emits_deferred_marker(): + broadcaster = EventBroadcaster(queue_maxsize=3, queue_drop_to=1) + queue = await broadcaster.subscribe() + controls = [ + {"type": f"control.{index}", "properties": {"index": index}} + for index in range(3) + ] + for event in controls: + broadcaster._publish_to_queue(queue, event) + + broadcaster._publish_to_queue(queue, { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-dropped", + "messageID": "message-1", + "sessionID": "session-1", + "type": "text", + "text": "dropped", + }, + "delta": "dropped", + }, + }) + + assert queue.qsize() == 3 + assert await queue.get() == controls[0] + assert await queue.get() == controls[1] + assert await queue.get() == controls[2] + marker = await queue.get() + assert marker["type"] == "server.events_dropped" + assert marker["properties"]["dropped"] == 1 + + +@pytest.mark.asyncio +async def test_session_events_are_filtered_by_subscriber_user(monkeypatch: pytest.MonkeyPatch): + broadcaster = EventBroadcaster() + alice = AuthUser(id="user-alice", username="alice", role="member") + bob = AuthUser(id="user-bob", username="bob", role="member") + session = SessionInfo( + id="session-private", + projectID="project-1", + directory="/tmp/project-1", + ownerUserID=alice.id, + ownerUsername=alice.username, + ) + + async def _get_event_session(session_id: str): + assert session_id == session.id + return session + + monkeypatch.setattr(event_routes, "_get_event_session", _get_event_session) + alice_queue = await broadcaster.subscribe(alice) + bob_queue = await broadcaster.subscribe(bob) + + private_event = { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "messageID": "message-1", + "sessionID": session.id, + "type": "text", + "text": "private content", + } + }, + } + await broadcaster.publish(private_event) + + assert alice_queue.get_nowait() == private_event + assert bob_queue.empty() + + global_event = {"type": "workflow.updated", "properties": {"id": "workflow-1"}} + await broadcaster.publish(global_event) + assert alice_queue.get_nowait() == global_event + assert bob_queue.get_nowait() == global_event + + +@pytest.mark.asyncio +async def test_deleted_session_event_can_still_resolve_its_owner(monkeypatch: pytest.MonkeyPatch): + from flocks.session.session import Session + from flocks.storage.storage import Storage + + deleted = SessionInfo( + id="session-deleted", + projectID="project-1", + directory="/tmp/project-1", + status="deleted", + ownerUserID="user-owner", + ownerUsername="owner", + ) + monkeypatch.setattr(Session, "_all_sessions_cache", None) + monkeypatch.setattr(Session, "get_by_id", AsyncMock(return_value=None)) + monkeypatch.setattr(Storage, "list_keys", AsyncMock(return_value=["session:project-1:session-deleted"])) + monkeypatch.setattr(Storage, "get", AsyncMock(return_value=deleted)) + + assert await event_routes._get_event_session(deleted.id) == deleted + + +@pytest.mark.asyncio +async def test_global_event_subscription_keeps_authenticated_user(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import global_ as global_routes + + user = AuthUser(id="user-alice", username="alice", role="member") + request = Request({"type": "http", "method": "GET", "path": "/global/event", "headers": []}) + request.state.auth_user = user + broadcaster = EventBroadcaster() + subscribe = AsyncMock(wraps=broadcaster.subscribe) + monkeypatch.setattr(broadcaster, "subscribe", subscribe) + monkeypatch.setattr(global_routes.EventBroadcaster, "get", classmethod(lambda cls: broadcaster)) + + response = await global_routes.get_global_events(request) + + subscribe.assert_awaited_once_with(user) + await response.body_iterator.aclose() diff --git a/tests/server/routes/test_global_mutation_auth.py b/tests/server/routes/test_global_mutation_auth.py new file mode 100644 index 000000000..2c2bd8d69 --- /dev/null +++ b/tests/server/routes/test_global_mutation_auth.py @@ -0,0 +1,162 @@ +"""Authorization boundaries for global provider and Hub mutations.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient +import pytest + +from flocks.auth.context import AuthUser + + +def _user_client(router, *, prefix: str, role: str) -> TestClient: + app = FastAPI() + + @app.middleware("http") + async def user_auth(request, call_next): + request.state.auth_user = AuthUser( + id=f"{role}-test", + username=f"{role}-test", + role=role, + status="active", + must_reset_password=False, + ) + return await call_next(request) + + app.include_router(router, prefix=prefix) + return TestClient(app, raise_server_exceptions=True) + + +def test_provider_global_configuration_credentials_and_tests_require_admin(): + from flocks.server.routes.provider import router + + client = _user_client(router, prefix="/api/provider", role="member") + responses = [ + client.post("/api/provider/example/oauth/authorize?method=0"), + client.post("/api/provider/example/oauth/callback?method=0"), + client.post("/api/provider/example/configure", json={}), + client.post("/api/provider/example/test"), + client.put("/api/provider/example", json={}), + client.get("/api/provider/example/credentials"), + client.post("/api/provider/example/credentials", json={"api_key": "secret"}), + client.delete("/api/provider/example/credentials"), + client.get("/api/provider/example/service-credentials"), + client.post("/api/provider/example/service-credentials", json={"api_key": "secret"}), + client.post("/api/provider/example/test-credentials", json={}), + client.patch("/api/provider/api-services/example", json={"enabled": False}), + client.delete("/api/provider/api-services/example"), + client.post("/api/provider/api-services/refresh"), + ] + + assert [response.status_code for response in responses] == [403] * len(responses) + + +def test_admin_credential_routes_return_only_masked_secrets(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import provider as provider_routes + + raw_llm_key = "sk-raw-llm-secret-1234" + raw_service_key = "raw-service-secret-5678" + secrets = MagicMock() + secrets.get.side_effect = lambda key: { + "llm-example_llm_key": raw_llm_key, + "service-example_api_key": raw_service_key, + }.get(key) + monkeypatch.setattr("flocks.security.get_secret_manager", lambda: secrets) + monkeypatch.setattr( + provider_routes.ConfigWriter, + "get_provider_raw", + lambda _provider_id: None, + ) + monkeypatch.setattr( + provider_routes.ConfigWriter, + "get_api_service_raw", + lambda _provider_id: { + "apiKey": "{secret:service-example_api_key}", + "base_url": "https://service.example.test", + }, + ) + monkeypatch.setattr( + provider_routes, + "_load_api_service_metadata_data", + lambda _provider_id: { + "credential_fields": [ + { + "key": "api_key", + "storage": "secret", + "sensitive": True, + "config_key": "apiKey", + "secret_id": "service-example_api_key", + }, + { + "key": "base_url", + "storage": "config", + "config_key": "base_url", + }, + ], + }, + ) + + client = _user_client(provider_routes.router, prefix="/api/provider", role="admin") + llm_response = client.get("/api/provider/llm-example/credentials") + service_response = client.get("/api/provider/service-example/service-credentials") + + assert llm_response.status_code == 200 + assert service_response.status_code == 200 + llm_payload = llm_response.json() + service_payload = service_response.json() + assert llm_payload["api_key"] is None + assert llm_payload["api_key_masked"] + assert service_payload["api_key"] is None + assert service_payload["api_key_masked"] + assert service_payload["fields"]["api_key"] != raw_service_key + assert service_payload["fields"]["base_url"] == "https://service.example.test" + assert raw_llm_key not in llm_response.text + assert raw_service_key not in service_response.text + + +def test_admin_provider_update_never_writes_raw_api_key_to_storage(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import provider as provider_routes + + runtime_provider = MagicMock() + storage_write = AsyncMock() + monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", MagicMock()) + monkeypatch.setattr(provider_routes.Provider, "get", lambda _provider_id: runtime_provider) + monkeypatch.setattr(provider_routes.Storage, "write", storage_write) + monkeypatch.setattr( + provider_routes, + "get_provider", + AsyncMock(return_value=provider_routes.ProviderInfo(id="example", name="Example")), + ) + + client = _user_client(provider_routes.router, prefix="/api/provider", role="admin") + response = client.put( + "/api/provider/example", + json={ + "api_key": "raw-update-secret", + "base_url": "https://provider.example.test", + "custom_settings": {"timeout": 10}, + }, + ) + + assert response.status_code == 200 + storage_write.assert_awaited_once() + stored_payload = storage_write.await_args.args[1] + assert "api_key" not in stored_payload + assert "raw-update-secret" not in repr(stored_payload) + + +def test_hub_global_mutations_require_admin(): + from flocks.server.routes.hub import router + + client = _user_client(router, prefix="/api", role="member") + responses = [ + client.post("/api/hub/plugins/skill/example/install", json={"scope": "global"}), + client.post("/api/hub/plugins/component/example/install/stream", json={"scope": "global"}), + client.post("/api/hub/plugins/skill/example/update", json={"scope": "global"}), + client.delete("/api/hub/plugins/skill/example"), + client.post("/api/hub/refresh"), + ] + + assert [response.status_code for response in responses] == [403, 403, 403, 403, 403] diff --git a/tests/server/routes/test_interaction_access_routes.py b/tests/server/routes/test_interaction_access_routes.py new file mode 100644 index 000000000..00642bcf3 --- /dev/null +++ b/tests/server/routes/test_interaction_access_routes.py @@ -0,0 +1,228 @@ +"""Session ownership checks for question and permission interactions.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from flocks.auth.context import AuthUser +from flocks.server.auth import require_user +from flocks.session.session import SessionInfo + + +def _user(user_id: str, username: str, *, role: str = "member") -> AuthUser: + return AuthUser( + id=user_id, + username=username, + role=role, + status="active", + must_reset_password=False, + ) + + +def _client(router, *, prefix: str, user: AuthUser) -> TestClient: + app = FastAPI() + app.dependency_overrides[require_user] = lambda: user + app.include_router(router, prefix=prefix) + return TestClient(app, raise_server_exceptions=True) + + +@pytest.fixture() +def owned_session() -> SessionInfo: + return SessionInfo( + id="ses_owner", + project_id="project", + directory="/tmp", + title="Owned session", + owner_user_id="owner-id", + owner_username="owner", + ) + + +def test_question_routes_reject_non_owner(monkeypatch: pytest.MonkeyPatch, owned_session: SessionInfo): + from flocks.server.routes import question as question_routes + + request_id = "question-owner-only" + question_routes.store_question_request(request_id, { + "id": request_id, + "sessionID": owned_session.id, + "questions": [{"question": "Continue?"}], + }) + monkeypatch.setattr(question_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + client = _client(question_routes.router, prefix="/question", user=_user("other-id", "other")) + + try: + assert client.get(f"/question/session/{owned_session.id}/pending").status_code == 403 + assert client.post( + f"/question/{request_id}/reply", + json={"answers": [["yes"]]}, + ).status_code == 403 + assert client.post(f"/question/{request_id}/reject").status_code == 403 + assert question_routes.get_question_request(request_id) is not None + finally: + question_routes.clear_request_state(request_id) + + +def test_question_owner_can_list_and_reply(monkeypatch: pytest.MonkeyPatch, owned_session: SessionInfo): + from flocks.server.routes import question as question_routes + + request_id = "question-owner-reply" + question_routes.store_question_request(request_id, { + "id": request_id, + "sessionID": owned_session.id, + "questions": [{"question": "Continue?"}], + }) + monkeypatch.setattr(question_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + monkeypatch.setattr("flocks.server.routes.event.publish_event", AsyncMock()) + client = _client(question_routes.router, prefix="/question", user=_user("owner-id", "owner")) + + try: + pending = client.get(f"/question/session/{owned_session.id}/pending") + assert pending.status_code == 200 + assert [item["id"] for item in pending.json()] == [request_id] + reply = client.post( + f"/question/{request_id}/reply", + json={"answers": [["yes"]]}, + ) + assert reply.status_code == 200 + assert question_routes.get_request_answer(request_id) == [["yes"]] + finally: + question_routes.clear_request_state(request_id) + + +def test_permission_routes_filter_and_reject_non_owner( + monkeypatch: pytest.MonkeyPatch, + owned_session: SessionInfo, +): + from flocks.permission.next import PermissionRequestInfo + from flocks.server.routes import permission as permission_routes + + info = PermissionRequestInfo( + id="perm-owner-only", + sessionID=owned_session.id, + permission="bash", + patterns=["*"], + metadata={"messageID": "msg-1"}, + always=[], + tool={"name": "bash"}, + time={"created": 1}, + ) + monkeypatch.setattr(permission_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + monkeypatch.setattr(permission_routes.PermissionNext, "list_pending_infos", AsyncMock(return_value=[info])) + monkeypatch.setattr(permission_routes.PermissionNext, "get_pending_info", AsyncMock(return_value=info)) + reply_mock = AsyncMock() + monkeypatch.setattr(permission_routes.PermissionNext, "reply", reply_mock) + client = _client(permission_routes.router, prefix="/permission", user=_user("other-id", "other")) + + assert client.get("/permission").json() == [] + assert client.get(f"/permission/{info.id}").status_code == 403 + assert client.post( + f"/permission/{info.id}/reply", + json={"allow": True, "always": False}, + ).status_code == 403 + reply_mock.assert_not_awaited() + + +def test_permission_owner_can_reply(monkeypatch: pytest.MonkeyPatch, owned_session: SessionInfo): + from flocks.permission.next import PermissionRequestInfo + from flocks.server.routes import permission as permission_routes + + info = PermissionRequestInfo( + id="perm-owner-reply", + sessionID=owned_session.id, + permission="bash", + patterns=["*"], + metadata={"messageID": "msg-1"}, + always=[], + tool={"name": "bash"}, + time={"created": 1}, + ) + monkeypatch.setattr(permission_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + monkeypatch.setattr(permission_routes.PermissionNext, "get_pending_info", AsyncMock(return_value=info)) + reply_mock = AsyncMock() + monkeypatch.setattr(permission_routes.PermissionNext, "reply", reply_mock) + client = _client(permission_routes.router, prefix="/permission", user=_user("owner-id", "owner")) + + response = client.post( + f"/permission/{info.id}/reply", + json={"allow": True, "always": False}, + ) + + assert response.status_code == 200 + reply_mock.assert_awaited_once_with(info.id, "allow", session_id=owned_session.id) + + +@pytest.mark.parametrize( + ("allow", "expected_reply"), + [(True, "allow_session"), (False, "deny_session")], +) +def test_permission_member_remember_is_session_scoped( + monkeypatch: pytest.MonkeyPatch, + owned_session: SessionInfo, + allow: bool, + expected_reply: str, +): + from flocks.permission.next import PermissionRequestInfo + from flocks.server.routes import permission as permission_routes + + info = PermissionRequestInfo( + id=f"perm-member-remember-{allow}", + sessionID=owned_session.id, + permission="bash", + patterns=["*"], + ) + monkeypatch.setattr(permission_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + monkeypatch.setattr(permission_routes.PermissionNext, "get_pending_info", AsyncMock(return_value=info)) + reply_mock = AsyncMock() + monkeypatch.setattr(permission_routes.PermissionNext, "reply", reply_mock) + client = _client(permission_routes.router, prefix="/permission", user=_user("owner-id", "owner")) + + response = client.post( + f"/permission/{info.id}/reply", + json={"allow": allow, "always": True}, + ) + + assert response.status_code == 200 + reply_mock.assert_awaited_once_with(info.id, expected_reply, session_id=owned_session.id) + + +@pytest.mark.parametrize( + ("allow", "expected_reply"), + [(True, "always"), (False, "never")], +) +def test_permission_admin_remember_can_be_global( + monkeypatch: pytest.MonkeyPatch, + owned_session: SessionInfo, + allow: bool, + expected_reply: str, +): + from flocks.permission.next import PermissionRequestInfo + from flocks.server.routes import permission as permission_routes + + admin = _user("admin-id", "admin", role="admin") + admin_session = owned_session.model_copy(update={ + "owner_user_id": admin.id, + "owner_username": admin.username, + }) + info = PermissionRequestInfo( + id=f"perm-admin-remember-{allow}", + sessionID=admin_session.id, + permission="bash", + patterns=["*"], + ) + monkeypatch.setattr(permission_routes.Session, "get_by_id", AsyncMock(return_value=admin_session)) + monkeypatch.setattr(permission_routes.PermissionNext, "get_pending_info", AsyncMock(return_value=info)) + reply_mock = AsyncMock() + monkeypatch.setattr(permission_routes.PermissionNext, "reply", reply_mock) + client = _client(permission_routes.router, prefix="/permission", user=admin) + + response = client.post( + f"/permission/{info.id}/reply", + json={"allow": allow, "always": True}, + ) + + assert response.status_code == 200 + reply_mock.assert_awaited_once_with(info.id, expected_reply, session_id=admin_session.id) diff --git a/tests/server/routes/test_monitoring_routes.py b/tests/server/routes/test_monitoring_routes.py new file mode 100644 index 000000000..3886c9cf4 --- /dev/null +++ b/tests/server/routes/test_monitoring_routes.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.parametrize("mcp_status", ["disconnected", "timeout", "mystery-state"]) +async def test_status_degrades_for_non_operational_mcp_states( + monkeypatch: pytest.MonkeyPatch, + mcp_status: str, +): + from flocks.server.routes import monitoring as monitoring_routes + + monkeypatch.setattr( + monitoring_routes.MCP, + "status_snapshot", + lambda: ({"demo-mcp": SimpleNamespace(status=mcp_status)}, True), + ) + + response = await monitoring_routes.get_status() + + assert response.status == "degraded" + assert response.mcpServers == {"demo-mcp": mcp_status} + + +async def test_status_degrades_when_mcp_status_check_fails( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import monitoring as monitoring_routes + + def _status_snapshot(): + raise RuntimeError("MCP status failed") + + monkeypatch.setattr(monitoring_routes.MCP, "status_snapshot", _status_snapshot) + + response = await monitoring_routes.get_status() + + assert response.status == "degraded" + assert response.mcpServers == {} + + +async def test_status_snapshot_does_not_trigger_mcp_initialization( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import monitoring as monitoring_routes + + async def _unexpected_status(): + raise AssertionError("monitoring must not initialize MCP while reading status") + + monkeypatch.setattr(monitoring_routes.MCP, "status", _unexpected_status) + monkeypatch.setattr( + monitoring_routes.MCP, + "status_snapshot", + lambda: ({}, False), + ) + + response = await monitoring_routes.get_status() + + assert response.status == "degraded" + assert response.mcpServers == {} + + +@pytest.mark.parametrize("mcp_status", ["connected", "disabled"]) +async def test_status_accepts_operational_or_intentionally_disabled_mcp_states( + monkeypatch: pytest.MonkeyPatch, + mcp_status: str, +): + from flocks.server.routes import monitoring as monitoring_routes + + monkeypatch.setattr( + monitoring_routes.MCP, + "status_snapshot", + lambda: ({"demo-mcp": SimpleNamespace(status=mcp_status)}, True), + ) + + response = await monitoring_routes.get_status() + + assert response.status == "healthy" + + +async def test_metrics_do_not_label_tool_parse_failures_as_system_errors( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import monitoring as monitoring_routes + + monitor = SimpleNamespace( + get_metrics=lambda: { + "global": {"total_calls": 12, "failed_parses": 3}, + } + ) + monkeypatch.setattr(monitoring_routes, "get_monitor", lambda: monitor) + monkeypatch.setattr( + monitoring_routes, + "_sample_tool_rates", + lambda total, failed: (12.0, 3.0), + ) + + response = await monitoring_routes.get_metrics() + payload = response.model_dump() + + assert response.toolCallRate == 12.0 + assert response.toolParseFailureRate == 3.0 + assert response.errorRate is None + assert payload["errorRate"] is None + assert payload["toolParseFailureRate"] == 3.0 diff --git a/tests/server/routes/test_provider_optional_api_key.py b/tests/server/routes/test_provider_optional_api_key.py index ef4309606..5aa342202 100644 --- a/tests/server/routes/test_provider_optional_api_key.py +++ b/tests/server/routes/test_provider_optional_api_key.py @@ -25,6 +25,7 @@ def patched_runtime(monkeypatch: pytest.MonkeyPatch): so credentials calls remain hermetic and never write to disk. """ fake_secrets = MagicMock() + fake_secrets.get.return_value = None runtime_provider = MagicMock() runtime_provider._client = None @@ -125,6 +126,63 @@ async def test_strict_provider_still_rejects_empty_api_key(self, patched_runtime assert excinfo.value.detail == "API key required" patched_runtime["secrets"].set.assert_not_called() + @pytest.mark.asyncio + async def test_strict_provider_blank_key_preserves_existing_stored_secret( + self, patched_runtime + ): + """Editing non-secret settings must not require or rewrite the saved key.""" + patched_runtime["secrets"].get.side_effect = lambda key: { + "openai_llm_key": "sk-existing-secret", + }.get(key) + + result = await provider_routes.set_provider_credentials( + "openai", + provider_routes.ProviderCredentialRequest( + base_url="https://new.example.com/v1", + ), + ) + + assert result["success"] is True + patched_runtime["secrets"].set.assert_not_called() + provider_routes.ConfigWriter.update_provider_field.assert_called_once_with( + "openai", "options.baseURL", "https://new.example.com/v1" + ) + configured = patched_runtime["provider"].configure.call_args.args[0] + assert configured.api_key == "sk-existing-secret" + assert configured.base_url == "https://new.example.com/v1" + + @pytest.mark.asyncio + async def test_strict_provider_blank_key_preserves_existing_inline_secret( + self, patched_runtime, monkeypatch: pytest.MonkeyPatch + ): + """Legacy inline keys are reused without copying them into the secret store.""" + monkeypatch.setattr( + provider_routes.ConfigWriter, + "get_provider_raw", + lambda _provider_id: { + "options": { + "apiKey": "inline-existing-secret", + "baseURL": "https://old.example.com/v1", + } + }, + ) + + result = await provider_routes.set_provider_credentials( + "openai", + provider_routes.ProviderCredentialRequest( + api_key="", + base_url="https://new.example.com/v1", + ), + ) + + assert result["success"] is True + patched_runtime["secrets"].set.assert_not_called() + provider_routes.ConfigWriter.update_provider_field.assert_called_once_with( + "openai", "options.baseURL", "https://new.example.com/v1" + ) + configured = patched_runtime["provider"].configure.call_args.args[0] + assert configured.api_key == "inline-existing-secret" + @pytest.mark.asyncio async def test_sentinel_logged_with_explicit_marker_not_naive_mask( self, patched_runtime, capfd @@ -334,7 +392,7 @@ async def test_placeholder_is_hidden_but_has_credential_true( assert response.base_url == "http://internal/v1" @pytest.mark.asyncio - async def test_real_api_key_still_returned_and_masked( + async def test_real_api_key_is_only_returned_masked( self, monkeypatch: pytest.MonkeyPatch ): fake_secrets = MagicMock() @@ -353,7 +411,7 @@ async def test_real_api_key_still_returned_and_masked( response = await provider_routes.get_provider_credentials("openai") - assert response.api_key == "sk-real-secret-1234567890" + assert response.api_key is None assert response.api_key_masked is not None assert response.api_key_masked != "sk-real-secret-1234567890" assert response.has_credential is True diff --git a/tests/server/routes/test_provider_route_responsiveness.py b/tests/server/routes/test_provider_route_responsiveness.py new file mode 100644 index 000000000..ba6967e37 --- /dev/null +++ b/tests/server/routes/test_provider_route_responsiveness.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import asyncio +import threading +import time + +import pytest + + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(autouse=True) +def _reset_provider_route_async_state(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import provider as provider_routes + + monkeypatch.setattr(provider_routes, "_provider_initialization_lock", asyncio.Lock()) + monkeypatch.setattr(provider_routes, "_provider_initialization_task", None) + monkeypatch.setattr(provider_routes, "_dynamic_provider_load_tasks", set()) + + +async def test_initialized_provider_fast_path_does_not_schedule_worker_thread( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + async def _unexpected_to_thread(*args, **kwargs): + raise AssertionError("initialized provider should stay on the fast path") + + monkeypatch.setattr(provider_routes.Provider, "_initialized", True) + monkeypatch.setattr(provider_routes.asyncio, "to_thread", _unexpected_to_thread) + + await provider_routes._ensure_provider_initialized() + + +async def test_concurrent_provider_initialization_uses_one_worker( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + calls = 0 + + def _slow_initialize() -> None: + nonlocal calls + calls += 1 + time.sleep(0.05) + provider_routes.Provider._initialized = True + + monkeypatch.setattr(provider_routes.Provider, "_initialized", False) + monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", _slow_initialize) + + await asyncio.gather(*[ + provider_routes._ensure_provider_initialized() + for _ in range(10) + ]) + + assert calls == 1 + + +async def test_dynamic_provider_loading_does_not_block_event_loop( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + monkeypatch.setattr( + provider_routes.Provider, + "_load_dynamic_providers", + lambda: time.sleep(0.05), + ) + + load_task = asyncio.create_task(provider_routes._load_dynamic_providers()) + heartbeat_ticks = 0 + while not load_task.done(): + await asyncio.sleep(0.005) + heartbeat_ticks += 1 + await load_task + + assert heartbeat_ticks >= 3 + + +async def test_concurrent_dynamic_load_triggers_are_serialized_but_not_merged( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + first_started = threading.Event() + release_first = threading.Event() + second_started = threading.Event() + calls = 0 + config_version = "provider-a" + scanned_versions: list[str] = [] + active_workers = 0 + max_active_workers = 0 + + def _blocking_load() -> None: + nonlocal calls, active_workers, max_active_workers + calls += 1 + scanned_versions.append(config_version) + active_workers += 1 + max_active_workers = max(max_active_workers, active_workers) + try: + if calls == 1: + first_started.set() + release_first.wait(timeout=1) + else: + second_started.set() + finally: + active_workers -= 1 + + monkeypatch.setattr( + provider_routes.Provider, + "_load_dynamic_providers", + _blocking_load, + ) + + first = asyncio.create_task(provider_routes._load_dynamic_providers()) + while not first_started.is_set(): + await asyncio.sleep(0.001) + + config_version = "provider-b" + second = asyncio.create_task(provider_routes._load_dynamic_providers()) + try: + await asyncio.sleep(0.01) + assert calls == 1 + finally: + release_first.set() + + await asyncio.wait_for(asyncio.gather(first, second), timeout=0.2) + assert second_started.is_set() + assert calls == 2 + assert scanned_versions == ["provider-a", "provider-b"] + assert max_active_workers == 1 + + +async def test_cancelled_dynamic_load_waiter_keeps_its_scan_and_serial_order( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + first_started = threading.Event() + release_first = threading.Event() + second_started = threading.Event() + calls = 0 + active_workers = 0 + max_active_workers = 0 + + def _blocking_load() -> None: + nonlocal calls, active_workers, max_active_workers + calls += 1 + active_workers += 1 + max_active_workers = max(max_active_workers, active_workers) + try: + if calls == 1: + first_started.set() + release_first.wait(timeout=1) + else: + second_started.set() + finally: + active_workers -= 1 + + monkeypatch.setattr( + provider_routes.Provider, + "_load_dynamic_providers", + _blocking_load, + ) + + cancelled_waiter = asyncio.create_task(provider_routes._load_dynamic_providers()) + while not first_started.is_set(): + await asyncio.sleep(0.001) + + cancelled_waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled_waiter + + second = asyncio.create_task(provider_routes._load_dynamic_providers()) + try: + await asyncio.sleep(0.01) + assert calls == 1 + finally: + release_first.set() + + await asyncio.wait_for(second, timeout=0.2) + assert second_started.is_set() + assert calls == 2 + assert max_active_workers == 1 + + +async def test_list_providers_initialization_does_not_block_event_loop( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + def _slow_initialize() -> None: + time.sleep(0.1) + + async def _config(): + raise RuntimeError("no config in focused route test") + + monkeypatch.setattr(provider_routes.Provider, "_initialized", False) + monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", _slow_initialize) + monkeypatch.setattr(provider_routes.Config, "get", _config) + monkeypatch.setattr(provider_routes.ConfigWriter, "list_provider_ids", lambda: []) + + route_task = asyncio.create_task(provider_routes.list_providers()) + heartbeat_ticks = 0 + while not route_task.done(): + await asyncio.sleep(0.005) + heartbeat_ticks += 1 + + response = await route_task + + assert heartbeat_ticks >= 3 + assert response.all == [] diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index feb8bf695..ce681b707 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -673,6 +673,7 @@ async def test_reply_missing_allow_field_returns_422(self, client: AsyncClient): async def test_permission_routes_preserve_request_created_time(self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch): """List/detail routes should expose the stored permission request timestamp.""" from flocks.permission.next import PermissionRequestInfo + from flocks.session.session import SessionInfo info = PermissionRequestInfo( id="perm_time_test", @@ -693,6 +694,17 @@ async def test_permission_routes_preserve_request_created_time(self, client: Asy "flocks.server.routes.permission.PermissionNext.get_pending_info", AsyncMock(return_value=info), ) + monkeypatch.setattr( + "flocks.server.routes.permission.Session.get_by_id", + AsyncMock(return_value=SessionInfo( + id="ses_time_test", + project_id="test", + directory="/tmp", + title="Permission route test", + owner_user_id="api-token-service", + owner_username="api-token-service", + )), + ) list_resp = await client.get("/permission") assert list_resp.status_code == status.HTTP_200_OK diff --git a/tests/server/routes/test_session_routes.py b/tests/server/routes/test_session_routes.py index 5aa2e8994..4db5ba2a8 100644 --- a/tests/server/routes/test_session_routes.py +++ b/tests/server/routes/test_session_routes.py @@ -182,6 +182,135 @@ async def test_get_session(self, client: AsyncClient, session_id: str): assert resp.status_code == status.HTTP_200_OK assert resp.json()["id"] == session_id + @pytest.mark.asyncio + async def test_context_usage_keeps_inflight_task_after_cancelled_request( + self, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import session as session_routes + from flocks.session.context_usage import ContextUsageSnapshot + + session_routes._context_usage_cache.clear() + session_routes._context_usage_inflight.clear() + + calls = 0 + started = asyncio.Event() + release = asyncio.Event() + session = SimpleNamespace(time=SimpleNamespace(updated=123), provider=None, model=None) + + async def fake_build_context_usage_snapshot(session_id: str, *, session=None): + nonlocal calls + calls += 1 + started.set() + await release.wait() + return ContextUsageSnapshot( + sessionID=session_id, + usedTokens=42, + contextWindow=100, + estimatedTokens=42, + ) + + monkeypatch.setattr( + session_routes, + "build_context_usage_snapshot", + fake_build_context_usage_snapshot, + ) + + first = asyncio.create_task( + session_routes._cached_context_usage_snapshot("ses_context_cancel", session=session) + ) + second = None + try: + await started.wait() + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + + second = asyncio.create_task( + session_routes._cached_context_usage_snapshot("ses_context_cancel", session=session) + ) + await asyncio.sleep(0) + assert calls == 1 + + release.set() + snapshot = await asyncio.wait_for(second, timeout=1) + assert snapshot.used_tokens == 42 + + cached = await session_routes._cached_context_usage_snapshot("ses_context_cancel", session=session) + assert cached.used_tokens == 42 + assert calls == 1 + finally: + release.set() + if second is not None and not second.done(): + await asyncio.wait_for(second, timeout=1) + session_routes._context_usage_cache.clear() + session_routes._context_usage_inflight.clear() + + @pytest.mark.asyncio + async def test_context_usage_keeps_newer_cache_when_older_task_finishes_late( + self, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import session as session_routes + from flocks.session.context_usage import ContextUsageSnapshot + + session_routes._context_usage_cache.clear() + session_routes._context_usage_inflight.clear() + + old_started = asyncio.Event() + new_started = asyncio.Event() + old_release = asyncio.Event() + new_release = asyncio.Event() + old_session = SimpleNamespace(time=SimpleNamespace(updated=100), provider=None, model=None) + new_session = SimpleNamespace(time=SimpleNamespace(updated=200), provider=None, model=None) + + async def fake_build_context_usage_snapshot(session_id: str, *, session=None): + updated = session.time.updated + if updated == 100: + old_started.set() + await old_release.wait() + else: + new_started.set() + await new_release.wait() + return ContextUsageSnapshot( + sessionID=session_id, + usedTokens=updated, + contextWindow=1000, + estimatedTokens=updated, + ) + + monkeypatch.setattr( + session_routes, + "build_context_usage_snapshot", + fake_build_context_usage_snapshot, + ) + + old_task = asyncio.create_task( + session_routes._cached_context_usage_snapshot("ses_context_order", session=old_session) + ) + new_task = asyncio.create_task( + session_routes._cached_context_usage_snapshot("ses_context_order", session=new_session) + ) + try: + await old_started.wait() + await new_started.wait() + + new_release.set() + new_snapshot = await asyncio.wait_for(new_task, timeout=1) + assert new_snapshot.used_tokens == 200 + + old_release.set() + old_snapshot = await asyncio.wait_for(old_task, timeout=1) + assert old_snapshot.used_tokens == 100 + + assert ("ses_context_order", 200) in session_routes._context_usage_cache + assert ("ses_context_order", 100) not in session_routes._context_usage_cache + finally: + old_release.set() + new_release.set() + session_routes._context_usage_cache.clear() + session_routes._context_usage_inflight.clear() + @pytest.mark.asyncio async def test_get_session_not_found(self, client: AsyncClient): """GET for an unknown session ID returns 404.""" diff --git a/tests/server/routes/test_tool_routes.py b/tests/server/routes/test_tool_routes.py index 898b03d63..faa087fa8 100644 --- a/tests/server/routes/test_tool_routes.py +++ b/tests/server/routes/test_tool_routes.py @@ -11,7 +11,15 @@ from flocks.auth.context import AuthUser from flocks.session.message import Message, MessageRole from flocks.session.session import Session -from flocks.tool.registry import Tool, ToolCategory, ToolInfo, ToolRegistry, ToolResult +from flocks.tool.registry import ( + ParameterType, + Tool, + ToolCategory, + ToolInfo, + ToolParameter, + ToolRegistry, + ToolResult, +) @contextmanager @@ -19,6 +27,7 @@ def _temporary_tool(tool: Tool) -> Iterator[None]: ToolRegistry.init() existing = ToolRegistry._tools.get(tool.info.name) ToolRegistry.register(tool) + _clear_tool_summary_cache() try: yield finally: @@ -27,6 +36,13 @@ def _temporary_tool(tool: Tool) -> Iterator[None]: ToolRegistry._tools[tool.info.name] = existing else: ToolRegistry._tools.pop(tool.info.name, None) + _clear_tool_summary_cache() + + +def _clear_tool_summary_cache() -> None: + from flocks.server.routes import tool as tool_routes + + tool_routes._invalidate_tool_summary_cache() class _FakeSessionUser: @@ -386,3 +402,118 @@ async def handler(ctx) -> ToolResult: assert kwargs["session_id"] == session_id assert kwargs["metadata"]["messageID"] == message_id assert kwargs["tool"] == {"name": "http_batch_named_tool"} + + +class TestToolListPageRoute: + @pytest.mark.asyncio + async def test_list_page_searches_server_side_and_omits_parameter_payload(self, client: AsyncClient): + async def handler(ctx, text: str) -> ToolResult: + return ToolResult(success=True, output=f"{text}:{ctx.session_id}") + + tool = Tool( + info=ToolInfo( + name="page_unique_alpha_tool", + description="Needle Alpha paginated search tool", + category=ToolCategory.CUSTOM, + source="plugin_py", + parameters=[ + ToolParameter( + name="text", + type=ParameterType.STRING, + description="Text to echo", + ) + ], + ), + handler=handler, + ) + api_tool = Tool( + info=ToolInfo( + name="page_unique_alpha_api_tool", + description="Needle Alpha paginated API tool", + category=ToolCategory.CUSTOM, + source="api", + provider="page-api-provider", + ), + handler=handler, + ) + + with _temporary_tool(tool), _temporary_tool(api_tool): + response = await client.get( + "/api/tools/page", + params={"q": "needle alpha", "source": "plugin_py", "offset": 0, "limit": 20}, + ) + detail = await client.get("/api/tools/page_unique_alpha_tool") + + assert response.status_code == 200, response.text + payload = response.json() + assert payload["total"] == 1 + assert payload["offset"] == 0 + assert payload["limit"] == 20 + assert payload["facets"]["source"]["plugin_py"] == 1 + assert payload["facets"]["source"]["api"] == 1 + assert payload["facets"]["source_groups"]["api"] == 1 + assert payload["items"][0]["name"] == "page_unique_alpha_tool" + assert payload["items"][0]["parameters"] == [] + assert payload["items"][0]["parameters_count"] == 1 + + assert detail.status_code == 200, detail.text + assert len(detail.json()["parameters"]) == 1 + + @pytest.mark.asyncio + async def test_list_page_reuses_lightweight_summary_cache( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import tool as tool_routes + + async def handler(ctx) -> ToolResult: + return ToolResult(success=True, output=ctx.session_id) + + tool = Tool( + info=ToolInfo( + name="page_summary_cache_unique_tool", + description="NeedlePageSummaryCacheUnique", + category=ToolCategory.CUSTOM, + source="plugin_py", + ), + handler=handler, + ) + + original_build_tool_index_item = tool_routes._build_tool_index_item + index_builds = 0 + + def counted_build_tool_index_item(tool_info): + nonlocal index_builds + index_builds += 1 + return original_build_tool_index_item(tool_info) + + monkeypatch.setattr(tool_routes, "_build_tool_index_item", counted_build_tool_index_item) + + with _temporary_tool(tool): + first = await client.get( + "/api/tools/page", + params={"q": "needlepagesummarycacheunique", "limit": 25}, + ) + first_builds = index_builds + second = await client.get( + "/api/tools/page", + params={"q": "needlepagesummarycacheunique", "source": "plugin_py", "limit": 25}, + ) + second_builds = index_builds + + tool_routes._invalidate_tool_summary_cache() + third = await client.get( + "/api/tools/page", + params={"q": "needlepagesummarycacheunique", "limit": 25}, + ) + + assert first.status_code == 200, first.text + assert second.status_code == 200, second.text + assert third.status_code == 200, third.text + assert first.json()["total"] == 1 + assert second.json()["total"] == 1 + assert third.json()["total"] == 1 + assert first_builds > 0 + assert second_builds == first_builds + assert index_builds > second_builds diff --git a/tests/server/routes/test_update_routes.py b/tests/server/routes/test_update_routes.py index 8be953356..060da1462 100644 --- a/tests/server/routes/test_update_routes.py +++ b/tests/server/routes/test_update_routes.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import asyncio from urllib.parse import quote, unquote, urlparse import pytest @@ -19,6 +20,15 @@ def _request() -> Request: return Request({"type": "http", "method": "GET", "path": "/api/update/check", "headers": []}) +@pytest.fixture(autouse=True) +def _clear_update_cache(): + from flocks.server.routes import update as update_routes + + update_routes.clear_update_check_cache() + yield + update_routes.clear_update_check_cache() + + def _manual_real_upgrade_branch() -> str: branch_input = ( os.environ.get(_MANUAL_REAL_UPGRADE_BRANCH_ENV, "").strip() @@ -126,6 +136,167 @@ async def _fake_check_update(**kwargs): assert info.current_version == "v2026.5.9" +async def test_check_version_reuses_cached_flocks_result(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + calls = 0 + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + assert kwargs == {"locale": "zh-CN", "force_console_manifest": False} + return VersionInfo(current_version=f"v{calls}") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + first = await update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + second = await update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + forced = await update_routes.check_version(_request(), locale="zh-CN", edition="flocks", force=True) + + assert calls == 2 + assert first.current_version == "v1" + assert second.current_version == "v1" + assert forced.current_version == "v2" + + +async def test_check_version_deduplicates_inflight_requests(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + calls = 0 + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + await asyncio.sleep(0.01) + return VersionInfo(current_version=f"v{calls}") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + results = await asyncio.gather(*[ + update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + for _ in range(5) + ]) + + assert calls == 1 + assert [item.current_version for item in results] == ["v1"] * 5 + + +async def test_check_version_deduplicates_concurrent_forced_requests( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + calls = 0 + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + await asyncio.sleep(0.01) + return VersionInfo(current_version=f"v{calls}") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + results = await asyncio.gather(*[ + update_routes.check_version( + _request(), locale="zh-CN", edition="flocks", force=True + ) + for _ in range(10) + ]) + + assert calls == 1 + assert [item.current_version for item in results] == ["v1"] * 10 + + +async def test_check_version_keeps_inflight_task_after_cancelled_request(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + calls = 0 + started = asyncio.Event() + release = asyncio.Event() + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + started.set() + await release.wait() + return VersionInfo(current_version=f"v{calls}") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + first = asyncio.create_task(update_routes.check_version(_request(), locale="zh-CN", edition="flocks")) + second = None + try: + await started.wait() + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + + second = asyncio.create_task(update_routes.check_version(_request(), locale="zh-CN", edition="flocks")) + await asyncio.sleep(0) + assert calls == 1 + + release.set() + result = await asyncio.wait_for(second, timeout=1) + assert result.current_version == "v1" + + cached = await update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + assert cached.current_version == "v1" + assert calls == 1 + finally: + release.set() + if second is not None and not second.done(): + await asyncio.wait_for(second, timeout=1) + + +async def test_forced_check_reuses_older_same_key_inflight_request( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + older_started = asyncio.Event() + release_older = asyncio.Event() + calls = 0 + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + if calls == 1: + older_started.set() + await release_older.wait() + return VersionInfo(current_version="v1") + return VersionInfo(current_version="v2") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + older = asyncio.create_task( + update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + ) + await older_started.wait() + + forced = asyncio.create_task( + update_routes.check_version( + _request(), locale="zh-CN", edition="flocks", force=True + ) + ) + await asyncio.sleep(0) + assert calls == 1 + + release_older.set() + assert (await older).current_version == "v1" + assert (await forced).current_version == "v1" + + cached = await update_routes.check_version( + _request(), locale="zh-CN", edition="flocks" + ) + assert cached.current_version == "v1" + assert calls == 1 + + @pytest.mark.skipif( os.environ.get(_MANUAL_REAL_UPGRADE_ENV) != "1", reason=f"manual real upgrade test; set {_MANUAL_REAL_UPGRADE_ENV}=1 to enable", diff --git a/tests/server/routes/test_webui_perf_routes.py b/tests/server/routes/test_webui_perf_routes.py new file mode 100644 index 000000000..51d33f3a2 --- /dev/null +++ b/tests/server/routes/test_webui_perf_routes.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import asyncio +import threading +import time +from collections.abc import AsyncGenerator +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI, status +from httpx import ASGITransport, AsyncClient + +from flocks.auth.context import AuthUser +from flocks.server.auth import require_user + + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def webui_perf_client() -> AsyncGenerator[AsyncClient, None]: + from flocks.server.routes.monitoring import router as monitoring_router + from flocks.server.routes.permission import router as permission_router + from flocks.server.routes.stats import router as stats_router + + app = FastAPI() + app.dependency_overrides[require_user] = lambda: AuthUser( + id="webui-perf-user", + username="webui-perf-user", + role="member", + status="active", + must_reset_password=False, + ) + app.include_router(stats_router, prefix="/api/stats") + app.include_router(monitoring_router, prefix="/api/monitoring") + app.include_router(permission_router, prefix="/api/permission") + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + yield client + + +async def test_stats_summary_returns_lightweight_counts( + webui_perf_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import stats as stats_routes + + async def _task_dashboard(): + return {"completed_week": 2, "failed_week": 1, "scheduled_active": 4} + + async def _count_agents(): + return 5 + + async def _count_workflows(): + return 6 + + async def _count_skills(): + return 7 + + async def _count_tools(): + return 8 + + async def _count_models(): + return 9 + + monkeypatch.setattr(stats_routes, "_task_dashboard", _task_dashboard) + monkeypatch.setattr(stats_routes, "_count_agents", _count_agents) + monkeypatch.setattr(stats_routes, "_count_workflows", _count_workflows) + monkeypatch.setattr(stats_routes, "_count_skills", _count_skills) + monkeypatch.setattr(stats_routes, "_count_tools", _count_tools) + monkeypatch.setattr(stats_routes, "_count_models", _count_models) + + response = await webui_perf_client.get("/api/stats/summary") + + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json() == { + "tasks": {"week": 3, "scheduledActive": 4}, + "agents": {"total": 5}, + "workflows": {"total": 6}, + "skills": {"total": 7}, + "tools": {"total": 8}, + "models": {"total": 9}, + "system": {"status": "healthy", "message": "所有服务运行正常"}, + } + + +async def test_stats_summary_starts_independent_sources_concurrently( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import stats as stats_routes + + started: set[str] = set() + all_started = asyncio.Event() + release = asyncio.Event() + + async def _count(name: str, value): + started.add(name) + if len(started) == 6: + all_started.set() + await release.wait() + return value + + async def _task_dashboard(): + return await _count("dashboard", {}) + + async def _count_tools(): + return await _count("tools", 8) + + async def _count_skills(): + return await _count("skills", 7) + + async def _count_agents(): + return await _count("agents", 5) + + async def _count_workflows(): + return await _count("workflows", 6) + + async def _count_models(): + return await _count("models", 9) + + monkeypatch.setattr(stats_routes, "_task_dashboard", _task_dashboard) + monkeypatch.setattr(stats_routes, "_count_tools", _count_tools) + monkeypatch.setattr(stats_routes, "_count_skills", _count_skills) + monkeypatch.setattr(stats_routes, "_count_agents", _count_agents) + monkeypatch.setattr(stats_routes, "_count_workflows", _count_workflows) + monkeypatch.setattr(stats_routes, "_count_models", _count_models) + + summary_task = asyncio.create_task(stats_routes.get_system_stats_summary()) + await asyncio.wait_for(all_started.wait(), timeout=1) + release.set() + response = await summary_task + + assert started == {"dashboard", "agents", "workflows", "skills", "tools", "models"} + assert response.tools.total == 8 + assert response.skills.total == 7 + assert response.agents.total == 5 + + +async def test_skill_discovery_does_not_block_the_event_loop( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import stats as stats_routes + from flocks.skill.skill import Skill + + def slow_discover(): + time.sleep(0.06) + return {"demo": SimpleNamespace(name="demo", category="user")} + + monkeypatch.setattr(Skill, "_discover", slow_discover) + Skill.clear_cache() + count_task = asyncio.create_task(stats_routes._count_skills()) + heartbeat_ticks = 0 + while not count_task.done(): + await asyncio.sleep(0.005) + heartbeat_ticks += 1 + + assert await count_task == 1 + assert heartbeat_ticks >= 3 + Skill.clear_cache() + + +async def test_skill_invalidation_during_discovery_cannot_restore_stale_cache( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.skill.skill import Skill + + discovery_started = threading.Event() + release_first_discovery = threading.Event() + discovery_count = 0 + + def discover(): + nonlocal discovery_count + discovery_count += 1 + if discovery_count == 1: + discovery_started.set() + assert release_first_discovery.wait(timeout=1) + name = "stale" + else: + name = "fresh" + return {name: SimpleNamespace(name=name, category="user")} + + monkeypatch.setattr(Skill, "_discover", discover) + Skill.clear_cache() + load_task = asyncio.create_task(Skill.all()) + assert await asyncio.to_thread(discovery_started.wait, 1) + Skill.clear_cache() + release_first_discovery.set() + + skills = await load_task + + assert [skill.name for skill in skills] == ["fresh"] + assert list((Skill._cache or {}).keys()) == ["fresh"] + Skill.clear_cache() + + +async def test_monitoring_routes_back_existing_webui_calls(webui_perf_client: AsyncClient): + status_response = await webui_perf_client.get("/api/monitoring/status") + metrics_response = await webui_perf_client.get("/api/monitoring/metrics") + llm_response = await webui_perf_client.get("/api/monitoring/performance/llm") + tool_response = await webui_perf_client.get("/api/monitoring/performance/tool") + + assert status_response.status_code == status.HTTP_200_OK, status_response.text + assert metrics_response.status_code == status.HTTP_200_OK, metrics_response.text + assert llm_response.status_code == status.HTTP_200_OK, llm_response.text + assert tool_response.status_code == status.HTTP_200_OK, tool_response.text + + status_payload = status_response.json() + assert status_payload["status"] == "healthy" + assert "uptime" in status_payload + assert "mcpServers" in status_payload + + metrics_payload = metrics_response.json() + assert metrics_payload["messageRate"] is None + assert metrics_payload["avgResponseTime"] is None + assert metrics_payload["activeRequests"] is None + assert llm_response.json() == [] + assert tool_response.json() == [] + + +async def test_monitoring_tool_rates_use_counter_deltas(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import monitoring as monitoring_routes + + samples = iter([100.0, 101.0, 160.0, 161.0]) + monkeypatch.setattr(monitoring_routes, "_metrics_clock", lambda: next(samples)) + monkeypatch.setattr(monitoring_routes, "_metrics_rate_state", None) + + assert monitoring_routes._sample_tool_rates(10, 2) == (None, None) + assert monitoring_routes._sample_tool_rates(11, 2) == (None, None) + assert monitoring_routes._sample_tool_rates(15, 3) == (5.0, 1.0) + # Additional clients inside the same window see the same sample instead + # of advancing the global baseline and distorting each other's rates. + assert monitoring_routes._sample_tool_rates(16, 3) == (5.0, 1.0) + + +async def test_api_permission_alias_matches_frontend_route(webui_perf_client: AsyncClient): + response = await webui_perf_client.get("/api/permission") + + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json() == [] diff --git a/tests/server/test_asgi_middleware.py b/tests/server/test_asgi_middleware.py new file mode 100644 index 000000000..227545326 --- /dev/null +++ b/tests/server/test_asgi_middleware.py @@ -0,0 +1,384 @@ +import asyncio +from collections.abc import Awaitable, Callable + +import pytest +from starlette.applications import Starlette +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import StreamingResponse +from starlette.routing import Route +from starlette.types import ASGIApp, Message, Receive, Scope + +from flocks.server import app as server_app + + +def _http_scope(path: str, *, method: str = "GET") -> Scope: + return { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "root_path": "", + "headers": [], + "client": ("127.0.0.1", 12345), + "server": ("127.0.0.1", 8000), + "state": {}, + } + + +async def _run_auth_middleware( + monkeypatch: pytest.MonkeyPatch, + *, + hook: Callable[[Request, dict], Awaitable[None]], + endpoint: ASGIApp, + receive: Receive, +) -> None: + async def apply_auth(_request: Request): + return None, object(), None + + async def send(_message: Message) -> None: + return None + + monkeypatch.setattr(server_app, "_run_http_middleware_hooks", hook) + monkeypatch.setattr(server_app, "apply_auth_for_request", apply_auth) + monkeypatch.setattr(server_app, "clear_auth_context", lambda _token: None) + + await server_app._AuthGuardMiddleware(endpoint)( + _http_scope("/api/session", method="POST"), + receive, + send, + ) + + +def test_production_http_middleware_is_pure_asgi_and_keeps_order() -> None: + middleware_classes = [entry.cls for entry in server_app.app.user_middleware] + + assert BaseHTTPMiddleware not in middleware_classes + assert middleware_classes == [ + server_app._DeferredCORSMiddleware, + server_app._StaticWebUIMiddleware, + server_app._AuthGuardMiddleware, + server_app._RequestLoggingMiddleware, + server_app._InstanceContextMiddleware, + server_app._SecurityHeadersMiddleware, + ] + + +@pytest.mark.asyncio +async def test_request_log_is_emitted_when_stream_headers_start( + monkeypatch: pytest.MonkeyPatch, +) -> None: + never = asyncio.Event() + response_started = asyncio.Event() + logged: list[tuple[str, dict]] = [] + + async def streaming_app(_scope, _receive, send) -> None: + await send({"type": "http.response.start", "status": 200, "headers": []}) + response_started.set() + await never.wait() + + async def receive(): + await never.wait() + return {"type": "http.disconnect"} + + async def send(_message): + return None + + monkeypatch.setattr(server_app.log, "info", lambda event, payload: logged.append((event, payload))) + middleware = server_app._RequestLoggingMiddleware(streaming_app) + request = asyncio.create_task(middleware(_http_scope("/api/provider"), receive, send)) + try: + await response_started.wait() + + assert not request.done() + assert logged[0][0] == "request.complete" + assert logged[0][1]["status"] == 200 + finally: + request.cancel() + await asyncio.gather(request, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_auth_hook_request_body_is_replayed_to_endpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, object] = {} + incoming = iter( + [ + {"type": "http.request", "body": b"hello ", "more_body": True}, + {"type": "http.request", "body": b"world", "more_body": False}, + ] + ) + + async def receive(): + return next(incoming) + + async def inspect_body(request: Request, _context: dict) -> None: + seen["hook"] = await request.body() + + async def endpoint(_scope, downstream_receive, _send) -> None: + body = bytearray() + messages = [] + while True: + message = await downstream_receive() + messages.append(message) + body.extend(message.get("body", b"")) + if not message.get("more_body", False): + break + seen["endpoint"] = bytes(body) + seen["message_count"] = len(messages) + seen["same_body_object"] = messages[0]["body"] is seen["hook"] + + await _run_auth_middleware( + monkeypatch, + hook=inspect_body, + endpoint=endpoint, + receive=receive, + ) + + assert seen == { + "hook": b"hello world", + "endpoint": b"hello world", + "message_count": 1, + "same_body_object": True, + } + + +@pytest.mark.asyncio +async def test_auth_hook_stream_consumption_keeps_legacy_downstream_behavior( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, bytes] = {} + incoming = iter( + [ + {"type": "http.request", "body": b"hello ", "more_body": True}, + {"type": "http.request", "body": b"world", "more_body": False}, + ] + ) + + async def receive(): + return next(incoming) + + async def inspect_stream(request: Request, _context: dict) -> None: + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if body == b"hello world": + break + seen["hook"] = bytes(body) + + async def endpoint(scope, downstream_receive, _send) -> None: + seen["endpoint"] = await Request(scope, receive=downstream_receive).body() + + await _run_auth_middleware( + monkeypatch, + hook=inspect_stream, + endpoint=endpoint, + receive=receive, + ) + + assert seen == {"hook": b"hello world", "endpoint": b""} + + +@pytest.mark.asyncio +async def test_auth_hook_partial_stream_read_continues_from_upstream( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, bytes] = {} + incoming = iter( + [ + {"type": "http.request", "body": b"hello ", "more_body": True}, + {"type": "http.request", "body": b"world", "more_body": False}, + ] + ) + + async def receive(): + return next(incoming) + + async def inspect_stream(request: Request, _context: dict) -> None: + async for chunk in request.stream(): + seen["hook"] = chunk + break + + async def endpoint(scope, downstream_receive, _send) -> None: + seen["endpoint"] = await Request(scope, receive=downstream_receive).body() + + await _run_auth_middleware( + monkeypatch, + hook=inspect_stream, + endpoint=endpoint, + receive=receive, + ) + + assert seen == {"hook": b"hello ", "endpoint": b"world"} + + +@pytest.mark.asyncio +async def test_auth_hook_terminal_empty_break_does_not_block_downstream( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, bytes] = {} + incoming = iter( + [ + {"type": "http.request", "body": b"hello", "more_body": False}, + ] + ) + + async def receive(): + return next(incoming) + + async def inspect_stream(request: Request, _context: dict) -> None: + async for chunk in request.stream(): + if not chunk: + break + + async def endpoint(scope, downstream_receive, _send) -> None: + seen["endpoint"] = await Request(scope, receive=downstream_receive).body() + + await _run_auth_middleware( + monkeypatch, + hook=inspect_stream, + endpoint=endpoint, + receive=receive, + ) + + assert seen == {"endpoint": b""} + + +@pytest.mark.asyncio +async def test_auth_hook_observed_disconnect_is_replayed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: list[Message] = [] + incoming = iter( + [ + {"type": "http.request", "body": b"hello", "more_body": False}, + {"type": "http.disconnect"}, + ] + ) + never = asyncio.Event() + + async def receive(): + try: + return next(incoming) + except StopIteration: + await never.wait() + return {"type": "http.disconnect"} + + async def inspect_disconnect(request: Request, _context: dict) -> None: + await request.body() + assert await request.is_disconnected() + + async def endpoint(_scope, downstream_receive, _send) -> None: + seen.append(await downstream_receive()) + seen.append(await downstream_receive()) + seen.append(await downstream_receive()) + + await _run_auth_middleware( + monkeypatch, + hook=inspect_disconnect, + endpoint=endpoint, + receive=receive, + ) + + assert seen == [ + {"type": "http.request", "body": b"hello", "more_body": False}, + {"type": "http.disconnect"}, + {"type": "http.disconnect"}, + ] + + +@pytest.mark.asyncio +async def test_auth_without_body_read_passes_original_receive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + endpoint_called = False + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def no_hooks(_request: Request, _context: dict) -> None: + return None + + async def endpoint(_scope, downstream_receive, _send) -> None: + nonlocal endpoint_called + endpoint_called = True + assert downstream_receive is receive + + await _run_auth_middleware( + monkeypatch, + hook=no_hooks, + endpoint=endpoint, + receive=receive, + ) + + assert endpoint_called + + +@pytest.mark.asyncio +async def test_long_lived_streams_do_not_spawn_base_http_middleware_tasks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + never = asyncio.Event() + all_streams_started = asyncio.Event() + cleared_tokens: list[object] = [] + started_streams = 0 + + async def stream(): + nonlocal started_streams + started_streams += 1 + if started_streams == 11: + all_streams_started.set() + yield b"data: connected\n\n" + await never.wait() + + async def endpoint(_request: Request) -> StreamingResponse: + return StreamingResponse(stream(), media_type="text/event-stream") + + async def apply_auth(_request: Request): + return None, object(), None + + async def no_static_response(_request: Request): + return None + + monkeypatch.setattr(server_app, "apply_auth_for_request", apply_auth) + monkeypatch.setattr(server_app, "clear_auth_context", cleared_tokens.append) + monkeypatch.setattr(server_app, "maybe_serve_static_webui", no_static_response) + monkeypatch.setattr(server_app, "_should_log_request", lambda *_args: False) + + app = Starlette(routes=[Route("/global/event", endpoint)]) + app.add_middleware(server_app._SecurityHeadersMiddleware) + app.add_middleware(server_app._InstanceContextMiddleware) + app.add_middleware(server_app._RequestLoggingMiddleware) + app.add_middleware(server_app._AuthGuardMiddleware) + app.add_middleware(server_app._StaticWebUIMiddleware) + + scope = _http_scope("/global/event") + + async def receive(): + await never.wait() + return {"type": "http.disconnect"} + + async def send(_message): + return None + + requests = [asyncio.create_task(app(dict(scope), receive, send)) for _ in range(11)] + try: + await asyncio.wait_for(all_streams_started.wait(), timeout=1) + + base_http_tasks = [task for task in asyncio.all_tasks() if "BaseHTTPMiddleware" in task.get_coro().__qualname__] + + assert started_streams == 11 + assert all(not request.done() for request in requests) + assert base_http_tasks == [] + assert cleared_tokens == [] + finally: + for request in requests: + request.cancel() + await asyncio.gather(*requests, return_exceptions=True) + + assert len(cleared_tokens) == 11 diff --git a/tests/server/test_static_webui.py b/tests/server/test_static_webui.py index 8e478c5ac..4c918815f 100644 --- a/tests/server/test_static_webui.py +++ b/tests/server/test_static_webui.py @@ -1,3 +1,4 @@ +import mimetypes from pathlib import Path import pytest @@ -47,10 +48,12 @@ async def test_static_webui_serves_browser_root(monkeypatch, tmp_path: Path) -> @pytest.mark.asyncio async def test_static_webui_serves_assets_with_immutable_cache(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("FLOCKS_WEBUI_DIST_DIR", str(_write_dist(tmp_path))) + monkeypatch.setattr(mimetypes, "guess_type", lambda *_args, **_kwargs: ("text/plain", None)) async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: response = await client.get("/assets/app.12345678.js") assert response.status_code == 200 + assert response.headers["Content-Type"].startswith("application/javascript") assert "console.log" in response.text assert response.headers["Cache-Control"] == "public, max-age=31536000, immutable" diff --git a/tests/storage/test_storage.py b/tests/storage/test_storage.py index 27c5fbdac..62ddb0c6e 100644 --- a/tests/storage/test_storage.py +++ b/tests/storage/test_storage.py @@ -7,6 +7,7 @@ import os import shutil import sqlite3 +import threading import pytest from pathlib import Path import tempfile @@ -252,17 +253,22 @@ async def test_storage_init_quarantines_invalid_header_and_boots(tmp_path): with patch.object(Storage, "_initialized", False), \ patch.object(Storage, "_db_path", None): await Storage.init(db_path) - - # Fresh DB is now usable - await Storage.set("hello", {"value": 1}) - assert await Storage.get("hello") == {"value": 1} + # Fresh DB is now usable before the patched global state is restored. + await Storage.set("hello", {"value": 1}) + assert await Storage.get("hello") == {"value": 1} siblings = sorted(p.name for p in tmp_path.iterdir()) assert "flocks.db" in siblings corrupt_files = [name for name in siblings if ".corrupt." in name] assert any(name.startswith("flocks.db.corrupt.") for name in corrupt_files), siblings - assert any(name.startswith("flocks.db-wal.corrupt.") for name in corrupt_files), siblings - assert any(name.startswith("flocks.db-shm.corrupt.") for name in corrupt_files), siblings + quarantined_main = next( + tmp_path / name + for name in corrupt_files + if name.startswith("flocks.db.corrupt.") + and not name.endswith(("-wal", "-shm")) + ) + assert quarantined_main.with_name(quarantined_main.name + "-wal").exists() + assert quarantined_main.with_name(quarantined_main.name + "-shm").exists() @pytest.mark.asyncio @@ -291,26 +297,31 @@ async def test_storage_init_recovers_when_pragma_reports_corruption(tmp_path): @pytest.mark.asyncio -async def test_storage_get_recovers_corrupt_db_after_init(tmp_path): - """A request-time storage read should quarantine corruption and retry once.""" +async def test_storage_get_defers_corruption_recovery_until_restart(tmp_path): + """A request-time read must not replace a DB while the server is running.""" db_path = tmp_path / "flocks.db" with patch.object(Storage, "_initialized", False), \ - patch.object(Storage, "_db_path", None): + patch.object(Storage, "_db_path", None), \ + patch.object( + Storage, + "_quarantine_corrupt_db", + side_effect=AssertionError("request-time recovery must not quarantine the live DB"), + ): await Storage.init(db_path) await Storage.set("hello", {"value": "before"}) - db_path.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 2048) - db_path.with_name("flocks.db-wal").write_bytes(b"fake wal payload") - db_path.with_name("flocks.db-shm").write_bytes(b"fake shm payload") + corrupt_payload = Storage._SQLITE_MAGIC + b"\xff" * 2048 + db_path.write_bytes(corrupt_payload) - assert await Storage.get("hello") is None - await Storage.set("hello", {"value": "after"}) - assert await Storage.get("hello") == {"value": "after"} + with pytest.raises(sqlite3.DatabaseError): + await Storage.get("hello") + + assert db_path.read_bytes() == corrupt_payload siblings = sorted(p.name for p in tmp_path.iterdir()) assert "flocks.db" in siblings - assert any(name.startswith("flocks.db.corrupt.") for name in siblings), siblings + assert not any(".corrupt." in name for name in siblings), siblings def test_try_sqlite_recover_installs_recovered_db(tmp_path): @@ -337,6 +348,8 @@ def test_try_sqlite_recover_installs_recovered_db(tmp_path): "INSERT INTO storage (key, value, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", ("hello", '{"value": "recovered"}', "json", "old", "old"), ) + conn.execute("CREATE TABLE lost_and_found (value TEXT NOT NULL)") + conn.execute("INSERT INTO lost_and_found VALUES ('business-data')") conn.commit() finally: conn.close() @@ -349,10 +362,278 @@ def test_try_sqlite_recover_installs_recovered_db(tmp_path): ("hello",), ).fetchone()[0] == '{"value": "recovered"}' assert recovered.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + assert recovered.execute("SELECT value FROM lost_and_found").fetchone() == ( + "business-data", + ) finally: recovered.close() +def test_sqlite_recover_reads_wal_paired_with_quarantined_main(tmp_path): + """A committed WAL must stay paired with the renamed main DB during recovery.""" + if shutil.which("sqlite3") is None: + pytest.skip("sqlite3 CLI is not available") + + source = tmp_path / "source.db" + source_conn = sqlite3.connect(source) + try: + assert source_conn.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" + source_conn.execute("PRAGMA wal_autocheckpoint=0") + source_conn.execute( + """ + CREATE TABLE storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + source_conn.execute( + "INSERT INTO storage (key, value, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ("wal-key", '{"value": "committed"}', "json", "old", "old"), + ) + source_conn.commit() + + db_path = tmp_path / "flocks.db" + shutil.copy2(source, db_path) + shutil.copy2(source.with_name("source.db-wal"), db_path.with_name("flocks.db-wal")) + + quarantined = Storage._quarantine_corrupt_db(db_path) + assert quarantined is not None + paired_wal = quarantined.with_name(quarantined.name + "-wal") + assert paired_wal.exists() + assert not db_path.with_name("flocks.db-wal").exists() + quarantined_bytes = quarantined.read_bytes() + wal_bytes = paired_wal.read_bytes() + + assert Storage._try_sqlite_recover_sync(quarantined, db_path) == db_path + assert quarantined.read_bytes() == quarantined_bytes + assert paired_wal.read_bytes() == wal_bytes + recovered = sqlite3.connect(db_path) + try: + assert recovered.execute( + "SELECT value FROM storage WHERE key = ?", + ("wal-key",), + ).fetchone()[0] == '{"value": "committed"}' + finally: + recovered.close() + finally: + source_conn.close() + + +def test_quarantine_rolls_back_main_when_wal_cannot_be_paired(tmp_path, monkeypatch): + """A WAL rename failure must abort instead of recovering an incomplete snapshot.""" + + db_path = tmp_path / "flocks.db" + wal_path = tmp_path / "flocks.db-wal" + main_payload = b"SQLite format 3\x00" + b"main" + wal_payload = b"committed transactions" + db_path.write_bytes(main_payload) + wal_path.write_bytes(wal_payload) + + real_rename = Path.rename + + def fail_wal_rename(path: Path, target: Path) -> Path: + if path == wal_path: + raise OSError("simulated WAL rename failure") + return real_rename(path, target) + + monkeypatch.setattr(Path, "rename", fail_wal_rename) + + assert Storage._quarantine_corrupt_db(db_path) is None + assert db_path.read_bytes() == main_payload + assert wal_path.read_bytes() == wal_payload + assert not list(tmp_path.glob("flocks.db.corrupt.*")) + + +@pytest.mark.asyncio +async def test_recover_corrupt_primary_refuses_online_replacement(tmp_path): + """No partial connection barrier may advertise unsafe online recovery.""" + db_path = tmp_path / "flocks.db" + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None), \ + patch.object(Storage, "_corruption_recovery_generation", 0), \ + patch.object( + Storage, + "_quarantine_corrupt_db", + side_effect=AssertionError("online recovery must fail before quarantine"), + ): + try: + await Storage.init(db_path) + inode_before = db_path.stat().st_ino + async with Storage.connect(db_path) as held: + await held.execute("CREATE TABLE recovery_probe (id TEXT PRIMARY KEY)") + await held.execute("INSERT INTO recovery_probe VALUES ('before')") + await held.commit() + + with pytest.raises(Storage.StorageError, match="while Flocks is running"): + await Storage.recover_corrupt_db( + db_path, + action="test.explicit_recovery", + exc=sqlite3.DatabaseError("database disk image is malformed"), + ) + + await held.execute("INSERT INTO recovery_probe VALUES ('after')") + await held.commit() + + assert db_path.stat().st_ino == inode_before + with sqlite3.connect(db_path) as fresh: + rows = fresh.execute("SELECT id FROM recovery_probe ORDER BY id").fetchall() + assert rows == [("after",), ("before",)] + finally: + await Storage.shutdown() + + +@pytest.mark.asyncio +async def test_live_database_path_disappearance_fails_closed(tmp_path): + """A moved live DB must not cause _ensure_init to create a second inode.""" + from flocks.channel.inbound import session_binding + + await session_binding.close_binding_db() + db_path = tmp_path / "flocks.db" + archived_path = tmp_path / "flocks.db.moved" + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None): + try: + await Storage.init(db_path) + held = await session_binding._get_db() + await held.execute("CREATE TABLE live_probe (value TEXT)") + await held.execute("INSERT INTO live_probe VALUES ('before')") + await held.commit() + + db_path.rename(archived_path) + for suffix in ("-wal", "-shm"): + sidecar = db_path.with_name(db_path.name + suffix) + if sidecar.exists(): + sidecar.rename(archived_path.with_name(archived_path.name + suffix)) + + with pytest.raises(Storage.StorageError, match="active SQLite database disappeared"): + await Storage.set("must-not-create", {"value": 1}) + + assert not db_path.exists() + with pytest.raises(ValueError, match="no active connection"): + await held.execute("SELECT 1") + finally: + await session_binding.close_binding_db() + await Storage.shutdown() + + +@pytest.mark.asyncio +async def test_live_database_inode_replacement_fails_closed(tmp_path): + """An atomic restore over the live path must not mix old and new DB handles.""" + from flocks.channel.inbound import session_binding + + await session_binding.close_binding_db() + db_path = tmp_path / "flocks.db" + archived_path = tmp_path / "flocks.db.before-restore" + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None), \ + patch.object(Storage, "_db_identity", None): + try: + await Storage.init(db_path) + original_identity = Storage._db_identity + held = await session_binding._get_db() + await held.execute("CREATE TABLE live_probe (value TEXT)") + await held.execute("INSERT INTO live_probe VALUES ('old-inode')") + await held.commit() + + db_path.rename(archived_path) + for suffix in ("-wal", "-shm"): + sidecar = db_path.with_name(db_path.name + suffix) + if sidecar.exists(): + sidecar.rename(archived_path.with_name(archived_path.name + suffix)) + with sqlite3.connect(db_path) as replacement: + replacement.execute("CREATE TABLE restored_probe(value TEXT)") + replacement.execute("INSERT INTO restored_probe VALUES ('new-inode')") + replacement.commit() + + assert Storage._file_identity(db_path) != original_identity + with pytest.raises(Storage.StorageError, match="different file identity"): + async with Storage.connect(db_path): + pass + with pytest.raises(Storage.StorageError, match="different file identity"): + Storage.connect_sync(db_path) + with pytest.raises(Storage.StorageError, match="replaced on disk"): + await Storage.set("must-not-write", {"value": 1}) + with pytest.raises(ValueError, match="no active connection"): + await held.execute("SELECT 1") + + with sqlite3.connect(db_path) as replacement: + assert replacement.execute("SELECT value FROM restored_probe").fetchone() == ( + "new-inode", + ) + assert not replacement.execute( + "SELECT 1 FROM sqlite_schema WHERE name='storage'" + ).fetchone() + finally: + await session_binding.close_binding_db() + await Storage.shutdown() + + +@pytest.mark.asyncio +async def test_cancelled_recovery_waits_for_worker_to_finish(): + """Cancellation must not release callers while a recovery thread still mutates files.""" + started = threading.Event() + release = threading.Event() + + def blocked_worker() -> None: + started.set() + release.wait(timeout=5) + + worker = asyncio.create_task(asyncio.to_thread(blocked_worker)) + waiter = asyncio.create_task(Storage._wait_for_recovery_worker_on_cancel(worker)) + assert await asyncio.to_thread(started.wait, 2) + + waiter.cancel() + await asyncio.sleep(0) + assert not waiter.done() + + release.set() + with pytest.raises(asyncio.CancelledError): + await waiter + assert worker.done() + + +@pytest.mark.asyncio +async def test_cancelled_startup_waits_for_recovery_thread(tmp_path, monkeypatch): + """The real Storage.init recovery branch must not leave a detached installer thread.""" + db_path = tmp_path / "flocks.db" + db_path.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 4096) + started = threading.Event() + release = threading.Event() + finished = threading.Event() + + def blocked_recovery(_quarantined: Path, _target: Path) -> None: + started.set() + release.wait(timeout=5) + finished.set() + + monkeypatch.setattr(Storage, "_try_sqlite_recover_sync", staticmethod(blocked_recovery)) + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None), \ + patch.object(Storage, "_db_identity", None): + init_task = asyncio.create_task(Storage.init(db_path)) + assert await asyncio.to_thread(started.wait, 2) + + init_task.cancel() + await asyncio.sleep(0) + assert not init_task.done() + + release.set() + with pytest.raises(asyncio.CancelledError): + await init_task + assert finished.is_set() + + @pytest.mark.asyncio async def test_storage_init_recovers_real_malformed_sqlite_file(tmp_path): """Startup should recover a real DB that fails SQLite integrity checks.""" @@ -412,42 +693,50 @@ async def test_storage_init_recovers_real_malformed_sqlite_file(tmp_path): @pytest.mark.asyncio -async def test_storage_get_uses_recovered_db_before_empty_rebuild(tmp_path, monkeypatch): - """A recovered DB should be installed before retrying the failed read.""" - db_path = tmp_path / "flocks.db" - - def fake_recover(quarantined_path: Path, target_path: Path): - assert quarantined_path.name.startswith("flocks.db.corrupt.") - conn = sqlite3.connect(target_path) - try: - conn.execute( - """ - CREATE TABLE storage ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - type TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - """ - ) - conn.execute( - "INSERT INTO storage (key, value, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", - ("hello", '{"value": "recovered"}', "json", "old", "old"), +async def test_request_time_corruption_does_not_call_recover_corrupt_db(tmp_path): + """The generic request wrapper must surface corruption without replacing files.""" + + async def corrupt_operation(): + raise sqlite3.DatabaseError("database disk image is malformed") + + with patch.object(Storage, "recover_corrupt_db", new_callable=AsyncMock) as recover, \ + patch.object(Storage._log, "error") as error_log: + with pytest.raises(sqlite3.DatabaseError, match="malformed"): + await Storage._run_with_corruption_recovery( + corrupt_operation, + db_path=tmp_path / "flocks.db", + action="test.request_read", ) - conn.commit() - finally: - conn.close() - return target_path - monkeypatch.setattr(Storage, "_try_sqlite_recover_sync", staticmethod(fake_recover)) + recover.assert_not_awaited() + assert error_log.call_args.args[0] == "storage.corruption.deferred_to_restart" + + +@pytest.mark.asyncio +async def test_request_time_corruption_is_recovered_only_after_restart(tmp_path): + """Close the fail-closed loop: no online swap, then startup recovery succeeds.""" + db_path = tmp_path / "flocks.db" with patch.object(Storage, "_initialized", False), \ - patch.object(Storage, "_db_path", None): + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None): + await Storage.init(db_path) + await Storage.set("before", {"value": 1}) + + corrupt_payload = Storage._SQLITE_MAGIC + b"\xff" * 4096 + db_path.write_bytes(corrupt_payload) + with pytest.raises(sqlite3.DatabaseError): + await Storage.get("before") + assert db_path.read_bytes() == corrupt_payload + assert not list(tmp_path.glob("flocks.db.corrupt.*")) + + await Storage.shutdown() await Storage.init(db_path) - db_path.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 2048) + await Storage.set("after", {"value": 2}) + assert await Storage.get("after") == {"value": 2} + await Storage.shutdown() - assert await Storage.get("hello") == {"value": "recovered"} + assert list(tmp_path.glob("flocks.db.corrupt.*")) @pytest.mark.asyncio diff --git a/tests/tool/test_tool_refresh_outcomes.py b/tests/tool/test_tool_refresh_outcomes.py new file mode 100644 index 000000000..12f826f47 --- /dev/null +++ b/tests/tool/test_tool_refresh_outcomes.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +import threading + +import pytest + +from flocks.tool.registry import ( + Tool, + ToolCategory, + ToolInfo, + ToolRefreshError, + ToolRegistry, +) + + +def _tool(name: str, source: str) -> Tool: + return Tool( + info=ToolInfo( + name=name, + description=name, + category=ToolCategory.CUSTOM, + source=source, + ), + handler=lambda _ctx, **_kwargs: None, + ) + + +@contextmanager +def _isolated_registry() -> Iterator[None]: + state = { + "initialized": ToolRegistry._initialized, + "tools": ToolRegistry._tools, + "defaults": ToolRegistry._enabled_defaults, + "plugin_names": ToolRegistry._plugin_tool_names, + "dynamic_modules": ToolRegistry._dynamic_modules, + "dynamic_tools": ToolRegistry._dynamic_tools_by_module, + } + try: + ToolRegistry._initialized = True + ToolRegistry._tools = {} + ToolRegistry._enabled_defaults = {} + ToolRegistry._plugin_tool_names = [] + ToolRegistry._dynamic_modules = {} + ToolRegistry._dynamic_tools_by_module = {} + yield + finally: + ToolRegistry._initialized = state["initialized"] + ToolRegistry._tools = state["tools"] + ToolRegistry._enabled_defaults = state["defaults"] + ToolRegistry._plugin_tool_names = state["plugin_names"] + ToolRegistry._dynamic_modules = state["dynamic_modules"] + ToolRegistry._dynamic_tools_by_module = state["dynamic_tools"] + + +def test_plugin_refresh_restores_last_known_good_on_load_error( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + + def _failed_load(cls, errors): + replacement = _tool("partial_replacement", "plugin_py") + cls._tools[replacement.info.name] = replacement + cls._plugin_tool_names = [replacement.info.name] + errors.append("broken plugin") + + monkeypatch.setattr(ToolRegistry, "_load_plugin_tools", classmethod(_failed_load)) + + with pytest.raises(ToolRefreshError, match="broken plugin"): + ToolRegistry.refresh_plugin_tools() + + assert ToolRegistry._tools == {previous.info.name: previous} + assert ToolRegistry._plugin_tool_names == [previous.info.name] + + +def test_dynamic_refresh_restores_last_known_good_on_load_error( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("previous_dynamic", "custom") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._dynamic_modules = {"demo": "/old/demo.py"} + ToolRegistry._dynamic_tools_by_module = {"demo": [previous.info.name]} + + def _failed_load(cls, errors): + cls._tools.pop(previous.info.name) + replacement = _tool("partial_dynamic", "custom") + cls._tools[replacement.info.name] = replacement + cls._dynamic_modules = {"demo": "/new/demo.py"} + cls._dynamic_tools_by_module = {"demo": [replacement.info.name]} + errors.append("broken dynamic module") + + monkeypatch.setattr(ToolRegistry, "_register_dynamic_tools", classmethod(_failed_load)) + + with pytest.raises(ToolRefreshError, match="broken dynamic module"): + ToolRegistry.refresh_dynamic_tools() + + assert ToolRegistry._tools == {previous.info.name: previous} + assert ToolRegistry._dynamic_modules == {"demo": "/old/demo.py"} + assert ToolRegistry._dynamic_tools_by_module == {"demo": [previous.info.name]} + + +def test_refresh_transactions_are_serialized_across_stages( + monkeypatch: pytest.MonkeyPatch, +): + plugin_load_entered = threading.Event() + allow_plugin_failure = threading.Event() + dynamic_load_entered = threading.Event() + allow_dynamic_finish = threading.Event() + plugin_failures: list[Exception] = [] + dynamic_failures: list[Exception] = [] + + with _isolated_registry(): + base = _tool("base", "builtin") + ToolRegistry._tools[base.info.name] = base + + def _failed_plugin_load(cls, errors): + plugin_load_entered.set() + assert allow_plugin_failure.wait(timeout=2) + errors.append("broken plugin") + + def _successful_dynamic_load(cls, _errors): + dynamic_load_entered.set() + dynamic = _tool("new_dynamic", "custom") + cls._tools[dynamic.info.name] = dynamic + cls._dynamic_modules = {"demo": "/new/demo.py"} + cls._dynamic_tools_by_module = {"demo": [dynamic.info.name]} + assert allow_dynamic_finish.wait(timeout=2) + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_failed_plugin_load), + ) + monkeypatch.setattr( + ToolRegistry, + "_register_dynamic_tools", + classmethod(_successful_dynamic_load), + ) + monkeypatch.setattr(ToolRegistry, "_bump_revision", lambda _reason: None) + + def _refresh_plugin(): + try: + ToolRegistry.refresh_plugin_tools() + except Exception as exc: + plugin_failures.append(exc) + + def _refresh_dynamic(): + try: + ToolRegistry.refresh_dynamic_tools() + except Exception as exc: + dynamic_failures.append(exc) + + plugin_thread = threading.Thread(target=_refresh_plugin) + dynamic_thread = threading.Thread(target=_refresh_dynamic) + plugin_thread.start() + assert plugin_load_entered.wait(timeout=1) + dynamic_thread.start() + + # If transactions are not serialized, make the dynamic refresh commit + # before the plugin rollback to deterministically reproduce lost state. + dynamic_entered_while_plugin_blocked = dynamic_load_entered.wait(timeout=0.2) + if dynamic_entered_while_plugin_blocked: + allow_dynamic_finish.set() + dynamic_thread.join(timeout=1) + allow_plugin_failure.set() + else: + allow_plugin_failure.set() + allow_dynamic_finish.set() + + plugin_thread.join(timeout=1) + dynamic_thread.join(timeout=1) + + assert not plugin_thread.is_alive() + assert not dynamic_thread.is_alive() + assert len(plugin_failures) == 1 + assert isinstance(plugin_failures[0], ToolRefreshError) + assert dynamic_failures == [] + assert set(ToolRegistry._tools) == {"base", "new_dynamic"} + + +def test_registry_readers_do_not_observe_an_in_progress_refresh( + monkeypatch: pytest.MonkeyPatch, +): + load_entered = threading.Event() + allow_failure = threading.Event() + reader_finished = threading.Event() + reader_results: list[Tool | None] = [] + + with _isolated_registry(): + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + + def _failed_plugin_load(cls, errors): + load_entered.set() + assert allow_failure.wait(timeout=2) + errors.append("broken plugin") + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_failed_plugin_load), + ) + + refresh_thread = threading.Thread( + target=lambda: pytest.raises( + ToolRefreshError, + ToolRegistry.refresh_plugin_tools, + ), + ) + + def _read_plugin() -> None: + reader_results.append(ToolRegistry.get(previous.info.name)) + reader_finished.set() + + reader_thread = threading.Thread(target=_read_plugin) + refresh_thread.start() + assert load_entered.wait(timeout=1) + reader_thread.start() + + assert not reader_finished.wait(timeout=0.1) + allow_failure.set() + refresh_thread.join(timeout=1) + reader_thread.join(timeout=1) + + assert not refresh_thread.is_alive() + assert not reader_thread.is_alive() + assert reader_results == [previous] + + +def test_tool_extension_consumer_reports_invalid_specs(): + from flocks.plugin import PluginLoader + + extension_points_before = PluginLoader._extension_points.copy() + try: + PluginLoader.clear_extension_points() + ToolRegistry._register_plugin_extension_point() + extension = PluginLoader._extension_points["TOOLS"] + + errors = extension.consumer( + [ + {"name": "missing_handler"}, + {"name": "not_callable", "handler": 42}, + ], + "invalid_tools.py", + ) + + assert errors == [ + "tool definition requires name and handler", + "tool not_callable: handler must be callable", + ] + finally: + PluginLoader.clear_extension_points() + PluginLoader._extension_points.update(extension_points_before) + + +@pytest.mark.asyncio +async def test_refresh_route_reports_partial_and_total_stage_failures( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import tool as tool_routes + + monkeypatch.setattr(tool_routes.ToolRegistry, "init", lambda: None) + monkeypatch.setattr(tool_routes.ToolRegistry, "all_tool_ids", lambda: ["working"]) + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_dynamic_tools", + lambda: (_ for _ in ()).throw(ToolRefreshError("dynamic", ["bad dynamic"])), + ) + monkeypatch.setattr(tool_routes.ToolRegistry, "refresh_plugin_tools", lambda: ["working"]) + + partial = await tool_routes.refresh_tools(_admin=object()) + + assert partial.status == "partial" + assert partial.stages == {"dynamic": "error", "plugin": "success"} + assert partial.errors == ["dynamic: bad dynamic"] + + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_plugin_tools", + lambda: (_ for _ in ()).throw(ToolRefreshError("plugin", ["bad plugin"])), + ) + + failed = await tool_routes.refresh_tools(_admin=object()) + + assert failed.status == "error" + assert failed.stages == {"dynamic": "error", "plugin": "error"} + assert failed.errors == ["dynamic: bad dynamic", "plugin: bad plugin"] + + +@pytest.mark.asyncio +async def test_delete_tool_cleans_hub_record_when_registry_refresh_fails( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.hub import local as hub_local + from flocks.server.routes import tool as tool_routes + from flocks.tool import tool_loader + + removed_records: list[tuple[str, str]] = [] + monkeypatch.setattr(tool_routes.ToolRegistry, "init", lambda: None) + monkeypatch.setattr(tool_loader, "find_yaml_tool", lambda _name: object()) + monkeypatch.setattr(tool_loader, "delete_yaml_tool", lambda _name: True) + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_plugin_tools", + lambda: (_ for _ in ()).throw(ToolRefreshError("plugin", ["broken"])), + ) + monkeypatch.setattr( + hub_local, + "remove_installed_record", + lambda kind, name: removed_records.append((kind, name)), + ) + + response = await tool_routes.delete_tool("demo", _admin=object()) + + assert response["status"] == "partial" + assert response["errors"] == ["registry refresh: plugin: broken"] + assert removed_records == [("tool", "demo")] + + +@pytest.mark.asyncio +async def test_delete_tool_reports_hub_cleanup_failure_as_partial( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.hub import local as hub_local + from flocks.server.routes import tool as tool_routes + from flocks.tool import tool_loader + + monkeypatch.setattr(tool_routes.ToolRegistry, "init", lambda: None) + monkeypatch.setattr(tool_loader, "find_yaml_tool", lambda _name: object()) + monkeypatch.setattr(tool_loader, "delete_yaml_tool", lambda _name: True) + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_plugin_tools", + lambda: [], + ) + monkeypatch.setattr( + hub_local, + "remove_installed_record", + lambda _kind, _name: (_ for _ in ()).throw(RuntimeError("index locked")), + ) + + response = await tool_routes.delete_tool("demo", _admin=object()) + + assert response["status"] == "partial" + assert response["errors"] == ["Hub record cleanup: index locked"] + + +@pytest.mark.asyncio +async def test_delete_tool_reports_all_post_delete_cleanup_failures( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.hub import local as hub_local + from flocks.server.routes import tool as tool_routes + from flocks.tool import tool_loader + + monkeypatch.setattr(tool_routes.ToolRegistry, "init", lambda: None) + monkeypatch.setattr(tool_loader, "find_yaml_tool", lambda _name: object()) + monkeypatch.setattr(tool_loader, "delete_yaml_tool", lambda _name: True) + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_plugin_tools", + lambda: (_ for _ in ()).throw(ToolRefreshError("plugin", ["broken"])), + ) + monkeypatch.setattr( + hub_local, + "remove_installed_record", + lambda _kind, _name: (_ for _ in ()).throw(RuntimeError("index locked")), + ) + + response = await tool_routes.delete_tool("demo", _admin=object()) + + assert response["status"] == "partial" + assert response["errors"] == [ + "registry refresh: plugin: broken", + "Hub record cleanup: index locked", + ] diff --git a/uv.lock b/uv.lock index 95cf9d106..f1c30af20 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = "==3.12.*" resolution-markers = [ "sys_platform == 'win32'", @@ -9,25 +9,25 @@ resolution-markers = [ [[package]] name = "aiofiles" version = "25.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695" }, + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, ] [[package]] name = "aiohappyeyeballs" version = "2.6.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] name = "aiohttp" version = "3.13.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, @@ -37,90 +37,90 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416" }, - { url = "https://mirrors.aliyun.com/pypi/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, ] [[package]] name = "aiokafka" version = "0.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout" }, { name = "packaging" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1" } +sdist = { url = "https://files.pythonhosted.org/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1", size = 601374, upload-time = "2026-04-29T10:43:03.574Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a" }, + { url = "https://files.pythonhosted.org/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320", size = 348458, upload-time = "2026-04-29T10:42:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b", size = 351057, upload-time = "2026-04-29T10:42:38.69Z" }, + { url = "https://files.pythonhosted.org/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9", size = 1142238, upload-time = "2026-04-29T10:42:39.96Z" }, + { url = "https://files.pythonhosted.org/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e", size = 1131567, upload-time = "2026-04-29T10:42:41.542Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68", size = 312160, upload-time = "2026-04-29T10:42:42.811Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a", size = 331897, upload-time = "2026-04-29T10:42:43.984Z" }, ] [[package]] name = "aiosignal" version = "1.4.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "aiosqlite" version = "0.22.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb" }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] [[package]] name = "annotated-doc" version = "0.0.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] name = "anthropic" version = "0.86.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -131,304 +131,304 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57" }, + { url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" }, ] [[package]] name = "anyio" version = "4.13.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "asttokens" version = "3.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a" }, + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] [[package]] name = "async-timeout" version = "5.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] [[package]] name = "asyncssh" version = "2.22.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537", size = 540523, upload-time = "2025-12-21T23:38:30.5Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7", size = 374938, upload-time = "2025-12-21T23:38:28.976Z" }, ] [[package]] name = "attrs" version = "26.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "backoff" version = "2.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8" }, + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] [[package]] name = "beautifulsoup4" version = "4.14.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb" }, + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] [[package]] name = "cattrs" version = "26.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096" }, + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, ] [[package]] name = "cdp-use" version = "1.4.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0", size = 193961, upload-time = "2026-02-22T04:32:50.574Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb" }, + { url = "https://files.pythonhosted.org/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb", size = 350504, upload-time = "2026-02-22T04:32:49.22Z" }, ] [[package]] name = "certifi" version = "2026.2.25" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] name = "cffi" version = "2.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] name = "claude" version = "0.4.11" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fa/0d/40d26e7074d10f946ae06f6125de511ef0318598d7c3cc27c4ce9c9eb0a8/claude-0.4.11.tar.gz", hash = "sha256:a76ecc7e28869866c4353a39d176397a3b6964b543ef176363db5ea32726868d" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/0d/40d26e7074d10f946ae06f6125de511ef0318598d7c3cc27c4ce9c9eb0a8/claude-0.4.11.tar.gz", hash = "sha256:a76ecc7e28869866c4353a39d176397a3b6964b543ef176363db5ea32726868d", size = 820, upload-time = "2025-06-10T18:49:08.992Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/63/2b/aa9b6b6fbf63e094c3dfe2ec0010ddd952934647ee51e13a97186b91a44d/claude-0.4.11-py3-none-any.whl", hash = "sha256:369b5945461154bdcb9957f375944e9268ad343e15a924af2476a05b1c167478" }, + { url = "https://files.pythonhosted.org/packages/63/2b/aa9b6b6fbf63e094c3dfe2ec0010ddd952934647ee51e13a97186b91a44d/claude-0.4.11-py3-none-any.whl", hash = "sha256:369b5945461154bdcb9957f375944e9268ad343e15a924af2476a05b1c167478", size = 1167, upload-time = "2025-06-10T18:49:08.17Z" }, ] [[package]] name = "click" version = "8.1.8" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coloredlogs" version = "15.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "humanfriendly" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] [[package]] name = "coverage" version = "7.13.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422" }, - { url = "https://mirrors.aliyun.com/pypi/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [[package]] name = "croniter" version = "6.2.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960" }, + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, ] [[package]] name = "cryptography" version = "46.0.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, ] [[package]] name = "datasketch" version = "1.10.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "scipy" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714", size = 91699, upload-time = "2026-04-17T23:06:56.388Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7" }, + { url = "https://files.pythonhosted.org/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7", size = 99914, upload-time = "2026-04-17T23:06:54.39Z" }, ] [[package]] name = "ddddocr" version = "1.6.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "onnxruntime" }, @@ -436,73 +436,73 @@ dependencies = [ { name = "opencv-python-headless", marker = "sys_platform == 'linux'" }, { name = "pillow" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/07/5f/7c06bbb594b77062e6d0d43f06dc88668aaeb699c8737c84542aaa39da8c/ddddocr-1.6.1.tar.gz", hash = "sha256:1c59d84d63d8703c6c486465a32389c9e41dd92852c794c5e4c0181a5f82d43a" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5f/7c06bbb594b77062e6d0d43f06dc88668aaeb699c8737c84542aaa39da8c/ddddocr-1.6.1.tar.gz", hash = "sha256:1c59d84d63d8703c6c486465a32389c9e41dd92852c794c5e4c0181a5f82d43a", size = 75943309, upload-time = "2026-03-11T10:48:41.681Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0e/48/cbaed3981b8d8d51141b9b4779b811f4728e65d952a1e3e2e5e929539183/ddddocr-1.6.1-py3-none-any.whl", hash = "sha256:c7c70f4ae2d0335440ae8b272eea48c9f6888ecef46785fe2311f0c97a133935" }, + { url = "https://files.pythonhosted.org/packages/0e/48/cbaed3981b8d8d51141b9b4779b811f4728e65d952a1e3e2e5e929539183/ddddocr-1.6.1-py3-none-any.whl", hash = "sha256:c7c70f4ae2d0335440ae8b272eea48c9f6888ecef46785fe2311f0c97a133935", size = 75983593, upload-time = "2026-03-11T10:48:23.702Z" }, ] [[package]] name = "decorator" version = "5.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a" }, + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] [[package]] name = "defusedxml" version = "0.7.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] [[package]] name = "dingtalk-stream" version = "0.24.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "requests" }, { name = "websockets" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad" }, + { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" }, ] [[package]] name = "distro" version = "1.9.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] name = "docstring-parser" version = "0.17.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708" }, + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] [[package]] name = "executing" version = "2.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] name = "fastapi" version = "0.135.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, @@ -510,45 +510,45 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] [[package]] name = "fastuuid" version = "0.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, ] [[package]] name = "filelock" version = "3.25.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] name = "flatbuffers" version = "25.12.19" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] @@ -695,72 +695,72 @@ dev = [ [[package]] name = "frozenlist" version = "1.8.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "fsspec" version = "2026.2.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437" }, + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] [[package]] name = "gitdb" version = "4.0.12" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smmap" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf" }, + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, ] [[package]] name = "gitpython" version = "3.1.46" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f" } +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058" }, + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, ] [[package]] name = "google-auth" version = "2.49.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, ] [package.optional-dependencies] @@ -771,7 +771,7 @@ requests = [ [[package]] name = "google-genai" version = "1.68.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -784,105 +784,106 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15" }, + { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" }, ] [[package]] name = "googleapis-common-protos" version = "1.73.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a" } +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8" }, + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, ] [[package]] name = "greenlet" version = "3.3.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, ] [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "hf-xet" version = "1.4.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea", size = 672357, upload-time = "2026-03-13T06:58:51.077Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87" }, + { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847, upload-time = "2026-03-13T06:58:25.989Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843, upload-time = "2026-03-13T06:58:44.59Z" }, + { url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751, upload-time = "2026-03-13T06:58:46.533Z" }, + { url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149, upload-time = "2026-03-13T06:58:57.07Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" }, ] [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] name = "httptools" version = "0.7.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03" }, - { url = "https://mirrors.aliyun.com/pypi/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, ] [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [package.optional-dependencies] @@ -893,16 +894,16 @@ socks = [ [[package]] name = "httpx-sse" version = "0.4.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] name = "huggingface-hub" version = "1.7.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "fsspec" }, @@ -914,57 +915,57 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b" } +sdist = { url = "https://files.pythonhosted.org/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b", size = 724684, upload-time = "2026-03-20T10:36:08.767Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e" }, + { url = "https://files.pythonhosted.org/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e", size = 618036, upload-time = "2026-03-20T10:36:06.824Z" }, ] [[package]] name = "humanfriendly" version = "10.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyreadline3", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477" }, + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, ] [[package]] name = "idna" version = "3.11" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "importlib-metadata" version = "8.5.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b" }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "ipython" version = "9.11.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "decorator" }, @@ -977,103 +978,103 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667" } +sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19" }, + { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, ] [[package]] name = "ipython-pygments-lexers" version = "1.1.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c" }, + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] [[package]] name = "jedi" version = "0.19.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0" } +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9" }, + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "jiter" version = "0.13.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152" }, - { url = "https://mirrors.aliyun.com/pypi/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] [[package]] name = "jsonschema" version = "4.23.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4" } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566" }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, ] [[package]] name = "jsonschema-specifications" version = "2025.9.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[package]] name = "langfuse" version = "4.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, { name = "httpx" }, @@ -1085,15 +1086,15 @@ dependencies = [ { name = "pydantic" }, { name = "wrapt" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e", size = 272749, upload-time = "2026-03-19T14:03:34.508Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524" }, + { url = "https://files.pythonhosted.org/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524", size = 465049, upload-time = "2026-03-19T14:03:32.527Z" }, ] [[package]] name = "lark-oapi" version = "1.5.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pycryptodome" }, @@ -1102,34 +1103,34 @@ dependencies = [ { name = "websockets" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36" }, + { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, ] [[package]] name = "librt" version = "0.8.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440" }, - { url = "https://mirrors.aliyun.com/pypi/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, ] [[package]] name = "litellm" version = "1.83.7" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, @@ -1144,80 +1145,80 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633" } +sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694, upload-time = "2026-04-13T17:35:01.606Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111" }, + { url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" }, ] [[package]] name = "lsprotocol" version = "2025.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896, upload-time = "2025-06-17T21:30:18.156Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7" }, + { url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250, upload-time = "2025-06-17T21:30:19.455Z" }, ] [[package]] name = "magika" version = "0.6.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "numpy" }, { name = "onnxruntime" }, { name = "python-dotenv" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784, upload-time = "2025-10-30T15:22:34.499Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3" }, + { url = "https://files.pythonhosted.org/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d", size = 2969474, upload-time = "2025-10-30T15:22:25.298Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b", size = 13358841, upload-time = "2025-10-30T15:22:27.413Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64", size = 15367335, upload-time = "2025-10-30T15:22:29.907Z" }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3", size = 12692831, upload-time = "2025-10-30T15:22:32.063Z" }, ] [[package]] name = "markdown" version = "3.10.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36" }, + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] name = "markdown-it-py" version = "4.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "markdownify" version = "1.2.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, { name = "six" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a" }, + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, ] [[package]] name = "markitdown" version = "0.1.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, { name = "charset-normalizer" }, @@ -1226,46 +1227,46 @@ dependencies = [ { name = "markdownify" }, { name = "requests" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a" } +sdist = { url = "https://files.pythonhosted.org/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a", size = 45099, upload-time = "2026-02-20T19:45:23.886Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b1/8b/fd7e042455a829a1ede0bc8e9e3061aa6c7c4cf745385526ef62ff1b5a5b/markitdown-0.1.5-py3-none-any.whl", hash = "sha256:5180a9a841e20fc01c2c09dbc5d039638429bbebcdc2af1b2615c3c427840434" }, + { url = "https://files.pythonhosted.org/packages/b1/8b/fd7e042455a829a1ede0bc8e9e3061aa6c7c4cf745385526ef62ff1b5a5b/markitdown-0.1.5-py3-none-any.whl", hash = "sha256:5180a9a841e20fc01c2c09dbc5d039638429bbebcdc2af1b2615c3c427840434", size = 63402, upload-time = "2026-02-20T19:45:27.195Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, ] [[package]] name = "matplotlib-inline" version = "0.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76" }, + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] [[package]] name = "mcp" version = "1.26.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpx" }, @@ -1282,118 +1283,118 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "mpmath" version = "1.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] [[package]] name = "multidict" version = "6.7.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] name = "mypy" version = "1.19.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "numpy" version = "2.4.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, ] [[package]] name = "olefile" version = "0.47" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f" }, + { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, ] [[package]] name = "onnxruntime" version = "1.20.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coloredlogs" }, { name = "flatbuffers" }, @@ -1403,17 +1404,17 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb" }, + { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580, upload-time = "2024-11-21T00:49:07.029Z" }, + { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833, upload-time = "2024-11-21T00:49:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903, upload-time = "2024-11-21T00:49:12.984Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562, upload-time = "2024-11-21T00:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482, upload-time = "2024-11-21T00:49:19.412Z" }, ] [[package]] name = "openai" version = "2.30.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -1424,68 +1425,68 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885" } +sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d" }, + { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, ] [[package]] name = "opencv-python" version = "4.13.0.92" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19" }, - { url = "https://mirrors.aliyun.com/pypi/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5" }, + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] [[package]] name = "opencv-python-headless" version = "4.13.0.92" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "sys_platform != 'win32'" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e" }, + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, ] [[package]] name = "opentelemetry-api" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9" }, + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa" } +sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "opentelemetry-api" }, @@ -1495,323 +1496,323 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, ] [[package]] name = "opentelemetry-proto" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, ] [[package]] name = "opentelemetry-sdk" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2" } +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1" }, + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" version = "0.61b0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2" }, + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] [[package]] name = "packaging" version = "25.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "parso" version = "0.8.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff" }, + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, ] [[package]] name = "pathspec" version = "1.0.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] name = "pexpect" version = "4.9.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" } +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523" }, + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] [[package]] name = "pillow" version = "12.2.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, ] [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "prompt-toolkit" version = "3.0.52" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955" }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] name = "propcache" version = "0.4.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] [[package]] name = "protobuf" version = "6.33.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] name = "ptyprocess" version = "0.7.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35" }, + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] [[package]] name = "pure-eval" version = "0.2.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0" }, + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] [[package]] name = "pyasn1" version = "0.6.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] name = "pyasn1-modules" version = "0.4.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a" }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] name = "pycparser" version = "3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pycryptodome" version = "3.23.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] [[package]] name = "pydantic" version = "2.12.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] name = "pydantic-core" version = "2.41.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69" }, - { url = "https://mirrors.aliyun.com/pypi/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05" }, - { url = "https://mirrors.aliyun.com/pypi/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b" }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] [[package]] name = "pydantic-settings" version = "2.13.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] name = "pygls" version = "2.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, { name = "lsprotocol" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/39/06/de0a8db764391f1b3ab8ba4eaa6d1fe923151a1999350c15b987b46a3718/pygls-2.1.0.tar.gz", hash = "sha256:3f2247717deeda9174d9c2f76130ff4d3e0e0788a5be47212df248d163453aac" } +sdist = { url = "https://files.pythonhosted.org/packages/39/06/de0a8db764391f1b3ab8ba4eaa6d1fe923151a1999350c15b987b46a3718/pygls-2.1.0.tar.gz", hash = "sha256:3f2247717deeda9174d9c2f76130ff4d3e0e0788a5be47212df248d163453aac", size = 54852, upload-time = "2026-03-19T09:16:56.853Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl", hash = "sha256:cfa8443561488cb15b59f6ce64cabfa37d79753f7120c1bf729419246bf747f9" }, + { url = "https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl", hash = "sha256:cfa8443561488cb15b59f6ce64cabfa37d79753f7120c1bf729419246bf747f9", size = 68719, upload-time = "2026-03-19T09:16:57.961Z" }, ] [[package]] name = "pygments" version = "2.19.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyjwt" version = "2.12.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -1822,40 +1823,40 @@ crypto = [ [[package]] name = "pymupdf" version = "1.27.2.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335", size = 85354380, upload-time = "2026-03-20T09:47:58.386Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b" }, + { url = "https://files.pythonhosted.org/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0", size = 23987563, upload-time = "2026-03-19T12:35:42.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73", size = 23263089, upload-time = "2026-03-20T09:44:16.982Z" }, + { url = "https://files.pythonhosted.org/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b", size = 24318817, upload-time = "2026-03-20T09:44:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e", size = 24948135, upload-time = "2026-03-20T09:44:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6", size = 25169585, upload-time = "2026-03-20T09:45:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30", size = 18008861, upload-time = "2026-03-20T09:45:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b", size = 19238032, upload-time = "2026-03-20T09:45:37.941Z" }, ] [[package]] name = "pypdf" version = "6.9.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844" }, + { url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" }, ] [[package]] name = "pyreadline3" version = "3.5.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] [[package]] name = "pytest" version = "9.0.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -1863,560 +1864,560 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-asyncio" version = "1.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] name = "pytest-cov" version = "7.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-dotenv" version = "1.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, ] [[package]] name = "python-multipart" version = "0.0.22" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] name = "python-socks" version = "2.8.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909, upload-time = "2026-02-16T05:24:00.745Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035" }, + { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" }, ] [[package]] name = "pytz" version = "2026.1.post1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a" }, + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] [[package]] name = "pywin32" version = "311" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, ] [[package]] name = "referencing" version = "0.37.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "regex" version = "2026.2.28" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, ] [[package]] name = "requests" version = "2.32.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "requests-toolbelt" version = "1.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] [[package]] name = "rich" version = "14.3.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "rpds-py" version = "0.30.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, ] [[package]] name = "ruff" version = "0.15.7" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] [[package]] name = "scipy" version = "1.17.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086" }, - { url = "https://mirrors.aliyun.com/pypi/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, ] [[package]] name = "shellingham" version = "1.5.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] name = "six" version = "1.17.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "smmap" version = "5.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f" }, + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] [[package]] name = "sniffio" version = "1.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "socksio" version = "1.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, ] [[package]] name = "soupsieve" version = "2.8.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] name = "sqlalchemy" version = "2.0.48" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096" }, + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] [[package]] name = "sse-starlette" version = "3.3.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049" } +sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d" }, + { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, ] [[package]] name = "stack-data" version = "0.6.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, { name = "executing" }, { name = "pure-eval" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9" } +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" }, + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] [[package]] name = "starlette" version = "1.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6" } +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89" }, + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] [[package]] name = "striprtf" version = "0.0.29" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/86/7154b7c625a3ff704581dab70c05389e1de90233b7a751f79f712c2ca0e9/striprtf-0.0.29.tar.gz", hash = "sha256:5a822d075e17417934ed3add6fc79b5fc8fb544fe4370b2f894cdd28f0ddd78e" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/86/7154b7c625a3ff704581dab70c05389e1de90233b7a751f79f712c2ca0e9/striprtf-0.0.29.tar.gz", hash = "sha256:5a822d075e17417934ed3add6fc79b5fc8fb544fe4370b2f894cdd28f0ddd78e", size = 7533, upload-time = "2025-03-27T22:55:56.874Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/08/3e/1418afacc4aae04690cff282078f22620c89a99490499878ececc3021654/striprtf-0.0.29-py3-none-any.whl", hash = "sha256:0fc6a41999d015358d19627776b616424dd501ad698105c81d76734d1e14d91b" }, + { url = "https://files.pythonhosted.org/packages/08/3e/1418afacc4aae04690cff282078f22620c89a99490499878ececc3021654/striprtf-0.0.29-py3-none-any.whl", hash = "sha256:0fc6a41999d015358d19627776b616424dd501ad698105c81d76734d1e14d91b", size = 7879, upload-time = "2025-03-27T22:55:55.977Z" }, ] [[package]] name = "sympy" version = "1.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mpmath" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517" } +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5" }, + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] [[package]] name = "tenacity" version = "9.1.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] name = "tiktoken" version = "0.12.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd" }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, ] [[package]] name = "tokenizers" version = "0.22.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917" } +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48" }, - { url = "https://mirrors.aliyun.com/pypi/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc" }, + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] [[package]] name = "toml" version = "0.10.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" }, + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, ] [[package]] name = "tqdm" version = "4.67.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] name = "traitlets" version = "5.14.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f" }, + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] [[package]] name = "tree-sitter" version = "0.25.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99" }, - { url = "https://mirrors.aliyun.com/pypi/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5" }, + { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, + { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, ] [[package]] name = "typer" version = "0.23.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e" }, + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "ulid-py" version = "1.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3b/53/d14a8ec344048e21431821cb49e9a6722384f982b889c2dd449428dbdcc1/ulid-py-1.1.0.tar.gz", hash = "sha256:dc6884be91558df077c3011b9fb0c87d1097cb8fc6534b11f310161afd5738f0" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/53/d14a8ec344048e21431821cb49e9a6722384f982b889c2dd449428dbdcc1/ulid-py-1.1.0.tar.gz", hash = "sha256:dc6884be91558df077c3011b9fb0c87d1097cb8fc6534b11f310161afd5738f0", size = 22514, upload-time = "2020-09-15T15:35:09.414Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/42/7c/a12c879fe6c2b136a718c142115ff99397fbf62b4929d970d58ae386d55f/ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987" }, + { url = "https://files.pythonhosted.org/packages/42/7c/a12c879fe6c2b136a718c142115ff99397fbf62b4929d970d58ae386d55f/ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987", size = 25753, upload-time = "2020-09-15T15:35:08.075Z" }, ] [[package]] name = "urllib3" version = "2.6.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] name = "uvicorn" version = "0.42.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [package.optional-dependencies] @@ -2433,159 +2434,159 @@ standard = [ [[package]] name = "uvloop" version = "0.22.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, ] [[package]] name = "watchdog" version = "6.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948" }, - { url = "https://mirrors.aliyun.com/pypi/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] [[package]] name = "watchfiles" version = "1.1.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, ] [[package]] name = "wcwidth" version = "0.6.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad" }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] [[package]] name = "websockets" version = "16.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79" }, - { url = "https://mirrors.aliyun.com/pypi/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "wecom-aibot-sdk" version = "1.0.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "websockets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/04/f3/3809ff4562145094f29e96e8b4e0267a54941c40d26579a82d9c5928ad33/wecom_aibot_sdk-1.0.5.tar.gz", hash = "sha256:78b0d748f945fd877ca94a8e369d13033028dc3d758fe1635945df31bf984e1a" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f3/3809ff4562145094f29e96e8b4e0267a54941c40d26579a82d9c5928ad33/wecom_aibot_sdk-1.0.5.tar.gz", hash = "sha256:78b0d748f945fd877ca94a8e369d13033028dc3d758fe1635945df31bf984e1a", size = 58176, upload-time = "2026-03-24T04:05:34.801Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/25/c2/e3a681e0cbaa4394fca003aa0f47a212665264ae49c343e144b458dd5ef0/wecom_aibot_sdk-1.0.5-py3-none-any.whl", hash = "sha256:3f23c7eca0bf8092244840c4563d7fda6aa6741604ff4c329f85c2e6bb47bf05" }, + { url = "https://files.pythonhosted.org/packages/25/c2/e3a681e0cbaa4394fca003aa0f47a212665264ae49c343e144b458dd5ef0/wecom_aibot_sdk-1.0.5-py3-none-any.whl", hash = "sha256:3f23c7eca0bf8092244840c4563d7fda6aa6741604ff4c329f85c2e6bb47bf05", size = 26229, upload-time = "2026-03-24T04:05:33.298Z" }, ] [[package]] name = "wrapt" version = "1.17.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]] name = "yarl" version = "1.23.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] [[package]] name = "zipp" version = "3.23.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e" }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] diff --git a/webui/public/channel-whatsapp.png b/webui/public/channel-whatsapp.png new file mode 100644 index 000000000..338198d43 Binary files /dev/null and b/webui/public/channel-whatsapp.png differ diff --git a/webui/src/api/hub.ts b/webui/src/api/hub.ts index 32c50a576..b65dce47e 100644 --- a/webui/src/api/hub.ts +++ b/webui/src/api/hub.ts @@ -112,12 +112,40 @@ export interface HubCatalogParams { q?: string; } +export interface HubCatalogPageParams extends HubCatalogParams { + offset?: number; + limit?: number; +} + +export interface HubCatalogFacets { + type: Record; + category: Record; + tags: Record; + useCases: Record; + state: Record; + trust: Record; + riskLevel: Record; +} + +export interface HubCatalogPageResponse { + items: HubCatalogEntry[]; + total: number; + offset: number; + limit: number; + facets: HubCatalogFacets; +} + export const hubAPI = { catalog: (params?: HubCatalogParams) => client.get('/api/hub/catalog', { params }), - categories: () => - client.get('/api/hub/categories'), + catalogPage: (params?: HubCatalogPageParams) => + client.get('/api/hub/catalog', { params }), + + categories: (params?: { includeCounts?: boolean }) => + client.get('/api/hub/categories', { + params: params ? { include_counts: params.includeCounts } : undefined, + }), get: (type: HubPluginType, id: string) => client.get(`/api/hub/plugins/${type}/${id}`), diff --git a/webui/src/api/monitoring.ts b/webui/src/api/monitoring.ts index e74a2c22d..564169cf4 100644 --- a/webui/src/api/monitoring.ts +++ b/webui/src/api/monitoring.ts @@ -3,19 +3,20 @@ import client from './client'; export interface SystemStatus { status: 'healthy' | 'degraded' | 'down'; uptime: number; - activeSessions: number; - activeAgents: number; + activeSessions: number | null; + activeAgents: number | null; mcpServers: Record; timestamp: number; } export interface MetricsSnapshot { timestamp: number; - messageRate: number; - toolCallRate: number; - errorRate: number; - avgResponseTime: number; - activeRequests: number; + messageRate: number | null; + toolCallRate: number | null; + errorRate: number | null; + toolParseFailureRate: number | null; + avgResponseTime: number | null; + activeRequests: number | null; } export interface PerformanceData { diff --git a/webui/src/api/provider.test.ts b/webui/src/api/provider.test.ts new file mode 100644 index 000000000..1e2cda5ce --- /dev/null +++ b/webui/src/api/provider.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPatch = vi.fn(); +const mockDelete = vi.fn(); +const mockPut = vi.fn(); + +vi.mock('./client', () => ({ + default: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + patch: (...args: unknown[]) => mockPatch(...args), + delete: (...args: unknown[]) => mockDelete(...args), + put: (...args: unknown[]) => mockPut(...args), + }, +})); + +function serviceSummary(id: string, name: string) { + return { + id, + name, + enabled: true, + status: 'unknown', + tool_count: 0, + verify_ssl: false, + }; +} + +describe('providerAPI.listApiServices', () => { + afterEach(() => { + vi.resetModules(); + vi.useRealTimers(); + mockGet.mockReset(); + mockPost.mockReset(); + mockPatch.mockReset(); + mockDelete.mockReset(); + mockPut.mockReset(); + }); + + it('shares concurrent API service list requests', async () => { + let resolveRequest: (value: { data: ReturnType[] }) => void = () => undefined; + mockGet.mockImplementation( + () => new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + + const { providerAPI } = await import('./provider'); + const first = providerAPI.listApiServices(); + const second = providerAPI.listApiServices(); + + expect(mockGet).toHaveBeenCalledTimes(1); + expect(mockGet).toHaveBeenCalledWith('/api/provider/api-services'); + + resolveRequest({ data: [serviceSummary('svc-a', 'Service A')] }); + const [firstResponse, secondResponse] = await Promise.all([first, second]); + + firstResponse.data[0].name = 'Mutated'; + expect(secondResponse.data[0].name).toBe('Service A'); + }); + + it('serves a fresh cached response without another request', async () => { + mockGet.mockResolvedValue({ data: [serviceSummary('svc-a', 'Service A')] }); + + const { providerAPI } = await import('./provider'); + const firstResponse = await providerAPI.listApiServices(); + firstResponse.data[0].name = 'Mutated'; + const secondResponse = await providerAPI.listApiServices(); + + expect(mockGet).toHaveBeenCalledTimes(1); + expect(secondResponse.data[0].name).toBe('Service A'); + }); + + it('invalidates the cached list after an API service update', async () => { + mockGet + .mockResolvedValueOnce({ data: [serviceSummary('svc-a', 'Service A')] }) + .mockResolvedValueOnce({ data: [serviceSummary('svc-a', 'Service A Updated')] }); + mockPatch.mockResolvedValue({ data: serviceSummary('svc-a', 'Service A Updated') }); + + const { providerAPI } = await import('./provider'); + await providerAPI.listApiServices(); + await providerAPI.updateApiService('svc-a', { enabled: false }); + const response = await providerAPI.listApiServices(); + + expect(mockPatch).toHaveBeenCalledWith('/api/provider/api-services/svc-a', { enabled: false }); + expect(mockGet).toHaveBeenCalledTimes(2); + expect(response.data[0].name).toBe('Service A Updated'); + }); + + it('does not let an older list request repopulate the cache after a mutation', async () => { + let resolveOldList: (value: { data: ReturnType[] }) => void = () => undefined; + mockGet + .mockImplementationOnce(() => new Promise((resolve) => { + resolveOldList = resolve; + })) + .mockResolvedValueOnce({ data: [serviceSummary('svc-a', 'Fresh Service')] }); + mockPatch.mockResolvedValue({ data: serviceSummary('svc-a', 'Fresh Service') }); + + const { providerAPI } = await import('./provider'); + const oldRequest = providerAPI.listApiServices(); + await providerAPI.updateApiService('svc-a', { enabled: false }); + const freshRequest = providerAPI.listApiServices(); + resolveOldList({ data: [serviceSummary('svc-a', 'Stale Service')] }); + await Promise.all([oldRequest, freshRequest]); + + const response = await providerAPI.listApiServices(); + + expect(mockGet).toHaveBeenCalledTimes(2); + expect(response.data[0].name).toBe('Fresh Service'); + }); + + it('keeps a forced request authoritative when an older request finishes later', async () => { + let resolveOldList: (value: { data: ReturnType[] }) => void = () => undefined; + let resolveForcedList: (value: { data: ReturnType[] }) => void = () => undefined; + mockGet + .mockImplementationOnce(() => new Promise((resolve) => { + resolveOldList = resolve; + })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveForcedList = resolve; + })); + + const { providerAPI } = await import('./provider'); + const oldRequest = providerAPI.listApiServices(); + const forcedRequest = providerAPI.listApiServices({ force: true }); + + resolveForcedList({ data: [serviceSummary('svc-a', 'Forced Service')] }); + await forcedRequest; + resolveOldList({ data: [serviceSummary('svc-a', 'Stale Service')] }); + await oldRequest; + + const response = await providerAPI.listApiServices(); + + expect(mockGet).toHaveBeenCalledTimes(2); + expect(response.data[0].name).toBe('Forced Service'); + }); +}); diff --git a/webui/src/api/provider.ts b/webui/src/api/provider.ts index 5eb77ecb6..a35235f42 100644 --- a/webui/src/api/provider.ts +++ b/webui/src/api/provider.ts @@ -16,6 +16,71 @@ import type { APIServiceMetadata, } from '@/types'; +const API_SERVICES_LIST_CACHE_TTL_MS = 5000; + +function requestApiServicesList() { + return client.get('/api/provider/api-services'); +} + +type APIServiceListResponse = Awaited>; + +let apiServicesListInFlight: Promise | null = null; +let apiServicesListInFlightGeneration: number | null = null; +let apiServicesListCache: { response: APIServiceListResponse; updatedAt: number } | null = null; +let apiServicesListGeneration = 0; + +function cloneApiServicesResponse(response: APIServiceListResponse): APIServiceListResponse { + const services = Array.isArray(response.data) ? response.data : []; + return { + ...response, + data: services.map((service) => ({ ...service })), + }; +} + +function invalidateApiServicesListCache(): void { + apiServicesListGeneration += 1; + apiServicesListCache = null; +} + +function listApiServicesCached(options: { force?: boolean } = {}) { + const useCache = !options.force; + if (useCache && apiServicesListCache && Date.now() - apiServicesListCache.updatedAt < API_SERVICES_LIST_CACHE_TTL_MS) { + return Promise.resolve(cloneApiServicesResponse(apiServicesListCache.response)); + } + if ( + useCache + && apiServicesListInFlight + && apiServicesListInFlightGeneration === apiServicesListGeneration + ) { + return apiServicesListInFlight.then(cloneApiServicesResponse); + } + + if (options.force) { + invalidateApiServicesListCache(); + } + const requestGeneration = apiServicesListGeneration; + const request = requestApiServicesList() + .then((response) => { + if (requestGeneration === apiServicesListGeneration) { + apiServicesListCache = { + response: cloneApiServicesResponse(response), + updatedAt: Date.now(), + }; + } + return response; + }) + .finally(() => { + if (apiServicesListInFlight === request) { + apiServicesListInFlight = null; + apiServicesListInFlightGeneration = null; + } + }); + apiServicesListInFlight = request; + apiServicesListInFlightGeneration = requestGeneration; + + return request.then(cloneApiServicesResponse); +} + // ==================== Provider API (Legacy + Enhanced) ==================== export const providerAPI = { @@ -54,7 +119,11 @@ export const providerAPI = { client.get(`/api/provider/${id}/service-credentials`), setServiceCredentials: (id: string, credentials: ProviderCredentialInput) => - client.post<{ success: boolean; message: string }>(`/api/provider/${id}/service-credentials`, credentials), + client.post<{ success: boolean; message: string }>(`/api/provider/${id}/service-credentials`, credentials) + .then((response) => { + invalidateApiServicesListCache(); + return response; + }), deleteCredentials: (id: string) => client.delete<{ success: boolean }>(`/api/provider/${id}/credentials`), @@ -70,17 +139,25 @@ export const providerAPI = { ), // API service status (connectivity) - listApiServices: () => - client.get('/api/provider/api-services'), + listApiServices: (options?: { force?: boolean }) => + listApiServicesCached(options), getServiceMetadata: (id: string) => client.get(`/api/provider/${id}/metadata`), updateApiService: (id: string, data: { enabled: boolean; verify_ssl?: boolean }) => - client.patch(`/api/provider/api-services/${id}`, data), + client.patch(`/api/provider/api-services/${id}`, data) + .then((response) => { + invalidateApiServicesListCache(); + return response; + }), deleteApiService: (id: string) => - client.delete<{ success: boolean }>(`/api/provider/api-services/${id}`), + client.delete<{ success: boolean }>(`/api/provider/api-services/${id}`) + .then((response) => { + invalidateApiServicesListCache(); + return response; + }), getApiServiceStatuses: () => client.get>( @@ -90,7 +167,10 @@ export const providerAPI = { refreshApiServiceStatuses: () => client.post<{ statuses: Record; refreshed_at: number }>( '/api/provider/api-services/refresh' - ), + ).then((response) => { + invalidateApiServicesListCache(); + return response; + }), getApiServiceStatus: (id: string) => client.get<{ status: string; message?: string; latency_ms?: number; tool_tested?: string; error?: string; checked_at?: number }>( diff --git a/webui/src/api/stats.test.ts b/webui/src/api/stats.test.ts index 9f9dc98db..7407e93ba 100644 --- a/webui/src/api/stats.test.ts +++ b/webui/src/api/stats.test.ts @@ -9,6 +9,7 @@ vi.mock('./client', () => ({ // Helper: build a default mock for every endpoint except /api/skills and /api/agent. function defaultMock(skillsData: unknown[], agentsData: unknown[] = []) { mockGet.mockImplementation((url: string) => { + if (url === '/api/stats/summary') return Promise.reject(new Error('legacy backend')); if (url === '/api/skills') return Promise.resolve({ data: skillsData }); if (url === '/api/task-system/dashboard') return Promise.resolve({ data: {} }); if (url === '/api/agent') return Promise.resolve({ data: agentsData }); @@ -23,6 +24,26 @@ function defaultMock(skillsData: unknown[], agentsData: unknown[] = []) { describe('statsApi.getSystemStats', () => { beforeEach(() => vi.clearAllMocks()); + it('uses the lightweight summary endpoint when available', async () => { + const summary = { + tasks: { week: 3, scheduledActive: 1 }, + agents: { total: 2 }, + workflows: { total: 4 }, + skills: { total: 5 }, + tools: { total: 6 }, + models: { total: 7 }, + system: { status: 'healthy', message: 'ok' }, + }; + mockGet.mockResolvedValue({ data: summary }); + + const { statsApi } = await import('./stats'); + const result = await statsApi.getSystemStats(); + + expect(result).toEqual(summary); + expect(mockGet).toHaveBeenCalledTimes(1); + expect(mockGet).toHaveBeenCalledWith('/api/stats/summary'); + }); + it('counts only non-system skills', async () => { defaultMock([ { category: 'custom' }, @@ -62,6 +83,7 @@ describe('statsApi.getSystemStats', () => { it('handles skills API failure gracefully (returns 0)', async () => { mockGet.mockImplementation((url: string) => { + if (url === '/api/stats/summary') return Promise.reject(new Error('legacy backend')); if (url === '/api/skills') return Promise.reject(new Error('network')); if (url === '/api/task-system/dashboard') return Promise.resolve({ data: {} }); if (url === '/api/agent') return Promise.resolve({ data: [] }); diff --git a/webui/src/api/stats.ts b/webui/src/api/stats.ts index 363fb8095..89ad2130f 100644 --- a/webui/src/api/stats.ts +++ b/webui/src/api/stats.ts @@ -32,61 +32,87 @@ function shouldCountForAgentPage(agent: any): boolean { return !Array.isArray(agent.tags) || !agent.tags.includes('system'); } +function isSystemStats(value: any): value is SystemStats { + return Boolean( + value && + typeof value === 'object' && + value.tasks && + value.agents && + value.workflows && + value.skills && + value.tools && + value.models && + value.system + ); +} + +async function getSystemStatsLegacy(): Promise { + const [taskDash, agents, workflows, skills, tools, providers, health] = await Promise.all([ + apiClient.get('/api/task-system/dashboard').catch(() => ({ data: {} })), + apiClient.get('/api/agent').catch(() => ({ data: [] })), + apiClient.get('/api/workflow').catch(() => ({ data: [] })), + apiClient.get('/api/skills').catch(() => ({ data: [] })), + apiClient.get('/api/tools').catch(() => ({ data: [] })), + apiClient.get('/api/provider').catch(() => ({ data: { all: [] } })), + apiClient.get('/api/health').catch(() => ({ data: { status: 'error' } })), + ]); + + const dash = taskDash.data || {}; + const agentList = (Array.isArray(agents.data) ? agents.data : []).filter(shouldCountForAgentPage); + const workflowList = Array.isArray(workflows.data) ? workflows.data : []; + // Exclude `system` category skills so the count matches the Skills page, + // which hides system skills (e.g. onboarding) from the user. + const skillList = (Array.isArray(skills.data) ? skills.data : []).filter( + (s: any) => s?.category !== 'system' + ); + const toolList = Array.isArray(tools.data) ? tools.data : []; + const providerData = providers.data ?? {}; + const providerAll: any[] = providerData.all ?? (Array.isArray(providers.data) ? providers.data : []); + const connectedSet = new Set(providerData.connected ?? []); + const totalModels = providerAll + .filter((p: any) => connectedSet.has(p.id)) + .reduce((sum: number, p: any) => sum + Object.keys(p.models ?? {}).length, 0); + + return { + tasks: { + week: (dash.completed_week ?? 0) + (dash.failed_week ?? 0), + scheduledActive: dash.scheduled_active ?? 0, + }, + agents: { total: agentList.length }, + workflows: { total: workflowList.length }, + skills: { total: skillList.length }, + tools: { total: toolList.length }, + models: { total: totalModels }, + system: { + status: health.data.status === 'healthy' ? 'healthy' : 'error', + message: health.data.status === 'healthy' ? '所有服务运行正常' : '部分服务异常', + }, + }; +} + export const statsApi = { getSystemStats: async (): Promise => { try { - const [taskDash, agents, workflows, skills, tools, providers, health] = await Promise.all([ - apiClient.get('/api/task-system/dashboard').catch(() => ({ data: {} })), - apiClient.get('/api/agent').catch(() => ({ data: [] })), - apiClient.get('/api/workflow').catch(() => ({ data: [] })), - apiClient.get('/api/skills').catch(() => ({ data: [] })), - apiClient.get('/api/tools').catch(() => ({ data: [] })), - apiClient.get('/api/provider').catch(() => ({ data: { all: [] } })), - apiClient.get('/api/health').catch(() => ({ data: { status: 'error' } })), - ]); - - const dash = taskDash.data || {}; - const agentList = (Array.isArray(agents.data) ? agents.data : []).filter(shouldCountForAgentPage); - const workflowList = Array.isArray(workflows.data) ? workflows.data : []; - // Exclude `system` category skills so the count matches the Skills page, - // which hides system skills (e.g. onboarding) from the user. - const skillList = (Array.isArray(skills.data) ? skills.data : []).filter( - (s: any) => s?.category !== 'system' - ); - const toolList = Array.isArray(tools.data) ? tools.data : []; - const providerData = providers.data ?? {}; - const providerAll: any[] = providerData.all ?? (Array.isArray(providers.data) ? providers.data : []); - const connectedSet = new Set(providerData.connected ?? []); - const totalModels = providerAll - .filter((p: any) => connectedSet.has(p.id)) - .reduce((sum: number, p: any) => sum + Object.keys(p.models ?? {}).length, 0); - - return { - tasks: { - week: (dash.completed_week ?? 0) + (dash.failed_week ?? 0), - scheduledActive: dash.scheduled_active ?? 0, - }, - agents: { total: agentList.length }, - workflows: { total: workflowList.length }, - skills: { total: skillList.length }, - tools: { total: toolList.length }, - models: { total: totalModels }, - system: { - status: health.data.status === 'healthy' ? 'healthy' : 'error', - message: health.data.status === 'healthy' ? '所有服务运行正常' : '部分服务异常', - }, - }; + const response = await apiClient.get('/api/stats/summary'); + if (isSystemStats(response.data)) { + return response.data; + } + return await getSystemStatsLegacy(); } catch (error) { - console.error('Failed to fetch system stats:', error); - return { - tasks: { week: 0, scheduledActive: 0 }, - agents: { total: 0 }, - workflows: { total: 0 }, - skills: { total: 0 }, - tools: { total: 0 }, - models: { total: 0 }, - system: { status: 'error', message: '无法连接到后端服务' }, - }; + try { + return await getSystemStatsLegacy(); + } catch (fallbackError) { + console.error('Failed to fetch system stats:', fallbackError || error); + return { + tasks: { week: 0, scheduledActive: 0 }, + agents: { total: 0 }, + workflows: { total: 0 }, + skills: { total: 0 }, + tools: { total: 0 }, + models: { total: 0 }, + system: { status: 'error', message: '无法连接到后端服务' }, + }; + } } }, }; diff --git a/webui/src/api/tool.test.ts b/webui/src/api/tool.test.ts new file mode 100644 index 000000000..5d6d3f056 --- /dev/null +++ b/webui/src/api/tool.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getMock = vi.fn(); + +vi.mock('./client', () => ({ + default: { + get: (...args: unknown[]) => getMock(...args), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('listAllToolPages', () => { + beforeEach(() => { + getMock.mockReset(); + }); + + it('loads every page for one service without changing the list page size', async () => { + getMock + .mockResolvedValueOnce({ + data: { + items: [{ name: 'first' }], + total: 2, + offset: 0, + limit: 200, + facets: {}, + }, + }) + .mockResolvedValueOnce({ + data: { + items: [{ name: 'second' }], + total: 2, + offset: 1, + limit: 200, + facets: {}, + }, + }); + + const { listAllToolPages } = await import('./tool'); + const result = await listAllToolPages({ + source: 'api', + sourceName: 'service-a', + enabled: 'true', + q: 'indicator', + }); + + expect(result.map((tool) => tool.name)).toEqual(['first', 'second']); + expect(getMock).toHaveBeenNthCalledWith(1, '/api/tools/page', { + params: expect.objectContaining({ + source: 'api', + source_name: 'service-a', + enabled: 'true', + q: 'indicator', + offset: 0, + limit: 200, + }), + }); + expect(getMock).toHaveBeenNthCalledWith(2, '/api/tools/page', { + params: expect.objectContaining({ source: 'api', source_name: 'service-a', offset: 1, limit: 200 }), + }); + }); + + it('stops on an empty page even when the reported total is stale', async () => { + getMock.mockResolvedValue({ + data: { + items: [], + total: 500, + offset: 0, + limit: 200, + facets: {}, + }, + }); + + const { listAllToolPages } = await import('./tool'); + const result = await listAllToolPages({ source: 'mcp', sourceName: 'server-a' }); + + expect(result).toEqual([]); + expect(getMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webui/src/api/tool.ts b/webui/src/api/tool.ts index b0299f6b8..36729ddb2 100644 --- a/webui/src/api/tool.ts +++ b/webui/src/api/tool.ts @@ -22,15 +22,75 @@ export interface ToolFixture { has_assertion: boolean; } +export type ToolListSortField = 'category' | 'source' | 'source_name' | 'enabled' | 'name'; +export type ToolListSortDir = 'asc' | 'desc'; + +export interface ToolListPageParams { + source?: string; + category?: string; + sourceName?: string; + enabled?: string; + q?: string; + sortBy?: ToolListSortField; + sortDir?: ToolListSortDir; + offset?: number; + limit?: number; +} + +export interface ToolListFacets { + category: Record; + source: Record; + source_groups: Record; + source_name: Record; + enabled: Record; +} + +export interface ToolListPageResponse { + items: Tool[]; + total: number; + offset: number; + limit: number; + facets: ToolListFacets; +} + +export interface ToolRefreshResponse { + status: 'success' | 'partial' | 'error'; + tool_count: number; + message: string; + stages: Record; + errors: string[]; +} + +export interface ToolDeleteResponse { + status: 'success' | 'partial'; + message: string; + errors?: string[]; +} + export const toolAPI = { list: (params?: { source?: ToolSource; category?: string }) => client.get('/api/tools', { params }), + listPage: (params?: ToolListPageParams) => + client.get('/api/tools/page', { + params: { + source: params?.source, + category: params?.category, + source_name: params?.sourceName, + enabled: params?.enabled, + q: params?.q, + sort_by: params?.sortBy, + sort_dir: params?.sortDir, + offset: params?.offset, + limit: params?.limit, + }, + }), + get: (name: string) => client.get(`/api/tools/${name}`), refresh: () => - client.post('/api/tools/refresh'), + client.post('/api/tools/refresh'), test: (name: string, params: Record) => client.post(`/api/tools/${name}/test`, { params }), @@ -56,8 +116,28 @@ export const toolAPI = { client.post(`/api/tools/${name}/reset`), delete: (name: string) => - client.delete<{ status: string; message: string }>(`/api/tools/${name}`), + client.delete(`/api/tools/${name}`), }; +export async function listAllToolPages(params: ToolListPageParams): Promise { + const pageSize = 200; + const items: Tool[] = []; + let offset = 0; + + while (true) { + const response = await toolAPI.listPage({ + ...params, + offset, + limit: pageSize, + }); + const pageItems = Array.isArray(response.data.items) ? response.data.items : []; + items.push(...pageItems); + offset += pageItems.length; + if (pageItems.length === 0 || offset >= response.data.total) break; + } + + return items; +} + export const canDirectlyTestTool = (tool: Pick) => tool.source !== 'builtin'; diff --git a/webui/src/api/update.ts b/webui/src/api/update.ts index 10de702f6..bd10d894f 100644 --- a/webui/src/api/update.ts +++ b/webui/src/api/update.ts @@ -52,11 +52,16 @@ export interface UpdateProgress { // API // ====================================================================== -export const checkUpdate = async (locale?: string, edition?: UpdateEdition): Promise => { +export const checkUpdate = async ( + locale?: string, + edition?: UpdateEdition, + force?: boolean, +): Promise => { const response = await client.get('/api/update/check', { params: { ...(locale ? { locale } : {}), ...(edition ? { edition } : {}), + ...(force ? { force: true } : {}), }, }); return response.data; diff --git a/webui/src/components/common/ChatPromptSelectors.test.tsx b/webui/src/components/common/ChatPromptSelectors.test.tsx index 147ad560c..60ea93cbe 100644 --- a/webui/src/components/common/ChatPromptSelectors.test.tsx +++ b/webui/src/components/common/ChatPromptSelectors.test.tsx @@ -1,8 +1,32 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ChatModelPicker, type ChatModelProviderGroup } from './ChatPromptSelectors'; +import { + __resetChatModelOptionsResourcesForTesting, + ChatModelPicker, + useChatModelOptions, + type ChatModelProviderGroup, +} from './ChatPromptSelectors'; + +const { listDefinitionsMock, getResolvedMock, useProvidersMock } = vi.hoisted(() => ({ + listDefinitionsMock: vi.fn(), + getResolvedMock: vi.fn(), + useProvidersMock: vi.fn(), +})); + +vi.mock('@/api/provider', () => ({ + modelV2API: { + listDefinitions: listDefinitionsMock, + }, + defaultModelAPI: { + getResolved: getResolvedMock, + }, +})); + +vi.mock('@/hooks/useProviders', () => ({ + useProviders: useProvidersMock, +})); vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -40,6 +64,45 @@ const groupedOptions: ChatModelProviderGroup[] = [ }, ]; +function makeProvider(id: string) { + return { + id, + name: id, + source: 'builtin', + env: [], + key: null, + options: {}, + models: {}, + configured: true, + modelCount: 1, + category: 'connected', + }; +} + +function makeModelDefinition(providerId = 'provider-1', modelId = 'model-1') { + return { + id: modelId, + name: modelId, + provider_id: providerId, + model_type: 'chat', + status: 'active', + capabilities: { supports_vision: false }, + limits: { context_window: 128000 }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + __resetChatModelOptionsResourcesForTesting(); + useProvidersMock.mockReturnValue({ + providers: [makeProvider('provider-1')], + loading: false, + error: null, + connectedIds: ['provider-1'], + refetch: vi.fn(), + }); +}); + describe('ChatModelPicker', () => { it('opens the model menu toward the left edge of the trigger', async () => { const user = userEvent.setup(); @@ -62,3 +125,39 @@ describe('ChatModelPicker', () => { expect(menu).not.toHaveClass('left-0'); }); }); + +describe('useChatModelOptions', () => { + it('shares enabled model and default model requests across concurrent hook instances', async () => { + let resolveDefinitions: (value: { data: { models: any[] } }) => void = () => {}; + listDefinitionsMock.mockReturnValue(new Promise((resolve) => { + resolveDefinitions = resolve; + })); + getResolvedMock.mockResolvedValue({ + data: { provider_id: 'provider-1', model_id: 'model-1' }, + }); + + const first = renderHook(() => useChatModelOptions()); + const second = renderHook(() => useChatModelOptions()); + + expect(listDefinitionsMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveDefinitions({ + data: { models: [makeModelDefinition()] }, + }); + }); + + await waitFor(() => { + expect(first.result.current.loading).toBe(false); + expect(second.result.current.loading).toBe(false); + expect(first.result.current.options).toHaveLength(1); + expect(second.result.current.options).toHaveLength(1); + }); + + await waitFor(() => { + expect(first.result.current.selectedModelKey).toBe('provider-1::model-1'); + expect(second.result.current.selectedModelKey).toBe('provider-1::model-1'); + }); + expect(getResolvedMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webui/src/components/common/ChatPromptSelectors.tsx b/webui/src/components/common/ChatPromptSelectors.tsx index bcf576204..0df85d411 100644 --- a/webui/src/components/common/ChatPromptSelectors.tsx +++ b/webui/src/components/common/ChatPromptSelectors.tsx @@ -3,7 +3,11 @@ import { Bot, ChevronDown, Cpu, Info } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { Agent } from '@/api/agent'; -import { defaultModelAPI, modelV2API } from '@/api/provider'; +import { + __resetChatModelResourcesForTesting, + useEnabledChatModelDefinitions, + useResolvedDefaultModel, +} from '@/hooks/useChatModelResources'; import { useAgents } from '@/hooks/useAgents'; import { useProviders } from '@/hooks/useProviders'; import { getAgentDisplayDescription, getAgentDisplayName, isAgentUsableInChat } from '@/utils/agentDisplay'; @@ -36,6 +40,10 @@ type SelectorTooltip = { y: number; }; +export function __resetChatModelOptionsResourcesForTesting(): void { + __resetChatModelResourcesForTesting(); +} + function formatAgentName(name: string): string { return name ? name.charAt(0).toUpperCase() + name.slice(1) : name; } @@ -68,28 +76,12 @@ export function useChatAgentOptions(options: { allowedAgentNames?: string[] } = export function useChatModelOptions() { const { t } = useTranslation('session'); const { providers, loading: loadingProviders } = useProviders(); - const [enabledModelDefinitions, setEnabledModelDefinitions] = useState([]); - const [loadingEnabledModels, setLoadingEnabledModels] = useState(true); + const { + data: enabledModelDefinitions, + loading: loadingEnabledModels, + } = useEnabledChatModelDefinitions(); const [selectedModelKey, setSelectedModelKey] = useState(null); - useEffect(() => { - let cancelled = false; - setLoadingEnabledModels(true); - Promise.resolve(modelV2API.listDefinitions({ enabled_only: true })) - .then((response) => { - if (!cancelled) setEnabledModelDefinitions(response?.data?.models ?? []); - }) - .catch(() => { - if (!cancelled) setEnabledModelDefinitions([]); - }) - .finally(() => { - if (!cancelled) setLoadingEnabledModels(false); - }); - return () => { - cancelled = true; - }; - }, []); - const options = useMemo(() => { const providerById = new Map( providers @@ -162,24 +154,19 @@ export function useChatModelOptions() { [options, selectedModelKey], ); + const { + data: resolvedDefaultModel, + initialized: resolvedDefaultModelInitialized, + } = useResolvedDefaultModel(options.length > 0); + useEffect(() => { - if (selectedModelKey || options.length === 0) return; - let cancelled = false; - Promise.resolve(defaultModelAPI.getResolved()) - .then((response) => { - if (cancelled) return; - const { provider_id: providerID, model_id: modelID } = response?.data ?? {}; - const defaultKey = `${providerID}::${modelID}`; - const fallbackKey = options[0]?.key ?? null; - setSelectedModelKey(options.some((option) => option.key === defaultKey) ? defaultKey : fallbackKey); - }) - .catch(() => { - if (!cancelled) setSelectedModelKey(options[0]?.key ?? null); - }); - return () => { - cancelled = true; - }; - }, [options, selectedModelKey]); + if (selectedModelKey || options.length === 0 || !resolvedDefaultModelInitialized) return; + const defaultKey = resolvedDefaultModel + ? `${resolvedDefaultModel.providerID}::${resolvedDefaultModel.modelID}` + : null; + const fallbackKey = options[0]?.key ?? null; + setSelectedModelKey(defaultKey && options.some((option) => option.key === defaultKey) ? defaultKey : fallbackKey); + }, [options, resolvedDefaultModel, resolvedDefaultModelInitialized, selectedModelKey]); useEffect(() => { if (loadingEnabledModels || options.length === 0 || !selectedModelKey) return; diff --git a/webui/src/components/common/EntitySheet.test.tsx b/webui/src/components/common/EntitySheet.test.tsx index 99a8ac78f..ffde9c541 100644 --- a/webui/src/components/common/EntitySheet.test.tsx +++ b/webui/src/components/common/EntitySheet.test.tsx @@ -178,14 +178,14 @@ describe('EntitySheet', () => { expect(screen.getByText('Form content')).toBeInTheDocument(); }); - it('defaults Rex workbench process details collapsed like workflow workbenches', () => { + it('defaults Rex workbench process details collapsed like workflow workbenches', async () => { render(
Form content
, ); - expect(screen.getByTestId('session-chat')).toHaveAttribute( + expect(await screen.findByTestId('session-chat')).toHaveAttribute( 'data-display', JSON.stringify({ collapseIntermediateSteps: true, processGroupsDefaultOpen: false }), ); diff --git a/webui/src/components/common/EntitySheet.tsx b/webui/src/components/common/EntitySheet.tsx index 802631221..37e39b752 100644 --- a/webui/src/components/common/EntitySheet.tsx +++ b/webui/src/components/common/EntitySheet.tsx @@ -12,7 +12,7 @@ * 3. 传入 onRunTest 启用「测试」Tab */ -import { useState, useEffect, useRef, useCallback, createContext, useContext } from 'react'; +import { lazy, Suspense, useState, useEffect, useRef, useCallback, createContext, useContext } from 'react'; import { X, FileText, @@ -27,7 +27,7 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import client from '@/api/client'; -import SessionChat, { buildInstructionDisplayText, type SessionChatDisplay } from './SessionChat'; +import { buildInstructionDisplayText, type SessionChatDisplay } from '@/features/session-chat'; import { useSessionChat } from '@/hooks/useSessionChat'; import { useDefaultModelVision } from '@/hooks/useDefaultModelVision'; import ChatGuideDock, { type ChatGuideAction } from './ChatGuideDock'; @@ -40,6 +40,16 @@ import { } from './sidePanelSizing'; // ─── Context ────────────────────────────────────────────────────────────────── +const LazySessionChat = lazy(() => import('./SessionChat')); + +function ChatPanelFallback() { + return ( +
+ +
+ ); +} + interface EntitySheetCtx { /** Switch to the Rex tab, optionally sending an initial message */ openRex: (prefillMessage?: string) => void; @@ -582,13 +592,15 @@ export default function EntitySheet({ {activeTab === 'test' && (
{testSessionId ? ( - + }> + + ) : (
{testError ? ( @@ -674,69 +686,71 @@ export default function EntitySheet({
)} {!sessionError && rexSessionHydrated && ( - createAndSendRex({ - text, - imageParts, - agent: agentOverride || rexAgentName, - model: modelOverride === undefined ? rexModel : modelOverride, - displayText: options?.displayText, - }) : undefined} - welcomeContent={( - hasRexGuideActions ? ( - } - title={rexGuidePanelTitle ?? t('entity.rexAssist')} - description={rexGuidePanelDesc ?? t('entity.rexReady')} - groups={rexWelcomeGuideGroups} - onStartPrompt={startRexGuidePrompt} - /> - ) : ( -
- -

{t('entity.rexAssist')}

-

{t('entity.rexReady')}

-
- ) - )} - conversationBottomSlot={({ sendPrompt, sending, streaming, hasMessages }) => ( - hasRexGuideActions && hasMessages ? ( - { - if (prompt === EXTRACT_FROM_REX_GUIDE_PROMPT) { - void handleExtract(); - return; - } - - sendPrompt(prompt, { - displayText: buildInstructionDisplayText(label), - }); - }} - /> - ) : null - )} - /> + }> + createAndSendRex({ + text, + imageParts, + agent: agentOverride || rexAgentName, + model: modelOverride === undefined ? rexModel : modelOverride, + displayText: options?.displayText, + }) : undefined} + welcomeContent={( + hasRexGuideActions ? ( + } + title={rexGuidePanelTitle ?? t('entity.rexAssist')} + description={rexGuidePanelDesc ?? t('entity.rexReady')} + groups={rexWelcomeGuideGroups} + onStartPrompt={startRexGuidePrompt} + /> + ) : ( +
+ +

{t('entity.rexAssist')}

+

{t('entity.rexReady')}

+
+ ) + )} + conversationBottomSlot={({ sendPrompt, sending, streaming, hasMessages }) => ( + hasRexGuideActions && hasMessages ? ( + { + if (prompt === EXTRACT_FROM_REX_GUIDE_PROMPT) { + void handleExtract(); + return; + } + + sendPrompt(prompt, { + displayText: buildInstructionDisplayText(label), + }); + }} + /> + ) : null + )} + /> +
)}
)} diff --git a/webui/src/components/common/LazyLoadErrorBoundary.test.tsx b/webui/src/components/common/LazyLoadErrorBoundary.test.tsx new file mode 100644 index 000000000..ab147219d --- /dev/null +++ b/webui/src/components/common/LazyLoadErrorBoundary.test.tsx @@ -0,0 +1,67 @@ +import { Component, type ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import LazyLoadErrorBoundary from './LazyLoadErrorBoundary'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +function BrokenContent({ error }: { error: Error }): never { + throw error; +} + +class CapturingBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + if (this.state.error) return
{this.state.error.message}
; + return this.props.children; + } +} + +describe('LazyLoadErrorBoundary', () => { + it('shows a recoverable fallback when lazy content fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const onRetry = vi.fn(); + const user = userEvent.setup(); + const error = new Error('Loading chunk route failed'); + error.name = 'ChunkLoadError'; + + render( + + + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('error.chunkLoadFailed'); + await user.click(screen.getByRole('button', { name: 'button.retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('propagates ordinary render errors to the surrounding error boundary', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + render( + + + + + , + ); + + expect(screen.getByTestId('outer-error')).toHaveTextContent('ordinary render failure'); + expect(screen.queryByText('error.chunkLoadFailed')).not.toBeInTheDocument(); + }); +}); diff --git a/webui/src/components/common/LazyLoadErrorBoundary.tsx b/webui/src/components/common/LazyLoadErrorBoundary.tsx new file mode 100644 index 000000000..960581ccf --- /dev/null +++ b/webui/src/components/common/LazyLoadErrorBoundary.tsx @@ -0,0 +1,70 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { isChunkLoadError, retryChunkLoad } from '@/utils/chunkLoadRecovery'; + +interface LazyLoadErrorBoundaryProps { + children: ReactNode; + mode?: 'page' | 'overlay'; + onRetry?: () => void; +} + +interface LazyLoadErrorBoundaryState { + failed: boolean; +} + +function LazyLoadErrorFallback({ + mode, + onRetry, +}: Required>) { + const { t } = useTranslation('common'); + const containerClassName = mode === 'overlay' + ? 'fixed inset-0 z-[120] flex items-center justify-center bg-black/30 p-6' + : 'flex min-h-64 items-center justify-center p-6'; + + return ( +
+
+

+ {t('error.chunkLoadFailed')} +

+

+ {t('error.chunkLoadHint')} +

+ +
+
+ ); +} + +export default class LazyLoadErrorBoundary extends Component< + LazyLoadErrorBoundaryProps, + LazyLoadErrorBoundaryState +> { + state: LazyLoadErrorBoundaryState = { failed: false }; + + static getDerivedStateFromError(error: Error): LazyLoadErrorBoundaryState { + if (!isChunkLoadError(error)) throw error; + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[LazyLoadErrorBoundary] Failed to load lazy UI:', error, info.componentStack); + } + + render() { + if (!this.state.failed) return this.props.children; + return ( + + ); + } +} diff --git a/webui/src/components/common/LoadingIndicators.test.tsx b/webui/src/components/common/LoadingIndicators.test.tsx new file mode 100644 index 000000000..cea074360 --- /dev/null +++ b/webui/src/components/common/LoadingIndicators.test.tsx @@ -0,0 +1,41 @@ +import { act, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import LoadingSpinner from './LoadingSpinner'; +import RoutePageSkeleton from './RoutePageSkeleton'; + +describe('delayed loading indicators', () => { + it('hides short spinner flashes until the delay elapses', async () => { + vi.useFakeTimers(); + + render(); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(179); + }); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.getByRole('status')).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it('hides short route skeleton flashes until the delay elapses', async () => { + vi.useFakeTimers(); + + render(); + + expect(screen.queryByTestId('route-page-skeleton')).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(180); + }); + expect(screen.getByTestId('route-page-skeleton')).toBeInTheDocument(); + + vi.useRealTimers(); + }); +}); diff --git a/webui/src/components/common/LoadingSpinner.tsx b/webui/src/components/common/LoadingSpinner.tsx index 90dd7fd0c..3b25f59ac 100644 --- a/webui/src/components/common/LoadingSpinner.tsx +++ b/webui/src/components/common/LoadingSpinner.tsx @@ -1,19 +1,24 @@ import { Loader2 } from 'lucide-react'; +import { useDelayedVisible } from '@/hooks/useDelayedVisible'; interface LoadingSpinnerProps { size?: 'sm' | 'md' | 'lg'; className?: string; + delayMs?: number; } -export default function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) { +export default function LoadingSpinner({ size = 'md', className = '', delayMs = 0 }: LoadingSpinnerProps) { + const visible = useDelayedVisible(delayMs); const sizeClasses = { sm: 'w-4 h-4', md: 'w-8 h-8', lg: 'w-12 h-12', }; + if (!visible) return null; + return ( -
+
); diff --git a/webui/src/components/common/RoutePageSkeleton.tsx b/webui/src/components/common/RoutePageSkeleton.tsx index 82581c05f..223b7f780 100644 --- a/webui/src/components/common/RoutePageSkeleton.tsx +++ b/webui/src/components/common/RoutePageSkeleton.tsx @@ -1,6 +1,15 @@ -export default function RoutePageSkeleton() { +import { useDelayedVisible } from '@/hooks/useDelayedVisible'; + +interface RoutePageSkeletonProps { + delayMs?: number; +} + +export default function RoutePageSkeleton({ delayMs = 0 }: RoutePageSkeletonProps) { + const visible = useDelayedVisible(delayMs); + if (!visible) return null; + return ( -
+
diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index 934b5df8e..0551d8ab4 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -7,7 +7,9 @@ import type { Message } from '@/types'; import { areChatMessagePartsRenderEqual, + areChatTimelineItemsRenderEqual, buildInstructionDisplayText, + buildChatTimelineItems, buildContextUsageBreakdown, buildTodoSummary, ChatMessageBubble, @@ -29,6 +31,7 @@ import { isActiveSessionStatus, listUploadedDocumentPaths, shouldRenderMessage, + shouldForwardSSEEventToParent, shouldRefetchFinishedMessage, truncateToolDisplayText, } from './SessionChat'; @@ -163,9 +166,13 @@ vi.mock('@/hooks/useReasoningToggle', () => ({ }), })); -vi.mock('@/hooks/usePendingQuestions', () => ({ - usePendingQuestions: () => pendingQuestionsHookMock, -})); +vi.mock('@/features/session-chat', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePendingQuestions: () => pendingQuestionsHookMock, + }; +}); vi.mock('./Toast', () => ({ useToast: () => toastMock, @@ -996,6 +1003,155 @@ describe('getRenderableThinkingText', () => { }); }); +describe('ChatMessageBubble reasoning streaming', () => { + it.each(['reasoning', 'thinking'] as const)( + 'paces an active %s part after a tool and flushes the completed text', + (partType) => { + type RafCallback = (time: number) => void; + const callbacks = new Map(); + let nextRafId = 0; + vi.stubGlobal('requestAnimationFrame', (callback: RafCallback) => { + const id = ++nextRafId; + callbacks.set(id, callback); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + callbacks.delete(id); + }); + + const makeReasoningMessage = (text: string, finish?: Message['finish']) => makeMessage({ + id: 'assistant-reasoning-stream', + role: 'assistant', + finish, + parts: [ + { + id: 'tool-before-reasoning', + messageID: 'assistant-reasoning-stream', + sessionID: 'sess-1', + type: 'tool', + tool: 'read', + state: { status: 'completed', output: 'done' }, + } as any, + { + id: 'reasoning-stream', + messageID: 'assistant-reasoning-stream', + sessionID: 'sess-1', + type: partType, + text, + } as any, + ], + }); + + let unmount = () => {}; + try { + const rendered = render(React.createElement(ChatMessageBubble, { + message: makeReasoningMessage('思'), + isActive: true, + })); + unmount = rendered.unmount; + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeReasoningMessage('思考过程'), + isActive: true, + })); + + expect(screen.getByText('思考中...')).toBeInTheDocument(); + expect(screen.getByText('思')).toBeInTheDocument(); + expect(screen.queryByText('思考过程')).not.toBeInTheDocument(); + + act(() => { + const pending = [...callbacks.values()]; + callbacks.clear(); + pending.forEach(callback => callback(1000 / 60)); + }); + expect(screen.getByText('思考')).toBeInTheDocument(); + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeReasoningMessage('思考过程', 'stop'), + isActive: false, + })); + expect(screen.getByText('思考过程')).toBeInTheDocument(); + } finally { + unmount(); + vi.unstubAllGlobals(); + } + }, + ); + + it('does not animate reasoning while its process group is closed', () => { + let nextRafId = 0; + const requestAnimationFrameSpy = vi.fn(() => ++nextRafId); + vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + const messageId = 'assistant-hidden-reasoning'; + const processGroupKey = `${messageId}:process:0`; + const makeHiddenReasoningMessage = (text: string) => makeMessage({ + id: messageId, + role: 'assistant', + parts: [ + { + id: 'tool-before-hidden-reasoning', + messageID: messageId, + sessionID: 'sess-1', + type: 'tool', + tool: 'read', + state: { status: 'completed', output: 'done' }, + } as any, + { + id: 'hidden-reasoning', + messageID: messageId, + sessionID: 'sess-1', + type: 'reasoning', + text, + } as any, + ], + }); + + let unmount = () => {}; + try { + const rendered = render(React.createElement(ChatMessageBubble, { + message: makeHiddenReasoningMessage('隐藏'), + isActive: true, + collapseIntermediateSteps: true, + processGroupsOpenWhileActive: true, + processGroupOpenState: { [processGroupKey]: false }, + })); + unmount = rendered.unmount; + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeHiddenReasoningMessage('隐藏更新'), + isActive: true, + collapseIntermediateSteps: true, + processGroupsOpenWhileActive: true, + processGroupOpenState: { [processGroupKey]: false }, + })); + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeHiddenReasoningMessage('隐藏更新'), + isActive: true, + collapseIntermediateSteps: true, + processGroupsOpenWhileActive: true, + processGroupOpenState: { [processGroupKey]: true }, + })); + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeHiddenReasoningMessage('隐藏更新继续'), + isActive: true, + collapseIntermediateSteps: true, + processGroupsOpenWhileActive: true, + processGroupOpenState: { [processGroupKey]: true }, + })); + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + } finally { + unmount(); + vi.unstubAllGlobals(); + } + }); +}); + describe('getMessageErrorText', () => { it('prefers user-facing display messages over raw provider errors', () => { expect(getMessageErrorText(makeMessage({ @@ -1028,6 +1184,146 @@ describe('getMessageErrorText', () => { }); }); +describe('shouldForwardSSEEventToParent', () => { + it('forwards global workflow, task, and session update events', () => { + expect(shouldForwardSSEEventToParent({ + type: 'workflow.updated', + properties: { id: 'workflow-1' }, + }, 'sess-1')).toBe(true); + expect(shouldForwardSSEEventToParent({ + type: 'task.updated', + properties: { executionID: 'task-1' }, + }, 'sess-1')).toBe(true); + expect(shouldForwardSSEEventToParent({ + type: 'session.updated', + properties: { id: 'other-session' }, + }, 'sess-1')).toBe(true); + }); + + it('forwards chat events only for the current session', () => { + expect(shouldForwardSSEEventToParent({ + type: 'message.part.updated', + properties: { part: { sessionID: 'sess-1' } }, + }, 'sess-1')).toBe(true); + expect(shouldForwardSSEEventToParent({ + type: 'message.part.updated', + properties: { part: { sessionID: 'other-session' } }, + }, 'sess-1')).toBe(false); + expect(shouldForwardSSEEventToParent({ + type: 'context.usage.updated', + properties: { sessionID: 'other-session' }, + }, 'sess-1')).toBe(false); + }); + + it('skips heartbeat-style events without payloads', () => { + expect(shouldForwardSSEEventToParent({ + type: 'server.heartbeat', + }, 'sess-1')).toBe(false); + }); +}); + +describe('buildChatTimelineItems', () => { + it('filters skipped and non-renderable messages while marking the active assistant', () => { + const messages = [ + makeMessage({ + id: 'user-1', + role: 'user', + parts: [{ id: 'user-part', type: 'text', text: 'hello' }] as Message['parts'], + }), + makeMessage({ + id: 'synthetic-1', + role: 'assistant', + parts: [{ id: 'synthetic-part', type: 'text', text: '', synthetic: true }] as Message['parts'], + }), + makeMessage({ + id: 'assistant-empty', + role: 'assistant', + parts: [], + finish: null, + }), + makeMessage({ + id: 'assistant-active', + role: 'assistant', + parts: [], + finish: null, + }), + ]; + + const items = buildChatTimelineItems({ + messages, + skipIndices: new Set([1]), + isStreaming: true, + }); + + expect(items.map((item) => item.message.id)).toEqual(['user-1', 'assistant-active']); + expect(items.map((item) => item.isActive)).toEqual([false, true]); + }); + + it('keeps the same visible set when not streaming', () => { + const messages = [ + makeMessage({ + id: 'assistant-empty', + role: 'assistant', + parts: [], + finish: null, + }), + makeMessage({ + id: 'assistant-text', + role: 'assistant', + parts: [{ id: 'text-part', type: 'text', text: 'done' }] as Message['parts'], + finish: 'stop', + }), + ]; + + const items = buildChatTimelineItems({ + messages, + skipIndices: new Set(), + isStreaming: false, + }); + + expect(items.map((item) => item.message.id)).toEqual(['assistant-text']); + expect(items[0].isActive).toBe(false); + }); +}); + +describe('areChatTimelineItemsRenderEqual', () => { + it('treats cloned assistant messages with identical visible parts as equal', () => { + const prevMessage = makeMessage({ + id: 'assistant-1', + role: 'assistant', + agent: 'rex', + parts: [{ id: 'text-1', type: 'text', text: 'hello' }] as Message['parts'], + finish: 'stop', + }); + const nextMessage = { + ...prevMessage, + parts: [{ id: 'text-1', type: 'text', text: 'hello' }] as Message['parts'], + }; + + expect(areChatTimelineItemsRenderEqual( + [{ message: prevMessage as any, isActive: false }], + [{ message: nextMessage as any, isActive: false }], + )).toBe(true); + }); + + it('detects visible text changes in otherwise stable timeline items', () => { + const prevMessage = makeMessage({ + id: 'assistant-1', + role: 'assistant', + parts: [{ id: 'text-1', type: 'text', text: 'hello' }] as Message['parts'], + }); + const nextMessage = { + ...prevMessage, + parts: [{ id: 'text-1', type: 'text', text: 'hello world' }] as Message['parts'], + }; + + expect(areChatTimelineItemsRenderEqual( + [{ message: prevMessage as any, isActive: false }], + [{ message: nextMessage as any, isActive: false }], + )).toBe(false); + }); +}); + describe('SessionChat error rendering', () => { it('renders empty assistant error messages instead of the thinking indicator', () => { useSessionMessagesMock.mockReturnValue({ diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index 8e2d4a98a..c52d08b8a 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -18,7 +18,7 @@ import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'; import { Send, Loader2, ChevronDown, Square, Copy, User, FileText, AlertCircle, X, RefreshCw, Pencil, Save, ImageIcon, Paperclip, ArrowUp, Clock, CheckCircle2, XCircle, Brain, Trash2, Bot, Check, ListTree } from 'lucide-react'; -import { StreamingMarkdown } from './StreamingMarkdown'; +import { StreamingMarkdown, useStreamingContent } from './StreamingMarkdown'; import { useTranslation } from 'react-i18next'; import LoadingSpinner from './LoadingSpinner'; import { QuestionTool, type QuestionItem } from './QuestionTool'; @@ -28,10 +28,9 @@ import ImageLightbox from './ImageLightbox'; import { useSessionMessages } from '@/hooks/useSessions'; import { useSSE, type SSEConnectionStatus } from '@/hooks/useSSE'; import { useReasoningToggle } from '@/hooks/useReasoningToggle'; -import { usePendingQuestions, type PendingQuestion } from '@/hooks/usePendingQuestions'; import { sessionApi, type ContextUsageSnapshot, type QueuedPrompt } from '@/api/session'; import client, { getApiBase } from '@/api/client'; -import { commandAPI, type Command } from '@/api/skill'; +import type { Command } from '@/api/skill'; import type { Agent } from '@/api/agent'; import { useToast } from './Toast'; import { buildRunWorkflowHeaderSummary } from './toolStageSummary'; @@ -50,9 +49,33 @@ import { type ImagePartData, } from '@/utils/imageUpload'; import type { Message, MessagePart, SessionGoalState, ToolState } from '@/types'; +import { + buildInstructionDisplayText, + fetchSessionChatCommands, + getQueuedPromptText, + parseInstructionDisplayText, + resolveSessionChatSSEAction, + shouldForwardSSEEventToParent, + type CompactionStage, + usePendingQuestions, + useSessionContextUsage, + useSessionPromptQueue, + type PendingQuestion, + type PromptDisplayOptions, + type SSEChatEvent, + type SessionChatDisplay, +} from '@/features/session-chat'; export { formatSmartTime }; export type { SSEConnectionStatus }; +export { + buildInstructionDisplayText, + parseInstructionDisplayText, + shouldForwardSSEEventToParent, + type PromptDisplayOptions, + type SSEChatEvent, + type SessionChatDisplay, +} from '@/features/session-chat'; // ============================================================================ // Types @@ -60,11 +83,6 @@ export type { SSEConnectionStatus }; export type MergedMessage = Message & { _merged?: boolean }; -export interface SSEChatEvent { - type: string; - properties?: Record; -} - /** Node reference shown above the chat input as a dismissible chip */ export interface NodeRef { id: string; @@ -90,22 +108,6 @@ export interface ConversationBottomSlotActions { hasMessages: boolean; } -export interface PromptDisplayOptions { - displayText?: string; -} - -const INSTRUCTION_DISPLAY_PREFIX = '@@flocks-instruction:'; - -export function buildInstructionDisplayText(label: string): string { - return `${INSTRUCTION_DISPLAY_PREFIX}${label}`; -} - -export function parseInstructionDisplayText(text: string): string | null { - return text.startsWith(INSTRUCTION_DISPLAY_PREFIX) - ? text.slice(INSTRUCTION_DISPLAY_PREFIX.length).trim() || null - : null; -} - function getMessagePartDisplayText(part: MessagePart): string { const metadataDisplayText = part.metadata?.displayText ?? part.metadata?.display_text; return typeof metadataDisplayText === 'string' && metadataDisplayText @@ -113,24 +115,6 @@ function getMessagePartDisplayText(part: MessagePart): string { : part.text || ''; } -/** Display-related options grouped to reduce prop surface. */ -export interface SessionChatDisplay { - /** Compact mode for panels/dialogs (default: true). Set false for full-page. */ - compact?: boolean; - /** Let embedded chats use the full available message width. */ - fullWidth?: boolean; - /** Show copy action on assistant messages */ - showActions?: boolean; - /** Show timestamp below each message */ - showTimestamp?: boolean; - /** Default-collapse intermediate reasoning and tool-process details in embedded panels. */ - collapseIntermediateSteps?: boolean; - /** Initial open state for grouped reasoning/tool-process details. */ - processGroupsDefaultOpen?: boolean; - /** Keep grouped reasoning/tool-process details open while the assistant message is actively streaming. */ - processGroupsOpenWhileActive?: boolean; -} - export interface SessionChatProps { /** When null/undefined, only welcomeContent + input are rendered (lazy session). */ sessionId?: string | null; @@ -254,6 +238,17 @@ export function getRenderableThinkingText(part: Pick{displayContent}; +}); + function stringifyToolPayload(value: unknown): string { if (value == null) return ''; if (typeof value === 'string') return value; @@ -708,18 +703,6 @@ export function listUploadedDocumentPaths(items: UploadedDocumentAttachmentLike[ // don't share a draft, and namespaced to avoid colliding with other features. import { readChatDraft, writeChatDraft } from '@/utils/chatDraft'; -// Backend stages emitted by ``SessionCompaction.process`` / -// ``summarize_chunked`` via the ``session.compaction_progress`` SSE event. -// Keep in sync with ``flocks/session/lifecycle/compaction/{compaction,summary}.py``. -type CompactionStage = - | 'load' - | 'strategy' - | 'chunk_done' - | 'merge_started' - | 'merge_done' - | 'summarize_done' - | 'complete'; - interface CompactionStageEntry { stage: CompactionStage; data: Record; @@ -985,6 +968,91 @@ export function shouldRenderMessage( return true; } +export interface ChatTimelineItem { + message: MergedMessage; + isActive: boolean; +} + +export function buildChatTimelineItems({ + messages, + skipIndices, + isStreaming, +}: { + messages: MergedMessage[]; + skipIndices: Set; + isStreaming: boolean; +}): ChatTimelineItem[] { + const items: ChatTimelineItem[] = []; + for (let index = 0; index < messages.length; index++) { + if (skipIndices.has(index)) continue; + const message = messages[index]; + const isActive = + isStreaming && + index === messages.length - 1 && + message.role === 'assistant' && + !message.finish; + if (!shouldRenderMessage(message, { isActive })) continue; + items.push({ message, isActive }); + } + return items; +} + +export function areChatTimelineItemsRenderEqual( + prevItems: ChatTimelineItem[], + nextItems: ChatTimelineItem[], +): boolean { + if (prevItems.length !== nextItems.length) return false; + + for (let index = 0; index < prevItems.length; index++) { + const prev = prevItems[index]; + const next = nextItems[index]; + if (prev.isActive !== next.isActive) return false; + + const prevMessage = prev.message; + const nextMessage = next.message; + if (prevMessage === nextMessage) continue; + if (prevMessage.id !== nextMessage.id) return false; + if (prevMessage.role !== nextMessage.role) return false; + if (prevMessage.finish !== nextMessage.finish) return false; + if (prevMessage.error !== nextMessage.error) return false; + if (prevMessage.agent !== nextMessage.agent) return false; + if (prevMessage.timestamp !== nextMessage.timestamp) return false; + if (prevMessage.compacted !== nextMessage.compacted) return false; + if (!areChatMessagePartsRenderEqual(prevMessage.parts, nextMessage.parts)) return false; + } + + return true; +} + +function useStableChatTimelineSegments(items: ChatTimelineItem[]): { + historyItems: ChatTimelineItem[]; + tailItems: ChatTimelineItem[]; +} { + const previousRef = useRef<{ + historyItems: ChatTimelineItem[]; + tailItems: ChatTimelineItem[]; + } | null>(null); + + return useMemo(() => { + const tailStart = items.length > 0 && items[items.length - 1].isActive + ? items.length - 1 + : items.length; + const nextHistoryItems = tailStart === items.length ? items : items.slice(0, tailStart); + const nextTailItems = tailStart === items.length ? [] : items.slice(tailStart); + const previous = previousRef.current; + + const historyItems = previous && areChatTimelineItemsRenderEqual(previous.historyItems, nextHistoryItems) + ? previous.historyItems + : nextHistoryItems; + const tailItems = previous && areChatTimelineItemsRenderEqual(previous.tailItems, nextTailItems) + ? previous.tailItems + : nextTailItems; + const next = { historyItems, tailItems }; + previousRef.current = next; + return next; + }, [items]); +} + export function getMessageErrorText(message: Pick): string { const error = message.error as any; if (!error) return ''; @@ -1107,13 +1175,6 @@ function isAllowedUploadFile(file: File): boolean { return ALLOWED_UPLOAD_EXTENSIONS.has(getFileExtension(file.name)); } -function getQueuedPromptText(item: QueuedPrompt): string { - if (typeof item.displayText === 'string' && item.displayText) return item.displayText; - if (typeof item.display_text === 'string' && item.display_text) return item.display_text; - const textPart = item.parts.find((part) => part.type === 'text' && typeof part.text === 'string'); - return typeof textPart?.text === 'string' ? textPart.text : ''; -} - function getGoalBannerKey(goal: GoalBannerState | null): string { return goal ? `${goal.status}:${goal.objective}` : ''; } @@ -1502,11 +1563,23 @@ export default function SessionChat({ const [compactingMessage, setCompactingMessage] = useState(''); const [goalBanner, setGoalBanner] = useState(null); const [dismissedGoalKey, setDismissedGoalKey] = useState(() => readDismissedGoalKey(sessionId)); - const [queuedPrompts, setQueuedPrompts] = useState([]); - const [queueExpanded, setQueueExpanded] = useState(true); - const [editingQueueId, setEditingQueueId] = useState(null); - const [editingQueueText, setEditingQueueText] = useState(''); - const [queueActionId, setQueueActionId] = useState(null); + const { + items: queuedPrompts, + expanded: queueExpanded, + setExpanded: setQueueExpanded, + editingId: editingQueueId, + editingText: editingQueueText, + setEditingText: setEditingQueueText, + actionId: queueActionId, + refresh: fetchPromptQueue, + applyItems: applyPromptQueueItems, + enqueue: enqueuePrompt, + startEdit: startQueuedEdit, + cancelEdit: cancelQueuedEdit, + saveEdit: saveQueuedEdit, + remove: removeQueuedPrompt, + runNow: runQueuedPromptNow, + } = useSessionPromptQueue(sessionId); const [processGroupOpenState, setProcessGroupOpenState] = useState(() => ( readProcessGroupOpenState(sessionId) )); @@ -1569,12 +1642,14 @@ export default function SessionChat({ const [editingRole, setEditingRole] = useState(null); const [editingText, setEditingText] = useState(''); const [actionMessageId, setActionMessageId] = useState(null); - const [contextUsageSnapshot, setContextUsageSnapshot] = useState(null); - const [contextUsageRefreshing, setContextUsageRefreshing] = useState(false); - const [contextUsageWindowTokens, setContextUsageWindowTokens] = useState(0); - const contextUsageRequestRef = useRef<{ sessionId: string; promise: Promise } | null>(null); - const contextUsageRequestSeqRef = useRef(0); - const lastContextUsagePushAtRef = useRef(0); + const { + snapshot: contextUsageSnapshot, + refreshing: contextUsageRefreshing, + contextWindowTokens: contextUsageWindowTokens, + refresh: refreshContextUsage, + applyPushSnapshot: applyContextUsagePushSnapshot, + stopRefreshing: stopContextUsageRefreshing, + } = useSessionContextUsage(sessionId); const isCompactingRef = useRef(false); const prevStreamingRef = useRef(false); // Tracks "sessionId::message" key to prevent double-send in React StrictMode @@ -1599,6 +1674,7 @@ export default function SessionChat({ const messagesContentRef = useRef(null); const scrollContainerRef = useRef(null); const isAtBottomRef = useRef(true); + const scrollToBottomRafRef = useRef(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); const isComposingRef = useRef(false); @@ -1608,7 +1684,8 @@ export default function SessionChat({ const [showCommandDropdown, setShowCommandDropdown] = useState(false); const [commandQuery, setCommandQuery] = useState(''); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); - const commandsLoadedRef = useRef(false); + const commandsLoadedAtRef = useRef(0); + const commandsLoadingRef = useRef(false); const [mentionQuery, setMentionQuery] = useState(''); const [mentionRange, setMentionRange] = useState<{ start: number; end: number } | null>(null); const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); @@ -1638,11 +1715,20 @@ export default function SessionChat({ const scrollToBottom = useCallback(() => { if (!isAtBottomRef.current) return; - requestAnimationFrame(() => { + if (scrollToBottomRafRef.current !== null) return; + scrollToBottomRafRef.current = requestAnimationFrame(() => { + scrollToBottomRafRef.current = null; messagesEndRef.current?.scrollIntoView({ behavior: 'instant' }); }); }, []); + useEffect(() => () => { + if (scrollToBottomRafRef.current !== null) { + cancelAnimationFrame(scrollToBottomRafRef.current); + scrollToBottomRafRef.current = null; + } + }, []); + const loadOlderMessagesRef = useRef<(() => Promise) | null>(null); const hasMoreMessagesRef = useRef(false); const loadingOlderMessagesRef = useRef(false); @@ -1719,92 +1805,6 @@ export default function SessionChat({ const sseEnabled = Boolean(sessionId) && (live || isStreaming || !hideInput); - const fetchPromptQueue = useCallback(async () => { - if (!sessionId) { - setQueuedPrompts([]); - return; - } - try { - const response = await sessionApi.listPromptQueue(sessionId); - setQueuedPrompts(response.items ?? []); - } catch (err) { - console.warn('[SessionChat] Failed to fetch prompt queue:', err); - } - }, [sessionId]); - - const refreshContextUsage = useCallback((options?: { clear?: boolean; skipIfFreshMs?: number }) => { - if (!sessionId) { - setContextUsageSnapshot(null); - setContextUsageRefreshing(false); - setContextUsageWindowTokens(0); - contextUsageRequestSeqRef.current += 1; - contextUsageRequestRef.current = null; - lastContextUsagePushAtRef.current = 0; - return; - } - if (options?.clear) { - setContextUsageSnapshot(null); - setContextUsageRefreshing(true); - contextUsageRequestSeqRef.current += 1; - contextUsageRequestRef.current = null; - lastContextUsagePushAtRef.current = 0; - } else if ( - options?.skipIfFreshMs && - Date.now() - lastContextUsagePushAtRef.current < options.skipIfFreshMs - ) { - return; - } - - const existingRequest = contextUsageRequestRef.current; - if (existingRequest?.sessionId === sessionId) { - return existingRequest.promise; - } - - const requestSessionId = sessionId; - const requestSeq = contextUsageRequestSeqRef.current; - const request = sessionApi.getContextUsage(requestSessionId).then((snapshot) => { - if (requestSeq === contextUsageRequestSeqRef.current && snapshot.sessionID === sessionId) { - setContextUsageSnapshot(snapshot); - if (snapshot.contextWindow && snapshot.contextWindow > 0) { - setContextUsageWindowTokens(snapshot.contextWindow); - } - setContextUsageRefreshing(false); - } - }).catch((err) => { - setContextUsageRefreshing(false); - console.warn('[SessionChat] Failed to fetch context usage:', err); - }).finally(() => { - if (contextUsageRequestRef.current?.promise === request) { - contextUsageRequestRef.current = null; - } - }); - contextUsageRequestRef.current = { sessionId: requestSessionId, promise: request }; - return request; - }, [sessionId]); - - useEffect(() => { - if (!sessionId) { - void refreshContextUsage({ clear: true }); - return; - } - const requestIdle = (window as any).requestIdleCallback as - | ((cb: () => void, options?: { timeout?: number }) => number) - | undefined; - const cancelIdle = (window as any).cancelIdleCallback as - | ((id: number) => void) - | undefined; - if (requestIdle) { - const idleId = requestIdle(() => { - void refreshContextUsage({ clear: true }); - }, { timeout: 1500 }); - return () => cancelIdle?.(idleId); - } - const timer = window.setTimeout(() => { - void refreshContextUsage({ clear: true }); - }, 250); - return () => window.clearTimeout(timer); - }, [refreshContextUsage]); - useEffect(() => { goalHydrationVersionRef.current += 1; const hydrationVersion = goalHydrationVersionRef.current; @@ -1832,184 +1832,174 @@ export default function SessionChat({ const handleSSEEvent = useCallback( (event: SSEChatEvent) => { - const { type, properties } = event; - - // Forward events with payload to parent (e.g. session.updated, workflow.updated). - // Skip empty events like heartbeats to avoid noisy callbacks. - if (properties) onSSEEvent?.(event); - - if (!properties || !sessionId) return; - - if (type === 'session.cleared' && properties.sessionID === sessionId) { - abortingRef.current = false; - sessionBusyRef.current = false; - activeToolPartIdsRef.current.clear(); - abortedMessageIdRef.current = null; - suppressStreamingUntilIdleRef.current = false; - setContextUsageSnapshot(null); - setContextUsageRefreshing(true); - setContextUsageWindowTokens(0); - setIsStreaming(false); - setGoalBanner(null); - setDismissedGoalKey(''); - refetch(); - void refreshContextUsage({ clear: true }); - } else if ( - (type === 'session.status' && properties.sessionID === sessionId) - || (type === 'session.updated' && properties.id === sessionId && properties.status === 'idle') - ) { - const statusType = type === 'session.status' ? properties.status?.type : properties.status; - if (statusType === 'busy') { - sessionBusyRef.current = true; - if ( - !abortingRef.current && - !suppressStreamingUntilIdleRef.current - ) setIsStreaming(true); - setIsCompacting(false); - isCompactingRef.current = false; - } else if (statusType === 'compacting') { - sessionBusyRef.current = true; - if ( - !abortingRef.current && - !suppressStreamingUntilIdleRef.current - ) setIsStreaming(true); - setIsCompacting(true); - isCompactingRef.current = true; - setCompactingMessage(properties.status?.message || t('chat.compacting')); - // Reset progress state on each new compaction cycle so a stale - // run's stages do not leak into a fresh "Compacting..." panel. - setCompactionStages([]); - } else if (statusType === 'idle') { + // Forward only global events or events relevant to this chat. The global + // stream can be very noisy when multiple sessions run in parallel. + if (shouldForwardSSEEventToParent(event, sessionId)) onSSEEvent?.(event); + + const action = resolveSessionChatSSEAction(event, sessionId); + + switch (action.kind) { + case 'ignore': + return; + case 'session-cleared': + abortingRef.current = false; sessionBusyRef.current = false; - suppressStreamingUntilIdleRef.current = false; activeToolPartIdsRef.current.clear(); + abortedMessageIdRef.current = null; + suppressStreamingUntilIdleRef.current = false; setIsStreaming(false); - setIsCompacting(false); - isCompactingRef.current = false; - setCompactingMessage(''); - setCompactionStages([]); + setGoalBanner(null); + setDismissedGoalKey(''); refetch(); - void refreshContextUsage({ skipIfFreshMs: 500 }); - } - } else if (type === 'message.updated' && properties.info?.sessionID === sessionId) { - updateMessage(properties.info); - if ( - properties.info.role === 'assistant' && - (abortingRef.current || suppressStreamingUntilIdleRef.current) - ) { - abortedMessageIdRef.current = properties.info.id; - markMessageStopped(properties.info.id); - setIsStreaming(false); - setSending(false); - if (properties.info.finish || properties.info.time?.completed) { - void refreshContextUsage(); - } - } else if (properties.info.finish || properties.info.time?.completed) { - const shouldRefetch = shouldRefetchFinishedMessage({ - finishedMessageId: properties.info.id, - abortedMessageId: abortedMessageIdRef.current, - }); - // Preserve locally streamed partial text when the user aborts. The - // backend never persists in-flight text chunks, so refetching here - // would replace the visible partial response with an empty message. - if (shouldRefetch) { + void refreshContextUsage({ clear: true }); + return; + case 'session-status': + if (action.statusType === 'busy') { + sessionBusyRef.current = true; + if ( + !abortingRef.current && + !suppressStreamingUntilIdleRef.current + ) setIsStreaming(true); + setIsCompacting(false); + isCompactingRef.current = false; + } else if (action.statusType === 'compacting') { + sessionBusyRef.current = true; + if ( + !abortingRef.current && + !suppressStreamingUntilIdleRef.current + ) setIsStreaming(true); + setIsCompacting(true); + isCompactingRef.current = true; + setCompactingMessage(action.message || t('chat.compacting')); + // Reset progress state on each new compaction cycle so a stale + // run's stages do not leak into a fresh "Compacting..." panel. + setCompactionStages([]); + } else if (action.statusType === 'idle') { + sessionBusyRef.current = false; + suppressStreamingUntilIdleRef.current = false; + activeToolPartIdsRef.current.clear(); + setIsStreaming(false); + setIsCompacting(false); + isCompactingRef.current = false; + setCompactingMessage(''); + setCompactionStages([]); refetch(); - if (!sessionBusyRef.current && activeToolPartIdsRef.current.size === 0) { - setIsStreaming(false); + void refreshContextUsage({ skipIfFreshMs: 500 }); + } + return; + case 'message-updated': { + const { info } = action; + updateMessage(info); + if ( + info.role === 'assistant' && + (abortingRef.current || suppressStreamingUntilIdleRef.current) + ) { + if (info.id) { + abortedMessageIdRef.current = info.id; + markMessageStopped(info.id); + } + setIsStreaming(false); + setSending(false); + if (info.finish || info.time?.completed) { + void refreshContextUsage(); + } + } else if (info.finish || info.time?.completed) { + const shouldRefetch = shouldRefetchFinishedMessage({ + finishedMessageId: info.id, + abortedMessageId: abortedMessageIdRef.current, + }); + // Preserve locally streamed partial text when the user aborts. The + // backend never persists in-flight text chunks, so refetching here + // would replace the visible partial response with an empty message. + if (shouldRefetch) { + refetch(); + if (!sessionBusyRef.current && activeToolPartIdsRef.current.size === 0) { + setIsStreaming(false); + } } + void refreshContextUsage(); + abortingRef.current = false; + abortedMessageIdRef.current = null; + } else if ( + info.role === 'assistant' && + !info.finish && + !abortingRef.current + ) { + setIsStreaming(true); } - void refreshContextUsage(); - abortingRef.current = false; - abortedMessageIdRef.current = null; - } else if ( - properties.info.role === 'assistant' && - !properties.info.finish && - !abortingRef.current - ) { - setIsStreaming(true); + return; } - } else if (type === 'message.part.updated' && properties.part?.sessionID === sessionId) { - const part = properties.part as Pick; - if (part.id) { - if (isActiveToolPart(part)) { - activeToolPartIdsRef.current.add(part.id); - if (!abortingRef.current && !suppressStreamingUntilIdleRef.current) setIsStreaming(true); - } else { - activeToolPartIdsRef.current.delete(part.id); + case 'message-part-updated': { + const part = action.part as Pick; + if (part.id) { + if (isActiveToolPart(part)) { + activeToolPartIdsRef.current.add(part.id); + if (!abortingRef.current && !suppressStreamingUntilIdleRef.current) setIsStreaming(true); + } else { + activeToolPartIdsRef.current.delete(part.id); + } } - } - updateMessagePart(properties.part, properties.delta); - scrollToBottom(); - } else if (type === 'question.asked' && properties.sessionID === sessionId) { - const callID: string | undefined = properties.tool?.callID; - const requestId: string | undefined = properties.id; - if (callID && requestId) { - handleQuestionAsked(callID, requestId, properties.questions || []); + updateMessagePart(action.part, action.delta); scrollToBottom(); + return; } - } else if ( - (type === 'question.replied' || type === 'question.rejected') && - properties.sessionID === sessionId - ) { - const requestId: string | undefined = properties.requestID; - if (requestId) { - removeByRequestId(requestId); - } - } else if (type === 'session.compaction_progress' && properties.sessionID === sessionId) { - const stage = properties.stage as CompactionStage | undefined; - const data = (properties.data ?? {}) as Record; - if (!stage) return; - if (stage === 'complete' && data.result === 'continue') { - void refreshContextUsage({ skipIfFreshMs: 500 }); - } - // Single source of truth: append into ``compactionStages`` and let - // the progress bar derive ``done/total`` from it via useMemo. - // ``chunk_done`` arrives in non-deterministic order under - // ``asyncio.gather``; deduplicate by chunk index here so SSE - // reconnects / accidental re-deliveries are idempotent. - setCompactionStages((prev) => { - if (stage === 'chunk_done') { - const chunkIdx = typeof data.chunk === 'number' ? data.chunk : undefined; - if (chunkIdx !== undefined && prev.some( - (e) => e.stage === 'chunk_done' && (e.data as { chunk?: number }).chunk === chunkIdx, - )) { - return prev; + case 'question-asked': + handleQuestionAsked(action.callID, action.requestId, action.questions as QuestionItem[]); + scrollToBottom(); + return; + case 'question-resolved': + removeByRequestId(action.requestId); + return; + case 'compaction-progress': + if (action.stage === 'complete' && action.data.result === 'continue') { + void refreshContextUsage({ skipIfFreshMs: 500 }); + } + // Single source of truth: append into ``compactionStages`` and let + // the progress bar derive ``done/total`` from it via useMemo. + // ``chunk_done`` arrives in non-deterministic order under + // ``asyncio.gather``; deduplicate by chunk index here so SSE + // reconnects / accidental re-deliveries are idempotent. + setCompactionStages((prev) => { + if (action.stage === 'chunk_done') { + const chunkIdx = typeof action.data.chunk === 'number' ? action.data.chunk : undefined; + if (chunkIdx !== undefined && prev.some( + (e) => e.stage === 'chunk_done' && (e.data as { chunk?: number }).chunk === chunkIdx, + )) { + return prev; + } } + return [...prev, { stage: action.stage, data: action.data, ts: Date.now() }]; + }); + return; + case 'prompt-queue-updated': + applyPromptQueueItems(action.items); + return; + case 'goal-updated': { + const nextGoal = toGoalBannerState(action.goal); + if (nextGoal) { + goalHydrationVersionRef.current += 1; + setGoalBanner(nextGoal); + setDismissedGoalKey(readDismissedGoalKey(sessionId)); } - return [...prev, { stage, data, ts: Date.now() }]; - }); - } else if (type === 'session.prompt_queue.updated' && properties.sessionID === sessionId) { - const items = Array.isArray(properties.items) ? properties.items : []; - setQueuedPrompts(items as QueuedPrompt[]); - if (items.length > 0) setQueueExpanded(true); - } else if (type === 'session.goal.updated' && properties.sessionID === sessionId) { - const nextGoal = toGoalBannerState(properties as SessionGoalState); - if (nextGoal) { - goalHydrationVersionRef.current += 1; - setGoalBanner(nextGoal); - setDismissedGoalKey(readDismissedGoalKey(sessionId)); - } - } else if (type === 'context.compacted' && properties.sessionID === sessionId) { - void refreshContextUsage({ skipIfFreshMs: 500 }); - } else if (type === 'context.usage.updated' && properties.sessionID === sessionId) { - setContextUsageSnapshot(properties as ContextUsageSnapshot); - if (typeof properties.contextWindow === 'number' && properties.contextWindow > 0) { - setContextUsageWindowTokens(properties.contextWindow); + return; } - contextUsageRequestSeqRef.current += 1; - contextUsageRequestRef.current = null; - lastContextUsagePushAtRef.current = Date.now(); - setContextUsageRefreshing(false); - } else if (type === 'session.error' && properties.sessionID === sessionId) { - setIsStreaming(false); - setIsCompacting(false); - setCompactionStages([]); - setContextUsageRefreshing(false); - void refreshContextUsage({ skipIfFreshMs: 500 }); - abortingRef.current = false; - sessionBusyRef.current = false; - activeToolPartIdsRef.current.clear(); - onError?.(properties.error?.message || t('chat.placeholder')); + case 'context-compacted': + void refreshContextUsage({ skipIfFreshMs: 500 }); + return; + case 'context-usage-updated': + applyContextUsagePushSnapshot(action.snapshot); + return; + case 'session-error': + setIsStreaming(false); + setIsCompacting(false); + setCompactionStages([]); + stopContextUsageRefreshing(); + void refreshContextUsage({ skipIfFreshMs: 500 }); + abortingRef.current = false; + sessionBusyRef.current = false; + activeToolPartIdsRef.current.clear(); + onError?.(action.message || t('chat.placeholder')); + return; } }, [ @@ -2018,11 +2008,15 @@ export default function SessionChat({ updateMessagePart, refetch, refreshContextUsage, + applyContextUsagePushSnapshot, + stopContextUsageRefreshing, handleQuestionAsked, removeByRequestId, + applyPromptQueueItems, onSSEEvent, onError, scrollToBottom, + t, ], ); @@ -2125,11 +2119,6 @@ export default function SessionChat({ setCompactionStages([]); setGoalBanner(null); setDismissedGoalKey(''); - setQueuedPrompts([]); - setEditingQueueId(null); - setEditingQueueText(''); - setQueueActionId(null); - setContextUsageWindowTokens(0); setMentionRange(null); setMentionQuery(''); setSelectedMentionIndex(0); @@ -2237,32 +2226,17 @@ export default function SessionChat({ return () => { if (timer) clearTimeout(timer); }; }, [isCompacting, sessionId, refetch]); - /** Lazily load slash commands on first use (for autocomplete dropdown). */ + /** Lazily load slash commands and periodically revalidate while autocomplete is used. */ const loadCommandsIfNeeded = useCallback(async (): Promise => { - if (commandsLoadedRef.current) return; - commandsLoadedRef.current = true; // Optimistic: prevent concurrent fetches + if (commandsLoadingRef.current || Date.now() - commandsLoadedAtRef.current < 5_000) return; + commandsLoadingRef.current = true; try { - const res = await commandAPI.list(); - const serverCommands = res.data ?? []; - // Merge client-side /new command into the autocomplete list - setCommands([ - { - name: 'new', - canonical_name: 'new', - description: 'Create a new session', - template: '', - hidden: false, - aliases: [], - visible_surfaces: [], - execution_kind: 'session_control', - allow_attachments: false, - requires_existing_session: false, - channel_safe: false, - } satisfies Command, - ...serverCommands, - ]); + setCommands(await fetchSessionChatCommands()); + commandsLoadedAtRef.current = Date.now(); } catch { - commandsLoadedRef.current = false; // Allow retry on failure + commandsLoadedAtRef.current = 0; + } finally { + commandsLoadingRef.current = false; } }, []); @@ -2610,14 +2584,12 @@ export default function SessionChat({ if (!sessionId) return; const effectiveAgent = agentOverride || agentName; try { - await sessionApi.enqueuePrompt(sessionId, { + await enqueuePrompt({ parts: buildPromptParts(text, imageParts), ...(effectiveAgent ? { agent: effectiveAgent } : {}), ...(model ? { model } : {}), ...(options?.displayText ? { displayText: options.displayText } : {}), }); - await fetchPromptQueue(); - setQueueExpanded(true); } catch (err: any) { const statusCode = err?.response?.status; const detail = err?.response?.data?.detail; @@ -2904,62 +2876,41 @@ export default function SessionChat({ }, [isStreaming, markMessageStopped, sending, sessionId]); const handleQueuedEditStart = useCallback((item: QueuedPrompt) => { - setEditingQueueId(item.id); - setEditingQueueText(getQueuedPromptText(item)); - }, []); + startQueuedEdit(item); + }, [startQueuedEdit]); const handleQueuedEditCancel = useCallback(() => { - setEditingQueueId(null); - setEditingQueueText(''); - }, []); + cancelQueuedEdit(); + }, [cancelQueuedEdit]); const handleQueuedEditSave = useCallback(async (item: QueuedPrompt) => { - if (!sessionId) return; - const text = editingQueueText.trim(); - if (!text) return; - setQueueActionId(item.id); try { - await sessionApi.updateQueuedPrompt(sessionId, item.id, text); - handleQueuedEditCancel(); - await fetchPromptQueue(); + await saveQueuedEdit(item); } catch (err: any) { toast.error(err?.response?.data?.detail || err?.message || t('chat.queue.updateFailed')); - } finally { - setQueueActionId(null); } - }, [editingQueueText, fetchPromptQueue, handleQueuedEditCancel, sessionId, t, toast]); + }, [saveQueuedEdit, t, toast]); const handleQueuedRemove = useCallback(async (item: QueuedPrompt) => { - if (!sessionId) return; - setQueueActionId(item.id); try { - await sessionApi.removeQueuedPrompt(sessionId, item.id); - if (editingQueueId === item.id) handleQueuedEditCancel(); - await fetchPromptQueue(); + await removeQueuedPrompt(item); } catch (err: any) { toast.error(err?.response?.data?.detail || err?.message || t('chat.queue.removeFailed')); - } finally { - setQueueActionId(null); } - }, [editingQueueId, fetchPromptQueue, handleQueuedEditCancel, sessionId, t, toast]); + }, [removeQueuedPrompt, t, toast]); const handleQueuedRunNow = useCallback(async (item: QueuedPrompt) => { - if (!sessionId) return; - setQueueActionId(item.id); try { - await sessionApi.runQueuedPromptNow(sessionId, item.id); - if (editingQueueId === item.id) handleQueuedEditCancel(); - await fetchPromptQueue(); + const didRun = await runQueuedPromptNow(item); + if (!didRun) return; abortingRef.current = false; abortedMessageIdRef.current = null; suppressStreamingUntilIdleRef.current = false; setIsStreaming(true); } catch (err: any) { toast.error(err?.response?.data?.detail || err?.message || t('chat.queue.runNowFailed')); - } finally { - setQueueActionId(null); } - }, [editingQueueId, fetchPromptQueue, handleQueuedEditCancel, sessionId, t, toast]); + }, [runQueuedPromptNow, t, toast]); // Fire onStreamingDone when isStreaming transitions true → false useEffect(() => { @@ -3150,6 +3101,10 @@ export default function SessionChat({ return { merged, skipIndices }; }, [messages]); + const timelineItems = useMemo(() => ( + buildChatTimelineItems({ messages: merged, skipIndices, isStreaming }) + ), [isStreaming, merged, skipIndices]); + const { historyItems, tailItems } = useStableChatTimelineSegments(timelineItems); // ── Styling based on compact mode ── const msgAreaClass = compact @@ -3210,44 +3165,56 @@ export default function SessionChat({
)} - {merged.map((msg, i) => { - if (skipIndices.has(i)) return null; - const isActiveMessage = - isStreaming && - i === merged.length - 1 && - msg.role === 'assistant' && - !msg.finish; - if (!shouldRenderMessage(msg, { isActive: isActiveMessage })) return null; - return ( - - ); - })} + + {/* Compacting indicator with live progress stages */} {isCompacting && ( @@ -3795,6 +3762,72 @@ export interface ChatMessageBubbleProps { onRegenerate?: (messageId: string) => Promise; } +interface ChatMessageTimelineProps extends Omit { + items: ChatTimelineItem[]; +} + +function ChatMessageTimelineInner({ + items, + pendingQuestions, + onQuestionAnswer, + onQuestionReject, + showActions, + showTimestamp, + collapseIntermediateSteps, + processGroupsDefaultOpen, + processGroupsOpenWhileActive, + processGroupOpenState, + onProcessGroupOpenChange, + compact, + onCopy, + editingMessageId, + editingText, + actionsDisabled, + actionMessageId, + onEditStart, + onEditChange, + onEditCancel, + onEditSave, + onEditSend, + onRegenerate, +}: ChatMessageTimelineProps) { + return ( + <> + {items.map(({ message, isActive }) => ( + + ))} + + ); +} + +export const ChatMessageTimeline = memo(ChatMessageTimelineInner); + function ProcessGroupDetails({ defaultOpen, open, @@ -3874,7 +3907,7 @@ function ChatMessageBubbleInner({ const { t } = useTranslation('session'); const isUser = message.role === 'user'; const parts: MessagePart[] = Array.isArray(message.parts) ? message.parts : []; - const { getPartExpanded, togglePart, isReasoningDone } = useReasoningToggle(parts, message.finish); + const { getPartExpanded, togglePart } = useReasoningToggle(parts, message.finish); // Lightbox state for inline image previews. Browsers block top-level // navigation to ``data:`` URLs (the format we send for chat images), so a // ``window.open`` would land on a blank page. We open an in-app overlay @@ -4004,7 +4037,10 @@ function ChatMessageBubbleInner({ if (part.type === 'file') return !!part.url; return false; }; - const renderPart = (part: MessagePart, i: number) => ( + const activeTailPart = isActive + ? [...displayParts].reverse().find(isRenderableDisplayPart) + : undefined; + const renderPart = (part: MessagePart, i: number, isVisible = true) => ( // Spacing between consecutive parts is owned by this wrapper, // not by individual part components. Each part used to set its // own `mt-2 first:mt-0`, but since every part lives in its own @@ -4065,8 +4101,8 @@ function ChatMessageBubbleInner({ const thinkingText = getRenderableThinkingText(part); if (!thinkingText) return null; const partKey = part.id || `reasoning-${i}`; - const isExpanded = getPartExpanded(partKey); - const isThinking = !isReasoningDone; + const isThinking = part === activeTailPart; + const isExpanded = isThinking || getPartExpanded(partKey); return ( // Vertical spacing is provided by the parent part wrapper // (see `otherParts.map` above); keep this container neutral @@ -4099,9 +4135,12 @@ function ChatMessageBubbleInner({ )}
- {isExpanded && ( + {isExpanded && isVisible && (
- {thinkingText} +
)}
@@ -4146,14 +4185,14 @@ function ChatMessageBubbleInner({ )} >
- {group.map(({ part, index }) => renderPart(part, index))} + {group.map(({ part, index }) => renderPart(part, index, effectiveProcessGroupOpen))}
); }; const renderDisplayParts = () => { if (!collapseIntermediateSteps || isUser) { - return displayParts.map(renderPart); + return displayParts.map((part, index) => renderPart(part, index)); } const nodes: React.ReactNode[] = []; let processGroup: Array<{ part: MessagePart; index: number }> = []; diff --git a/webui/src/components/common/StreamingMarkdown.test.tsx b/webui/src/components/common/StreamingMarkdown.test.tsx index 6b85a6931..27af58ed8 100644 --- a/webui/src/components/common/StreamingMarkdown.test.tsx +++ b/webui/src/components/common/StreamingMarkdown.test.tsx @@ -1,39 +1,50 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act, render } from '@testing-library/react'; -import { StreamingMarkdown, useStreamingContent } from './StreamingMarkdown'; +import { + StreamingMarkdown, + fallbackSplitStreamingGraphemes, + splitStreamingGraphemes, + useStreamingContent, +} from './StreamingMarkdown'; // ─── rAF fake setup ────────────────────────────────────────────────────────── type RafCallback = (time: number) => void; -let rafQueue: RafCallback[] = []; +let rafQueue = new Map(); let rafIdCounter = 0; +let rafTime = 0; function setupFakeRaf() { vi.stubGlobal('requestAnimationFrame', (cb: RafCallback) => { - rafIdCounter++; - rafQueue.push(cb); - return rafIdCounter; + const id = ++rafIdCounter; + rafQueue.set(id, cb); + return id; }); vi.stubGlobal('cancelAnimationFrame', (id: number) => { - // Mark cancelled by removing; simplified — good enough for these tests - rafQueue = rafQueue.filter((_, i) => i !== id - 1); + rafQueue.delete(id); }); } -function flushRaf() { - const pending = [...rafQueue]; - rafQueue = []; - pending.forEach(cb => cb(performance.now())); +function flushRafAt(time: number) { + rafTime = time; + const pending = [...rafQueue.values()]; + rafQueue.clear(); + pending.forEach(cb => cb(time)); +} + +function flushRaf(stepMs = 1000 / 60) { + flushRafAt(rafTime + stepMs); } // ─── Tests ─────────────────────────────────────────────────────────────────── describe('useStreamingContent', () => { beforeEach(() => { - rafQueue = []; + rafQueue = new Map(); rafIdCounter = 0; + rafTime = 0; setupFakeRaf(); }); @@ -86,8 +97,9 @@ describe('useStreamingContent', () => { it('streaming: multiple content updates in same frame only trigger one rAF', () => { const rafSpy = vi.fn().mockImplementation((cb: RafCallback) => { - rafQueue.push(cb); - return ++rafIdCounter; + const id = ++rafIdCounter; + rafQueue.set(id, cb); + return id; }); vi.stubGlobal('requestAnimationFrame', rafSpy); @@ -105,7 +117,7 @@ describe('useStreamingContent', () => { }); it('streaming→done: cancels pending rAF and applies final content immediately', () => { - const cancelSpy = vi.fn(); + const cancelSpy = vi.fn((id: number) => rafQueue.delete(id)); vi.stubGlobal('cancelAnimationFrame', cancelSpy); const { result, rerender } = renderHook( @@ -121,15 +133,19 @@ describe('useStreamingContent', () => { expect(cancelSpy).toHaveBeenCalled(); expect(result.current).toBe('chunk1 chunk2 final'); + + act(() => { flushRaf(); }); + expect(result.current).toBe('chunk1 chunk2 final'); }); - it('streaming: drains queued deltas progressively instead of jumping to latest content', () => { + it('streaming: types a small English backlog one character per frame', () => { const { result, rerender } = renderHook( ({ content, isStreaming }) => useStreamingContent(content, isStreaming), { initialProps: { content: 'a', isStreaming: true } }, ); - // Multiple updates before the frame fires + // Multiple updates before the frame fires still retain a typewriter-sized + // first step rather than jumping to the latest accumulated snapshot. act(() => { rerender({ content: 'ab', isStreaming: true }); }); act(() => { rerender({ content: 'abc', isStreaming: true }); }); act(() => { rerender({ content: 'abcd', isStreaming: true }); }); @@ -145,7 +161,27 @@ describe('useStreamingContent', () => { expect(result.current).toBe('abcd'); }); - it('streaming: catches up large backlogs within a bounded number of frames', () => { + it('streaming: types a small Chinese backlog one character per frame', () => { + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: '你', isStreaming: true } }, + ); + + act(() => { + rerender({ content: '你好世界', isStreaming: true }); + }); + + act(() => { flushRaf(); }); + expect(result.current).toBe('你好'); + + act(() => { flushRaf(); }); + expect(result.current).toBe('你好世'); + + act(() => { flushRaf(); }); + expect(result.current).toBe('你好世界'); + }); + + it('streaming: starts a large backlog with one character, then accelerates in bounded steps', () => { const fullContent = `a${'b'.repeat(120)}`; const { result, rerender } = renderHook( ({ content, isStreaming }) => useStreamingContent(content, isStreaming), @@ -159,10 +195,16 @@ describe('useStreamingContent', () => { act(() => { flushRaf(); }); - expect(result.current.length).toBeGreaterThan('a'.length); - expect(result.current.length).toBeLessThan(fullContent.length); + expect(result.current).toBe('ab'); + + act(() => { + flushRaf(); + }); + const acceleratedLength = result.current.length; + expect(acceleratedLength).toBeGreaterThan(2); + expect(acceleratedLength - 2).toBeLessThanOrEqual(8); - for (let i = 0; i < 12; i += 1) { + for (let i = 0; i < 90; i += 1) { act(() => { flushRaf(); }); @@ -170,6 +212,102 @@ describe('useStreamingContent', () => { expect(result.current).toBe(fullContent); }); + + it('streaming: advances at nearly the same rate on 60 Hz and 120 Hz displays', () => { + const progressAfter = (frameMs: number) => { + rafQueue.clear(); + rafTime = 0; + const fullContent = `a${'b'.repeat(300)}`; + const hook = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: 'a', isStreaming: true } }, + ); + + act(() => { hook.rerender({ content: fullContent, isStreaming: true }); }); + const frameCount = Math.round(500 / frameMs); + for (let frame = 1; frame <= frameCount; frame += 1) { + act(() => { flushRafAt(frame * frameMs); }); + } + const length = hook.result.current.length; + hook.unmount(); + return length; + }; + + const progress60Hz = progressAfter(1000 / 60); + const progress120Hz = progressAfter(1000 / 120); + expect(Math.abs(progress60Hz - progress120Hz)).toBeLessThanOrEqual(4); + }); + + it('streaming: clamps a long stalled frame instead of dumping the backlog', () => { + const fullContent = `a${'b'.repeat(300)}`; + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: 'a', isStreaming: true } }, + ); + + act(() => { rerender({ content: fullContent, isStreaming: true }); }); + act(() => { flushRafAt(1000 / 60); }); + expect(result.current).toBe('ab'); + + act(() => { flushRafAt(5000); }); + expect(result.current.length - 2).toBeGreaterThan(0); + expect(result.current.length - 2).toBeLessThanOrEqual(8); + + const afterStallLength = result.current.length; + act(() => { flushRaf(); }); + expect(result.current.length - afterStallLength).toBeLessThanOrEqual(7); + }); + + it('streaming: never splits a grapheme cluster across frames', () => { + const graphemes = ['A', '👍🏽', '👨‍👩‍👧‍👦', '🇨🇳', 'é']; + const fullContent = graphemes.join(''); + expect(splitStreamingGraphemes(fullContent)).toEqual(graphemes); + + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: graphemes[0], isStreaming: true } }, + ); + act(() => { rerender({ content: fullContent, isStreaming: true }); }); + + for (let index = 2; index <= graphemes.length; index += 1) { + act(() => { flushRaf(); }); + expect(result.current).toBe(graphemes.slice(0, index).join('')); + } + }); + + it('streaming: re-segments an unpainted grapheme split across SSE updates', () => { + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: 'A', isStreaming: true } }, + ); + + act(() => { rerender({ content: 'A👍', isStreaming: true }); }); + act(() => { rerender({ content: 'A👍🏽', isStreaming: true }); }); + act(() => { flushRaf(); }); + + expect(result.current).toBe('A👍🏽'); + }); + + it('fallback: keeps common compound graphemes intact without Intl.Segmenter', () => { + const graphemes = ['A', '👍🏽', '👨‍👩‍👧‍👦', '🇨🇳', 'é', '1️⃣']; + expect(fallbackSplitStreamingGraphemes(graphemes.join(''))).toEqual(graphemes); + }); + + it('streaming: the final delta and finish settle synchronously with no stale frame', () => { + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: 'almost', isStreaming: true } }, + ); + + act(() => { + rerender({ content: 'almost done', isStreaming: true }); + rerender({ content: 'almost done', isStreaming: false }); + }); + expect(result.current).toBe('almost done'); + + act(() => { flushRafAt(5000); }); + expect(result.current).toBe('almost done'); + }); }); describe('StreamingMarkdown', () => { diff --git a/webui/src/components/common/StreamingMarkdown.tsx b/webui/src/components/common/StreamingMarkdown.tsx index 5e1a1330a..8f6e4d83d 100644 --- a/webui/src/components/common/StreamingMarkdown.tsx +++ b/webui/src/components/common/StreamingMarkdown.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { memo, useState, useEffect, useRef, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import rehypeRaw from 'rehype-raw'; @@ -12,6 +12,117 @@ const sanitizeSchema = { strip: [...(defaultSchema.strip || []), 'style'], }; +const BASE_STREAMING_GRAPHEMES_PER_SECOND = 60; +const BACKLOG_RATE_BOOST = 4; +const MAX_STREAMING_GRAPHEMES_PER_SECOND = 360; +const MAX_STREAMING_GRAPHEMES_PER_FRAME = 8; +const MAX_DRAIN_ELAPSED_MS = 50; + +interface SegmentData { + segment: string; +} + +interface GraphemeSegmenter { + segment(input: string): Iterable; +} + +type GraphemeSegmenterConstructor = new ( + locales?: string | string[], + options?: { granularity: 'grapheme' }, +) => GraphemeSegmenter; + +const Segmenter = (Intl as typeof Intl & { Segmenter?: GraphemeSegmenterConstructor }).Segmenter; +const graphemeSegmenter = Segmenter + ? new Segmenter(undefined, { granularity: 'grapheme' }) + : null; + +const MARK_PATTERN = /\p{Mark}/u; + +/** Narrow fallback for engines that predate Intl.Segmenter. */ +export function fallbackSplitStreamingGraphemes(value: string): string[] { + const clusters: string[] = []; + let cluster = ''; + let regionalIndicatorCount = 0; + + for (const codePoint of Array.from(value)) { + const code = codePoint.codePointAt(0) ?? 0; + const isRegionalIndicator = code >= 0x1f1e6 && code <= 0x1f1ff; + const isEmojiModifier = code >= 0x1f3fb && code <= 0x1f3ff; + const isVariationSelector = ( + (code >= 0xfe00 && code <= 0xfe0f) + || (code >= 0xe0100 && code <= 0xe01ef) + ); + const isEmojiTag = code >= 0xe0020 && code <= 0xe007f; + const isJoiner = code === 0x200d; + const joinsPrevious = ( + MARK_PATTERN.test(codePoint) + || isEmojiModifier + || isVariationSelector + || isEmojiTag + || isJoiner + || cluster.endsWith('\u200d') + || (isRegionalIndicator && regionalIndicatorCount === 1) + ); + + if (!cluster || joinsPrevious) { + cluster += codePoint; + } else { + clusters.push(cluster); + cluster = codePoint; + } + + if (isRegionalIndicator) { + regionalIndicatorCount = joinsPrevious ? regionalIndicatorCount + 1 : 1; + } else if (!isVariationSelector && !isEmojiModifier && !MARK_PATTERN.test(codePoint)) { + regionalIndicatorCount = 0; + } + } + + if (cluster) clusters.push(cluster); + return clusters; +} + +export function splitStreamingGraphemes(value: string): string[] { + if (!graphemeSegmenter) return fallbackSplitStreamingGraphemes(value); + return Array.from(graphemeSegmenter.segment(value), ({ segment }) => segment); +} + +interface DrainBudget { + count: number; + credit: number; +} + +/** + * Convert elapsed time into a bounded render budget. A small backlog keeps a + * natural typewriter cadence; a growing backlog raises the target rate so the + * UI catches up without dumping an entire model chunk in one paint. + */ +export function getStreamingDrainBudget( + backlogLength: number, + elapsedMs: number, + credit: number, + firstFrame: boolean, +): DrainBudget { + if (backlogLength <= 0) return { count: 0, credit: 0 }; + if (firstFrame) return { count: 1, credit: 0 }; + + const boundedElapsedMs = Math.max(0, Math.min(MAX_DRAIN_ELAPSED_MS, elapsedMs)); + const targetRate = Math.min( + MAX_STREAMING_GRAPHEMES_PER_SECOND, + BASE_STREAMING_GRAPHEMES_PER_SECOND + + Math.max(0, backlogLength - 1) * BACKLOG_RATE_BOOST, + ); + const availableCredit = credit + targetRate * boundedElapsedMs / 1000; + const count = Math.min( + backlogLength, + MAX_STREAMING_GRAPHEMES_PER_FRAME, + Math.floor(availableCredit), + ); + // Preserve only fractional credit. Integer work rejected by the per-frame + // cap must not become a multi-frame burst after a long main-thread stall. + return { count, credit: availableCredit - Math.floor(availableCredit) }; +} + /** * Smooths streamed content by queueing appended text and draining it across * animation frames. The previous implementation collapsed all updates that @@ -23,31 +134,46 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri const pendingRafRef = useRef(null); const incomingContentRef = useRef(content); const displayedContentRef = useRef(content); - const queuedCharsRef = useRef([]); + const queuedGraphemesRef = useRef([]); const isStreamingRef = useRef(isStreaming); + const lastDrainTimeRef = useRef(null); + const drainCreditRef = useRef(0); const scheduleDrain = useCallback((drainQueue: (time: number) => void) => { if (pendingRafRef.current !== null) return; pendingRafRef.current = requestAnimationFrame(drainQueue); }, []); - const drainQueue = useCallback(() => { + const drainQueue = useCallback((time: number) => { pendingRafRef.current = null; - if (queuedCharsRef.current.length === 0) { + if (queuedGraphemesRef.current.length === 0) { + lastDrainTimeRef.current = null; + drainCreditRef.current = 0; return; } - // Drain progressively so a stalled frame does not dump the whole backlog - // in a single repaint. Larger backlogs should still catch up within a - // handful of frames instead of lagging behind for visibly too long. - const charsToRenderCount = Math.max(1, Math.ceil(queuedCharsRef.current.length / 3)); - const nextChunk = queuedCharsRef.current.splice(0, charsToRenderCount).join(''); - displayedContentRef.current += nextChunk; - setDisplayContent(displayedContentRef.current); + const previousDrainTime = lastDrainTimeRef.current; + const budget = getStreamingDrainBudget( + queuedGraphemesRef.current.length, + previousDrainTime === null ? 0 : time - previousDrainTime, + drainCreditRef.current, + previousDrainTime === null, + ); + lastDrainTimeRef.current = time; + drainCreditRef.current = budget.credit; - if (queuedCharsRef.current.length > 0 && isStreamingRef.current) { + if (budget.count > 0) { + const nextChunk = queuedGraphemesRef.current.splice(0, budget.count).join(''); + displayedContentRef.current += nextChunk; + setDisplayContent(displayedContentRef.current); + } + + if (queuedGraphemesRef.current.length > 0 && isStreamingRef.current) { scheduleDrain(drainQueue); + } else { + lastDrainTimeRef.current = null; + drainCreditRef.current = 0; } }, [scheduleDrain]); @@ -60,7 +186,9 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri cancelAnimationFrame(pendingRafRef.current); pendingRafRef.current = null; } - queuedCharsRef.current = []; + queuedGraphemesRef.current = []; + lastDrainTimeRef.current = null; + drainCreditRef.current = 0; incomingContentRef.current = content; displayedContentRef.current = content; setDisplayContent(content); @@ -72,7 +200,9 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri if (!content.startsWith(previousIncoming)) { // Content replaced or rewound: reset immediately to preserve correctness. - queuedCharsRef.current = []; + queuedGraphemesRef.current = []; + lastDrainTimeRef.current = null; + drainCreditRef.current = 0; displayedContentRef.current = content; setDisplayContent(content); return; @@ -81,7 +211,11 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri const delta = content.slice(previousIncoming.length); if (!delta) return; - queuedCharsRef.current.push(...Array.from(delta)); + // The last queued grapheme may be completed by the next SSE delta (for + // example 👍 + 🏽 or an emoji ZWJ sequence). Re-segment the unpainted tail + // together with the new text before it reaches the screen. + const queuedTail = queuedGraphemesRef.current.pop() ?? ''; + queuedGraphemesRef.current.push(...splitStreamingGraphemes(queuedTail + delta)); scheduleDrain(drainQueue); }, [content, isStreaming, drainQueue, scheduleDrain]); @@ -95,7 +229,9 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri [], ); - return displayContent; + // Completion must win in the same paint as the finish event. The effect + // above still clears queued work, but rendering does not wait for it. + return isStreaming ? displayContent : content; } export interface StreamingMarkdownProps { @@ -110,9 +246,7 @@ export interface StreamingMarkdownProps { * Content updates are throttled via requestAnimationFrame while streaming, * limiting ReactMarkdown re-parses to ~60fps instead of every SSE chunk. */ -export function StreamingMarkdown({ content, isStreaming }: StreamingMarkdownProps) { - const displayContent = useStreamingContent(content, isStreaming); - +const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) { return (
- {displayContent} + {content}
); +}); + +export function StreamingMarkdown({ content, isStreaming }: StreamingMarkdownProps) { + const displayContent = useStreamingContent(content, isStreaming); + return ; } diff --git a/webui/src/components/common/UpdateModal.test.tsx b/webui/src/components/common/UpdateModal.test.tsx new file mode 100644 index 000000000..67e4cda8e --- /dev/null +++ b/webui/src/components/common/UpdateModal.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import UpdateModal from './UpdateModal'; + +const { applyUpdate, checkUpdate } = vi.hoisted(() => ({ + applyUpdate: vi.fn(), + checkUpdate: vi.fn(), +})); + +vi.mock('@/api/update', () => ({ + applyUpdate, + checkUpdate, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'zh-CN' }, + }), +})); + +const currentVersion = { + current_version: '2026.07.10', + latest_version: '2026.07.10', + has_update: false, + release_notes: null, + release_url: null, + error: null, +}; + +describe('UpdateModal update checks', () => { + beforeEach(() => { + vi.clearAllMocks(); + checkUpdate.mockResolvedValue(currentVersion); + }); + + it('forces the initial request when Layout opens a manual check', async () => { + render( + , + ); + + await waitFor(() => { + expect(checkUpdate).toHaveBeenCalledWith('zh-CN', 'flocks', true); + }); + }); + + it('forces explicit refreshes from the modal', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'checkUpdate' })); + + await waitFor(() => { + expect(checkUpdate).toHaveBeenCalledWith('zh-CN', 'flocks', true); + }); + }); +}); diff --git a/webui/src/components/common/UpdateModal.tsx b/webui/src/components/common/UpdateModal.tsx index ab312d31f..bef95e371 100644 --- a/webui/src/components/common/UpdateModal.tsx +++ b/webui/src/components/common/UpdateModal.tsx @@ -53,13 +53,21 @@ function formatBytes(value?: number | null): string { interface UpdateModalProps { initialInfo?: VersionInfo | null; + forceInitialCheck?: boolean; edition?: UpdateEdition; canUpgrade?: boolean; onClose: () => void; onDismiss?: () => void; } -export default function UpdateModal({ initialInfo, edition = 'flocks', canUpgrade = true, onClose, onDismiss }: UpdateModalProps) { +export default function UpdateModal({ + initialInfo, + forceInitialCheck = false, + edition = 'flocks', + canUpgrade = true, + onClose, + onDismiss, +}: UpdateModalProps) { const { t, i18n } = useTranslation('update'); const [info, setInfo] = useState(initialInfo ?? null); const [checking, setChecking] = useState(false); @@ -83,11 +91,11 @@ export default function UpdateModal({ initialInfo, edition = 'flocks', canUpgrad setRestarting(val); }; - const fetchVersion = useCallback(async () => { + const fetchVersion = useCallback(async (force = false) => { setChecking(true); setError(null); try { - const data = await checkUpdate(i18n.language, edition); + const data = await checkUpdate(i18n.language, edition, force); setInfo(data); if (data.error) setError(data.error); } catch (e: any) { @@ -99,9 +107,9 @@ export default function UpdateModal({ initialInfo, edition = 'flocks', canUpgrad useEffect(() => { if (!initialInfo) { - fetchVersion(); + void fetchVersion(forceInitialCheck); } - }, [fetchVersion, initialInfo]); + }, [fetchVersion, forceInitialCheck, initialInfo]); const handleUpgrade = useCallback(async () => { if (!info?.has_update) return; @@ -407,7 +415,7 @@ export default function UpdateModal({ initialInfo, edition = 'flocks', canUpgrad
+ {isPaired && ( +

+ + {t('whatsapp.sessionConfigured')} +

+ )} +
+ + {showModal && ( +
+
+ +

{t('whatsapp.qrModalTitle')}

+ {qrPhase === 'loading' && ( +
+ +
+ )} + {qrPhase === 'scanning' && qrValue && ( + + )} + {(qrPhase === 'connected' || qrPhase === 'complete') && ( +
+ +

+ {qrPhase === 'complete' ? t('whatsapp.qrComplete') : t('whatsapp.qrConnected')} +

+
+ )} + {qrPhase === 'error' && ( +
+ +

{qrError || t('whatsapp.qrError')}

+ +
+ )} +

+ {qrPhase === 'loading' && t('whatsapp.qrHintLoading')} + {qrPhase === 'scanning' && t('whatsapp.qrHintScanning')} + {qrPhase === 'connected' && t('whatsapp.qrHintConnected')} + {qrPhase === 'complete' && t('whatsapp.qrHintComplete')} +

+ {qrPhase === 'complete' && ( + + )} +
+
+ )} + +
+ + + set('sessionPath', v || undefined)} + placeholder={t('whatsapp.optional')} + /> + + + set('dmPolicy', v)} + options={[ + { value: 'allowlist', label: t('whatsapp.dmPolicyAllowlist') }, + { value: 'open', label: t('whatsapp.dmPolicyOpen') }, + { value: 'disabled', label: t('whatsapp.dmPolicyDisabled') }, + ]} + /> + + + onChange({ + ...config, + dmPolicy: enabled ? 'allowlist' : 'open', + allowFrom: enabled ? (config.allowFrom ?? []) : undefined, + })} + /> + + {allowFromEnabled && ( + + set('allowFrom', v)} + placeholder={t('whatsapp.allowFromPlaceholder')} + /> + + )} + + set('groupTrigger', v)} + options={[ + { value: 'mention', label: t('whatsapp.triggerMention') }, + { value: 'all', label: t('whatsapp.triggerAll') }, + ]} + /> + + )} + + +
+ + set('bridgePort', v)} + min={1} + /> + + + set('replyPrefix', v || undefined)} + placeholder={t('whatsapp.optional')} + /> + + + set('textBatchDelaySeconds', v)} + min={0} + /> + + + set('sendChunkDelayMs', v)} + min={0} + /> + + + set('sendTimeoutMs', v)} + min={1000} + /> + + + set('mediaCacheDir', v || undefined)} + placeholder={t('whatsapp.optional')} + /> + +
+ + ); +} + // ============================================================================ // Weixin Config Panel // ============================================================================ @@ -1926,9 +2315,9 @@ export default function ChannelPage() { const originalConfigsRef = useRef>({}); const toggleInFlightRef = useRef(false); - const fetchAll = useCallback(async () => { + const fetchAll = useCallback(async (showLoading = false) => { try { - setLoading(true); + if (showLoading) setLoading(true); const [listRes, configRes] = await Promise.all([ client.get('/api/channel/list'), client.get('/api/config'), @@ -1956,6 +2345,21 @@ export default function ChannelPage() { configs[ch.id] = { ...defaultDingTalkConfig(), ...saved }; } else if (ch.id === 'telegram') { configs[ch.id] = { ...defaultTelegramConfig(), ...saved }; + } else if (ch.id === 'whatsapp') { + const whatsappCfg = { ...defaultWhatsAppConfig(), ...saved }; + if (whatsappCfg.sessionPath) { + try { + const sessionRes = await client.get('/api/channel/whatsapp/session-status', { + params: { sessionPath: whatsappCfg.sessionPath }, + }); + whatsappCfg._paired = Boolean(sessionRes.data?.paired); + } catch { + whatsappCfg._paired = false; + } + } else { + whatsappCfg._paired = false; + } + configs[ch.id] = whatsappCfg; } else if (ch.id === 'weixin') { configs[ch.id] = { ...defaultWeixinConfig(), ...saved }; } else { @@ -1993,7 +2397,7 @@ export default function ChannelPage() { }, []); useEffect(() => { - fetchAll(); + fetchAll(true); fetchStatuses(true); const interval = setInterval(() => fetchStatuses(true), 15000); return () => clearInterval(interval); @@ -2090,6 +2494,37 @@ export default function ChannelPage() { setTimeout(() => { fetchAll(); fetchStatuses(true); }, 8000); }; + const handleWhatsAppPairSuccess = async ( + data: { sessionPath: string } + ) => { + const channelId = 'whatsapp'; + const savedChannelCfg = (fullConfig.channels?.[channelId] ?? {}) as Record; + const updatedChannelCfg: Record = { + ...savedChannelCfg, + ...stripEmpty(channelConfigs[channelId] ?? {}), + enabled: true, + }; + if (data.sessionPath) updatedChannelCfg.sessionPath = data.sessionPath; + const nextUiConfig = { ...updatedChannelCfg, _paired: true }; + + const updatedChannels = { ...(fullConfig.channels ?? {}), [channelId]: updatedChannelCfg }; + const updated = { ...fullConfig, channels: updatedChannels }; + + await client.patch('/api/config/', updated); + setFullConfig(updated); + setChannelConfigs((prev) => ({ + ...prev, + [channelId]: { ...prev[channelId], ...nextUiConfig } as ChannelConfig, + })); + originalConfigsRef.current = { + ...originalConfigsRef.current, + [channelId]: { ...originalConfigsRef.current[channelId], ...nextUiConfig }, + }; + client.post(`/api/channel/${channelId}/restart`, {}, { timeout: 5000 }).catch(() => {}); + setTimeout(() => { fetchAll(); fetchStatuses(true); }, 3000); + setTimeout(() => { fetchAll(); fetchStatuses(true); }, 8000); + }; + // Manual restart — useful when connection drops and user wants to reconnect const handleRestart = async (channelId?: string) => { const id = channelId ?? selectedId; @@ -2176,7 +2611,7 @@ export default function ChannelPage() { if (loading) { return (
- +
); } @@ -2281,6 +2716,13 @@ export default function ChannelPage() { onRefresh={fetchAll} /> )} + {selectedId === 'whatsapp' && ( + handleChannelConfigChange('whatsapp', cfg)} + onPairSuccess={handleWhatsAppPairSuccess} + /> + )} {selectedId === 'weixin' && ( ): Record { const result: Record = {}; for (const [k, v] of Object.entries(obj)) { + if (k.startsWith('_')) continue; if (v === '' || v === undefined) continue; // Empty arrays ARE preserved: e.g. allowFrom:[] means "require pairing for everyone" // (distinct from absent key which means "open access"). diff --git a/webui/src/pages/DeviceIntegration/index.test.tsx b/webui/src/pages/DeviceIntegration/index.test.tsx index 4d4a93474..68d70730c 100644 --- a/webui/src/pages/DeviceIntegration/index.test.tsx +++ b/webui/src/pages/DeviceIntegration/index.test.tsx @@ -415,7 +415,7 @@ describe('DeviceIntegrationPage', () => { await openManualAddWizard(user); - expect(screen.getByText('API 接入')).toBeInTheDocument(); + expect(await screen.findByText('API 接入')).toBeInTheDocument(); expect(screen.getByText('浏览器接入')).toBeInTheDocument(); expect(screen.getByText('TDP 接入')).toBeInTheDocument(); expect(screen.getByText('OneSEC 接入')).toBeInTheDocument(); diff --git a/webui/src/pages/DeviceIntegration/index.tsx b/webui/src/pages/DeviceIntegration/index.tsx index 1538bdc89..121c388ec 100644 --- a/webui/src/pages/DeviceIntegration/index.tsx +++ b/webui/src/pages/DeviceIntegration/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Shield, CheckCircle, XCircle, AlertTriangle, RefreshCw, @@ -9,7 +9,6 @@ import { import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import { useToast } from '@/components/common/Toast'; -import SessionChat from '@/components/common/SessionChat'; import GuideInfoIcon from '@/components/common/GuideInfoIcon'; import { useRexComposerControls } from '@/components/common/useRexComposerControls'; import { useSessionChat, type CreateAndSendOptions } from '@/hooks/useSessionChat'; @@ -29,6 +28,15 @@ import { buildCustomDeviceModeRoutingPrompt } from './customDevice'; const DEFAULT_GROUP_ID = 'default-room'; const DEVICE_DRAWER_WIDTH = 560; const DEVICE_DRAWER_WIDTH_CSS = `${DEVICE_DRAWER_WIDTH}px`; +const LazySessionChat = lazy(() => import('@/components/common/SessionChat')); + +function SessionChatFallback() { + return ( +
+ +
+ ); +} /** Pull the backend's human-readable error detail (e.g. "机房名称已存在") * out of an axios error, falling back to a generic message. */ @@ -697,171 +705,172 @@ function DeviceAddRexPanel({ return (
- void detectLatestDraft(true)} - welcomeContent={ -
-
- {!showBuiltInTemplates ? ( - <> -
-
- + }> + void detectLatestDraft(true)} + welcomeContent={ +
+
+ {!showBuiltInTemplates ? ( + <> +
+
+ +
+

{t('wizard.guide.title')}

+

+ {t('wizard.guide.subtitle')} +

-

{t('wizard.guide.title')}

-

- {t('wizard.guide.subtitle')} -

-
-
- - startGuidedPrompt(t('wizard.guide.prompts.api'))} - /> - startGuidedPrompt(t('wizard.guide.prompts.browser'))} - /> - - - - handleCaseTemplate(['tdp'], t('wizard.guide.prompts.tdp'))} - /> - handleCaseTemplate(['onesec', 'one sec'], t('wizard.guide.prompts.onesec'))} - /> - setShowBuiltInTemplates(true)} - /> - -
- - ) : ( - <> -
- -

{t('wizard.supportedList.title')}

-

{t('wizard.supportedList.subtitle')}

-
+
+ + startGuidedPrompt(t('wizard.guide.prompts.api'))} + /> + startGuidedPrompt(t('wizard.guide.prompts.browser'))} + /> + -
- {vendorGroups.map(({ vendor, templates: vendorTemplates }) => { - const expanded = expandedVendors.has(vendor.id); - const vendorName = i18n.language.startsWith('zh') ? vendor.nameCn : vendor.nameEn; - const integratedCount = vendorTemplates.reduce( - (sum, template) => sum + (instanceCounts[template.storage_key] ?? 0), - 0, - ); - return ( -
- - {expanded && ( -
- {vendorTemplates.map((tpl) => { - const count = instanceCounts[tpl.storage_key] ?? 0; - const action = templateAction(tpl); - const installing = installingTemplateKey === tpl.storage_key; - const templateMeta = tpl.version ? formatTemplateVersion(tpl.version) : tpl.storage_key; - const stateBadge = tpl.installed - ? t('wizard.installState.installed') - : tpl.state === 'updateAvailable' - ? t('wizard.installState.updateAvailable') - : tpl.state === 'broken' - ? t('wizard.installState.brokenShort') - : t('wizard.installState.available'); - return ( -
+ + ) : ( + <> +
+ +

{t('wizard.supportedList.title')}

+

{t('wizard.supportedList.subtitle')}

+
+ +
+ {vendorGroups.map(({ vendor, templates: vendorTemplates }) => { + const expanded = expandedVendors.has(vendor.id); + const vendorName = i18n.language.startsWith('zh') ? vendor.nameCn : vendor.nameEn; + const integratedCount = vendorTemplates.reduce( + (sum, template) => sum + (instanceCounts[template.storage_key] ?? 0), + 0, + ); + return ( +
+ + {expanded && ( +
+ {vendorTemplates.map((tpl) => { + const count = instanceCounts[tpl.storage_key] ?? 0; + const action = templateAction(tpl); + const installing = installingTemplateKey === tpl.storage_key; + const templateMeta = tpl.version ? formatTemplateVersion(tpl.version) : tpl.storage_key; + const stateBadge = tpl.installed + ? t('wizard.installState.installed') + : tpl.state === 'updateAvailable' + ? t('wizard.installState.updateAvailable') + : tpl.state === 'broken' + ? t('wizard.installState.brokenShort') + : t('wizard.installState.available'); + return ( + - ); - })} -
- )} -
- ); - })} -
- - )} + + {count > 0 && {t('wizard.instanceCount', { count })}} + {installing ? t(action === 'update' ? 'wizard.installState.updating' : 'wizard.installState.installing') : stateBadge} + + {installing + ? + : ( + + )} + + ); + })} +
+ )} + + ); + })} +
+ + )}
} @@ -872,6 +881,7 @@ function DeviceAddRexPanel({ model: modelOverride === undefined ? rexComposerControls.rexModel : modelOverride, }) : undefined} /> + {detectedAction && (
diff --git a/webui/src/pages/Home/index.tsx b/webui/src/pages/Home/index.tsx index b4d664cb0..27d4198ae 100644 --- a/webui/src/pages/Home/index.tsx +++ b/webui/src/pages/Home/index.tsx @@ -196,7 +196,7 @@ export default function Home() { {/* Stats */} {loading ? (
- +
) : ( <> diff --git a/webui/src/pages/Hub/index.test.tsx b/webui/src/pages/Hub/index.test.tsx new file mode 100644 index 000000000..152054b3a --- /dev/null +++ b/webui/src/pages/Hub/index.test.tsx @@ -0,0 +1,448 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import HubPage from './index'; + +const { hubAPI, toastError } = vi.hoisted(() => ({ + hubAPI: { + catalog: vi.fn(), + catalogPage: vi.fn(), + categories: vi.fn(), + refresh: vi.fn(), + install: vi.fn(), + installStream: vi.fn(), + update: vi.fn(), + uninstall: vi.fn(), + get: vi.fn(), + files: vi.fn(), + fileContent: vi.fn(), + }, + toastError: vi.fn(), +})); + +vi.mock('@/api/hub', () => ({ hubAPI })); +vi.mock('@/hooks/useDebouncedValue', () => ({ + useDebouncedValue: (value: T) => value, +})); +vi.mock('@/contexts/ProductNameContext', () => ({ + useProductName: () => ({ productName: 'Flocks' }), +})); +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ user: { id: 'admin-1', role: 'admin' } }), +})); +vi.mock('@/components/common/Toast', () => ({ + useToast: () => ({ error: toastError }), +})); +vi.mock('@/components/common/LoadingSpinner', () => ({ + default: () =>
loading
, +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + i18n: { language: 'en-US' }, + }), +})); + +const emptyFacets = { + type: {}, + category: {}, + tags: {}, + useCases: {}, + state: {}, + trust: {}, + riskLevel: {}, +}; + +function catalogEntry(id: string, name: string, manifestPath = `${id}/manifest.json`) { + return { + id, + type: 'tool' as const, + name, + description: `${name} description`, + version: '1.0.0', + category: 'security', + tags: ['security'], + useCases: ['investigation'], + domains: [], + capabilities: [], + trust: 'verified', + riskLevel: 'low', + state: 'available' as const, + source: 'bundled', + manifestPath, + native: false, + }; +} + +function catalogPage(items: ReturnType[], total = items.length) { + return { + data: { + items, + total, + offset: 0, + limit: 25, + facets: emptyFacets, + }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function renderHub() { + return render( + + + , + ); +} + +describe('HubPage catalog loading', () => { + beforeEach(() => { + vi.clearAllMocks(); + hubAPI.categories.mockResolvedValue({ + data: { categories: [], tags: [], useCases: [] }, + }); + hubAPI.catalog.mockResolvedValue({ data: [] }); + hubAPI.files.mockResolvedValue({ + data: { name: 'root', path: '', type: 'directory', size: 0, previewable: false, children: [] }, + }); + }); + + it('ignores an older search response that finishes after the latest query', async () => { + const oldSearch = deferred>(); + const latestSearch = deferred>(); + hubAPI.catalogPage + .mockResolvedValueOnce(catalogPage([catalogEntry('initial', 'Initial result')])) + .mockImplementationOnce(() => oldSearch.promise) + .mockImplementationOnce(() => latestSearch.promise); + + renderHub(); + expect(await screen.findByText('Initial result')).toBeInTheDocument(); + + const search = screen.getByPlaceholderText('Search plugin name, description, tag, use case'); + fireEvent.change(search, { target: { value: 'old' } }); + await waitFor(() => expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2)); + fireEvent.change(search, { target: { value: 'latest' } }); + await waitFor(() => expect(hubAPI.catalogPage).toHaveBeenCalledTimes(3)); + + await act(async () => { + latestSearch.resolve(catalogPage([catalogEntry('latest', 'Latest result')])); + }); + expect(await screen.findByText('Latest result')).toBeInTheDocument(); + + await act(async () => { + oldSearch.resolve(catalogPage([catalogEntry('old', 'Old result')])); + }); + + expect(screen.getByText('Latest result')).toBeInTheDocument(); + expect(screen.queryByText('Old result')).not.toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ q: 'latest' }), + ); + }); + + it('loads the complete unpaged catalog for directory view', async () => { + const user = userEvent.setup(); + const pagedEntry = catalogEntry('paged', 'Paged Entry', 'paged-tree/manifest.json'); + const beyondPage = catalogEntry('complete', 'Complete Entry', 'complete-tree/manifest.json'); + hubAPI.catalogPage.mockResolvedValue(catalogPage([pagedEntry], 854)); + hubAPI.catalog.mockResolvedValue({ data: [pagedEntry, beyondPage] }); + + renderHub(); + expect(await screen.findByText('Paged Entry')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Directory View' })); + + expect(await screen.findByText('complete-tree')).toBeInTheDocument(); + expect(screen.getByText('paged-tree')).toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(1); + expect(hubAPI.catalogPage).toHaveBeenCalledWith( + expect.objectContaining({ offset: 0, limit: 25 }), + ); + expect(hubAPI.catalog).toHaveBeenCalledWith({ + q: undefined, + type: undefined, + useCases: undefined, + tags: undefined, + state: undefined, + }); + }); + + it('refreshes the selected entity after an action even when it leaves the filtered page', async () => { + const user = userEvent.setup(); + const available = catalogEntry('action-entry', 'Action Entry'); + const installed = { ...available, state: 'installed' as const, installedVersion: '1.0.0' }; + hubAPI.catalogPage + .mockResolvedValueOnce(catalogPage([available])) + .mockResolvedValueOnce(catalogPage([])); + hubAPI.catalog.mockResolvedValue({ data: [installed] }); + // The manifest endpoint intentionally has no dynamic catalog state. + hubAPI.get.mockResolvedValueOnce({ data: available }); + hubAPI.install.mockResolvedValue({ data: installed }); + + renderHub(); + await user.click(await screen.findByText('Action Entry')); + await waitFor(() => expect(hubAPI.get).toHaveBeenCalledTimes(1)); + + const installButtons = screen.getAllByRole('button', { name: 'Install' }); + await user.click(installButtons[installButtons.length - 1]); + + await waitFor(() => expect(hubAPI.install).toHaveBeenCalledWith('tool', 'action-entry')); + expect(hubAPI.catalog).toHaveBeenCalledWith({ q: 'action-entry', type: 'tool' }); + expect(hubAPI.get).toHaveBeenCalledTimes(1); + expect(await screen.findByRole('button', { name: 'Uninstall' })).toBeInTheDocument(); + expect(screen.getByText('Installed')).toBeInTheDocument(); + }); + + it('does not let a table action started under an old query overwrite the latest query', async () => { + const user = userEvent.setup(); + const actionEntry = catalogEntry('action-entry', 'Action Entry'); + const latestEntry = catalogEntry('latest-entry', 'Latest Entry'); + const installedEntry = { ...actionEntry, state: 'installed' as const, installedVersion: '1.0.0' }; + const install = deferred(); + hubAPI.catalogPage.mockImplementation(({ q }: { q?: string }) => ( + Promise.resolve(q === 'latest' ? catalogPage([latestEntry]) : catalogPage([actionEntry])) + )); + hubAPI.install.mockReturnValue(install.promise); + hubAPI.catalog.mockResolvedValue({ data: [installedEntry] }); + + renderHub(); + await user.click(await screen.findByRole('button', { name: 'Install' })); + await waitFor(() => expect(hubAPI.install).toHaveBeenCalledWith('tool', 'action-entry')); + + fireEvent.change( + screen.getByPlaceholderText('Search plugin name, description, tag, use case'), + { target: { value: 'latest' } }, + ); + expect(await screen.findByText('Latest Entry')).toBeInTheDocument(); + + await act(async () => { + install.resolve(); + await install.promise; + }); + + await waitFor(() => { + expect(hubAPI.catalog).toHaveBeenCalledWith({ q: 'action-entry', type: 'tool' }); + }); + expect(screen.getByText('Latest Entry')).toBeInTheDocument(); + expect(screen.queryByText('Action Entry')).not.toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2); + expect(hubAPI.catalogPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'latest' })); + }); + + it('does not let a refresh started under an old query overwrite the latest query', async () => { + const user = userEvent.setup(); + const initialEntry = catalogEntry('initial-entry', 'Initial Entry'); + const latestEntry = catalogEntry('latest-entry', 'Latest Entry'); + const refresh = deferred(); + hubAPI.catalogPage.mockImplementation(({ q }: { q?: string }) => ( + Promise.resolve(q === 'latest' ? catalogPage([latestEntry]) : catalogPage([initialEntry])) + )); + hubAPI.refresh.mockReturnValue(refresh.promise); + + renderHub(); + expect(await screen.findByText('Initial Entry')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + fireEvent.change( + screen.getByPlaceholderText('Search plugin name, description, tag, use case'), + { target: { value: 'latest' } }, + ); + expect(await screen.findByText('Latest Entry')).toBeInTheDocument(); + + await act(async () => { + refresh.resolve(); + await refresh.promise; + }); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()); + expect(screen.getByText('Latest Entry')).toBeInTheDocument(); + expect(screen.queryByText('Initial Entry')).not.toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2); + expect(hubAPI.catalogPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'latest' })); + }); + + it('shows a user-visible error when a manual refresh fails', async () => { + const user = userEvent.setup(); + hubAPI.catalogPage.mockResolvedValue(catalogPage([catalogEntry('initial-entry', 'Initial Entry')])); + hubAPI.refresh.mockRejectedValue(new Error('refresh unavailable')); + + renderHub(); + expect(await screen.findByText('Initial Entry')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith('Hub refresh failed', 'refresh unavailable'); + }); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(1); + }); + + it('distinguishes a completed manual refresh from a catalog reload failure', async () => { + const user = userEvent.setup(); + hubAPI.catalogPage + .mockResolvedValueOnce(catalogPage([catalogEntry('initial-entry', 'Initial Entry')])) + .mockRejectedValueOnce({ + response: { data: { detail: 'catalog temporarily unavailable' } }, + message: 'Request failed with status code 503', + }); + hubAPI.refresh.mockResolvedValue({ data: { status: 'success' } }); + + renderHub(); + expect(await screen.findByText('Initial Entry')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + 'Hub refresh completed, but catalog reload failed', + 'catalog temporarily unavailable', + ); + }); + expect(toastError).not.toHaveBeenCalledWith('Hub refresh failed', expect.anything()); + expect(hubAPI.refresh).toHaveBeenCalledTimes(1); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled(); + }); + + it('does not report a stale manual reload failure after the query changes', async () => { + const user = userEvent.setup(); + const staleReload = deferred>(); + hubAPI.catalogPage + .mockResolvedValueOnce(catalogPage([catalogEntry('initial-entry', 'Initial Entry')])) + .mockImplementationOnce(() => staleReload.promise) + .mockResolvedValueOnce(catalogPage([catalogEntry('latest-entry', 'Latest Entry')])); + hubAPI.refresh.mockResolvedValue({ data: { status: 'success' } }); + + renderHub(); + expect(await screen.findByText('Initial Entry')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2)); + + fireEvent.change( + screen.getByPlaceholderText('Search plugin name, description, tag, use case'), + { target: { value: 'latest' } }, + ); + expect(await screen.findByText('Latest Entry')).toBeInTheDocument(); + + await act(async () => { + staleReload.reject(new Error('obsolete catalog failure')); + try { + await staleReload.promise; + } catch { + // The component owns this rejected request. + } + }); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()); + expect(toastError).not.toHaveBeenCalledWith( + 'Hub refresh completed, but catalog reload failed', + expect.anything(), + ); + expect(screen.getByText('Latest Entry')).toBeInTheDocument(); + }); + + it.each([ + ['install', 'available', 'Install', 'Install failed'], + ['update', 'updateAvailable', 'Update', 'Update failed'], + ['uninstall', 'installed', 'Uninstall', 'Uninstall failed'], + ] as const)('shows a user-visible error when %s fails', async (action, state, buttonLabel, errorTitle) => { + const user = userEvent.setup(); + const entry = { ...catalogEntry('action-entry', 'Action Entry'), state }; + hubAPI.catalogPage.mockResolvedValue(catalogPage([entry])); + hubAPI[action].mockRejectedValue({ + response: { data: { detail: `${action} permission denied` } }, + message: `Request failed with status code 422`, + }); + + renderHub(); + await user.click(await screen.findByRole('button', { name: buttonLabel })); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + `${errorTitle}: Action Entry`, + `${action} permission denied`, + ); + }); + expect(screen.getByRole('button', { name: buttonLabel })).toBeEnabled(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(1); + }); + + it('does not report a completed action as failed when the follow-up reload fails', async () => { + const user = userEvent.setup(); + const entry = catalogEntry('action-entry', 'Action Entry'); + hubAPI.catalogPage.mockResolvedValue(catalogPage([entry])); + hubAPI.install.mockResolvedValue({ data: { ...entry, state: 'installed' } }); + hubAPI.catalog.mockRejectedValue({ + response: { data: { detail: 'catalog temporarily unavailable' } }, + message: 'Request failed with status code 503', + }); + + renderHub(); + await user.click(await screen.findByRole('button', { name: 'Install' })); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + 'Action completed, but Hub reload failed: Action Entry', + 'catalog temporarily unavailable', + ); + }); + expect(toastError).not.toHaveBeenCalledWith( + 'Install failed: Action Entry', + expect.anything(), + ); + expect(hubAPI.install).toHaveBeenCalledTimes(1); + }); + + it('does not let a tree action started under old filters overwrite the latest tree', async () => { + const user = userEvent.setup(); + const actionEntry = catalogEntry('tree-action', 'Tree Action'); + const latestEntry = catalogEntry('tree-latest', 'Tree Latest'); + const installedEntry = { ...actionEntry, state: 'installed' as const, installedVersion: '1.0.0' }; + const install = deferred(); + hubAPI.catalogPage.mockImplementation(({ q }: { q?: string }) => ( + Promise.resolve(q === 'latest' ? catalogPage([latestEntry]) : catalogPage([actionEntry])) + )); + hubAPI.catalog.mockImplementation(({ q, type }: { q?: string; type?: string }) => { + if (q === 'tree-action' && type === 'tool') return Promise.resolve({ data: [installedEntry] }); + return Promise.resolve({ data: q === 'latest' ? [latestEntry] : [actionEntry] }); + }); + hubAPI.get.mockResolvedValue({ data: actionEntry }); + hubAPI.install.mockReturnValue(install.promise); + + renderHub(); + expect(await screen.findByText('Tree Action')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Directory View' })); + await user.click(await screen.findByRole('button', { name: 'tree-action' })); + await user.click(await screen.findByRole('button', { name: 'Install' })); + await waitFor(() => expect(hubAPI.install).toHaveBeenCalledWith('tool', 'tree-action')); + + fireEvent.change( + screen.getByPlaceholderText('Search plugin name, description, tag, use case'), + { target: { value: 'latest' } }, + ); + expect(await screen.findByRole('button', { name: 'tree-latest' })).toBeInTheDocument(); + + await act(async () => { + install.resolve(); + await install.promise; + }); + + await waitFor(() => { + expect(hubAPI.catalog).toHaveBeenCalledWith({ q: 'tree-action', type: 'tool' }); + }); + expect(screen.getByRole('button', { name: 'tree-latest' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'tree-action' })).not.toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2); + expect(hubAPI.catalog.mock.calls.filter(([params]) => params?.q === undefined)).toHaveLength(1); + }); +}); diff --git a/webui/src/pages/Hub/index.tsx b/webui/src/pages/Hub/index.tsx index 0804f104e..4b0ed8f07 100644 --- a/webui/src/pages/Hub/index.tsx +++ b/webui/src/pages/Hub/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Archive, @@ -19,6 +19,8 @@ import { } from 'lucide-react'; import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; +import { useToast } from '@/components/common/Toast'; +import { extractErrorMessage } from '@/utils/error'; import SuiteInstallProgressPanel, { applySuiteInstallProgressEvent, createSuiteInstallProgressState, @@ -28,20 +30,25 @@ import SuiteInstallProgressPanel, { import { hubAPI, HubCatalogEntry, + type HubCatalogFacets, HubFileContent, HubFileNode, HubInstallProgressEvent, HubManifest, HubPluginType, } from '@/api/hub'; -import FlowCanvas from '@/pages/WorkflowDetail/FlowCanvas'; import type { WorkflowJSON } from '@/api/workflow'; import { useTranslation } from 'react-i18next'; import { getCatalogDescription } from '@/utils/mcpCatalog'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useProductName } from '@/contexts/ProductNameContext'; +import { useAuth } from '@/contexts/AuthContext'; type ViewMode = 'table' | 'tree'; +const FlowCanvas = lazy(() => import('@/pages/WorkflowDetail/FlowCanvas')); +const HUB_LOADING_DELAY_MS = 180; + interface HubTaxonomyCategory { id: string; name: string; @@ -138,6 +145,11 @@ const HUB_TEXT = { suiteInstallFailed: '安装失败', suiteInstallProgress: '安装进度', suiteInstallDismiss: '关闭', + refreshFailed: '刷新 Hub 失败', + refreshReloadFailed: 'Hub 刷新已完成,但列表重载失败', + actionRefreshFailed: '操作已完成,但刷新 Hub 列表失败', + actionFailed: { install: '安装失败', update: '更新失败', uninstall: '卸载失败' }, + unknownError: '未知错误', suiteItemStatuses: { pending: '等待中', installing: '安装中', @@ -194,6 +206,11 @@ const HUB_TEXT = { suiteInstallFailed: 'Install failed', suiteInstallProgress: 'Progress', suiteInstallDismiss: 'Close', + refreshFailed: 'Hub refresh failed', + refreshReloadFailed: 'Hub refresh completed, but catalog reload failed', + actionRefreshFailed: 'Action completed, but Hub reload failed', + actionFailed: { install: 'Install failed', update: 'Update failed', uninstall: 'Uninstall failed' }, + unknownError: 'Unknown error', suiteItemStatuses: { pending: 'Pending', installing: 'Installing', @@ -243,70 +260,21 @@ function getHubName(entry: Pick, lang return language.toLowerCase().startsWith('zh') ? (nameCn || name || entry.id) : (name || nameCn || entry.id); } -interface HubFilterSnapshot { - query?: string; - type?: HubPluginType | ''; - useCase?: string; - tag?: string; - state?: string; -} - -interface HubFacetCounts { - type: Record; - useCases: Record; - tags: Record; - state: Record; -} - -function matchesCatalogEntry(entry: HubCatalogEntry, filters: HubFilterSnapshot) { - if (filters.type && entry.type !== filters.type) return false; - if (filters.useCase && !entry.useCases.includes(filters.useCase)) return false; - if (filters.tag && !entry.tags.includes(filters.tag)) return false; - if (filters.state && entry.state !== filters.state) return false; - const query = filters.query?.trim().toLowerCase(); - if (query) { - const haystack = [ - entry.id, - entry.name, - entry.description, - entry.descriptionCn ?? '', - entry.category, - ...entry.tags, - ...entry.useCases, - ].join(' ').toLowerCase(); - if (!haystack.includes(query)) return false; - } - return true; -} - -function addFacetCount(counts: Record, key: string) { - counts[key] = (counts[key] ?? 0) + 1; -} - -function buildFacetCounts(items: HubCatalogEntry[], filters: HubFilterSnapshot): HubFacetCounts { - const counts: HubFacetCounts = { type: {}, useCases: {}, tags: {}, state: {} }; - - items.forEach(item => { - if (matchesCatalogEntry(item, { query: filters.query })) { - addFacetCount(counts.type, item.type); - } - if (matchesCatalogEntry(item, { query: filters.query, type: filters.type })) { - item.useCases.forEach(useCase => addFacetCount(counts.useCases, useCase)); - } - if (matchesCatalogEntry(item, { query: filters.query, type: filters.type, useCase: filters.useCase })) { - item.tags.forEach(tag => addFacetCount(counts.tags, tag)); - } - if (matchesCatalogEntry(item, { query: filters.query, type: filters.type, useCase: filters.useCase, tag: filters.tag })) { - addFacetCount(counts.state, item.state); - } - }); - - return counts; -} +const EMPTY_HUB_FACETS: HubCatalogFacets = { + type: {}, + category: {}, + tags: {}, + useCases: {}, + state: {}, + trust: {}, + riskLevel: {}, +}; export default function HubPage() { const { i18n } = useTranslation(); + const { user } = useAuth(); const { productName } = useProductName(); + const toast = useToast(); const [searchParams] = useSearchParams(); const text = i18n.language.toLowerCase().startsWith('zh') ? HUB_TEXT.zh : HUB_TEXT.en; const hubTitle = `${productName} Hub`; @@ -316,7 +284,12 @@ export default function HubPage() { const urlPluginId = searchParams.get('plugin') || searchParams.get('id') || ''; const urlType = normalizePluginType(searchParams.get('type')); const [catalogItems, setCatalogItems] = useState([]); + const [treeItems, setTreeItems] = useState([]); const [loading, setLoading] = useState(true); + const [treeLoading, setTreeLoading] = useState(false); + const [catalogError, setCatalogError] = useState(null); + const [treeError, setTreeError] = useState(null); + const [hasLoadedCatalog, setHasLoadedCatalog] = useState(false); const [refreshing, setRefreshing] = useState(false); const [query, setQuery] = useState(searchParams.get('q') || urlPluginId); const [typeFilter, setTypeFilter] = useState(urlType); @@ -331,26 +304,122 @@ export default function HubPage() { const [actionId, setActionId] = useState(null); const [suiteInstallProgress, setSuiteInstallProgress] = useState(null); const [taxonomy, setTaxonomy] = useState(null); - - const fetchCatalog = async (silent = false) => { + const [totalItems, setTotalItems] = useState(0); + const [facetCounts, setFacetCounts] = useState(EMPTY_HUB_FACETS); + const catalogRequestIdRef = useRef(0); + const treeRequestIdRef = useRef(0); + const debouncedQuery = useDebouncedValue(query, 250); + const catalogRequestKey = JSON.stringify([ + query, + debouncedQuery, + typeFilter, + useCaseFilter, + tagFilter, + stateFilter, + page, + pageSize, + ]); + const treeRequestKey = JSON.stringify([ + query, + debouncedQuery, + typeFilter, + useCaseFilter, + tagFilter, + stateFilter, + ]); + const currentCatalogRequestKeyRef = useRef(catalogRequestKey); + const currentTreeRequestKeyRef = useRef(treeRequestKey); + currentCatalogRequestKeyRef.current = catalogRequestKey; + currentTreeRequestKeyRef.current = treeRequestKey; + const canManageHub = user?.role === 'admin'; + + const fetchCatalog = useCallback(async (silent = false, propagateError = false) => { + const requestKey = catalogRequestKey; + if (requestKey !== currentCatalogRequestKeyRef.current) return null; + const requestId = ++catalogRequestIdRef.current; + const isCurrentRequest = () => ( + requestId === catalogRequestIdRef.current + && requestKey === currentCatalogRequestKeyRef.current + ); try { if (!silent) setLoading(true); - const res = await hubAPI.catalog(); - const nextItems = Array.isArray(res.data) ? res.data : []; - setCatalogItems(nextItems); - setSelected(current => { - if (!current) return current; - return nextItems.find(item => item.type === current.type && item.id === current.id) ?? current; + setCatalogError(null); + const res = await hubAPI.catalogPage({ + q: debouncedQuery.trim() || undefined, + type: typeFilter || undefined, + useCases: useCaseFilter || undefined, + tags: tagFilter || undefined, + state: stateFilter || undefined, + offset: (page - 1) * pageSize, + limit: pageSize, }); + if (!isCurrentRequest()) return null; + const nextItems = Array.isArray(res.data.items) ? res.data.items : []; + setCatalogItems(nextItems); + setTotalItems(res.data.total ?? nextItems.length); + setFacetCounts(res.data.facets ?? EMPTY_HUB_FACETS); return nextItems; + } catch (error) { + const currentRequest = isCurrentRequest(); + if (currentRequest) { + setCatalogError(error instanceof Error ? error.message : 'Failed to load Hub catalog'); + } + if (propagateError && currentRequest) throw error; + return null; } finally { - if (!silent) setLoading(false); + if (isCurrentRequest()) { + setHasLoadedCatalog(true); + if (!silent) setLoading(false); + } } - }; + }, [catalogRequestKey, debouncedQuery, page, pageSize, stateFilter, tagFilter, typeFilter, useCaseFilter]); + + const fetchTreeCatalog = useCallback(async (silent = false, propagateError = false) => { + const requestKey = treeRequestKey; + if (requestKey !== currentTreeRequestKeyRef.current) return; + const requestId = ++treeRequestIdRef.current; + const isCurrentRequest = () => ( + requestId === treeRequestIdRef.current + && requestKey === currentTreeRequestKeyRef.current + ); + try { + if (!silent) setTreeLoading(true); + setTreeError(null); + const res = await hubAPI.catalog({ + q: debouncedQuery.trim() || undefined, + type: typeFilter || undefined, + useCases: useCaseFilter || undefined, + tags: tagFilter || undefined, + state: stateFilter || undefined, + }); + if (!isCurrentRequest()) return; + setTreeItems(Array.isArray(res.data) ? res.data : []); + } catch (error) { + const currentRequest = isCurrentRequest(); + if (currentRequest) { + setTreeError(error instanceof Error ? error.message : 'Failed to load Hub tree'); + } + if (propagateError && currentRequest) throw error; + } finally { + if (isCurrentRequest() && !silent) { + setTreeLoading(false); + } + } + }, [debouncedQuery, stateFilter, tagFilter, treeRequestKey, typeFilter, useCaseFilter]); + + useEffect(() => { + if (query !== debouncedQuery) return; + void fetchCatalog(); + }, [debouncedQuery, fetchCatalog, query]); + + useEffect(() => { + if (viewMode === 'tree' && query === debouncedQuery) { + void fetchTreeCatalog(); + } + }, [debouncedQuery, fetchTreeCatalog, query, viewMode]); useEffect(() => { - fetchCatalog(); - hubAPI.categories().then(res => setTaxonomy(res.data as HubTaxonomyResponse)).catch(() => setTaxonomy(null)); + hubAPI.categories({ includeCounts: false }).then(res => setTaxonomy(res.data as HubTaxonomyResponse)).catch(() => setTaxonomy(null)); }, []); useEffect(() => { @@ -363,31 +432,7 @@ export default function HubPage() { } }, [catalogItems, urlPluginId, urlType]); - useEffect(() => { - setPage(1); - }, [query, typeFilter, stateFilter, tagFilter, useCaseFilter, viewMode, pageSize]); - - const items = useMemo( - () => catalogItems.filter(item => matchesCatalogEntry(item, { - query, - type: typeFilter, - useCase: useCaseFilter, - tag: tagFilter, - state: stateFilter, - })), - [catalogItems, query, typeFilter, useCaseFilter, tagFilter, stateFilter], - ); - - const facetCounts = useMemo( - () => buildFacetCounts(catalogItems, { - query, - type: typeFilter, - useCase: useCaseFilter, - tag: tagFilter, - state: stateFilter, - }), - [catalogItems, query, typeFilter, useCaseFilter, tagFilter, stateFilter], - ); + const items = catalogItems; const useCases = useMemo( () => taxonomy?.useCases ?? Array.from(new Set(catalogItems.flatMap(item => item.useCases))).sort(), @@ -398,17 +443,38 @@ export default function HubPage() { [catalogItems, taxonomy], ); const activeFilterCount = [typeFilter, useCaseFilter, tagFilter, stateFilter].filter(Boolean).length; - const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); const currentPage = Math.min(page, totalPages); - const pagedItems = useMemo( - () => items.slice((currentPage - 1) * pageSize, currentPage * pageSize), - [items, currentPage, pageSize], - ); + const pagedItems = items; + const isInitialLoading = loading && !hasLoadedCatalog; + const visibleCatalogError = viewMode === 'tree' ? (treeError || catalogError) : catalogError; + + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); const handleRefresh = async () => { setRefreshing(true); try { await hubAPI.refresh(); - await fetchCatalog(true); + } catch (error) { + toast.error( + text.refreshFailed, + extractErrorMessage(error, text.unknownError), + ); + setRefreshing(false); + return; + } + + try { + await Promise.all([ + fetchCatalog(true, true), + viewMode === 'tree' ? fetchTreeCatalog(true, true) : Promise.resolve(), + ]); + } catch (error) { + toast.error( + text.refreshReloadFailed, + extractErrorMessage(error, text.unknownError), + ); } finally { setRefreshing(false); } @@ -419,6 +485,7 @@ export default function HubPage() { }; const runAction = async (entry: HubCatalogEntry, action: 'install' | 'update' | 'uninstall') => { + if (!canManageHub) return; const key = `${entry.type}:${entry.id}:${action}`; setActionId(key); try { @@ -430,18 +497,38 @@ export default function HubPage() { } if (action === 'update') await hubAPI.update(entry.type, entry.id); if (action === 'uninstall') await hubAPI.uninstall(entry.type, entry.id); - const nextItems = await fetchCatalog(true); - const updated = nextItems?.find(item => item.type === entry.type && item.id === entry.id); - if (updated) { - setSelected(current => (current?.type === entry.type && current?.id === entry.id ? updated : current)); - } } catch (error) { if (action === 'install' && entry.type === 'component') { - const message = error instanceof Error ? error.message : text.suiteInstallFailed; + const message = extractErrorMessage(error, text.suiteInstallFailed); setSuiteInstallProgress(current => failSuiteInstallProgress(current, entry, message)); } else { - console.error(error); + toast.error( + `${text.actionFailed[action]}: ${getHubName(entry, i18n.language)}`, + extractErrorMessage(error, text.unknownError), + ); } + setActionId(null); + return; + } + + try { + const [, , refreshedEntry] = await Promise.all([ + fetchCatalog(true, true), + viewMode === 'tree' ? fetchTreeCatalog(true, true) : Promise.resolve(), + hubAPI.catalog({ q: entry.id, type: entry.type }).then(res => ( + (Array.isArray(res.data) ? res.data : []).find(item => ( + item.type === entry.type && item.id === entry.id + )) ?? null + )), + ]); + setSelected(current => ( + current?.type === entry.type && current?.id === entry.id ? refreshedEntry : current + )); + } catch (error) { + toast.error( + `${text.actionRefreshFailed}: ${getHubName(entry, i18n.language)}`, + extractErrorMessage(error, text.unknownError), + ); } finally { setActionId(null); } @@ -452,14 +539,32 @@ export default function HubPage() { setUseCaseFilter(''); setTagFilter(''); setStateFilter(''); + setPage(1); }; - if (loading) { - return
; + if (isInitialLoading) { + return
; + } + + if (catalogError && catalogItems.length === 0) { + return ( +
+
+

{catalogError}

+ +
+
+ ); } return ( -
+
setQuery(e.target.value)} + onChange={e => { + setQuery(e.target.value); + setPage(1); + }} placeholder={text.searchPlaceholder} className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg text-sm outline-none bg-white/90 focus:ring-2 focus:ring-slate-200 focus:border-slate-400" /> @@ -482,25 +590,30 @@ export default function HubPage() { {viewMode === 'table' ? : } {viewMode === 'table' ? text.treeView : text.tableView} - + {canManageHub && ( + + )}
} /> -
+
setTypeFilter(value as HubPluginType | '')} + onChange={value => { + setTypeFilter(value as HubPluginType | ''); + setPage(1); + }} options={[ { value: '', label: text.all }, ...HUB_PLUGIN_TYPES.map(type => ({ @@ -517,7 +630,10 @@ export default function HubPage() { { + setUseCaseFilter(value); + setPage(1); + }} options={[ { value: '', label: text.all }, ...useCases.map(useCase => ({ @@ -531,7 +647,10 @@ export default function HubPage() { { + setTagFilter(value); + setPage(1); + }} options={[ { value: '', label: text.all }, ...tags.map(tag => ({ @@ -545,7 +664,10 @@ export default function HubPage() { { + setStateFilter(value); + setPage(1); + }} options={[ { value: '', label: text.all }, ...(['available', 'installed', 'updateAvailable', 'incompatible'] as const).map(state => ({ @@ -580,23 +702,35 @@ export default function HubPage() {
-
+
+ {visibleCatalogError && ( +
+ {visibleCatalogError} +
+ )} {viewMode === 'table' ? ( + ) : treeLoading && treeItems.length === 0 ? ( +
+ +
) : ( - + )}
{viewMode === 'table' && ( { + setPageSize(nextPageSize); + setPage(1); + }} /> )} @@ -642,15 +776,15 @@ function FilterRow({ label, value, options, onChange }: { key={option.value || 'all'} title={option.title} onClick={() => onChange(option.value)} - className={`px-2 py-1 rounded-md transition-colors ${ + className={`inline-flex h-8 items-center whitespace-nowrap rounded-md px-2 py-1 font-medium tabular-nums transition-colors ${ active - ? 'bg-slate-800 text-white font-medium' + ? 'bg-slate-800 text-white' : 'text-gray-600 hover:bg-white hover:text-gray-900' }`} > {option.label} {option.count !== undefined && option.value && ( - {option.count} + {option.count} )} ); @@ -658,8 +792,8 @@ function FilterRow({ label, value, options, onChange }: { const [allOption, ...facetOptions] = options; return ( -
-
+
+
{label} @@ -686,7 +820,7 @@ function PaginationBar({ total, page, pageSize, totalPages, text, onPageChange, const start = total === 0 ? 0 : (page - 1) * pageSize + 1; const end = Math.min(total, page * pageSize); return ( -
+
{text.showing} {start}-{end} / {text.of} {total} {text.plugins}
@@ -729,7 +863,7 @@ function HubTable({ items, actionId, tagLabels, language, text, onSelect, onActi onAction: (entry: HubCatalogEntry, action: 'install' | 'update' | 'uninstall') => void; }) { return ( -
+
@@ -822,7 +956,7 @@ function HubTree({ items, actionId, text, onSelect, onAction }: { }, [items]); return ( -
+
); @@ -1019,7 +1153,9 @@ function PluginDetail({ entry, language, onClose, onAction, actionId, text }: { {text.workflowDiagram}
- +
}> + + ) : workflowError ? ( @@ -1093,11 +1229,13 @@ function ActionButtons({ item, actionId, text, onAction, compact = false }: { compact?: boolean; onAction: (entry: HubCatalogEntry, action: 'install' | 'update' | 'uninstall') => void; }) { + const { user } = useAuth(); const busy = actionId?.startsWith(`${item.type}:${item.id}:`); const buttonClass = compact ? 'p-1.5 rounded-md border border-gray-200 hover:bg-gray-50 disabled:opacity-50' : 'inline-flex items-center gap-1 whitespace-nowrap px-2 py-1 rounded-md border border-gray-200 text-xs hover:bg-gray-50 disabled:opacity-50'; if (busy) return ; + if (user?.role !== 'admin') return null; if (item.native && item.state === 'installed') return null; if (item.state === 'available') return ; if (item.state === 'updateAvailable') return ; diff --git a/webui/src/pages/MCP/index.tsx b/webui/src/pages/MCP/index.tsx index bb7e27891..e5ca185da 100644 --- a/webui/src/pages/MCP/index.tsx +++ b/webui/src/pages/MCP/index.tsx @@ -119,7 +119,7 @@ function ServersTab() { setSelectedServer(server); }; - if (loading) return
; + if (loading) return
; if (error) return (
@@ -290,7 +290,7 @@ function CatalogTab() { } }; - if (loading) return
; + if (loading) return
; return (
diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx index 06e092dc7..1515819c6 100644 --- a/webui/src/pages/Model/index.test.tsx +++ b/webui/src/pages/Model/index.test.tsx @@ -20,6 +20,9 @@ const mocks = vi.hoisted(() => ({ listDefinitions: vi.fn(), catalogList: vi.fn(), createProvider: vi.fn(), + getCredentials: vi.fn(), + setCredentials: vi.fn(), + testCredentials: vi.fn(), })); vi.mock('react-i18next', () => ({ @@ -42,6 +45,7 @@ vi.mock('react-i18next', () => ({ 'form.baseUrlRequired': 'Please enter Base URL', 'form.apiKeyOptional': '(optional, leave empty for no-auth gateways)', 'form.apiKeyOptionalHint': 'Leave empty for no-auth gateway', + 'form.apiKeyKeepExisting': 'Leave blank to keep the existing API key', 'form.searchProvider': 'Search providers...', 'form.noResults': 'No results', 'form.alreadyAdded': 'Already added', @@ -108,9 +112,9 @@ vi.mock('@/components/common/EntitySheet', () => ({ vi.mock('@/api/provider', () => ({ providerAPI: { - getCredentials: vi.fn(), - setCredentials: vi.fn(), - testCredentials: vi.fn(), + getCredentials: mocks.getCredentials, + setCredentials: mocks.setCredentials, + testCredentials: mocks.testCredentials, }, modelV2API: { listDefinitions: mocks.listDefinitions, @@ -218,3 +222,120 @@ describe('ModelPage add provider dialog', () => { }); }); }); + +describe('ModelPage configure provider dialog', () => { + const provider = { + id: 'openai', + name: 'OpenAI', + source: 'config', + env: [], + key: null, + options: {}, + models: {}, + configured: true, + modelCount: 1, + category: 'connected', + }; + const model = { + id: 'gpt-4o', + name: 'GPT-4o', + provider_id: 'openai', + model_type: 'chat', + status: 'active', + capabilities: { + features: [], + supports_streaming: true, + supports_tools: true, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + sessionStorage.clear(); + mocks.useProviders.mockReturnValue({ + providers: [provider], + connectedIds: ['openai'], + loading: false, + error: null, + refetch: mocks.refetch, + }); + mocks.getSummary.mockResolvedValue({ data: null }); + mocks.getResolved.mockResolvedValue({ data: null }); + mocks.listDefinitions.mockResolvedValue({ data: { models: [model], total: 1 } }); + mocks.catalogList.mockResolvedValue({ + data: { + providers: [{ id: 'openai', models: [] }], + }, + }); + mocks.getCredentials.mockResolvedValue({ + data: { + secret_id: 'openai_llm_key', + api_key: null, + api_key_masked: 'sk-***1234', + base_url: 'https://old.example.com/v1', + has_credential: true, + }, + }); + mocks.setCredentials.mockResolvedValue({ data: { success: true } }); + mocks.testCredentials.mockResolvedValue({ + data: { + success: true, + message: 'ok', + model_id: 'gpt-4o', + question: 'ping', + answer: 'pong', + latency_ms: 10, + }, + }); + }); + + async function openConfigureDialog(user: ReturnType) { + renderWithRouter(); + await user.click(await screen.findByTitle('Configure')); + expect(await screen.findByTestId('entity-sheet')).toBeInTheDocument(); + } + + it('preserves an existing secret when saving a new base URL with a blank key', async () => { + const user = userEvent.setup(); + await openConfigureDialog(user); + + const apiKeyInput = screen.getByPlaceholderText('Leave blank to keep the existing API key'); + expect(apiKeyInput).toHaveValue(''); + expect(apiKeyInput).not.toHaveValue('sk-***1234'); + + const baseUrlInput = screen.getByPlaceholderText('https://api.example.com/v1'); + await user.clear(baseUrlInput); + await user.type(baseUrlInput, 'https://new.example.com/v1'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(mocks.setCredentials).toHaveBeenCalled()); + const payload = mocks.setCredentials.mock.calls.at(-1)?.[1]; + expect(payload).toEqual(expect.objectContaining({ + base_url: 'https://new.example.com/v1', + })); + expect(payload).not.toHaveProperty('api_key'); + }); + + it('persists a base URL change without a key before testing the connection', async () => { + const user = userEvent.setup(); + await openConfigureDialog(user); + mocks.setCredentials.mockClear(); + mocks.testCredentials.mockClear(); + + const baseUrlInput = screen.getByPlaceholderText('https://api.example.com/v1'); + await user.clear(baseUrlInput); + await user.type(baseUrlInput, 'https://test.example.com/v1'); + await user.click(screen.getByRole('button', { name: 'form.testConnection2' })); + + await waitFor(() => expect(mocks.setCredentials).toHaveBeenCalled()); + const payload = mocks.setCredentials.mock.calls[0]?.[1]; + expect(payload).toEqual(expect.objectContaining({ + base_url: 'https://test.example.com/v1', + })); + expect(payload).not.toHaveProperty('api_key'); + await waitFor(() => { + expect(mocks.testCredentials).toHaveBeenCalledWith('openai', 'gpt-4o'); + }); + }); +}); diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index 8175777a6..3a8e1b4c8 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -31,7 +31,7 @@ import { import type { ProviderCredentials, ModelDefinitionV2, UsageStats, CatalogProvider, CatalogModel, CatalogCredentialField, ModelSettingV2, - CustomModelCreate, + CustomModelCreate, ProviderCredentialInput, } from '@/types'; // ==================== Provider Auth Helpers ==================== @@ -418,7 +418,7 @@ export default function ModelPage() { if (loading) { return (
- +
); } @@ -2192,7 +2192,10 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos const toast = useToast(); const { t } = useTranslation('model'); const hasExisting = existingCredentials?.has_credential ?? false; - const existingKey = existingCredentials?.api_key ?? ''; + // Credential reads intentionally never return the raw key. Keep the input + // empty and use hasExisting to express "leave blank to preserve"; in + // particular, never seed this field with api_key_masked. + const existingKey = ''; const existingBaseUrl = existingCredentials?.base_url ?? ''; const [baseUrl, setBaseUrl] = useState(existingCredentials?.base_url ?? ''); @@ -2269,19 +2272,21 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos }; const handleSubmit = async () => { - if (!apiKey.trim() && !providerAllowsEmptyApiKey(provider.id)) { + const nextApiKey = apiKey.trim(); + if (!nextApiKey && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { toast.warning('Please enter API Key'); return; } try { setLoading(true); - await providerAPI.setCredentials(provider.id, { - api_key: apiKey.trim() || 'not-needed', + const payload: ProviderCredentialInput = { base_url: baseUrl.trim() || undefined, provider_name: (provider.id === 'openai-compatible' || provider.id.startsWith('custom-')) ? (providerName.trim() || undefined) : undefined, - }); + }; + if (nextApiKey) payload.api_key = nextApiKey; + await providerAPI.setCredentials(provider.id, payload); // Sync catalog model list: add newly selected, delete deselected if (catalogModels.length > 0) { @@ -2320,24 +2325,28 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos { apiKey, baseUrl }, ); + const nextApiKey = apiKey.trim(); + if (!nextApiKey && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { + toast.warning('Please enter API Key first'); + return; + } + // Persist pending credential changes before testing so the backend uses // the latest base URL even when the API key itself did not change. - if (apiKey.trim() && hasPendingChanges) { + if (hasPendingChanges) { try { - await providerAPI.setCredentials(provider.id, { - api_key: apiKey.trim(), + const payload: ProviderCredentialInput = { base_url: baseUrl.trim() || undefined, provider_name: (provider.id === 'openai-compatible' || provider.id.startsWith('custom-')) ? (providerName.trim() || undefined) : undefined, - }); + }; + if (nextApiKey) payload.api_key = nextApiKey; + await providerAPI.setCredentials(provider.id, payload); } catch (err: any) { toast.error(t('deleteFailed'), err.message); return; } - } else if (!apiKey.trim() && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { - toast.warning('Please enter API Key first'); - return; } try { setTesting(true); @@ -2451,7 +2460,7 @@ ${hasExisting ? '你已有凭证配置,可以更新或测试连接。' : '请
@@ -127,7 +179,7 @@ export default function MonitoringPage() {
- {status?.activeAgents || 0} + {status?.activeAgents ?? t('unavailable')}
{t('activeAgents')}
@@ -137,7 +189,7 @@ export default function MonitoringPage() {
- {metrics?.messageRate.toFixed(1) || '0'} + {metrics?.messageRate == null ? t('unavailable') : metrics.messageRate.toFixed(1)}
{t('messagesPerMin')}
@@ -147,35 +199,41 @@ export default function MonitoringPage() { {metrics && (

{t('realTimeMetrics')}

-
+
{t('metrics.messageRate')}
- {metrics.messageRate.toFixed(1)}/min + {formatRate(metrics.messageRate)}
{t('metrics.toolCallRate')}
- {metrics.toolCallRate.toFixed(1)}/min + {formatRate(metrics.toolCallRate)}
{t('metrics.errorRate')}
- {metrics.errorRate.toFixed(1)}/min + {formatRate(metrics.errorRate)} +
+
+
+
{t('metrics.toolParseFailureRate')}
+
+ {formatRate(metrics.toolParseFailureRate)}
{t('metrics.avgResponse')}
- {metrics.avgResponseTime.toFixed(0)}ms + {metrics.avgResponseTime == null ? t('unavailable') : `${metrics.avgResponseTime.toFixed(0)}ms`}
{t('metrics.activeRequests')}
- {metrics.activeRequests} + {metrics.activeRequests ?? t('unavailable')}
diff --git a/webui/src/pages/Permission/index.tsx b/webui/src/pages/Permission/index.tsx index ff6f1c77d..b6b56bc1d 100644 --- a/webui/src/pages/Permission/index.tsx +++ b/webui/src/pages/Permission/index.tsx @@ -51,7 +51,7 @@ export default function PermissionPage() { if (loading) { return (
- +
); } diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 5940ab6ce..0f44eb568 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, useNavigate } from 'react-router-dom'; -import SessionPage from './index'; +import { __resetChatModelResourcesForTesting } from '@/hooks/useChatModelResources'; +import SessionPage, { getVisibleSessionGroupItems, groupSessionsByDate } from './index'; const { client, @@ -107,6 +108,7 @@ vi.mock('@/components/common/SessionChat', () => ({ initialMessage, initialDisplayText, onCreateAndSend, + onSSEEvent, agentName, model, display, @@ -137,6 +139,7 @@ vi.mock('@/components/common/SessionChat', () => ({ modelOverride?: unknown, options?: { displayText?: string }, ) => Promise | unknown; + onSSEEvent?: (event: { type: string; properties?: Record }) => void; }) { const [input, setInput] = React.useState(''); return ( @@ -162,6 +165,15 @@ vi.mock('@/components/common/SessionChat', () => ({ +
); }, @@ -248,9 +260,75 @@ function renderSessionPage( ); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe('session sidebar grouping helpers', () => { + it('groups sessions by updated date and applies search filtering', () => { + const now = new Date(2026, 6, 9, 12, 0, 0); + const makeSession = (id: string, title: string, updated: number) => ({ + ...session, + id, + title, + time: { ...session.time, updated }, + }); + + const groups = groupSessionsByDate([ + makeSession('today', 'Today Investigation', new Date(2026, 6, 9, 9).getTime()), + makeSession('yesterday', 'Yesterday Work', new Date(2026, 6, 8, 9).getTime()), + makeSession('earlier', 'Old Investigation', new Date(2026, 5, 1, 9).getTime()), + ], 'investigation', now); + + expect(groups.map((group) => [group.key, group.items.map((item) => item.id)])).toEqual([ + ['today', ['today']], + ['earlier', ['earlier']], + ]); + }); + + it('limits collapsed older groups and reports hidden count', () => { + const group = { + key: 'thisWeek' as const, + labelKey: 'groupThisWeek', + items: Array.from({ length: 7 }, (_, index) => ({ + ...session, + id: `session-${index}`, + title: `Session ${index}`, + })), + }; + + expect(getVisibleSessionGroupItems({ + group, + expanded: false, + searching: false, + })).toMatchObject({ + visibleItems: group.items.slice(0, 5), + hiddenCount: 2, + limit: 5, + }); + + expect(getVisibleSessionGroupItems({ + group, + expanded: false, + searching: true, + })).toMatchObject({ + visibleItems: group.items, + hiddenCount: 0, + limit: Infinity, + }); + }); +}); + describe('SessionPage session actions menu', () => { beforeEach(() => { vi.clearAllMocks(); + __resetChatModelResourcesForTesting(); localStorage.clear(); sessionStorage.clear(); @@ -336,6 +414,34 @@ describe('SessionPage session actions menu', () => { expect(sessionApi.update).toHaveBeenCalledTimes(1); }); + it('coalesces bursty session.updated events into one sidebar refetch', () => { + vi.useFakeTimers(); + try { + renderSessionPage(); + + const emitSessionUpdated = screen.getByRole('button', { name: 'mock-session-updated' }); + act(() => { + emitSessionUpdated.click(); + emitSessionUpdated.click(); + }); + + expect(updateSessionTitle).toHaveBeenCalledTimes(2); + expect(refetchSessions).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(499); + }); + expect(refetchSessions).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(refetchSessions).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it('downloads session data as CLI-compatible JSON', async () => { const user = userEvent.setup(); const OriginalBlob = Blob; @@ -690,6 +796,7 @@ describe('SessionPage session actions menu', () => { }); it('keeps a selected session that is valid but missing from the current list', async () => { + const request = deferred(); useSessions.mockReturnValue({ sessions: [], loading: false, @@ -700,17 +807,28 @@ describe('SessionPage session actions menu', () => { removeSessions, addSession, }); - sessionApi.get.mockResolvedValue({ + sessionApi.get.mockReturnValue(request.promise); + const fetchedSession = { ...session, id: 'session-missing-from-list', title: 'Fetched Session', canWrite: false, - }); + }; renderSessionPage('/sessions?session=session-missing-from-list'); await waitFor(() => { expect(sessionApi.get).toHaveBeenCalledWith('session-missing-from-list'); + }); + expect(screen.queryByTestId('session-chat')).not.toBeInTheDocument(); + expect(screen.getByText('loading-spinner')).toBeInTheDocument(); + + await act(async () => { + request.resolve(fetchedSession); + await request.promise; + }); + + await waitFor(() => { expect(screen.getByTestId('session-chat')).toHaveTextContent('session-missing-from-list'); expect(screen.getByTestId('session-chat')).toHaveAttribute('data-hide-input', 'true'); }); @@ -737,6 +855,27 @@ describe('SessionPage session actions menu', () => { }); }); + it('drops a URL initial message when the target session no longer exists', async () => { + const user = userEvent.setup(); + const message = 'Do not send this to another session'; + sessionApi.get.mockRejectedValue({ response: { status: 404 } }); + + renderSessionPage(`/sessions?session=session-deleted&message=${encodeURIComponent(message)}`); + + await waitFor(() => { + expect(sessionApi.get).toHaveBeenCalledWith('session-deleted'); + expect(screen.getByTestId('session-chat')).toHaveTextContent('no-session'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-initial-message', ''); + }); + + await user.click(screen.getByText('Original Session')); + + await waitFor(() => { + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-initial-message', ''); + }); + }); + it('lists the same visible agents as the Agent page selector logic', async () => { const user = userEvent.setup(); useAgents.mockReturnValue({ diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index ee5eb702d..bf3a4a124 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { memo, useState, useEffect, useMemo, useCallback, useRef, type RefObject } from 'react'; import { MessageSquare, Plus, Trash2, ChevronDown, Sparkles, Shield, Search, AlertTriangle, @@ -24,8 +24,8 @@ import type { Agent } from '@/api/agent'; import { useSessions } from '@/hooks/useSessions'; import { useAgents } from '@/hooks/useAgents'; import { useProviders } from '@/hooks/useProviders'; +import { useEnabledChatModelDefinitions, useResolvedDefaultModel } from '@/hooks/useChatModelResources'; import client from '@/api/client'; -import { defaultModelAPI, modelV2API } from '@/api/provider'; import { useDefaultModelVision } from '@/hooks/useDefaultModelVision'; import { buildPromptParts, type ImagePartData } from '@/utils/imageUpload'; import { getAgentDisplayDescription, getAgentDisplayName, isAgentUsableInChat } from '@/utils/agentDisplay'; @@ -45,6 +45,7 @@ const LAST_SELECTED_SESSION_STORAGE_KEY = 'flocks:last-selected-session'; const SESSION_PAGE_VISITED_STORAGE_KEY = 'flocks:sessions:visited'; const SOC_WORKSPACE_COMPONENT_ID = 'soc-workspace'; const INSTALLED_HUB_STATES = new Set(['installed', 'localOnly', 'updateAvailable']); +const SESSION_UPDATE_REFETCH_DEBOUNCE_MS = 500; type AgentSourceFilter = 'all' | 'builtin' | 'custom'; type ChatModelOption = { key: string; @@ -122,6 +123,219 @@ function makeModelKey(providerID: string, modelID: string): string { return `${providerID}::${modelID}`; } +export type SessionGroupKey = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'earlier'; + +export interface SessionGroup { + key: SessionGroupKey; + labelKey: string; + items: Session[]; +} + +const SESSION_GROUP_DEFAULT_LIMIT: Record = { + today: Infinity, + yesterday: Infinity, + thisWeek: 5, + lastWeek: 5, + earlier: 5, +}; + +const SESSION_GROUP_DEFS: Array> = [ + { key: 'today', labelKey: 'groupToday' }, + { key: 'yesterday', labelKey: 'groupYesterday' }, + { key: 'thisWeek', labelKey: 'groupThisWeek' }, + { key: 'lastWeek', labelKey: 'groupLastWeek' }, + { key: 'earlier', labelKey: 'groupEarlier' }, +]; + +export function groupSessionsByDate( + sessions: Session[], + searchQuery: string, + now = new Date(), +): SessionGroup[] { + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const yesterdayStart = todayStart - 86400000; + const dayOfWeek = now.getDay() === 0 ? 7 : now.getDay(); + const thisWeekStart = todayStart - (dayOfWeek - 1) * 86400000; + const lastWeekStart = thisWeekStart - 7 * 86400000; + + const q = searchQuery.toLowerCase().trim(); + const filtered = q ? sessions.filter(s => s.title.toLowerCase().includes(q)) : sessions; + const buckets: SessionGroup[] = SESSION_GROUP_DEFS.map((group) => ({ + ...group, + items: [], + })); + + for (const session of filtered) { + const ts = session.time?.updated ?? 0; + if (ts >= todayStart) buckets[0].items.push(session); + else if (ts >= yesterdayStart) buckets[1].items.push(session); + else if (ts >= thisWeekStart) buckets[2].items.push(session); + else if (ts >= lastWeekStart) buckets[3].items.push(session); + else buckets[4].items.push(session); + } + + return buckets.filter(group => group.items.length > 0); +} + +export function getVisibleSessionGroupItems({ + group, + expanded, + searching, +}: { + group: SessionGroup; + expanded: boolean; + searching: boolean; +}): { visibleItems: Session[]; hiddenCount: number; limit: number } { + const limit = searching ? Infinity : SESSION_GROUP_DEFAULT_LIMIT[group.key]; + const visibleItems = (searching || expanded || group.items.length <= limit) + ? group.items + : group.items.slice(0, limit); + return { + visibleItems, + hiddenCount: group.items.length - visibleItems.length, + limit, + }; +} + +interface SessionSidebarItemProps { + session: Session; + selected: boolean; + selectMode: boolean; + checked: boolean; + menuOpen: boolean; + renaming: boolean; + renameValue: string; + renameSubmitting: boolean; + t: (key: string, options?: Record) => string; + renameInputRef: RefObject; + onSelect: (sessionId: string) => void; + onToggleCheck: (sessionId: string) => void; + onRenameValueChange: (value: string) => void; + onSubmitRename: (sessionId: string) => void | Promise; + onCancelRename: () => void; + onToggleMenu: (sessionId: string, trigger: HTMLElement) => void; +} + +function SessionSidebarItemInner({ + session, + selected, + selectMode, + checked, + menuOpen, + renaming, + renameValue, + renameSubmitting, + t, + renameInputRef, + onSelect, + onToggleCheck, + onRenameValueChange, + onSubmitRename, + onCancelRename, + onToggleMenu, +}: SessionSidebarItemProps) { + return ( +
onSelect(session.id)} + className={`group relative mx-2 mb-1 px-3 py-2.5 rounded-xl border cursor-pointer transition-all duration-150 ${ + !selectMode && selected + ? 'bg-gray-100 border-gray-300 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-none' + : selectMode && checked + ? 'bg-blue-50 border-blue-200 dark:border-blue-500/40 dark:bg-blue-950/30' + : 'border-gray-100 hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm dark:border-transparent dark:hover:border-zinc-800 dark:hover:bg-zinc-900 dark:hover:shadow-none' + }`} + > +
+ {selectMode && ( + onToggleCheck(session.id)} + onClick={(e) => e.stopPropagation()} + className="flex-shrink-0 w-3.5 h-3.5 accent-blue-500 cursor-pointer rounded" + /> + )} + {session.category === 'workflow' && ( + + + + )} + {session.category === 'entity-config' && ( + + + + )} + {renaming ? ( + onRenameValueChange(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={() => void onSubmitRename(session.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); void onSubmitRename(session.id); } + if (e.key === 'Escape') { e.preventDefault(); onCancelRename(); } + }} + placeholder={t('renamePlaceholder')} + disabled={renameSubmitting} + className="w-full min-w-0 rounded border border-blue-300 bg-white px-1.5 py-0.5 text-sm text-gray-900 outline-none focus:border-blue-400 dark:border-blue-500/50 dark:bg-zinc-950 dark:text-zinc-100" + aria-label={t('rename')} + data-session-rename-input + /> + ) : ( +

+ {session.title} + {session.isShared && ( + + {t('sharedTag')} + + )} +

+ )} +
+ {session.time?.updated && !renaming && ( +

+ {formatSessionDate(session.time.updated)} +

+ )} + {!selectMode && ( +
+ +
+ )} +
+ ); +} + +const SessionSidebarItem = memo(SessionSidebarItemInner, (prev, next) => ( + prev.session.id === next.session.id && + prev.session.title === next.session.title && + prev.session.category === next.session.category && + prev.session.isShared === next.session.isShared && + prev.session.time?.updated === next.session.time?.updated && + prev.selected === next.selected && + prev.selectMode === next.selectMode && + prev.checked === next.checked && + prev.menuOpen === next.menuOpen && + prev.renaming === next.renaming && + prev.renameValue === next.renameValue && + prev.renameSubmitting === next.renameSubmitting && + prev.t === next.t +)); + export default function SessionPage() { const { t, i18n } = useTranslation('session'); const location = useLocation(); @@ -133,8 +347,6 @@ export default function SessionPage() { const [showAgentOptions, setShowAgentOptions] = useState(false); const [selectedModelKey, setSelectedModelKey] = useState(null); const [showModelOptions, setShowModelOptions] = useState(false); - const [enabledModelDefinitions, setEnabledModelDefinitions] = useState([]); - const [loadingEnabledModels, setLoadingEnabledModels] = useState(true); const [sseStatus, setSseStatus] = useState('disconnected'); const [creating, setCreating] = useState(false); const [installingSocWorkspace, setInstallingSocWorkspace] = useState(false); @@ -157,6 +369,7 @@ export default function SessionPage() { const [selectorTooltip, setSelectorTooltip] = useState(null); const renameInputRef = useRef(null); const renameSubmitInFlightRef = useRef(false); + const sessionUpdateRefetchTimerRef = useRef(null); const toast = useToast(); const { @@ -173,6 +386,10 @@ export default function SessionPage() { } = useSessions(searchQuery); const { agents, loading: loadingAgents } = useAgents(); const { providers, loading: loadingProviders } = useProviders(); + const { + data: enabledModelDefinitions, + loading: loadingEnabledModels, + } = useEnabledChatModelDefinitions(); const primaryAgents = useMemo(() => agents.filter((a) => a.mode === 'primary' && isAgentUsableInChat(a)), [agents]); const subAgents = useMemo( () => agents.filter((a) => a.mode !== 'primary' && isAgentUsableInChat(a)), @@ -256,6 +473,18 @@ export default function SessionPage() { .filter((group) => group.models.length > 0) .sort((a, b) => a.providerName.localeCompare(b.providerName)); }, [chatModelOptions, providers]); + const listedSelectedSession = useMemo( + () => sessions.find(s => s.id === selectedSessionId) ?? null, + [sessions, selectedSessionId], + ); + const selectedSession = listedSelectedSession + ?? (selectedSessionFallback?.id === selectedSessionId ? selectedSessionFallback : null); + const activeChatSessionId = selectedSession ? selectedSessionId : null; + const resolvingSelectedSession = Boolean(selectedSessionId && !selectedSession); + const pinnedModelKey = selectedSession?.model_pinned && selectedSession.provider && selectedSession.model + ? makeModelKey(selectedSession.provider, selectedSession.model) + : null; + const hasPinnedModelOption = !!pinnedModelKey && chatModelOptions.some((option) => option.key === pinnedModelKey); const selectedModelOption = useMemo( () => chatModelOptions.find((option) => option.key === selectedModelKey) ?? (selectedModelKey ? null : chatModelOptions[0] ?? null), [chatModelOptions, selectedModelKey], @@ -263,22 +492,11 @@ export default function SessionPage() { const selectedPromptModel = selectedModelOption ? { providerID: selectedModelOption.providerID, modelID: selectedModelOption.modelID } : null; + const { + data: resolvedDefaultModel, + initialized: resolvedDefaultModelInitialized, + } = useResolvedDefaultModel(chatModelOptions.length > 0 && !hasPinnedModelOption); const effectiveSupportsVision = selectedModelOption?.supportsVision ?? supportsVision; - const listedSelectedSession = useMemo( - () => sessions.find(s => s.id === selectedSessionId) ?? null, - [sessions, selectedSessionId], - ); - const selectedSession = listedSelectedSession - ?? (selectedSessionFallback?.id === selectedSessionId ? selectedSessionFallback : null); - - // 今天/昨天不限制;本周/上周/更早默认只显示 5 条 - const GROUP_DEFAULT_LIMIT: Record = { - today: Infinity, - yesterday: Infinity, - thisWeek: 5, - lastWeek: 5, - earlier: 5, - }; const [expandedGroups, setExpandedGroups] = useState>(new Set()); const toggleGroupExpand = useCallback((key: string) => { @@ -290,55 +508,54 @@ export default function SessionPage() { }, []); const groupedSessions = useMemo(() => { - const now = new Date(); - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const yesterdayStart = todayStart - 86400000; - // Week starts on Monday - const dayOfWeek = now.getDay() === 0 ? 7 : now.getDay(); - const thisWeekStart = todayStart - (dayOfWeek - 1) * 86400000; - const lastWeekStart = thisWeekStart - 7 * 86400000; - - const q = searchQuery.toLowerCase().trim(); - const filtered = q ? sessions.filter(s => s.title.toLowerCase().includes(q)) : sessions; - - const buckets: { key: string; labelKey: string; items: typeof sessions }[] = [ - { key: 'today', labelKey: 'groupToday', items: [] }, - { key: 'yesterday', labelKey: 'groupYesterday', items: [] }, - { key: 'thisWeek', labelKey: 'groupThisWeek', items: [] }, - { key: 'lastWeek', labelKey: 'groupLastWeek', items: [] }, - { key: 'earlier', labelKey: 'groupEarlier', items: [] }, - ]; - - for (const s of filtered) { - const ts = s.time?.updated ?? 0; - if (ts >= todayStart) buckets[0].items.push(s); - else if (ts >= yesterdayStart) buckets[1].items.push(s); - else if (ts >= thisWeekStart) buckets[2].items.push(s); - else if (ts >= lastWeekStart) buckets[3].items.push(s); - else buckets[4].items.push(s); - } - - return buckets.filter(b => b.items.length > 0); + return groupSessionsByDate(sessions, searchQuery); }, [sessions, searchQuery]); + const visibleSessionGroups = useMemo(() => { + const searching = searchQuery.trim().length > 0; + return groupedSessions.map((group) => ({ + ...group, + ...getVisibleSessionGroupItems({ + group, + expanded: expandedGroups.has(group.key), + searching, + }), + expanded: expandedGroups.has(group.key), + searching, + })); + }, [expandedGroups, groupedSessions, searchQuery]); // Handle SSE events for session-level updates (title changes, etc.) const handleChatError = useCallback((msg: string) => { toast.error(t('chat.error', 'Error'), msg); }, [toast, t]); + const scheduleSessionListRefetch = useCallback(() => { + if (sessionUpdateRefetchTimerRef.current !== null) return; + sessionUpdateRefetchTimerRef.current = window.setTimeout(() => { + sessionUpdateRefetchTimerRef.current = null; + void refetchSessions(); + }, SESSION_UPDATE_REFETCH_DEBOUNCE_MS); + }, [refetchSessions]); + + useEffect(() => () => { + if (sessionUpdateRefetchTimerRef.current !== null) { + window.clearTimeout(sessionUpdateRefetchTimerRef.current); + sessionUpdateRefetchTimerRef.current = null; + } + }, []); + const handleSSEEvent = useCallback((event: SSEChatEvent) => { if (event.type === 'session.updated' && event.properties?.id) { if (event.properties?.title) { // Instant local title update so the sidebar reflects the change immediately. updateSessionTitle(event.properties.id, event.properties.title); } - // Always do a silent background sync: session.updated also changes - // time.updated (affects ordering) and potentially other metadata. - // refetchSessions() is safe here — it never shows a loading spinner - // after the initial load (see initializedRef in useSessions). - refetchSessions(); + // Session/title updates can arrive in bursts when several sessions or + // background tasks finish together. Coalesce the full sidebar refresh so + // those bursts don't turn into a request/re-render storm. + scheduleSessionListRefetch(); } - }, [updateSessionTitle, refetchSessions]); + }, [scheduleSessionListRefetch, updateSessionTitle]); // Keep the selected session in sync with URL query params (e.g. onboarding // or other in-app navigation to `/sessions?session=...`). Clear the params @@ -357,6 +574,7 @@ export default function SessionPage() { setPendingInitialMessage(messageParam); setPendingInitialDisplayText(displayParam ? buildInstructionDisplayText(displayParam) : null); } else { + setPendingInitialMessage(null); setPendingInitialDisplayText(null); } setSearchParams({}, { replace: true }); @@ -381,9 +599,9 @@ export default function SessionPage() { }, [loadingSessions, location.state, searchParams, selectedSessionId]); useEffect(() => { - if (!selectedSessionId) return; + if (!selectedSessionId || selectedSession?.id !== selectedSessionId) return; writeLastSelectedSessionId(selectedSessionId); - }, [selectedSessionId]); + }, [selectedSession?.id, selectedSessionId]); useEffect(() => { if (!selectedSessionId) { @@ -394,6 +612,7 @@ export default function SessionPage() { setSelectedSessionFallback(null); return; } + if (selectedSessionFallback?.id === selectedSessionId) return; if (loadingSessions) return; let cancelled = false; @@ -408,31 +627,15 @@ export default function SessionPage() { if (statusCode === 403 || statusCode === 404) { setSelectedSessionId((current) => (current === selectedSessionId ? null : current)); setSelectedSessionFallback(null); + setPendingInitialMessage(null); + setPendingInitialDisplayText(null); writeLastSelectedSessionId(null); } }); return () => { cancelled = true; }; - }, [listedSelectedSession, loadingSessions, selectedSessionId]); - - useEffect(() => { - let cancelled = false; - setLoadingEnabledModels(true); - modelV2API.listDefinitions({ enabled_only: true }) - .then((response) => { - if (!cancelled) setEnabledModelDefinitions(response.data.models ?? []); - }) - .catch(() => { - if (!cancelled) setEnabledModelDefinitions([]); - }) - .finally(() => { - if (!cancelled) setLoadingEnabledModels(false); - }); - return () => { - cancelled = true; - }; - }, []); + }, [listedSelectedSession, loadingSessions, selectedSessionFallback?.id, selectedSessionId]); // Close agent dropdown on outside click useEffect(() => { @@ -461,35 +664,24 @@ export default function SessionPage() { return; } - const pinnedKey = selectedSession?.model_pinned && selectedSession.provider && selectedSession.model - ? makeModelKey(selectedSession.provider, selectedSession.model) - : null; - if (pinnedKey && chatModelOptions.some((option) => option.key === pinnedKey)) { - setSelectedModelKey(pinnedKey); + if (hasPinnedModelOption && pinnedModelKey) { + setSelectedModelKey(pinnedModelKey); return; } - let cancelled = false; setSelectedModelKey(null); - defaultModelAPI.getResolved() - .then((response) => { - if (cancelled) return; - const { provider_id: providerID, model_id: modelID } = response.data; - const defaultKey = makeModelKey(providerID, modelID); - const fallbackKey = chatModelOptions[0]?.key ?? null; - setSelectedModelKey(chatModelOptions.some((option) => option.key === defaultKey) ? defaultKey : fallbackKey); - }) - .catch(() => { - if (!cancelled) setSelectedModelKey(chatModelOptions[0]?.key ?? null); - }); - return () => { - cancelled = true; - }; + if (!resolvedDefaultModelInitialized) return; + const defaultKey = resolvedDefaultModel + ? makeModelKey(resolvedDefaultModel.providerID, resolvedDefaultModel.modelID) + : null; + const fallbackKey = chatModelOptions[0]?.key ?? null; + setSelectedModelKey(defaultKey && chatModelOptions.some((option) => option.key === defaultKey) ? defaultKey : fallbackKey); }, [ chatModelOptions, - selectedSession?.model, - selectedSession?.model_pinned, - selectedSession?.provider, + hasPinnedModelOption, + pinnedModelKey, + resolvedDefaultModel, + resolvedDefaultModelInitialized, selectedSessionId, ]); @@ -536,6 +728,7 @@ export default function SessionPage() { try { const response = await client.post('/api/session', { title: 'New Session' }); addSession(response.data); + setSelectedSessionFallback(response.data); setSelectedAgent('rex'); setSelectedModelKey(null); setSelectedSessionId(response.data.id); @@ -575,6 +768,7 @@ export default function SessionPage() { const newSessionId = response.data.id; addSession(response.data); + setSelectedSessionFallback(response.data); setSelectedModelKey(null); setSelectedSessionId(newSessionId); @@ -773,6 +967,24 @@ export default function SessionPage() { return next; }); }, []); + const handleSelectSessionRow = useCallback((sessionId: string) => { + if (selectMode) { + handleToggleCheck(sessionId); + } else { + setSelectedSessionId(sessionId); + } + }, [handleToggleCheck, selectMode]); + const handleToggleSessionMenu = useCallback((sessionId: string, trigger: HTMLElement) => { + setOpenMenuSessionId((current) => { + if (current === sessionId) { + setMenuAnchor(null); + return null; + } + const rect = trigger.getBoundingClientRect(); + setMenuAnchor({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); + return sessionId; + }); + }, []); const handleSelectAll = useCallback(() => { if (checkedIds.size === sessions.length) { @@ -816,7 +1028,7 @@ export default function SessionPage() { if (loadingSessions) { return (
- +
); } @@ -866,118 +1078,34 @@ export default function SessionPage() {

{t('noResults', 'No conversations found')}

) : ( - groupedSessions.map(({ key, labelKey, items }) => { - const isSearching = searchQuery.trim().length > 0; - const limit = isSearching ? Infinity : (GROUP_DEFAULT_LIMIT[key] ?? 5); - const isExpanded = expandedGroups.has(key); - const visibleItems = (isSearching || isExpanded || items.length <= limit) - ? items - : items.slice(0, limit); - const hiddenCount = items.length - visibleItems.length; - - return ( + visibleSessionGroups.map(({ key, labelKey, items, visibleItems, hiddenCount, limit, expanded, searching }) => (
{t(labelKey, labelKey)}
{visibleItems.map((session) => ( -
selectMode ? handleToggleCheck(session.id) : setSelectedSessionId(session.id)} - className={`group relative mx-2 mb-1 px-3 py-2.5 rounded-xl border cursor-pointer transition-all duration-150 ${ - !selectMode && selectedSessionId === session.id - ? 'bg-gray-100 border-gray-300 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-none' - : selectMode && checkedIds.has(session.id) - ? 'bg-blue-50 border-blue-200 dark:border-blue-500/40 dark:bg-blue-950/30' - : 'border-gray-100 hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm dark:border-transparent dark:hover:border-zinc-800 dark:hover:bg-zinc-900 dark:hover:shadow-none' - }`} - > - {/* Title row */} -
- {selectMode && ( - handleToggleCheck(session.id)} - onClick={(e) => e.stopPropagation()} - className="flex-shrink-0 w-3.5 h-3.5 accent-blue-500 cursor-pointer rounded" - /> - )} - {session.category === 'workflow' && ( - - - - )} - {session.category === 'entity-config' && ( - - - - )} - {renamingSessionId === session.id ? ( - setRenameValue(e.target.value)} - onClick={(e) => e.stopPropagation()} - onBlur={() => void handleSubmitRename(session.id)} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); void handleSubmitRename(session.id); } - if (e.key === 'Escape') { e.preventDefault(); handleCancelRename(); } - }} - placeholder={t('renamePlaceholder')} - disabled={renameSubmitting} - className="w-full min-w-0 rounded border border-blue-300 bg-white px-1.5 py-0.5 text-sm text-gray-900 outline-none focus:border-blue-400 dark:border-blue-500/50 dark:bg-zinc-950 dark:text-zinc-100" - aria-label={t('rename')} - data-session-rename-input - /> - ) : ( -

- {session.title} - {session.isShared && ( - - {t('sharedTag')} - - )} -

- )} -
- {/* Timestamp row */} - {session.time?.updated && renamingSessionId !== session.id && ( -

- {formatSessionDate(session.time.updated)} -

- )} - - {/* Three-dot menu trigger */} - {!selectMode && ( -
- -
- )} -
+ session={session} + selected={selectedSessionId === session.id} + selectMode={selectMode} + checked={checkedIds.has(session.id)} + menuOpen={openMenuSessionId === session.id} + renaming={renamingSessionId === session.id} + renameValue={renameValue} + renameSubmitting={renameSubmitting} + t={t} + renameInputRef={renameInputRef} + onSelect={handleSelectSessionRow} + onToggleCheck={handleToggleCheck} + onRenameValueChange={setRenameValue} + onSubmitRename={handleSubmitRename} + onCancelRename={handleCancelRename} + onToggleMenu={handleToggleSessionMenu} + /> ))} {/* 展开/收起按钮 */} - {!isSearching && hiddenCount > 0 && ( + {!searching && hiddenCount > 0 && ( )} - {!isSearching && isExpanded && items.length > (GROUP_DEFAULT_LIMIT[key] ?? 5) && ( + {!searching && expanded && items.length > limit && ( )}
- ); - }) + )) )} {hasMoreSessions && (
@@ -1075,10 +1202,15 @@ export default function SessionPage() {
{/* Chat — powered by unified SessionChat */} - + + + ) : ( + } - /> + /> + )} {selectorTooltip && ( diff --git a/webui/src/pages/Settings/index.tsx b/webui/src/pages/Settings/index.tsx index a8eca7d1a..337cd9e92 100644 --- a/webui/src/pages/Settings/index.tsx +++ b/webui/src/pages/Settings/index.tsx @@ -1,5 +1,5 @@ import { Suspense, lazy, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import type { ChangeEvent, ReactNode } from 'react'; +import type { ChangeEvent, ComponentType, ReactNode } from 'react'; import { Link, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -26,11 +26,24 @@ import { useAuth } from '@/contexts/AuthContext'; import { useProductName } from '@/contexts/ProductNameContext'; import { useToast } from '@/components/common/Toast'; import { flocksproUsersApi } from '@/api/flocksproUsers'; +import { preloadI18nNamespaces } from '@/i18nResources'; + +type LazySettingsModule = { default: ComponentType }; + +function lazySettingsPage( + loader: () => Promise, + namespaces: readonly string[] = [], +) { + return lazy(() => Promise.all([ + loader(), + preloadI18nNamespaces(namespaces), + ]).then(([module]) => module)); +} -const ConfigPage = lazy(() => import('@/pages/Config')); -const SystemLogPage = lazy(() => import('@/pages/SystemLog')); -const FlocksproUpgradePage = lazy(() => import('@/pages/FlocksproUpgrade')); -const AuditLogsPage = lazy(() => import('@/pages/AuditLogs')); +const ConfigPage = lazySettingsPage(() => import('@/pages/Config')); +const SystemLogPage = lazySettingsPage(() => import('@/pages/SystemLog')); +const FlocksproUpgradePage = lazySettingsPage(() => import('@/pages/FlocksproUpgrade'), ['flockspro']); +const AuditLogsPage = lazySettingsPage(() => import('@/pages/AuditLogs'), ['flockspro']); type SettingsSectionId = 'preferences' | 'account' | 'system-logs' | 'audit-logs' | 'flockspro'; @@ -367,7 +380,7 @@ function SettingsContent({ sectionId }: { sectionId: SettingsSectionId }) { if (sectionId === 'preferences') return ; return ( - }> + }> {sectionId === 'account' && } {sectionId === 'system-logs' && } {sectionId === 'audit-logs' && } diff --git a/webui/src/pages/Skill/SkillSheet.tsx b/webui/src/pages/Skill/SkillSheet.tsx index b11665c36..c1df862c5 100644 --- a/webui/src/pages/Skill/SkillSheet.tsx +++ b/webui/src/pages/Skill/SkillSheet.tsx @@ -7,16 +7,25 @@ * - Rex chat mode (natural language → extract config into form) */ -import { useMemo, useState } from 'react'; +import { lazy, Suspense, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { BookOpen, Lock, Pencil, Eye, Save, Loader2, Trash2 } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; import { skillAPI, Skill } from '@/api/skill'; import { useToast } from '@/components/common/Toast'; import EntitySheet from '@/components/common/EntitySheet'; import { buildGuidedCreateGroups } from '@/components/common/GuidedCreatePanel'; import { useRexComposerControls } from '@/components/common/useRexComposerControls'; +const ReactMarkdown = lazy(() => import('react-markdown')); + +function MarkdownFallback() { + return ( +
+ +
+ ); +} + interface SkillFormData { name: string; description: string; @@ -344,7 +353,9 @@ export default function SkillSheet({ skill, onClose, onSaved, onDeleted }: Skill className="px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg text-sm prose prose-sm max-w-none overflow-y-auto" style={{ minHeight: '320px', maxHeight: '65vh' }} > - {contentForRender || t('sheet.noContent')} + }> + {contentForRender || t('sheet.noContent')} + )} diff --git a/webui/src/pages/Skill/index.tsx b/webui/src/pages/Skill/index.tsx index f90f38c48..50e2bab9a 100644 --- a/webui/src/pages/Skill/index.tsx +++ b/webui/src/pages/Skill/index.tsx @@ -275,7 +275,7 @@ export default function SkillPage() { if (loading) { return (
- +
); } diff --git a/webui/src/pages/Task/QueuedSection.tsx b/webui/src/pages/Task/QueuedSection.tsx index 68a33fc31..2ebc15f5c 100644 --- a/webui/src/pages/Task/QueuedSection.tsx +++ b/webui/src/pages/Task/QueuedSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { ListTodo, Play, RotateCcw, XCircle, Trash2, @@ -6,7 +6,6 @@ import { } from 'lucide-react'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import EmptyState from '@/components/common/EmptyState'; -import SessionChat from '@/components/common/SessionChat'; import { useToast } from '@/components/common/Toast'; import { useConfirm } from '@/components/common/ConfirmDialog'; import { useTaskExecutions } from '@/hooks/useTasks'; @@ -16,6 +15,15 @@ import { StatusBadge, PriorityBadge, SourceBadge, ModeBadge, ActionButton } from import { formatTime, formatDuration, PAGE_SIZE } from './helpers'; const DETAIL_POLL_INTERVAL_MS = 30000; +const LazySessionChat = lazy(() => import('@/components/common/SessionChat')); + +function SessionChatFallback() { + return ( +
+ +
+ ); +} export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () => void }) { const { t } = useTranslation('task'); @@ -233,7 +241,7 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () setSelectedTasks(new Set()); }; - if (loading && effectiveTasks.length === 0) return
; + if (loading && effectiveTasks.length === 0) return
; if (error) return
{error}
; return ( @@ -454,18 +462,20 @@ function QueuedDetailPanel({ task, onClose, onAction, onRefresh }: { {isWorkflowExecution ? ( ) : ( - { - if (event.type === 'task.updated' && event.properties?.executionID === task.id) { - onRefresh?.(); - } - }} - /> + }> + { + if (event.type === 'task.updated' && event.properties?.executionID === task.id) { + onRefresh?.(); + } + }} + /> + )} diff --git a/webui/src/pages/Task/ScheduledSection.tsx b/webui/src/pages/Task/ScheduledSection.tsx index e1ebc1b38..ce9c9645a 100644 --- a/webui/src/pages/Task/ScheduledSection.tsx +++ b/webui/src/pages/Task/ScheduledSection.tsx @@ -48,7 +48,7 @@ export default function ScheduledSection({ onRefreshGlobal }: { onRefreshGlobal: } }; - if (loading && tasks.length === 0) return
; + if (loading && tasks.length === 0) return
; if (error) return
{error}
; return ( diff --git a/webui/src/pages/Tool/components/APITabContent.test.tsx b/webui/src/pages/Tool/components/APITabContent.test.tsx new file mode 100644 index 000000000..b6329a742 --- /dev/null +++ b/webui/src/pages/Tool/components/APITabContent.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import APITabContent from './APITabContent'; + +const { apiDetailProps, listAllToolPages, mcpAPI, providerAPI } = vi.hoisted(() => ({ + apiDetailProps: vi.fn(), + listAllToolPages: vi.fn(), + mcpAPI: { + catalogInstall: vi.fn(), + connect: vi.fn(), + }, + providerAPI: { + listApiServices: vi.fn(), + updateApiService: vi.fn(), + deleteApiService: vi.fn(), + }, +})); + +vi.mock('@/api/provider', () => ({ providerAPI })); +vi.mock('@/api/mcp', () => ({ mcpAPI })); +vi.mock('@/api/tool', () => ({ listAllToolPages })); + +vi.mock('@/components/common/LoadingSpinner', () => ({ + default: () =>
loading
, +})); + +vi.mock('@/components/common/EmptyState', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})); + +vi.mock('./ServiceDetailPanel', () => ({ + APIServiceDetailPanel: (props: { serviceTools: Array<{ name: string }> }) => { + apiDetailProps(props); + return ( +
+ detail-panel + {props.serviceTools.map((tool) => {tool.name})} +
+ ); + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'zh-CN' }, + }), +})); + +describe('APITabContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + providerAPI.listApiServices.mockResolvedValue({ data: [] }); + listAllToolPages.mockResolvedValue([]); + }); + + it('loads the complete tool list when a service detail drawer opens', async () => { + const user = userEvent.setup(); + providerAPI.listApiServices.mockResolvedValue({ + data: [ + { + id: 'service-a', + name: 'Service A', + description: 'Service A API', + enabled: true, + status: 'connected', + tool_count: 2, + verify_ssl: false, + }, + ], + }); + listAllToolPages.mockResolvedValue([ + { + name: 'complete-api-tool', + description: 'Loaded beyond the current page', + category: 'custom', + source: 'api', + source_name: 'service-a', + enabled: true, + }, + ]); + + render( + , + ); + + await user.click((await screen.findByText('Service A')).closest('button')!); + + await waitFor(() => { + expect(listAllToolPages).toHaveBeenCalledWith({ + source: 'api', + sourceName: 'service-a', + sortBy: 'name', + sortDir: 'asc', + }); + }); + expect(await screen.findByText('complete-api-tool')).toBeInTheDocument(); + expect(apiDetailProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + serviceTools: [expect.objectContaining({ name: 'complete-api-tool' })], + }), + ); + }); +}); diff --git a/webui/src/pages/Tool/components/APITabContent.tsx b/webui/src/pages/Tool/components/APITabContent.tsx index 3144ca050..13f54e3c7 100644 --- a/webui/src/pages/Tool/components/APITabContent.tsx +++ b/webui/src/pages/Tool/components/APITabContent.tsx @@ -5,9 +5,10 @@ import { import { useTranslation } from 'react-i18next'; import { mcpAPI } from '@/api/mcp'; import { providerAPI } from '@/api/provider'; -import type { Tool } from '@/api/tool'; +import { listAllToolPages, type Tool } from '@/api/tool'; import type { APIServiceSummary, MCPCatalogCategory, MCPCatalogEntry } from '@/types'; import EmptyState from '@/components/common/EmptyState'; +import LoadingSpinner from '@/components/common/LoadingSpinner'; import { getCatalogDescription } from '@/utils/mcpCatalog'; import { APIServiceDetailPanel } from './ServiceDetailPanel'; import { SERVICE_TAB_GRID_COLS } from './gridLayout'; @@ -64,6 +65,7 @@ export default function APITabContent({ const [installing, setInstalling] = useState(null); const [credModalEntry, setCredModalEntry] = useState(null); const [credValues, setCredValues] = useState>({}); + const [serviceToolCache, setServiceToolCache] = useState>({}); const fetchServices = useCallback(async () => { try { @@ -86,10 +88,36 @@ export default function APITabContent({ () => (selectedServiceId ? services.find((service) => service.id === selectedServiceId) ?? null : null), [selectedServiceId, services], ); - const selectedModuleTools = selectedServiceId ? (toolsByModule[selectedServiceId] || []) : []; + const selectedModuleTools = selectedServiceId + ? (serviceToolCache[selectedServiceId] ?? toolsByModule[selectedServiceId] ?? []) + : []; const isConfigured = useCallback((entryId: string) => configuredIds.has(entryId), [configuredIds]); const PRIORITY_IDS = useMemo(() => new Set(['virustotal_mcp', 'urlhaus']), []); + useEffect(() => { + setServiceToolCache({}); + }, [tools]); + + useEffect(() => { + if (!selectedServiceId || serviceToolCache[selectedServiceId]) return; + let cancelled = false; + void listAllToolPages({ + source: 'api', + sourceName: selectedServiceId, + sortBy: 'name', + sortDir: 'asc', + }).then((serviceTools) => { + if (!cancelled) { + setServiceToolCache((current) => ({ ...current, [selectedServiceId]: serviceTools })); + } + }).catch(() => { + // Keep the current page slice visible; reopening retries the complete service list. + }); + return () => { + cancelled = true; + }; + }, [selectedServiceId, serviceToolCache]); + const inactiveCatalogEntries = useMemo(() => { const activeModuleIds = new Set(services.map((service) => service.id)); return catalogEntries.filter((entry) => !activeModuleIds.has(entry.id)); @@ -301,12 +329,15 @@ export default function APITabContent({ )} - {services.length === 0 && filteredCatalog.length === 0 && !catalogLoading && !servicesLoading ? ( + {servicesLoading && services.length === 0 && filteredCatalog.length === 0 ? ( +
+ +
+ ) : services.length === 0 && filteredCatalog.length === 0 && !catalogLoading ? ( } title={t('api.noTools')} description={t('api.noToolsDesc')} /> ) : (
{services.map((service) => { - const serviceTools = toolsByModule[service.id] || []; const isSelected = selectedServiceId === service.id; const rowDescription = getServiceDescription(service) || `${service.name} API service`; @@ -356,7 +387,7 @@ export default function APITabContent({ {/* Stats column */}
- {serviceTools.length} + {service.tool_count} {service.latency_ms != null && {service.latency_ms}ms}
diff --git a/webui/src/pages/Tool/components/LocalTabContent.tsx b/webui/src/pages/Tool/components/LocalTabContent.tsx index ed0e64c3e..aa3e7b528 100644 --- a/webui/src/pages/Tool/components/LocalTabContent.tsx +++ b/webui/src/pages/Tool/components/LocalTabContent.tsx @@ -154,7 +154,7 @@ export default function LocalTabContent({ {/* Stats column */}
- {t('local.paramsCount', { count: tool.parameters?.length || 0 })} + {t('local.paramsCount', { count: tool.parameters_count ?? tool.parameters?.length ?? 0 })}
{/* Actions column */} diff --git a/webui/src/pages/Tool/components/MCPTabContent.test.tsx b/webui/src/pages/Tool/components/MCPTabContent.test.tsx index b6895b306..f5948fdf0 100644 --- a/webui/src/pages/Tool/components/MCPTabContent.test.tsx +++ b/webui/src/pages/Tool/components/MCPTabContent.test.tsx @@ -4,12 +4,14 @@ import userEvent from '@testing-library/user-event'; import MCPTabContent from './MCPTabContent'; -const { mcpAPI } = vi.hoisted(() => ({ +const { listAllToolPages, mcpAPI, mcpDetailProps } = vi.hoisted(() => ({ + listAllToolPages: vi.fn(), mcpAPI: { list: vi.fn(), catalogInstall: vi.fn(), connect: vi.fn(), }, + mcpDetailProps: vi.fn(), })); vi.mock('@/api/mcp', () => ({ @@ -17,6 +19,7 @@ vi.mock('@/api/mcp', () => ({ })); vi.mock('@/api/tool', () => ({ + listAllToolPages, toolAPI: { test: vi.fn(), }, @@ -31,7 +34,15 @@ vi.mock('@/components/common/EmptyState', () => ({ })); vi.mock('./ServiceDetailPanel', () => ({ - MCPServerDetailPanel: () =>
detail-panel
, + MCPServerDetailPanel: (props: { serverTools: Array<{ name: string }> }) => { + mcpDetailProps(props); + return ( +
+ detail-panel + {props.serverTools.map((tool) => {tool.name})} +
+ ); + }, })); vi.mock('react-i18next', () => ({ @@ -69,6 +80,7 @@ describe('MCPTabContent', () => { }, }); mcpAPI.connect.mockResolvedValue({ data: true }); + listAllToolPages.mockResolvedValue([]); }); it('collects credentials and env vars before installing protected catalog entries', async () => { @@ -145,4 +157,61 @@ describe('MCPTabContent', () => { expect(onConfiguredChange).toHaveBeenCalledWith('panther'); expect(onRefreshTools).toHaveBeenCalled(); }); + + it('loads the complete tool list when a server detail drawer opens', async () => { + const user = userEvent.setup(); + mcpAPI.list.mockResolvedValue({ + data: [ + { + name: 'server-a', + status: 'connected', + tools: [], + resources: [], + tools_count: 2, + resources_count: 0, + }, + ], + }); + listAllToolPages.mockResolvedValue([ + { + name: 'complete-tool', + description: 'Loaded beyond the current page', + category: 'custom', + source: 'mcp', + source_name: 'server-a', + enabled: true, + }, + ]); + + render( + , + ); + + await user.click(await screen.findByText('server-a')); + + await waitFor(() => { + expect(listAllToolPages).toHaveBeenCalledWith({ + source: 'mcp', + sourceName: 'server-a', + sortBy: 'name', + sortDir: 'asc', + }); + }); + expect(await screen.findByText('complete-tool')).toBeInTheDocument(); + expect(mcpDetailProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + serverTools: [expect.objectContaining({ name: 'complete-tool' })], + }), + ); + }); }); diff --git a/webui/src/pages/Tool/components/MCPTabContent.tsx b/webui/src/pages/Tool/components/MCPTabContent.tsx index b7ede1274..48fe0592c 100644 --- a/webui/src/pages/Tool/components/MCPTabContent.tsx +++ b/webui/src/pages/Tool/components/MCPTabContent.tsx @@ -5,7 +5,7 @@ import { Download, ExternalLink, Star, TestTube, Play, Info, CheckCircle, XCircle, AlertTriangle, Power, } from 'lucide-react'; import { mcpAPI } from '@/api/mcp'; -import { toolAPI } from '@/api/tool'; +import { listAllToolPages, toolAPI } from '@/api/tool'; import type { Tool } from '@/api/tool'; import type { MCPCatalogCategory, MCPCatalogEntry, MCPServer } from '@/types'; import LoadingSpinner from '@/components/common/LoadingSpinner'; @@ -73,6 +73,7 @@ export default function MCPTabContent({ const [setupEntry, setSetupEntry] = useState(null); const [setupCredentials, setSetupCredentials] = useState>({}); const [setupEnvOverrides, setSetupEnvOverrides] = useState>({}); + const [serverToolCache, setServerToolCache] = useState>({}); const fetchServers = useCallback(async () => { try { @@ -165,11 +166,48 @@ export default function MCPTabContent({ return map; }, [tools]); + useEffect(() => { + setServerToolCache({}); + }, [tools]); + + useEffect(() => { + if (!selectedServer || serverToolCache[selectedServer]) return; + let cancelled = false; + void listAllToolPages({ + source: 'mcp', + sourceName: selectedServer, + sortBy: 'name', + sortDir: 'asc', + }).then((serverTools) => { + if (!cancelled) { + setServerToolCache((current) => ({ ...current, [selectedServer]: serverTools })); + } + }).catch(() => { + // Keep the current page slice visible; reopening retries the complete server list. + }); + return () => { + cancelled = true; + }; + }, [selectedServer, serverToolCache]); + const setSelectedCard = useCallback((name: string | null) => { setSelectedServer(name); setSelectedServerData(name ? (servers.find((server) => server.name === name) ?? null) : null); }, [servers]); + const selectMcpTool = useCallback((tool: Tool) => { + setSelectedToolFromMCP(tool); + toolAPI.get(tool.name) + .then((response) => { + setSelectedToolFromMCP((current) => ( + current?.name === tool.name ? response.data : current + )); + }) + .catch(() => { + // Keep the summary row visible; reopening retries the detail request. + }); + }, []); + const selectedServerObj = useMemo(() => { if (!selectedServer) return undefined; const fromServers = servers.find((server) => server.name === selectedServer); @@ -424,7 +462,9 @@ export default function MCPTabContent({
{serversLoading && catalogLoading ? ( -
+
+ +
) : unifiedCards.length === 0 ? ( } title={t('mcp.noServers')} description={t('mcp.noServersDesc')} /> ) : ( @@ -497,7 +537,7 @@ export default function MCPTabContent({ {/* Stats column */}
- {serverTools.length} + {server.tools_count ?? serverTools.length} {server.connected_at ? ( {formatDuration(server.connected_at, t)} ) : entry ? ( @@ -696,13 +736,13 @@ export default function MCPTabContent({ )} handleConnect(selectedServer)} onDisconnect={() => handleDisconnect(selectedServer)} onRefresh={async () => { await mcpAPI.refresh(selectedServer); await fetchServers(); await onRefreshTools(); }} onStatusChange={async () => { await fetchServers(); await onRefreshTools(); }} onRemove={() => handleRemove(selectedServer)} - onSelectTool={setSelectedToolFromMCP} + onSelectTool={selectMcpTool} />
diff --git a/webui/src/pages/Tool/index.tsx b/webui/src/pages/Tool/index.tsx index e0f6eae94..cd0c44400 100644 --- a/webui/src/pages/Tool/index.tsx +++ b/webui/src/pages/Tool/index.tsx @@ -43,8 +43,10 @@ import { } from 'lucide-react'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import EmptyState from '@/components/common/EmptyState'; -import { useTools } from '@/hooks/useTools'; -import { canDirectlyTestTool, toolAPI, Tool, ToolFixture, ToolSource } from '@/api/tool'; +import { useToast } from '@/components/common/Toast'; +import { useToolPage } from '@/hooks/useTools'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { canDirectlyTestTool, toolAPI, Tool, ToolFixture } from '@/api/tool'; import { mcpAPI, MCPServer } from '@/api/mcp'; import { providerAPI } from '@/api/provider'; import client from '@/api/client'; @@ -54,22 +56,27 @@ import type { MCPFormData, ConnStatus as MCPConnStatus } from './ToolSheets'; import MCPTabContent from './components/MCPTabContent'; import APITabContent from './components/APITabContent'; import LocalTabContent from './components/LocalTabContent'; -import { getToolTabCounts } from './tabCounts'; import { getSourceLabel } from './constants'; import { getCatalogDescription, getMetadataDescription } from '@/utils/mcpCatalog'; +import { extractErrorMessage } from '@/utils/error'; import { getLocalizedToolDescription, getLocalizedFixtureLabel } from './toolDisplay'; +import { + DEFAULT_TOOL_TAB, + TOOL_PAGE_SIZE, + getTabSourceFilter, + shouldLoadMcpCatalog, + type TabKey, +} from './tabLoading'; +import { getToolTabCounts } from './tabCounts'; // ============================================================================ // Constants & Config // ============================================================================ -type TabKey = 'all' | 'mcp' | 'api' | 'local'; - interface TabConfig { key: TabKey; label: string; icon: React.ReactNode; - sourceFilter?: ToolSource | ToolSource[]; } /** 类别 badge (source) - preserve existing Tool page colors while fixing device label rendering */ @@ -105,7 +112,7 @@ const SOURCE_SORT_ORDER: Record = { device: 6, }; -const PAGE_SIZE = 20; +const PAGE_SIZE = TOOL_PAGE_SIZE; /** MCP/API 服务器详情抽屉宽度(px) */ const DETAIL_DRAWER_WIDTH = 560; @@ -138,21 +145,31 @@ const EMPTY_FILTERS: ColumnFilters = { enabled: new Set(), }; +function joinFilterValues(values: Set): string | undefined { + if (values.size === 0) return undefined; + return Array.from(values).sort().join(','); +} + +function mergeFacetKeys(facets: Record, activeValues: Set = new Set()): string[] { + return Array.from(new Set([...Object.keys(facets), ...Array.from(activeValues)])).sort(); +} + // ============================================================================ // Main Page // ============================================================================ export default function ToolPage() { const { t, i18n } = useTranslation('tool'); + const toast = useToast(); const TABS: TabConfig[] = [ { key: 'all', label: t('tabs.all'), icon: }, - { key: 'mcp', label: t('tabs.mcp'), icon: , sourceFilter: 'mcp' }, - { key: 'api', label: t('tabs.api'), icon: , sourceFilter: 'api' }, - { key: 'local', label: t('tabs.local'), icon: , sourceFilter: 'plugin_py' }, + { key: 'mcp', label: t('tabs.mcp'), icon: }, + { key: 'api', label: t('tabs.api'), icon: }, + { key: 'local', label: t('tabs.local'), icon: }, ]; - const [activeTab, setActiveTab] = useState('all'); + const [activeTab, setActiveTab] = useState(DEFAULT_TOOL_TAB); const [searchQuery, setSearchQuery] = useState(''); const [selectedTool, setSelectedTool] = useState(null); const [testParams, setTestParams] = useState('{}'); @@ -171,38 +188,103 @@ export default function ToolPage() { const [sort, setSort] = useState({ field: 'source', dir: 'asc' }); const [filters, setFilters] = useState(EMPTY_FILTERS); - const { tools, loading, error, refetch } = useTools(); const [apiEnabledServicesCount, setApiEnabledServicesCount] = useState(0); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 250); + const tabSourceFilter = getTabSourceFilter(activeTab); + const sourceFilterParam = useMemo(() => { + const values = new Set(filters.source); + if (tabSourceFilter) { + const tabSources = Array.isArray(tabSourceFilter) ? tabSourceFilter : [tabSourceFilter]; + tabSources.forEach((source) => values.add(source)); + } + return joinFilterValues(values); + }, [filters.source, tabSourceFilter]); + const toolPageParams = useMemo(() => ({ + source: sourceFilterParam, + category: joinFilterValues(filters.category), + sourceName: joinFilterValues(filters.source_name), + enabled: joinFilterValues(filters.enabled), + q: debouncedSearchQuery.trim() || undefined, + sortBy: sort.field, + sortDir: sort.dir, + offset: (currentPage - 1) * PAGE_SIZE, + limit: PAGE_SIZE, + }), [ + sourceFilterParam, + filters.category, + filters.source_name, + filters.enabled, + debouncedSearchQuery, + sort.field, + sort.dir, + currentPage, + ]); + const { + tools, + total: totalTools, + facets: toolFacets, + loading, + error, + initialized: toolPageInitialized, + refetch, + } = useToolPage(toolPageParams); + const [hasLoadedToolPage, setHasLoadedToolPage] = useState(false); // Catalog data (fetched once at top level, shared with MCP & API tabs) const [catalogEntries, setCatalogEntries] = useState([]); const [catalogCategories, setCatalogCategories] = useState>({}); - const [catalogLoading, setCatalogLoading] = useState(true); + const [catalogLoading, setCatalogLoading] = useState(false); + const [catalogError, setCatalogError] = useState(null); + const [catalogRetryKey, setCatalogRetryKey] = useState(0); const [configuredIds, setConfiguredIds] = useState>(new Set()); + const catalogLoadedRef = useRef(false); useEffect(() => { + if (!shouldLoadMcpCatalog(activeTab) || catalogLoadedRef.current) return; + let cancelled = false; + const loadCatalog = async () => { + let loaded = false; try { setCatalogLoading(true); + setCatalogError(null); const [entriesRes, catsRes] = await Promise.all([ mcpAPI.catalogList(), mcpAPI.catalogCategories(), ]); + if (cancelled) return; setCatalogEntries(entriesRes.data); setCatalogCategories(catsRes.data); + loaded = true; // Get currently configured IDs (no auto-setup to avoid re-adding removed entries) try { const confRes = await mcpAPI.catalogConfigured(); - setConfiguredIds(new Set(confRes.data)); + if (!cancelled) setConfiguredIds(new Set(confRes.data)); } catch { /* ignore */ } } catch (err) { console.error('Failed to load catalog:', err); + if (!cancelled) { + setCatalogError(err instanceof Error ? err.message : 'Failed to load MCP catalog'); + } } finally { - setCatalogLoading(false); + if (!cancelled) { + catalogLoadedRef.current = loaded; + setCatalogLoading(false); + } } }; - loadCatalog(); - }, []); + void loadCatalog(); + + return () => { + cancelled = true; + }; + }, [activeTab, catalogRetryKey]); + + useEffect(() => { + if (!shouldLoadMcpCatalog(activeTab)) { + setCatalogLoading(false); + } + }, [activeTab]); const onConfiguredChange = useCallback((id: string) => { setConfiguredIds(prev => new Set(prev).add(id)); @@ -227,112 +309,72 @@ export default function ToolPage() { }, [fetchApiServicesCount]); const refreshToolData = useCallback(async () => { - await Promise.all([ + const [refreshResult] = await Promise.all([ refetch(), fetchApiServicesCount(), ]); + return refreshResult; }, [refetch, fetchApiServicesCount]); + const showRefreshOutcome = useCallback((status: 'success' | 'partial' | 'error', message: string) => { + if (status === 'partial') { + toast.warning(t('alert.refreshPartialTitle'), message || t('alert.refreshPartialDefault')); + } else if (status === 'error') { + toast.error(t('alert.refreshFailedTitle'), message || t('alert.unknownError')); + } + }, [t, toast]); + + const refreshToolDataAfterMutation = useCallback(async () => { + try { + const result = await refreshToolData(); + showRefreshOutcome(result.status, result.message); + } catch (err: unknown) { + const message = extractErrorMessage(err, t('alert.unknownError')); + toast.error(t('alert.refreshFailedTitle'), message); + } + }, [refreshToolData, showRefreshOutcome, t, toast]); + // The backend still marks some valid MCP catalog entries as "api" based on category. // Until API catalog has its own rendering path, show all catalog entries in the MCP tab. const mcpCatalogEntries = useMemo(() => catalogEntries, [catalogEntries]); // API catalog not yet implemented — keep this empty so entries are not duplicated across tabs. const apiCatalogEntries = useMemo(() => [] as MCPCatalogEntry[], []); - // Compute tab counts: all = active tools; mcp/api = unique active servers/modules const tabCounts = useMemo( - () => getToolTabCounts(tools, apiEnabledServicesCount), - [tools, apiEnabledServicesCount], + () => getToolTabCounts(totalTools, toolFacets, apiEnabledServicesCount), + [totalTools, toolFacets, apiEnabledServicesCount], ); + const enabledSummary = useMemo(() => ({ + active: toolFacets.enabled['true'] ?? tools.filter((tool) => tool.enabled).length, + inactive: toolFacets.enabled['false'] ?? 0, + }), [toolFacets.enabled, tools]); - // Get unique values for filter options (active tools only) + // Filter dropdown options come from server-side facets, plus active values + // so a selected filter remains visible even when it narrows the result set. const filterOptions = useMemo(() => { - const cats = new Set(); - const sources = new Set(); - const sourceNames = new Set(); - tools.forEach((tool) => { - cats.add(tool.category); - sources.add(tool.source); - sourceNames.add(tool.source_name || 'Flocks'); - }); return { - category: Array.from(cats).sort(), - source: Array.from(sources).sort((a, b) => (SOURCE_SORT_ORDER[a] ?? 99) - (SOURCE_SORT_ORDER[b] ?? 99)), - source_name: Array.from(sourceNames).sort(), + category: mergeFacetKeys(toolFacets.category, filters.category), + source: mergeFacetKeys(toolFacets.source, filters.source) + .sort((a, b) => (SOURCE_SORT_ORDER[a] ?? 99) - (SOURCE_SORT_ORDER[b] ?? 99)), + source_name: mergeFacetKeys(toolFacets.source_name, filters.source_name), enabled: ['true', 'false'], }; - }, [tools]); - - // Apply tab filter + search + column filters + sort (All tab shows active tools only) - const processedTools = useMemo(() => { - let result = [...tools]; + }, [toolFacets, filters.category, filters.source, filters.source_name]); - // Tab filter - const tabConfig = TABS.find((tab) => tab.key === activeTab); - if (tabConfig?.sourceFilter) { - const sf = tabConfig.sourceFilter; - const allowed = Array.isArray(sf) ? sf : [sf]; - result = result.filter((tool) => allowed.includes(tool.source)); - } + // Pagination + const processedTools = tools; + const totalPages = Math.max(1, Math.ceil(totalTools / PAGE_SIZE)); + const paginatedTools = tools; - // Search - if (searchQuery) { - const q = searchQuery.toLowerCase(); - result = result.filter( - (tool) => - tool.name.toLowerCase().includes(q) || - tool.description.toLowerCase().includes(q) || - (tool.description_cn || '').toLowerCase().includes(q) || - (tool.source_name || '').toLowerCase().includes(q) - ); - } + useEffect(() => { + if (currentPage > totalPages) setCurrentPage(totalPages); + }, [currentPage, totalPages]); - // Column filters - if (filters.category.size > 0) { - result = result.filter((tool) => filters.category.has(tool.category)); - } - if (filters.source.size > 0) { - result = result.filter((tool) => filters.source.has(tool.source)); - } - if (filters.source_name.size > 0) { - result = result.filter((tool) => filters.source_name.has(tool.source_name || 'Flocks')); - } - if (filters.enabled.size > 0) { - result = result.filter((tool) => filters.enabled.has(String(tool.enabled))); + useEffect(() => { + if (toolPageInitialized && !error) { + setHasLoadedToolPage(true); } - - // Sort - result.sort((a, b) => { - let cmp = 0; - switch (sort.field) { - case 'category': { - const la = a.category; - const lb = b.category; - cmp = la.localeCompare(lb); - break; - } - case 'source': - cmp = (SOURCE_SORT_ORDER[a.source] ?? 99) - (SOURCE_SORT_ORDER[b.source] ?? 99); - break; - case 'source_name': - cmp = (a.source_name || 'Flocks').localeCompare(b.source_name || 'Flocks', 'zh'); - break; - case 'enabled': - cmp = (a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1); - break; - } - return sort.dir === 'desc' ? -cmp : cmp; - }); - - return result; - }, [tools, activeTab, searchQuery, filters, sort]); - - // Pagination - const totalPages = Math.ceil(processedTools.length / PAGE_SIZE); - const paginatedTools = useMemo(() => { - const start = (currentPage - 1) * PAGE_SIZE; - return processedTools.slice(start, start + PAGE_SIZE); - }, [processedTools, currentPage]); + }, [toolPageInitialized, error]); const handleTabChange = (tab: TabKey) => { setActiveTab(tab); @@ -351,14 +393,20 @@ export default function ToolPage() { if (refreshing) return; try { setRefreshing(true); - await Promise.all([ + setRefreshDone(false); + const [result] = await Promise.all([ refreshToolData(), new Promise((r) => setTimeout(r, 600)), ]); - setRefreshDone(true); - setTimeout(() => setRefreshDone(false), 2000); - } catch (err: any) { - alert(t('alert.refreshFailed', { error: err.message })); + if (result.status === 'success') { + setRefreshDone(true); + setTimeout(() => setRefreshDone(false), 2000); + } else { + showRefreshOutcome(result.status, result.message); + } + } catch (err: unknown) { + const message = extractErrorMessage(err, t('alert.unknownError')); + toast.error(t('alert.refreshFailedTitle'), message); } finally { setRefreshing(false); } @@ -384,6 +432,15 @@ export default function ToolPage() { setSelectedTool(tool); setTestParams('{}'); setTestResult(null); + toolAPI.get(tool.name) + .then((response) => { + setSelectedTool((current) => ( + current?.name === tool.name ? response.data : current + )); + }) + .catch(() => { + // Keep the lightweight row data visible; retry happens when reopened. + }); }; const toggleSort = (field: SortField) => { @@ -410,20 +467,23 @@ export default function ToolPage() { setCurrentPage(1); }; - if (loading) { + const isInitialLoading = loading && !hasLoadedToolPage; + const isTabPageLoading = loading && hasLoadedToolPage && !toolPageInitialized; + + if (isInitialLoading) { return (
- +
); } - if (error) { + if (error && !hasLoadedToolPage) { return (

{error}

-
@@ -432,7 +492,7 @@ export default function ToolPage() { } return ( -
+
{/* Page Header */}
@@ -442,9 +502,9 @@ export default function ToolPage() {

{t('pageTitle')}

- {tools.length} {t('statusBadge.active')} + {enabledSummary.active} {t('statusBadge.active')} · - {catalogEntries.length} {t('statusBadge.inactive')} + {enabledSummary.inactive} {t('statusBadge.inactive')}

@@ -487,24 +547,24 @@ export default function ToolPage() { {/* Tabs row with search */}
-
+
+ {error && ( +
+ {error} +
+ )} + + {activeTab === 'mcp' && catalogError && ( +
+ {catalogError} + +
+ )} + {/* Tab Content */} - {activeTab === 'mcp' ? ( - - ) : activeTab === 'api' ? ( - - ) : activeTab === 'local' ? ( - - ) : ( - /* All tab: active tools only */ -
- {/* Inactive services callout */} - {(mcpCatalogEntries.length > 0 || apiCatalogEntries.length > 0) && !searchQuery && ( -
- - {catalogEntries.length} {t('summary.inactiveServicesAvailable')} - -
- {mcpCatalogEntries.length > 0 && ( - - )} - {apiCatalogEntries.length > 0 && ( - - )} -
-
- )} - {processedTools.length === 0 ? ( - } - title={t('empty.noTools')} - description={searchQuery ? t('empty.tryOtherKeywords') : t('empty.noToolsInCategory')} +
+ {isTabPageLoading ? ( +
+ +
+ ) : activeTab === 'mcp' ? ( + + ) : activeTab === 'api' ? ( + + ) : activeTab === 'local' ? ( +
+ - ) : ( - - )} -
- )} +
+ ) : ( + /* All tab: active tools only */ +
+ {/* Inactive services callout */} + {(mcpCatalogEntries.length > 0 || apiCatalogEntries.length > 0) && !searchQuery && ( +
+ + {catalogEntries.length} {t('summary.inactiveServicesAvailable')} + +
+ {mcpCatalogEntries.length > 0 && ( + + )} + {apiCatalogEntries.length > 0 && ( + + )} +
+
+ )} + {totalTools === 0 ? ( + } + title={t('empty.noTools')} + description={searchQuery ? t('empty.tryOtherKeywords') : t('empty.noToolsInCategory')} + /> + ) : ( + + )} +
+ )} +
{/* Tool Detail Drawer */} {selectedTool && ( @@ -630,17 +724,25 @@ export default function ToolPage() { onTest={handleTest} onDelete={async (name) => { try { - await toolAPI.delete(name); + const { data: deleteResult } = await toolAPI.delete(name); setSelectedTool(null); setTestResult(null); + if (deleteResult.status === 'partial') { + toast.warning( + t('alert.deletePartialTitle'), + deleteResult.errors?.join('; ') || deleteResult.message, + ); + } await handleRefresh(); - } catch (err: any) { - alert(t('alert.deleteFailed', { error: err.response?.data?.detail || err.message })); + } catch (err: unknown) { + alert(t('alert.deleteFailed', { + error: extractErrorMessage(err, t('alert.unknownError')), + })); } }} onEnabledChange={(name, newEnabled) => { setSelectedTool((prev) => prev ? { ...prev, enabled: newEnabled } : prev); - void refreshToolData(); + void refreshToolDataAfterMutation(); }} /> )} @@ -3814,4 +3916,3 @@ const LANG_COLORS: Record = { // CatalogBrowser removed — catalog UI is now inline in MCPTabContent / APITabContent // (CatalogBrowser component removed — catalog UI is inline in MCPTabContent / APITabContent) - diff --git a/webui/src/pages/Tool/tabCounts.test.ts b/webui/src/pages/Tool/tabCounts.test.ts index a5b660397..2ca2cec9d 100644 --- a/webui/src/pages/Tool/tabCounts.test.ts +++ b/webui/src/pages/Tool/tabCounts.test.ts @@ -1,20 +1,18 @@ import { describe, expect, it } from 'vitest'; -import type { Tool } from '@/api/tool'; +import type { ToolListFacets } from '@/api/tool'; import { getToolTabCounts } from './tabCounts'; describe('getToolTabCounts', () => { it('uses enabled API service count for the API tab', () => { - const tools = [ - { source: 'api', source_name: 'f1' }, - { source: 'api', source_name: 'f2' }, - { source: 'api', source_name: 'f3' }, - { source: 'mcp', source_name: 'mcp-a' }, - { source: 'mcp', source_name: 'mcp-a' }, - { source: 'plugin_py', source_name: 'local' }, - { source: 'plugin_py', source_name: 'local' }, - ] as Tool[]; + const facets: ToolListFacets = { + category: {}, + source: { api: 3, mcp: 2, plugin_py: 2 }, + source_groups: { api: 3, mcp: 1, plugin_py: 1 }, + source_name: {}, + enabled: {}, + }; - expect(getToolTabCounts(tools, 3)).toEqual({ + expect(getToolTabCounts(2, facets, 3)).toEqual({ all: 7, mcp: 1, api: 3, diff --git a/webui/src/pages/Tool/tabCounts.ts b/webui/src/pages/Tool/tabCounts.ts index 76b12e62a..a81edc7b4 100644 --- a/webui/src/pages/Tool/tabCounts.ts +++ b/webui/src/pages/Tool/tabCounts.ts @@ -1,4 +1,4 @@ -import type { Tool } from '@/api/tool'; +import type { ToolListFacets } from '@/api/tool'; export interface ToolTabCounts { all: number; @@ -7,11 +7,16 @@ export interface ToolTabCounts { local: number; } -export function getToolTabCounts(tools: Tool[], apiEnabledServicesCount: number): ToolTabCounts { +export function getToolTabCounts( + totalTools: number, + facets: ToolListFacets, + apiEnabledServicesCount: number, +): ToolTabCounts { + const allTools = Object.values(facets.source).reduce((sum, count) => sum + count, 0); return { - all: tools.length, - mcp: new Set(tools.filter((tool) => tool.source === 'mcp').map((tool) => tool.source_name)).size, + all: allTools || totalTools, + mcp: facets.source_groups.mcp ?? 0, api: apiEnabledServicesCount, - local: tools.filter((tool) => tool.source === 'plugin_py').length, + local: facets.source.plugin_py ?? 0, }; } diff --git a/webui/src/pages/Tool/tabLoading.test.ts b/webui/src/pages/Tool/tabLoading.test.ts new file mode 100644 index 000000000..8d30c0407 --- /dev/null +++ b/webui/src/pages/Tool/tabLoading.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_TOOL_TAB, + TOOL_PAGE_SIZE, + getTabSourceFilter, + shouldLoadMcpCatalog, +} from './tabLoading'; + +describe('tool tab loading strategy', () => { + it('starts from all tools with 25 visible rows', () => { + expect(DEFAULT_TOOL_TAB).toBe('all'); + expect(TOOL_PAGE_SIZE).toBe(25); + expect(getTabSourceFilter(DEFAULT_TOOL_TAB)).toBeUndefined(); + }); + + it('loads each source group only for its tab', () => { + expect(getTabSourceFilter('all')).toBeUndefined(); + expect(getTabSourceFilter('mcp')).toBe('mcp'); + expect(getTabSourceFilter('api')).toBe('api'); + expect(getTabSourceFilter('local')).toBe('plugin_py'); + }); + + it('defers MCP catalog requests until the MCP tab is opened', () => { + expect(shouldLoadMcpCatalog('all')).toBe(false); + expect(shouldLoadMcpCatalog('api')).toBe(false); + expect(shouldLoadMcpCatalog('local')).toBe(false); + expect(shouldLoadMcpCatalog('mcp')).toBe(true); + }); +}); diff --git a/webui/src/pages/Tool/tabLoading.ts b/webui/src/pages/Tool/tabLoading.ts new file mode 100644 index 000000000..ef3501115 --- /dev/null +++ b/webui/src/pages/Tool/tabLoading.ts @@ -0,0 +1,17 @@ +import type { ToolSource } from '@/api/tool'; + +export type TabKey = 'all' | 'mcp' | 'api' | 'local'; + +export const DEFAULT_TOOL_TAB: TabKey = 'all'; +export const TOOL_PAGE_SIZE = 25; + +export function getTabSourceFilter(tab: TabKey): ToolSource | ToolSource[] | undefined { + if (tab === 'mcp') return 'mcp'; + if (tab === 'api') return 'api'; + if (tab === 'local') return 'plugin_py'; + return undefined; +} + +export function shouldLoadMcpCatalog(tab: TabKey): boolean { + return tab === 'mcp'; +} diff --git a/webui/src/pages/WebUIContractPageHost/PageRuntimeHost.tsx b/webui/src/pages/WebUIContractPageHost/PageRuntimeHost.tsx index e1cccee74..d1e87aa67 100644 --- a/webui/src/pages/WebUIContractPageHost/PageRuntimeHost.tsx +++ b/webui/src/pages/WebUIContractPageHost/PageRuntimeHost.tsx @@ -13,6 +13,7 @@ import { AlertCircle, Loader2 } from 'lucide-react'; import { getApiBase } from '@/api/client'; import { webuiContractPagesAPI } from '@/api/webuiContractPages'; import { useSSE } from '@/hooks/useSSE'; +import { useDelayedVisible } from '@/hooks/useDelayedVisible'; import { installWebUIContractPageRuntime, loadWebUIContractPageBundle } from './runtime'; interface WebUIContractPageErrorBoundaryProps { @@ -73,6 +74,7 @@ export default function PageRuntimeHost({ pageId }: PageRuntimeHostProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [buildHash, setBuildHash] = useState(''); + const showLoading = useDelayedVisible(loading ? 180 : 0); const loadBundle = useCallback(async (hash: string) => { if (!pageId || !hash) return; @@ -140,6 +142,7 @@ export default function PageRuntimeHost({ pageId }: PageRuntimeHostProps) { } if (loading) { + if (!showLoading) return null; return (
diff --git a/webui/src/pages/WebUIContractWorkspaceHost/index.tsx b/webui/src/pages/WebUIContractWorkspaceHost/index.tsx index df890edf8..cee5d4c5d 100644 --- a/webui/src/pages/WebUIContractWorkspaceHost/index.tsx +++ b/webui/src/pages/WebUIContractWorkspaceHost/index.tsx @@ -7,6 +7,7 @@ import { type WebUIContractWorkspaceListItem, } from '@/api/webuiContractPages'; import { useSSE } from '@/hooks/useSSE'; +import { useDelayedVisible } from '@/hooks/useDelayedVisible'; import { ThemeContext } from '@/contexts/ThemeContext'; import PageRuntimeHost from '@/pages/WebUIContractPageHost/PageRuntimeHost'; import { buildWebUIContractWorkspaceSections } from '@/utils/webuiContractWorkspaceSections'; @@ -17,6 +18,7 @@ export default function WebUIContractWorkspaceHost() { const [workspaces, setWorkspaces] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const showLoading = useDelayedVisible(loading ? 180 : 0); const { theme, setTemporaryThemeOverride } = useContext(ThemeContext); const fetchWorkspaces = useCallback(async (silent = false) => { @@ -82,6 +84,7 @@ export default function WebUIContractWorkspaceHost() { } if (loading) { + if (!showLoading) return null; return (
diff --git a/webui/src/pages/Workflow/index.tsx b/webui/src/pages/Workflow/index.tsx index 330c183a1..ec2acd37b 100644 --- a/webui/src/pages/Workflow/index.tsx +++ b/webui/src/pages/Workflow/index.tsx @@ -103,7 +103,7 @@ export default function WorkflowPage() { if (loading) { return (
- +
); } diff --git a/webui/src/pages/WorkflowDetail/index.tsx b/webui/src/pages/WorkflowDetail/index.tsx index a3074d55f..7e1bbd8a4 100644 --- a/webui/src/pages/WorkflowDetail/index.tsx +++ b/webui/src/pages/WorkflowDetail/index.tsx @@ -596,7 +596,7 @@ export default function WorkflowDetail() { if (loading) { return (
- +
); } diff --git a/webui/src/pages/WorkflowEditor/index.tsx b/webui/src/pages/WorkflowEditor/index.tsx index 536e15a35..d65350068 100644 --- a/webui/src/pages/WorkflowEditor/index.tsx +++ b/webui/src/pages/WorkflowEditor/index.tsx @@ -32,6 +32,7 @@ import { Trash2, } from 'lucide-react'; import { workflowAPI, Workflow, WorkflowExecution, WorkflowJSON, WorkflowNode as APINode } from '@/api/workflow'; +import LoadingSpinner from '@/components/common/LoadingSpinner'; import { ThemeContext } from '@/contexts/ThemeContext'; import { extractErrorMessage } from '@/utils/error'; import { @@ -779,7 +780,7 @@ export default function WorkflowEditor() { if (loading) { return (
-
+
); } diff --git a/webui/src/pages/Workspace/index.tsx b/webui/src/pages/Workspace/index.tsx index 2e8632ead..95d115dc7 100644 --- a/webui/src/pages/Workspace/index.tsx +++ b/webui/src/pages/Workspace/index.tsx @@ -1,18 +1,15 @@ -import { useState, useEffect, useCallback, useMemo, useRef, useReducer } from 'react'; +import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef, useReducer } from 'react'; import { FolderOpen, Upload, Download, Trash2, Edit3, Save, X, ChevronRight, ChevronLeft, ChevronDown, ChevronUp, RefreshCw, FolderPlus, Brain, AlertTriangle, Search, ArrowLeft, Maximize2, Code2, Eye, ZoomIn, ZoomOut, } from 'lucide-react'; -import * as pdfjsLib from 'pdfjs-dist'; -import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; import { useTranslation } from 'react-i18next'; import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import { useToast } from '@/components/common/Toast'; import { useConfirm } from '@/components/common/ConfirmDialog'; -import { StreamingMarkdown } from '@/components/common/StreamingMarkdown'; import { workspaceAPI, WorkspaceNode, formatBytes, formatDate, fileIcon, } from '@/api/workspace'; @@ -52,7 +49,8 @@ const IMAGE_MIN_SCALE = 0.5; const IMAGE_MAX_SCALE = 3; const IMAGE_SCALE_STEP = 0.25; -pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl; +const LazyStreamingMarkdown = lazy(() => import('@/components/common/StreamingMarkdown') + .then((module) => ({ default: module.StreamingMarkdown }))); function getViewportWidth(): number { return typeof window === 'undefined' ? PREVIEW_PANEL_MIN_WIDTH * 2 : window.innerWidth; @@ -361,6 +359,7 @@ function PdfPreview({ useEffect(() => { let cancelled = false; + let loadingTask: any = null; setPdfDoc(null); setPageNumber(1); setPageCount(0); @@ -370,26 +369,40 @@ function PdfPreview({ setLoading(true); setError(null); - const loadingTask = pdfjsLib.getDocument({ url: previewUrl, withCredentials: true }); - loadingTask.promise - .then((doc) => { + async function loadPdf() { + try { + const [pdfjsLib, pdfWorkerModule] = await Promise.all([ + import('pdfjs-dist'), + import('pdfjs-dist/build/pdf.worker.min.mjs?url'), + ]); + if (cancelled) { + return; + } + + pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerModule.default; + loadingTask = pdfjsLib.getDocument({ url: previewUrl, withCredentials: true }); + const doc = await loadingTask.promise; if (cancelled) { + doc?.destroy?.(); return; } + setPdfDoc(doc); setPageCount(doc.numPages); setPagesToRender(new Set(Array.from({ length: Math.min(doc.numPages, PDF_RENDER_WINDOW + 1) }, (_, index) => index + 1))); setLoading(false); - }) - .catch((e: any) => { + } catch (e: any) { if (cancelled) return; setError(e?.message ?? 'PDF preview failed'); setLoading(false); - }); + } + } + + loadPdf(); return () => { cancelled = true; - loadingTask.destroy(); + loadingTask?.destroy?.(); }; }, [previewUrl]); @@ -764,7 +777,9 @@ function RenderedPreview({ if (kind === 'markdown') { return (
- +
}> + +
); } diff --git a/webui/src/routes/index.tsx b/webui/src/routes/index.tsx index d26866ada..72e3113b1 100644 --- a/webui/src/routes/index.tsx +++ b/webui/src/routes/index.tsx @@ -1,46 +1,69 @@ import { Suspense, lazy } from 'react'; +import type { ComponentType, ReactNode } from 'react'; import { Routes as RouterRoutes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import Layout from '@/components/layout/Layout'; +import LazyLoadErrorBoundary from '@/components/common/LazyLoadErrorBoundary'; import RoutePageSkeleton from '@/components/common/RoutePageSkeleton'; import AuthLayout from '@/components/layout/AuthLayout'; import Home from '@/pages/Home'; import { useAuth } from '@/contexts/AuthContext'; +import { preloadI18nNamespaces } from '@/i18nResources'; +import { installVitePreloadErrorRecovery, recoverLazyLoad } from '@/utils/chunkLoadRecovery'; + +installVitePreloadErrorRecovery(); // All non-Home pages are code-split. Home stays eager because it's the very // first frame after auth and we don't want a Suspense flash on initial paint. // In particular, Session/Agent and the auth screens are kept lazy so heavy // transitive deps (SessionChat ~2.7k LOC + react-markdown + rehype/remark + // highlight.js) are not pulled into the main entry chunk. -const SessionPage = lazy(() => import('@/pages/Session')); -const AgentPage = lazy(() => import('@/pages/Agent')); -const LoginPage = lazy(() => import('@/pages/Login')); -const SetupAdminPage = lazy(() => import('@/pages/SetupAdmin')); -const ForceChangePasswordPage = lazy(() => import('@/pages/ForceChangePassword')); -const WorkflowListPage = lazy(() => import('@/pages/Workflow')); -const WorkflowCreate = lazy(() => import('@/pages/WorkflowCreate')); -const WorkflowEditor = lazy(() => import('@/pages/WorkflowEditor')); -const WorkflowDetail = lazy(() => import('@/pages/WorkflowDetail')); -const TaskPage = lazy(() => import('@/pages/Task')); -const ToolPage = lazy(() => import('@/pages/Tool')); -const HubPage = lazy(() => import('@/pages/Hub')); -const SkillPage = lazy(() => import('@/pages/Skill')); -const ModelPage = lazy(() => import('@/pages/Model')); -const ChannelPage = lazy(() => import('@/pages/Channel')); -const PermissionPage = lazy(() => import('@/pages/Permission')); -const MonitoringPage = lazy(() => import('@/pages/Monitoring')); -const WorkspacePage = lazy(() => import('@/pages/Workspace')); -const DeviceIntegrationPage = lazy(() => import('@/pages/DeviceIntegration')); -const FlocksproUpgradeCallbackPage = lazy(() => import('@/pages/FlocksproUpgrade/Callback')); -const SettingsPage = lazy(() => import('@/pages/Settings')); -const WebUIContractPageHost = lazy(() => import('@/pages/WebUIContractPageHost')); -const WebUIContractWorkspaceHost = lazy(() => import('@/pages/WebUIContractWorkspaceHost')); - -function LazyRoute({ children }: { children: React.ReactNode }) { +type LazyPageModule = { default: ComponentType }; + +function lazyPage( + loader: () => Promise, + namespaces: readonly string[] = [], +) { + return lazy(() => recoverLazyLoad( + Promise.all([ + loader(), + preloadI18nNamespaces(namespaces), + ]).then(([module]) => module), + )); +} + +const SessionPage = lazyPage(() => import('@/pages/Session'), ['session']); +const AgentPage = lazyPage(() => import('@/pages/Agent'), ['agent']); +const LoginPage = lazyPage(() => import('@/pages/Login')); +const SetupAdminPage = lazyPage(() => import('@/pages/SetupAdmin')); +const ForceChangePasswordPage = lazyPage(() => import('@/pages/ForceChangePassword')); +const WorkflowListPage = lazyPage(() => import('@/pages/Workflow'), ['workflow']); +const WorkflowCreate = lazyPage(() => import('@/pages/WorkflowCreate'), ['workflow']); +const WorkflowEditor = lazyPage(() => import('@/pages/WorkflowEditor'), ['workflow']); +const WorkflowDetail = lazyPage(() => import('@/pages/WorkflowDetail'), ['workflow']); +const TaskPage = lazyPage(() => import('@/pages/Task'), ['task']); +const ToolPage = lazyPage(() => import('@/pages/Tool'), ['tool']); +const HubPage = lazyPage(() => import('@/pages/Hub')); +const SkillPage = lazyPage(() => import('@/pages/Skill'), ['skill']); +const ModelPage = lazyPage(() => import('@/pages/Model'), ['model']); +const ChannelPage = lazyPage(() => import('@/pages/Channel'), ['channel']); +const PermissionPage = lazyPage(() => import('@/pages/Permission'), ['permission']); +const MonitoringPage = lazyPage(() => import('@/pages/Monitoring'), ['monitoring']); +const WorkspacePage = lazyPage(() => import('@/pages/Workspace'), ['workspace']); +const DeviceIntegrationPage = lazyPage(() => import('@/pages/DeviceIntegration'), ['device']); +const FlocksproUpgradeCallbackPage = lazyPage(() => import('@/pages/FlocksproUpgrade/Callback'), ['flockspro']); +const SettingsPage = lazyPage(() => import('@/pages/Settings')); +const WebUIContractPageHost = lazyPage(() => import('@/pages/WebUIContractPageHost')); +const WebUIContractWorkspaceHost = lazyPage(() => import('@/pages/WebUIContractWorkspaceHost')); +const ROUTE_FALLBACK_DELAY_MS = 180; + +function LazyRoute({ children }: { children: ReactNode }) { return ( - }> - {children} - + + }> + {children} + + ); } @@ -96,31 +119,37 @@ export function Routes() { if (!bootstrapped) { return ( - }> - - } /> - } /> - - + + }> + + } /> + } /> + + + ); } if (!user) { return ( - }> - - } /> - } /> - - + + }> + + } /> + } /> + + + ); } if (user.must_reset_password) { return ( - }> - - + + }> + + + ); } diff --git a/webui/src/types/index.ts b/webui/src/types/index.ts index 4b8995e03..e78dbfd28 100644 --- a/webui/src/types/index.ts +++ b/webui/src/types/index.ts @@ -203,6 +203,7 @@ export interface Tool { source: ToolSource; source_name?: string; parameters: ToolParameter[]; + parameters_count?: number; enabled: boolean; /** Factory default from the YAML/registration source (no overlay applied). */ enabled_default?: boolean; @@ -396,12 +397,10 @@ export interface MCPCatalogStats { export interface ProviderCredentials { secret_id?: string | null; /** - * The actual API key value, when one exists. - * Note: For openai-compatible / custom-* providers configured WITHOUT an - * API key (internal no-auth gateways) the backend stores a sentinel value - * but returns ``api_key: null`` while still setting ``has_credential: true`` - * and a real ``secret_id``. UI code should rely on ``has_credential`` (not - * ``api_key``) to decide whether a credential record exists. + * Write-only compatibility field. Credential reads never return the raw key; + * they return ``api_key: null`` plus ``api_key_masked`` and + * ``has_credential``. UI code must never submit the masked value as a new + * secret. Leave ``api_key`` out of an update to preserve the stored key. */ api_key?: string | null; api_key_masked?: string | null; @@ -409,6 +408,7 @@ export interface ProviderCredentials { secret_masked?: string | null; base_url?: string | null; username?: string | null; + /** Sensitive entries are masked on reads and must not be resubmitted unchanged. */ fields?: Record; secret_ids?: Record; has_credential: boolean; diff --git a/webui/src/utils/chunkLoadRecovery.test.ts b/webui/src/utils/chunkLoadRecovery.test.ts new file mode 100644 index 000000000..be99df7f2 --- /dev/null +++ b/webui/src/utils/chunkLoadRecovery.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { recoverLazyLoad, reloadOnceForChunkLoadError } from './chunkLoadRecovery'; + +describe('chunkLoadRecovery', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('reloads only once for the same failed chunk and allows a new chunk failure', () => { + const reload = vi.fn(); + const staleChunk = new TypeError('Failed to fetch /assets/Session-old.js'); + + expect(reloadOnceForChunkLoadError(staleChunk, reload)).toBe(true); + expect(reloadOnceForChunkLoadError(staleChunk, reload)).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + + expect(reloadOnceForChunkLoadError( + new TypeError('Failed to fetch /assets/Session-new.js'), + reload, + )).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('does not reload when session storage cannot guard against a loop', () => { + const reload = vi.fn(); + const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + + expect(reloadOnceForChunkLoadError(new Error('chunk failed'), reload)).toBe(false); + expect(reload).not.toHaveBeenCalled(); + getItem.mockRestore(); + }); + + it('reloads for an explicit dynamic-import failure', async () => { + const reload = vi.fn(); + const error = new TypeError('Failed to fetch dynamically imported module: /assets/Session-old.js'); + + await expect(recoverLazyLoad(Promise.reject(error), reload)).rejects.toBe(error); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('does not reload for a non-chunk lazy dependency failure', async () => { + const reload = vi.fn(); + const error = new Error('i18n instance is unavailable'); + + await expect(recoverLazyLoad(Promise.reject(error), reload)).rejects.toBe(error); + + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/webui/src/utils/chunkLoadRecovery.ts b/webui/src/utils/chunkLoadRecovery.ts new file mode 100644 index 000000000..240b3541e --- /dev/null +++ b/webui/src/utils/chunkLoadRecovery.ts @@ -0,0 +1,78 @@ +const CHUNK_RELOAD_STORAGE_KEY = 'flocks:chunk-load-reload'; + +type ReloadPage = () => void; + +const CHUNK_LOAD_ERROR_PATTERNS = [ + /failed to fetch dynamically imported module/i, + /error loading dynamically imported module/i, + /importing a module script failed/i, + /loading (?:css )?chunk .+ failed/i, + /unable to preload css/i, +]; + +export function isChunkLoadError(error: unknown): boolean { + const candidate = error && typeof error === 'object' + ? error as { name?: unknown; message?: unknown } + : null; + const name = candidate && typeof candidate.name === 'string' ? candidate.name : ''; + const message = candidate && typeof candidate.message === 'string' + ? candidate.message + : typeof error === 'string' ? error : ''; + return name === 'ChunkLoadError' || CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +} + +function errorFingerprint(error: unknown): string { + if (error instanceof Error) { + return `${error.name}:${error.message}`.slice(0, 1000); + } + if (typeof error === 'string') { + return error.slice(0, 1000); + } + return 'unknown-chunk-load-error'; +} + +export function reloadOnceForChunkLoadError( + error: unknown, + reload: ReloadPage = () => window.location.reload(), +): boolean { + const fingerprint = errorFingerprint(error); + try { + if (window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY) === fingerprint) { + return false; + } + window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, fingerprint); + } catch { + // Without session storage we cannot safely prevent a reload loop. + return false; + } + + reload(); + return true; +} + +export function recoverLazyLoad(promise: Promise, reload?: ReloadPage): Promise { + return promise.catch((error: unknown) => { + if (isChunkLoadError(error)) { + reloadOnceForChunkLoadError(error, reload); + } + throw error; + }); +} + +export function retryChunkLoad(): void { + window.location.reload(); +} + +let preloadRecoveryInstalled = false; + +export function installVitePreloadErrorRecovery(): void { + if (preloadRecoveryInstalled || typeof window === 'undefined') return; + preloadRecoveryInstalled = true; + + window.addEventListener('vite:preloadError', (event) => { + const preloadEvent = event as Event & { payload?: unknown }; + if (reloadOnceForChunkLoadError(preloadEvent.payload)) { + event.preventDefault(); + } + }); +}