diff --git a/.agents/skills/provider-adaptation/SKILL.md b/.agents/skills/provider-adaptation/SKILL.md new file mode 100644 index 000000000..821a5c4f9 --- /dev/null +++ b/.agents/skills/provider-adaptation/SKILL.md @@ -0,0 +1,304 @@ +--- +name: provider-adaptation +description: Agent2API(workbuddy)仓库接入新提供商(provider)与验收适配完整度的规范 —— README「支持情况一览」六列的口径、必须登记的代码与前端落点、能力探测脚本的用法、静默失效排查、六份 README 同步。凡是涉及新增一家反代 Agent / 客户端 provider、审核这类 PR、判断某家「做完了没有」、改 providers/ 下的适配器或能力位(supports_*)、给签到清单加一家、或补 README 支持矩阵时,都先按本 Skill 执行 —— 即使对方没提「provider」「适配」这类字眼。 +--- + +# Agent2API 提供商接入规范 + +覆盖两件事:**接一家新 provider 要改哪些地方**(第 2–5 节),以及**怎么判断它接完了没有**(第 1、6、7 节)。排查见第 8 节。 + +## 0. 一条红线:README 的支持矩阵就是验收口径 + +根 `README.md`(英文)的「Supported Providers」表有六列,每一格都是**用户可见的承诺**(中文译本在 `docs/README.zh-CN.md`,表述为「支持情况一览」): + +| 列 | 含义 | +|---|---| +| LLM 请求 | 能转发对话(含流式) | +| Token 自动续期 | 凭证过期能自己换新 | +| 模型列表 | 有清单,且支持在线拉取 | +| 余额查询 | 能读余额 / 积分 | +| 签到 | 有每日签到(或等价动作),且接进了自动签到框架 | +| 领取类 | 有可领取的活动(新手礼 / 每日任务 / 限时套餐) | + +**打 ✓ 的每一格,必须有一个落地物和一个可执行的验收动作**(见第 6 节)。**打 ✗ 或 — 的每一格,必须能说清「上游确实没有这个能力」是怎么排除的**,不能是「还没做」。 + +这条红线的由来:本仓库历史上多次出现「只做了对话,模型获取 / 余额 / 签到没做」的半成品,而表格和代码之间没有任何自动校验 —— 表格只能靠人自觉。因此: + +- **✗ 不等于没做,等于确认不存在。** 结论要能复述排查过程(打过哪些接口、返回什么)。 +- **默认值不是结论。** `ProviderAdapter` 的 `supports_*()` 默认全是 `false`,那是「尚未接入」的语义,不是「这家不支持」。 +- **不要为了让表格好看而打 ✓。** 一个必然报错的按钮比没有按钮更糟(见 `auto_checkin.rs` 的 `CHECKIN_PROVIDERS` 注释)。 + +## 1. 先做的三件事(动手写代码之前) + +### 1.1 摸清上游的真实形态 + +拿到这家客户端的通信证据(抓包 / 安装包逆向 / 官方 CLI 源码 / 参考实现),至少要回答: + +- 推理是 OpenAI 兼容,还是自有协议?(决定走 `build_chat_request` 还是 `forward_conversation`) +- 响应是 SSE、NDJSON,还是别的信封?(决定要不要新开 `UpstreamResponse` 变体) +- 鉴权头怎么拼?凭证从哪来、会不会过期、有没有续期接口? +- 有没有模型目录接口、余额接口、签到接口?**每一条都要有实际请求证据。** +- 有没有地区 / 额度池的区分?(决定是不是要拆成两个 provider) + +### 1.2 判断要不要拆成两家 provider + +**地区 / 额度池是 provider 身份,不是账号上的字段。** 这条在本仓库被反复确认过,走过这条路的:WorkBuddy、AutoClaw、Qoder、Accio、ZCode、MonkeyCode、ModelScope(两地),Cline(两池)。 + +把地区做成账号字段的后果每次都一样: + +1. 模型目录只有一份缓存,两个地区互相覆盖; +2. 模型规则只有一个 `(provider, id)` 命名空间,同名模型无法分别点名; +3. 转发候选不含地区,请求可能落到另一地区的账号上,而「模型不存在」的 400 是 `Fatal`、不换账号,直接失败。 + +判断依据是**账号体系是否分开**:两套账号、两份目录、令牌不通用 → 拆。只是同一套协议换域名、账号通用 → 不必拆(但仍需确认)。 + +拆了之后:`ProviderKind` 加两个变体,`PROVIDERS` 加两条(相邻排列),实现是**一套**、按 `Region` 参数化,两个静态实例由 `adapter_for` 分派。地区互查放在 `region.rs`(`kind` / `provider_id` / `from_provider_id`),**别处不要再写 `"xxx-intl"` 这类字面量**。 + +### 1.3 确认「没有某接口」的排除过程 + +CatPaw 与 AutoClaw 都曾把「上游没有目录接口」写进注释,后来两次都被证伪:CatPaw 的 `tenant/scene/env` 是 POST body 不是 query;AutoClaw 的目录在同 host 的 `/proxy/` 一级而不是对话用的 `/proxy/autoclaw`。 + +所以:**「这家没有某接口」的结论要能说清是怎么排除的**,否则它只是一个还没找到的接口。把这个过程写进对应模块的模块头,别只写结论。 + +## 2. 后端登记点(漏一个就是静默失效) + +按顺序改,每一条都写清「漏了会怎样」。 + +### 2.1 身份与注册表 + +| 文件 | 改什么 | 漏了会怎样 | +|---|---|---| +| `core/providers/mod.rs` | `ProviderKind` 加变体(**加在末尾**) | 编译不过 | +| 同上 | `PROVIDERS` 加 `ProviderMeta { id, label }` | 编译不过 / 界面无此项 | +| 同上 | `kind_from_id` 加分支 | 编译期不报,运行期「未知 provider」,账号静默失配 | +| 同上 | `kind_id` 加分支 | 编译报错(穷举 match) | +| `core/providers/adapter.rs` | `adapter_for` 加分支 | 编译报错(穷举 match) | +| 同上 | `implemented_kinds()` 加 kind | **不报错**,但后台目录刷新永不调度 —— 症状是「一直是内置兜底、手动刷新也没用」 | + +`ProviderMeta.id` 是落盘契约(`accounts.json` 里的 `provider` 字段),**存量 id 不能改名**。新家取新 id;只有国际版这类新增身份才用新串。 + +### 2.2 账号层 + +| 文件 | 改什么 | 漏了会怎样 | +|---|---|---| +| `api/accounts.rs` | `add_account` 加 `ProviderKind` 分派分支 | 这家根本加不了号 | +| 同上 | `refresh_provider_account` 分派(如果支持手动刷新凭证) | 界面上「刷新」按钮点了没反应或报未知 provider | +| `core/account_store/_accounts.rs` | 新建:账号记录的读写、公开形态转换、id 生成 | 账号无法落库 / 界面上看不到 | +| `core/account_store/mod.rs` | 加 `is__family(provider_id)` | 两地 / 两池的家判定失效 | +| `core/account_store/store_view.rs` | `public_account` 加分支(去掉凭证本体) | **凭证会随公开形态泄漏到前端** | + +公开形态**绝不能含 accessToken / refreshToken**,只留 `tokenTail` 与「有没有令牌」。这条是安全边界。 + +### 2.3 目录与转发 + +| 文件 | 改什么 | 漏了会怎样 | +|---|---|---| +| `core/providers//models.rs` | `list_models`(静态兜底 + 远程清单) | `/v1/models` 无此项 | +| `core/providers/catalog.rs` | `refresh_meta` 加分支(穷举 match) | 编译报错 | +| `core/providers/catalog_cache.rs` | `SCOPE_*` 常量 + 加进 `ALL_SCOPES` | **不报错**,清单进程重启后丢失 | +| `core/providers//adapter.rs` | `supports_model_refresh` + `refresh_models` | 界面「刷新模型」按钮无效 | + +`ALL_SCOPES` 漏了不会报错 —— 缓存能写进内存,但重启后读不回来。这是最容易漏的一处。 + +### 2.4 能力位 + +`ProviderAdapter` 的每个能力位都有明确的消费方,填错会直接影响界面行为: + +| 能力位 | 消费方 | 填错的后果 | +|---|---|---| +| `supports_chat` | `account_store::pick_current`(全局队首) | 无转发能力的家排进队首,顶栏显示成「当前登录态」 | +| `supports_refresh` | `credential_maintenance` | 声称支持但刷不动 → 每轮白跑 + 灌失败日志 | +| `supports_model_refresh` | `catalog_refresh` | 声称支持却拿不到新清单,用户以为坏了 | +| `supports_usage` | `usage_query` / 前端积分按钮 | 给了入口但必然失败 | +| `supports_web_login` | `api::session` 登录路由 | 标 true 但 `build_login_url` 返回 None → 能用的功能变 400 | + +口径统一为:**「上游到底有没有那个接口」,不是「我们想不想实现」**。判不出来时返回 `false`(宁可漏刷,也不要凭猜测打上游)。 + +### 2.5 签到与领取 + +| 文件 | 改什么 | 漏了会怎样 | +|---|---|---| +| `core/auto_checkin.rs` | `CHECKIN_PROVIDERS` 加 id(**数组长度 `[&str; 16]` 也要 +1**) | 签到中心无这家、定时任务不跑 | +| 同上 | `PROVIDERS_VERSION` +1,`PROVIDERS_ADDED_IN` 加条目(**数组长度 `[(&str, i64); 6]` 也要 +1**) | **不报错**,存量用户的签到清单里不会自动出现这家 | +| `core/billing/checkin.rs` | `checkin_for` 的 `match provider_id` 加分支 | 批量签到对这家算 `skipped` | +| `core/providers//checkin.rs` | 新建:claim 实现 | — | +| 领取类 | 各家自己的入口(`zcode::claim` / `codearts::welfare` / `chatglm::tasks` / `workbuddy::growth`) | 无统一位置,最容易被整块漏掉 | + +**`PROVIDERS_ADDED_IN` 是最隐蔽的一处。** 只往 `CHECKIN_PROVIDERS` 加 id 而不升版本号,新家不会进入存量用户的清单;而每次读都补齐又会让用户取消不掉勾选(读完就补回来)。版本号就是那个「只补一次」的记账。 + +加一家之前**先确认签到链路真的存在**。上游没有独立签到接口但有等价动作(每日首次登录、调一次信息接口就触发)也算,但要在模块头写清依据。完全没活动就不进清单 —— 一个点了必然报错的复选框比没有更糟。 + +### 2.6 错误分类(最容易做错的一档) + +`classify_error` 只能归到四档,**多出来的分类在编排层没有对应分支,会静默落进 `Fatal`**: + +| 档 | 动作 | 判错的后果 | +|---|---|---| +| `QuotaLimited` | 标记账号对该模型冷却 + 换账号 | 限额被当 Fatal → 账号永不轮换,每次都撞同一面墙 | +| `TokenExpired` | 刷新凭证后**同一账号**重试一次 | 凭证过期被当 Fatal → 用户看到 401 | +| `ContentBlocked` | **不罚账号**,换中性提示词重试一次 | 审核误报被当限额 → 白扔另一个账号的额度 | +| `Fatal` | 原样透传给客户端 | — | + +几条实践要点: + +- 有些上游把「额度耗尽」和「速率窗口」都用 429 表达,只能靠**文案**区分,而处置完全不同(前者冷却到次日零点,后者是分钟级窗口)。判据要写清来源。 +- 有些上游把业务失败包装成 **HTTP 200**(正文一句固定话术,或流内 `{"type":"error"}`)。这种要靠 `is_error_response` / `stream_refusal_finishes` / `stream_refusal_markers` 拦下,否则会被当成一次成功答复。 +- `stream_refusal_finishes` 判的是**内容策略**拒绝(不罚账号、换家);`stream_refusal_markers` 判的是**本账号额度**问题(必须罚账号)。两条别混。 +- 会话式转发(`is_stateful`)的错误默认走 `Fatal`。要参与限额记账必须覆写 `classify_conversation_error`,否则福利池耗尽后每个请求都从头撞一遍全部账号。 + +## 3. 前端登记点 + +| 文件 | 改什么 | 漏了会怎样 | +|---|---|---| +| `ui-islands/src/islands/add-account-configs.ts` | `ProviderConfig`:表单字段、登录方式(`webLogin` / `smsLogin` / `oauthLogin` / 访客) | 添加账号弹窗里没有这家的表单 | +| `ui-islands/src/islands/add-provider-pick.tsx` | 卡片列表登记 | 选不到这家 | +| `ui-islands/src/islands/add-provider-blocks.tsx` | 特殊块(如 WorkBuddy 的版本 + 打开方式) | 仅特殊形态需要 | +| `ui-islands/src/islands/accounts-domain.ts` | 账号行的能力标记(`usage` / `creditDetail` / `edition` / `identifier` / `expiry` / `longLived`) | 账号卡片上的徽章、余额格、有效期显示不对 | +| `ui-islands/src/islands/checkin-page.tsx` | `PROVIDER_DESC` 加链路说明 | 签到中心该家分组没有说明文字 | +| `ui/assets/providers/.png` | 图标 | 卡片没有图标 | + +`ui/providers.js` **不用改** —— 它从 `/api/session` 的 `accounts.providers`(后端注册表摘要)自动派生。 + +改完前端要重新构建岛:`npm run ui:islands`(产物 `ui/islands/ui.js`、`ui/islands/login.js` 都要提交)。 + +## 4. i18n 与文档 + +### 4.1 i18n + +中文是 i18n 的源语言,`i18n/i18n-src/` 下只有五份译文(`en` / `ja` / `ko` / `pt-BR` / `zh-Hant`)。新增的中文文案会被 `scan.mjs` 扫出来,缺译文就是空值。 + +```bash +cd desktop-tauri/i18n +npm run scan # 扫出中文键,补齐到 i18n-src/* +npm run check # 检查是否有空值(CI 会拦) +npm run build # 生成 ui/i18n/*.js +``` + +历史教训:上游 v3.0.7 加了六条中文文案但没跑扫描,`scan.mjs --check` 时被扫出空值。**改完前端一定要跑 `check`。** + +### 4.2 文档 + +`README.md` 的「Supported Providers」**六份一一对应**,加一家要六份一起改(注意:**根 `README.md` 是英文**,简体中文在 `docs/README.zh-CN.md`,别把中文写回根目录): + +- `README.md`(English,GitHub 默认展示) +- `docs/README.zh-CN.md`(简体中文,文案源语言) +- `docs/README.zh-Hant.md` +- `docs/README.ja.md` +- `docs/README.ko.md` +- `docs/README.pt-BR.md` + +另外注意「内置 N 家提供商」这个数字(「核心特性」一节,**六份 README 都有**,各自语言的措辞不同)。它数的是**表格里的内置行数**(不含「自定义提供商」那一行),不是 `PROVIDERS` 的条目数: + +- 表格把两地 / 两池合并成一行(如 `AutoClaw(国内版 / 国际版)`、`Cline(Free / Pass)`),而 `PROVIDERS` 里是两个条目; +- 所以加一家两地 provider 时,`PROVIDERS` +2、表格只 +1 行、这个数字也只 +1。 + +当前状态:表格 22 行内置 + 1 行自定义 = 23 行,`PROVIDERS` 29 条。改之前先数一遍,别按 `PROVIDERS` 的条数算。 + +安装 / 部署相关的内容只在 `docs/install*.md` 里维护,不要往 README 塞。 + +## 5. 施工顺序 + +按功能纵向闭环,不要先把后端全写完再做前端: + +1. **身份**:`ProviderKind` + `PROVIDERS` + `kind_from_id` / `kind_id` + `adapter_for` + `implemented_kinds` +2. **账号**:`_accounts.rs` + `add_account` 分派 + 公开形态 + 前端表单 → 能加号、能在列表里看到 +3. **目录**:`list_models` + `catalog_cache` scope + `refresh_models` → `/v1/models` 有内容、刷新按钮有效 +4. **转发**:`build_chat_request` 或 `forward_conversation` + `classify_error` → 能对话、限额能轮换 +5. **余额**:`supports_usage` + `query_usage` → 账号行能看到余额 +6. **签到 / 领取**:`CHECKIN_PROVIDERS` + 版本号 + `checkin_for` 分派 +7. **前端收尾**:`accounts-domain` 标记、签到中心说明、图标 +8. **i18n + 六份 README** + +每一步做完都能独立验证,不要攒到最后一起测。 + +## 6. 完成度验收清单 + +每一格都要能跑出结果,不是「我实现了」。 + +### 6.1 用探测脚本验(推荐) + +```bash +node scripts/probe-provider.mjs \ + --base http://127.0.0.1:3065 \ + --key sk-dev- \ + --provider \ + --model <模型名> +``` + +前置:在面板「网关 Key」页建一把**只允许目标 provider** 的 Key。不建的话,同名模型在多家里都有映射时会被路由到别家,结论无法归因(脚本启动时会先校验并报错退出)。 + +脚本由两个文件组成:`scripts/probe-provider.mjs`(CLI 外壳:参数、HTTP、前置校验、输出)与 `scripts/probe-cases.mjs`(九个用例的请求构造与判定)。加新用例改后者,`CASE_ORDER` 决定执行顺序。 + +用例与能力位的对应: + +| 用例 | 验的是什么 | 对应 README 列 / 能力位 | +|---|---|---| +| `stream` | 流式 SSE 能正常逐帧下发 | LLM 请求 | +| `tool_call` | `tools` 声明被上游接受,返回 `tool_calls` | LLM 请求(工具调用) | +| `parallel_tools` | 一次返回多个 `tool_calls` | LLM 请求(并行工具) | +| `tool_roundtrip` | 工具结果回灌后能继续生成 | LLM 请求(多轮循环) | +| `image` | 图片输入被接受 | LLM 请求(多模态) | +| `reasoning` | 思考字段能透出 | LLM 请求(思考) | +| `json_schema` | 结构化输出可用 | LLM 请求(结构化输出) | +| `usage` | 终帧带非零 usage | 报表 / 用量统计 | +| `context_window` | 目录声明的上下文窗口(读 `/v1/models`,不发上游) | 模型列表 | + +判定口径: + +- **必需项失败 = 这家的「LLM 请求」不能算接完。** `tool_call` 即使被目录声明为不支持,也记 **FAIL** 而不是 SKIP —— 记 SKIP 会让汇总显示「必需项全部通过」,而实际上 agent 客户端根本用不了这家。 +- 可选项目录声明不支持时(`image` / `reasoning`)记 **SKIP** 而不是 FAIL,否则每家都会刷一堆假失败。`--force` 可强测(用于「怀疑目录写错了」)。 +- `tool_call` 允许重试一次(模型偶尔就是不调工具),失败后换更硬的提示再发一次。 +- `parallel_tools` 只返回 1 个 call 时记 **INFO** —— 模型可能打算分两轮返回,那是行为差异不是适配缺陷。 +- `context_window` 记 **INFO/PASS** —— 它读的是目录声明,不是上游行为,和其余用例性质不同。 + +退出码:`0` = 没有必需项失败;`1` = 有必需项失败;`2` = 用法或前置条件错误(连不上网关、模型不在清单里、模型不由该家承载)。 + +### 6.2 逐列验收 + +| README 列 | 验收动作 | +|---|---| +| LLM 请求 ✓ | 探测 `stream` + `tool_call` + `tool_roundtrip` 全 PASS;用真实客户端(Claude Code / Codex 等)跑一轮多轮对话不报错 | +| Token 自动续期 ✓ | 账号页能看到有效期;等一个临期窗口(或手动改凭证时间)后跑 `credential_maintenance`,日志里有刷新成功记录;401 时能自动恢复而不是报错给客户端 | +| 模型列表 ✓ | `/v1/models` 有该家的条目;模型管理页点「获取模型」后清单变化;**重启进程后清单还在**(验 `ALL_SCOPES` 有没有漏) | +| 余额查询 ✓ | 账号行显示余额;点「积分明细」能展开;读不到的数值留空显示,不是 0 | +| 签到 ✓ | 签到中心出现该家分组且有说明文字;手动点一次成功;次日定时任务跑完台账有记录;**存量配置升级后这家在默认清单里**(验 `PROVIDERS_VERSION`) | +| 领取类 ✓ | 对应按钮能点且返回可读结果;没有活动时返回中性说明而不是红色失败 | +| 某列 ✗ | 能在代码注释里找到「怎么排除的」记录(打过哪些接口、返回什么) | + +### 6.3 静默失效自查 + +这些**不会报错**,只能靠主动验: + +- [ ] 重启进程后模型清单还在(`ALL_SCOPES`) +- [ ] 后台目录刷新真的会调度到这家(`implemented_kinds`) +- [ ] 存量用户升级后签到清单里有这家(`PROVIDERS_VERSION` + `PROVIDERS_ADDED_IN`) +- [ ] 公开形态里没有 token 本体(`store_view::public_account`) +- [ ] 限额错误真的触发了账号轮换,而不是当 Fatal 透传(看请求日志的 attempts 与冷却标记) +- [ ] 前端中文文案全部有译文(`npm run check`) + +## 7. 调试设施(都是现成的,别重造) + +| 要看的 | 怎么看 | +|---|---| +| **发往上游的最终 body**(已按家改写、已脱敏) | 面板设置页开「调试模式」→ 跑一次请求 → 请求日志详情页「原始报文」标签。数据在 `debug_traffic` 表,保留最近 500 条 | +| 上游原始响应(含 SSE 全文) | 同上,同一个标签 | +| 客户端发来的请求体 | 调试模式开启后同样可见(`request_raw` 表) | +| 单次转发的选路、重试链、usage | 模型管理页的「测试」按钮(`POST /api/models/test`),走真实转发链路、钉住 provider + account、结果失败也返回 2xx | +| 帧级翻译问题(Anthropic / Gemini / NDJSON / Chatglm 快照) | 调试报文里的上游原始 SSE,对照 `core/upstream/translate` 的输出 | + +**出站报文不需要新做工具** —— `TrafficCapture::reset_request` 抓的就是「即将发送前」那一份,四家路径全覆盖。 + +## 8. 已知坑 + +- **「上游没有这个接口」要先怀疑找法。** CatPaw 的目录参数在 POST body 里(看着像 query),AutoClaw 的目录在另一个路径前缀下(顺着 `chat/completions` 找永远找不到)。两次结论都错。 +- **429 不一定都是限额。** 有的家把额度耗尽、速率窗口、未验证邮箱都用 429/403 表达,处置完全不同(次日零点 vs 分钟级 vs 永久)。只看状态码会把三种混成一档。 +- **HTTP 200 可能是失败。** 正文一句固定话术、或流内 `{"type":"error"}`、或 `finish_reason: content_filter`,都要单独识别。 +- **`supports_*` 默认 false 不是「不支持」。** 它是「尚未接入」。判据必须是上游有没有那个接口。 +- **两个地区拆家时,`PROVIDERS` 里要相邻排列。** 中间隔着别家会让「找国际版」变成一次扫描。 +- **`implemented_kinds()` 与 `PROVIDERS` 是两个口径。** 前者是「适配器接线了没有」,后者是「注册了没有」。占位实现不该进前者。 +- **签到清单的默认全选是个陷阱。** 加一家进 `CHECKIN_PROVIDERS` 会让存量用户第二天零点就多发一轮请求。想默认开启就升版本号;不想默认开启就别进 `PROVIDERS_ADDED_IN`。 +- **改完前端要重新构建岛并提交产物。** `ui/islands/ui.js` 与 `ui/islands/login.js` 都是检入仓库的,只改 tsx 不构建等于没改。 + +## 9. 参考实现 + +`git show 1576330` 是 ModelScope 接入的完整提交,48 个文件,正好覆盖第 2–4 节的每一类落点。拿不准该改哪些文件时,对着这个提交的 `--stat` 过一遍。 diff --git a/.agents/skills/release-flow/SKILL.md b/.agents/skills/release-flow/SKILL.md new file mode 100644 index 000000000..c1438f23b --- /dev/null +++ b/.agents/skills/release-flow/SKILL.md @@ -0,0 +1,375 @@ +--- +name: release-flow +description: Agent2API(workbuddy)仓库的协作与发版规范 —— 分支落脚点、合并 PR 与回复 Issue 的署名、版本号递增、正式版与 beta 预发布流程、三个仓库(源码 / 构建 / 索引)的分工、构建仓库迁移、发版验收与排错。凡是涉及切分支、给 PR 选 base、合并 PR、回 Issue/PR、改版本号、写更新日志、打 tag、挂 GitHub Release、发 beta 版、改 CI 工作流、配签名 secret、换构建仓库、排查发版失败时,都先按本 Skill 执行 —— 即使对方没提「发版」「分支」这类字眼。 +--- + +# Agent2API 协作与发版规范 + +覆盖两类工作:**日常协作**(分支落脚点、PR 署名、Issue 落款,第 1–3 节)与**发版**(版本号、更新日志、tag、Release、验收,第 4–7 节),排错见第 8 节。 + +## 0. 两条人工红线(必须停下等确认) + +下列两件事**必须由人先审过、明确点头**,代理才能继续;可以准备草稿、可以列依据,不能自己拍板往下走: + +1. **发版**:进入发版流程(改版本号、建发布提交、推 main / beta、打 tag、挂 Release)之前,先把版本号与「这次要发什么」讲清楚,等用户确认。**未经确认不得推任何分支或 tag** —— tag 一旦推出去就会触发构建、产出公开的安装包与镜像,收不回来。 +2. **更新日志**:写好的日志(发布提交的提交信息,同时也是 GitHub Release 正文)必须先给用户逐条过目。用户要求改措辞、增删条目、调整分类前缀时,按反馈改完要**再确认一次**,不能因为「改得不多」就直接提交。 + +一次发版里两条同时适用:既要有版本号的确认,也要有日志的确认;用户说「继续」之后才动手。 + +## 0.5 三个仓库的分工(2026-10 起) + +构建不再在主仓库跑。发版涉及三个仓库,各管一段,弄混了就会出现「tag 推上去了,但没人收到更新」这类静默故障。 + +| 仓库 | 角色 | 里面有什么 | +|---|---|---| +| `aimod-cc/Agent2API` | **源码仓库**(就是当前仓库) | 代码、文档、`build.yml`(只做派发,不编译)。推 `v*` tag 只跑一个十几秒的 job | +| `Agent2Builder/agent2api-releases` | **构建仓库**(专用账号下) | `release.yml` 构建安装包,`publish-installers.yml` 发布并同步源码仓库,`docker.yml` 发布双架构镜像。**所有签名密钥只在这里** | +| `Agent2plugin/Agent2plugin` | **索引仓库** | `index.json`(插件包在哪)与 `app.json`(桌面端安装包在哪)。换构建仓库改这里,不必发版 | + +### 为什么这么分 + +七个平台 job + 两架构镜像,跑一次是实打实的重负载;而 tag 是发版动作、频率不可控。把「打一个版本标签」变成「自动兑换七台虚拟机」,最坏会被判定为 Actions 滥用。构建挪到专用账号下,额度与负载都落在那边。 + +### 一次 tag 的完整链路 + +``` +git push origin vX.Y.Z + └─ 源码仓库 build.yml(dispatch job,约 10 秒) + └─ repository_dispatch → Agent2Builder/agent2api-releases + ├─ release.yml 各平台构建 → publish-installers.yml + │ ├─ 发布安装包 → 构建仓库 Release + │ └─ 同步主仓库 → 相同附件与日志 + └─ docker.yml 双架构镜像 → Docker Hub 与 GHCR +``` + +`release.yml` 与 `docker.yml` 都监听 `repository_dispatch` 的 `release` 事件,所以一条派发同时起两边。 + +### 换构建仓库(构建账号被封 / 要迁移时) + +按顺序做,**顺序错了会让用户收不到更新**: + +1. 新建构建仓库,把两个工作流文件搬过去,配齐 secrets(见第 8 节); +2. 改**索引仓库**的 `app.json`:`app.repo` 指向新仓库。桌面端最多 30 分钟后自动切过去(`core::update::index` 有节流),用户不必做任何操作; +3. 改**索引仓库**的 `index.json`:`plugins[].source.repo` 指向新的插件构建仓库; +4. 改源码仓库里这三处的常量,让「索引读不到」时的兜底也指向新地方: + - `desktop-tauri/src-tauri/server/src/server/core/update/version.rs` 的 `DEFAULT_REPO` + - `desktop-tauri/src-tauri/server/src/server/core/plugin_host/update.rs` 的 `FALLBACK_REPO` + - `scripts/release.sh` 的 `BUILD_REPO` 默认值 +5. 改 `.github/workflows/build.yml` 里 `repository:` 的派发目标。 + +**为什么第 2 步最关键**:已经装好的旧版本二进制里烧死的是旧仓库名,只有索引能把它们领到新地方。索引改晚了,用户手上那个版本就永远卡在旧地址。 + +`app.json` 的格式(`schema` 目前只有 1): + +```json +{ + "schema": 1, + "updated": "2026-10-24", + "app": { "repo": "Agent2Builder/agent2api-releases" } +} +``` + +`app.repo` 必须是 `owner/repo` 形式;写成 URL 或带空格会被当成无效值,静默回落到内置默认值。 + +## 1. 分支落脚点:日常只碰 beta,发版才动 main + +| 分支 | 角色 | 什么时候动它 | +|---|---|---| +| `beta` | 集成分支(默认落脚点) | 日常修改、合并 PR、解冲突 —— 一律先到这里 | +| `main` | 发版分支 | 只在构建发版时:切 main、把 beta 合进来、打 tag | + +- **合并 PR 时 base 选 `beta`**,不要选 `main`。合进 `main` 等于把还没发布的改动提前塞进发版分支;之后还得反过来把 main 合回 beta,平白多一次合并提交、多一次冲突机会。已经把 PR 开成 base=main 的,先在 PR 页面把 base 改成 `beta` 再合。 +- **本地改动也提交到 `beta`**(写代码、改文档、解冲突都算),推 `origin/beta`;合并结果的核对、构建自检都在 beta 上做完。 +- **只有正式发版才切 `main`**:`git switch main` → 把 beta 合进来 → 写发布提交、推 main、打 tag;tag 必须落在 `main` 上。发完版 beta 再把 main 合回来,两边保持一致。 +- **不要自作主张选落脚分支**:用户没说要发版,就不要往 `main` 提交 / 推送,也不要把 PR 的 base 改成 `main`。 +- beta 版(预发布)是唯一例外:不切 `main`,tag 直接落在 `beta` 分支自己身上(见第 6 节)。 + +## 2. 合并 PR:保留贡献者署名 + +合并贡献者的 PR 时,提交作者(author)必须还是贡献者本人 —— 这直接决定对方能不能进仓库首页 Contributors 名单、贡献图里有没有这一笔。署名是贡献者应得的东西,不该因为合并方式丢掉。 + +GitHub 的归属规则只有一条:**提交头里的 author 邮箱出现在某个账号已验证的邮箱列表里,这次提交就算那个账号的**;否则记成匿名条目,不进 Contributors。另有两条口径:统计只认默认分支(本仓库 `main`),合并提交与空提交不计入。 + +- **默认用 GitHub 网页上的合并按钮**(`Merge pull request` / `Squash and merge` / `Rebase and merge`):三种都会保留原作者为 author;squash 时 PR 里的多位作者会一并进 co-author 名单。 +- **禁止「本地摘取重提」**:把对方的改动拷过来自己提交(本地 `git merge --squash` 后自己 commit、重抄一遍改动、或改了 author 再提交)都不行 —— 这样 author 是合并者,贡献者的署名在 GitHub 侧完全不可见。本仓库历史上出现过这种情况,别再来一次。 +- **确实要在本地处理时**(有冲突要解、要顺带调整),必须把署名带上:单个原作者用 `git commit --author="原作者 <原作者邮箱>"`;一个提交里有多个人的工作,在提交信息末尾加 trailer `Co-authored-by: 名字 <邮箱>`。 +- **摘除 AI 的 co-author trailer**:贡献者提交信息末尾的 `Co-Authored-By: Claude Code <…>` 这类 AI trailer,合并时一律删除 —— GitHub 会把它渲染成共同作者,进了 `main` 就出现在 Contributors 名单里,而 AI 不是贡献者。只删 trailer 行本身,author 与其余提交信息逐字保留;正文里对 AI 产品的**技术性提及**(「支持 Claude Code 客户端」「Claude 兼容端点」之类)与此无关,不动。摘除动的是已合入分支的历史,趁合并后、发版前尽早做(`git filter-branch --msg-filter` 摘行后 force-push),拖到发版后就得重写 `main`。 +- **邮箱必须能对上账号**:用对方 GitHub 账号里**已验证**的地址;对方想保密就用 GitHub 给的 `ID+用户名@users.noreply.github.com`(在对方账号的 Emails 设置里查)。内网地址、`user@机器名` 这类邮箱永远关联不上账号,提交会被记成匿名。 +- 合并后顺手核对一次:`git log --format='%an <%ae>' -1 <合并提交>`,或在 PR 页面看 commit 旁的头像是否指向原作者。 + +## 3. 回复 Issue / PR:标明 AI 来源 + +**由 AI 代理发出(含代笔)的每一条 Issue / PR 回复,末尾都要单独一行写明来源。** 收件人有权知道自己在跟谁说话 —— 与第 2 节的署名是同一条道理:**谁说的写谁**。 + +固定落款,逐字照用(别改写成「机器人」「助手」这类含糊说法): + +> 🤖 本条回复由 AI Agent 生成 + +- **位置**:整条回复的最后一行,单独成段(前后各留一个空行);不要塞进正文段落、列表项、引用块或代码块里 —— 引用别人的话时,落款落在自己的话之后。 +- **英文场合**(外部贡献者的 PR / Issue)在中文那行下面再补一行 `Generated by an AI agent.`,说的是同一件事,两边都不省。 +- **代理自己新建的 Issue / PR**,正文末尾同样加。 +- **代笔也算**:人在对话里让代理转达(哪怕逐字)一段话,落款照加 —— 它描述的是「这条回复是谁发出去的」,不是「谁构思的」。 +- 只约束 Issue / PR **回复**(评论、review 意见、关闭 / 合并说明)。提交信息不追加这一行,署名口径见第 2 节。 + +## 4. 版本号 + +- 版本号形如 `X.Y.Z`(如 `2.7.2`),日常小版本「递增 0.01」= 末位 +1(如 `2.7.8` → `2.7.9`)。 +- **递增幅度以用户要求为准**:默认用上面的「递增 0.01」;用户另有要求时(如「这次递增 0.1」)一律听用户的,按用户指定的幅度算出新版本号,不要自作主张套用默认幅度。 +- **版本号要同步改 6 处**(缺一处会导致安装包与显示版本对不上): + 1. `package.json`(根) + 2. `desktop-tauri/package.json` + 3. `desktop-tauri/src-tauri/Cargo.toml` + 4. `desktop-tauri/src-tauri/tauri.conf.json` + 5. `desktop-tauri/src-tauri/server/Cargo.toml` + 6. `desktop-tauri/mobile/package.json` —— 手机端安装包的版本号都从这里读:Android 的 `versionName` / `versionCode` 由 `android/variables.gradle` 读它(`versionCode` 由 semver 折算,只增不减,见该文件注释);iOS 的 `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` 由 `build.yml` 的 ios job 读它并传给 xcodebuild。不改它两端的包都还是旧版本号,产物名 `Agent2API-<版本>.apk` / `.ipa` 也取自这里 +- **改完第 6 处,必须顺手把 `desktop-tauri/mobile/package-lock.json` 根部的 `version` 也改成同一个值**(文件顶层的 `"version"` 与 `packages[""].version` 两处)。`npm ci` 对这两处不一致是**静默容忍**的(实测不会报错,也不会自动改),漏改 CI 照绿,但仓库里会留下版本号不一致的 lock 文件,下次谁跑 `npm install` 又会把它改回来,平白多出一个无关 diff。桌面端那几处没有这个问题(`desktop-tauri/package-lock.json` 的版本本来就落后,不随发版改)。 +- 应用内「关于」与更新检查读的是 `env!("CARGO_PKG_VERSION")`(第 5 处),改完跑一次 `cargo check` 让 `Cargo.lock` 跟着刷新。 +- 手机端界面目前**不显示**包自身版本号(只显示所连网关的版本);要核对版本,看系统「应用信息」页,或直接看 Release 附件名。 +- **顺手过一遍安装文档**:安装说明已经从 README 搬进 `docs/install.md`(README 只留速查表 + 一个链接),另有 5 份译文 `docs/install.{en,zh-Hant,ja,ko,pt-BR}.md`,六份内容一一对应。凡是这次改动碰到了**安装 / 升级 / 卸载步骤、产物体名、默认端口、环境变量、安装脚本参数**,六份都要同步改;README 速查表受影响时也一起改。 +- **README 语言落点**:根 `README.md` 是**英文**(GitHub 默认展示),简体中文在 `docs/README.zh-CN.md`,其余为 `docs/README.{zh-Hant,ja,ko,pt-BR}.md`。中文仍是文案源语言,支持矩阵等六份要同步的内容改完记得回填英文根 README。 + +## 5. 正式版发版流程 + +按顺序执行;第 0 节的两条红线贯穿全程。 + +```bash +# 1. 收干净工作区(tag 里的代码就是发出去的代码) +git status --short # 必须为空;有改动就先提交或清理 +cargo check # 或前端有改动时做一次构建自检 + +# 2. 切到 main 并把 beta 合进来 +git switch main && git pull +git merge beta + +# 3. 六处版本号一起改(默认末位 +1,幅度听用户);改完刷新 Cargo.lock +cargo check + +# 4. 写发布提交:提交信息 = 更新日志 +git commit -am "…更新日志…" + +# 5. 推 main、打 tag、推 tag +# 推 tag 只会在**源码仓库**跑一个十几秒的派发 job,真正的构建在 +# Agent2Builder/agent2api-releases 里跑(见 0.5 节) +git push origin main +git tag vX.Y.Z +git push origin vX.Y.Z + +# 6. 到**构建仓库**看进度(不是本仓库) +gh run list --repo Agent2Builder/agent2api-releases --limit 4 + +# 7. 构建完成后由工作流发布安装包并同步源码仓库,检查两个收尾任务 +# 本地 scripts/release.sh 只在自动流程不可用或旧版本补发时使用 +``` + +**发布提交的提交信息 = 更新日志**。自动发布任务读取固定源码提交的日志;应急脚本仍读取 `git log -1 --format=%B "$TAG"`。不得从其它分支生成替代日志。 + +- 格式沿用历史版本的编号清单,每行一条,带分类前缀:`新增:` / `优化:` / `修复:` / `更新:` / `移除:`,最后一条固定 `版本:X.Y.Z → X.Y.Z+1`; +- 内容取「上个 tag 以来的全部提交」综合整理(`git log vX.Y.Z..HEAD --oneline`),纯 CI / 临时的调试提交归并成一条流程性描述即可; +- 本次没有代码改动时用空提交承载:`git commit --allow-empty -F 更新日志.txt`; +- 更新日志草稿与历史发布说明统一写在 `docs/release-notes/<版本号>.md`(如 `docs/release-notes/2.9.2.md`),不要在项目根目录新建 `.release-notes-*.md` —— `docs/release-notes/` 已在忽略列表里(只忽略这个目录,`docs/` 下其它文档正常入库),新增文件不必再加忽略规则。 + +推 `v*` tag 后,**源码仓库**只跑 `build.yml` 的 `dispatch` job(约 10 秒);它向构建仓库派发,**构建仓库**里的两个工作流同时开跑: + +| 工作流(跑在构建仓库) | 产出 | 说明 | +|---|---|---| +| `release.yml` → `publish-installers.yml` | 桌面端、服务端与手机端构建产物 | 构建任务为 `macos`、`windows`、`linux-desktop`、`linux-server`(amd64 / arm64)、`windows-server`、`android`、`ios`。七个任务全部成功后,`publish-installers.yml` 由**完成事件自动触发**(`workflow_run`):校验十份桌面 / 服务端附件并发布,再同步一份到源码仓库。手机安装包只保留 Actions artifact。插件仍由独立插件构建仓库发布 | +| `docker.yml` | Docker Hub `aimodcc/agent2api:<版本>` 与 GHCR `ghcr.io/aimod-cc/agent2api:<版本>`,amd64 / arm64 双架构 | 正式版发布 `<版本>` + `:latest`;beta 预发布发布 `<版本>` + 滚动 `:beta`,**不移动 `:latest`**;手动 `workflow_dispatch` 且不填 ref 时只出 `:dev`,不移动 `:beta` 或 `:latest`。两个仓库推的是同一份清单 | + +**源码仓库的 `build.yml` 只做派发,`docker.yml` 已整体删除** —— 镜像构建现在只在构建仓库里跑(源码仓库保留那份只会让人误以为它还在工作)。 + +跟踪进度: + +```bash +gh run list --repo Agent2Builder/agent2api-releases --limit 4 +gh run watch --repo Agent2Builder/agent2api-releases --exit-status +``` + +**自动收尾**由构建仓库的 `publish-installers.yml` 完成,它监听 `release.yml` 的完成事件(`workflow_run`),只有一个 job、顺序做两步: + +- **发布到本仓库**:按那次构建 run 取产物,用 `.github/scripts/collect-assets.sh` 清点(**恰好十份**;四份桌面端包名里的版本号必须一致;服务端包的 sha256 与包本身对得上,容错 Windows 侧生成的 CRLF 行尾),先建草稿 Release、**逐个**上传、十份齐了才公开;beta 自动标预发布。**桌面端的更新检查读的就是这一份。** +- **镜像到源码仓库**:同一份附件与逐字相同的正文,用 `SOURCE_RELEASE_TOKEN` 建 / 改 `aimod-cc/Agent2API` 的 Release。缺这个 token 或网络失败时这一步明确失败,本仓库已发布的 Release 保留、用户更新不受影响。 +- **为什么发布与构建分成两个工作流**:发布出错只需要重跑 `publish-installers.yml`(几十秒、不重新编译),不会被迫把七个平台再建一遍 —— tag 是发版动作、频率不可控,重跑构建最坏会被判成 Actions 滥用。重跑用 `only` 挑步骤,按 run 号补发就传 `run_id`: + + ```bash + # 补发 / 重跑(不填 run_id 时用触发本工作流的那次构建) + gh workflow run publish-installers.yml --repo Agent2Builder/agent2api-releases -f run_id= + # 只重做镜像那一步 + gh workflow run publish-installers.yml --repo Agent2Builder/agent2api-releases -f run_id= -f only=mirror + ``` + +- **自动发布只认 `repository_dispatch` 触发的构建**(也就是 tag 发版)。手动触发的构建不会自动发布,免得把分支快照发出去;要发就手动传 `run_id`。 +- 上传是**同名覆盖**(逐个 `--clobber`):gh 批量上传遇到「附件已存在」会报 HTTP 422 并中断整批,症状是 Release 建好了附件少一半(2026-10-17 实测)。所以重跑天然幂等,只补缺的那几个。 + +本地 `scripts/release.sh` 保留为**应急入口**,只用于旧版本或自动流程不可用时: + +```bash +bash scripts/release.sh vX.Y.Z +``` + +脚本会清空当前目录的 `dist/`;使用前必须检查并保护已有产物,建议在独立工作区运行。 + +- 目标仓库由脚本里的 `BUILD_REPO` 决定,默认 `Agent2Builder/agent2api-releases`;临时换目标用环境变量:`BUILD_REPO=Other/repo bash scripts/release.sh vX.Y.Z`。 +- 脚本找的是构建仓库里名为 **`release`** 的工作流(不是 `build`)。源码仓库那个 `build.yml` 只做派发,没有产物可下。 +- **更新日志仍从源码仓库取**(`git log -1 --format=%B "$TAG"`),所以脚本要在源码仓库目录里跑。 + +### 工作流的跨仓库凭据 + +构建仓库的 `publish` 使用该任务的 `GITHUB_TOKEN` 和 `contents: write`。`mirror` 必须单独配置 `SOURCE_RELEASE_TOKEN`:仅授权 `aimod-cc/Agent2API` 的细粒度令牌,仓库权限为 **Contents: Read and write**;不需要工作流写权限,因为源码版本 tag 必须已经存在。 + +该 Secret 只传给 `mirror`,不传给平台构建或 `publish`。缺少时同步任务明确失败,构建仓库已完成的发布保留。不要把本机登录令牌直接复制为长期 Secret。 + +### 应急脚本的两把凭据 + +构建仓库属于专用构建账号,源码仓库属于主账号 —— 各自的 Release 只有**有权限的那一方**能创建 / 修改。应急脚本保留两个 token 变量: + +```bash +BUILD_TOKEN=... # 对构建仓库有 contents:write(默认取当前 gh 活动账号) +SOURCE_TOKEN=... # 对源码仓库有 contents:write(同上) +``` + +本机两个账号都在 gh 里时,按账号各给一把: + +```bash +BUILD_TOKEN=$(gh auth token --user Agent2Builder) \ +SOURCE_TOKEN=$(gh auth token --user aimod-cc) \ +bash scripts/release.sh vX.Y.Z +``` + +不传这两个变量时,脚本假定当前 `gh auth` 的账号对两边都有权限(单一账号管两个仓库的部署就是这样)。 + +⚠️ **症状对照**:用错 token 时 GitHub 回的是 `HTTP 404: Not Found`,不是 403 —— 看着像「Release 不存在」,实际是「这个 token 看不见它」。遇到 404 先查 token 属于哪个账号。 + +### 镜像到源码仓库 + +自动流程总是同步主仓库;应急脚本默认开镜像(`MIRROR_TO_SOURCE=0` 可关)。产物在构建仓库,但用户与贡献者习惯看源码仓库的 Releases 页,两个位置都应有发布。 + +镜像的正文与构建仓库那份**逐字相同**(同一个更新日志),不追加任何说明行 —— 两个 Release 的内容应当一致。**不要**在镜像正文里加「同一份发布,也是应用更新检查读取的那一份:…」这类指向构建仓库的句子,用户明确不要这一段;更新检查读哪个仓库由 `DEFAULT_REPO` 与索引 `app.repo` 决定,不需要在正文里解释。 + +⚠️ **Release 必须出现在构建仓库**。桌面端更新检查(`core::update::version` 的 `DEFAULT_REPO`)与索引里的 `app.repo` 都指向那里;只发在源码仓库的症状是「Release 建出来了,但所有人都显示已是最新」——不报错,最难查。 + +## 6. beta 版(预发布) + +应用里有「设置 → 软件更新 → 更新设置 → **加入 beta 版**」开关:**打开的人才收到 beta 更新,没打开的人只看正式发布。** + +beta 版不是另一条流水线,而是第 5 节流程的一个变体 —— 唯一结构差别是「**从 `beta` 分支打带预发布后缀的 tag**」,提交、tag、Release、验收逐字照旧。第 0 节的两条红线同样适用。 + +```bash +# 1. 在 beta 分支上动手(beta 版不碰 main) +git switch beta && git pull +git status --short # 必须为空 + +# 2. 六处版本号一起改成「与 tag 逐字一致」的形态: +# tag 打 v3.1.0-beta.1 → 六处都写 3.1.0-beta.1(清单见第 4 节) +cargo check # 顺带刷新 Cargo.lock + +# 3. 提交并推 beta 分支(提交信息 = 这次的 beta 更新日志,格式同第 5 节) +git commit -am "…beta 版日志…" +git push origin beta + +# 4. 打 tag 并推:tag 落在 beta 分支的提交上 +git tag v3.1.0-beta.1 +git push origin v3.1.0-beta.1 + +# 5. 到**构建仓库**看三个工作流(Release → publish-installers + docker) +gh run list --repo Agent2Builder/agent2api-releases --limit 4 + +# 6. 构建成功后 publish-installers 自动发布,并同步相同附件和正文到源码仓库 +# 核对发布 / 镜像那两步与 docker 成功,无需本地下载和上传 +``` + +验收就四条:Release 显示 **Prerelease**、exe / dmg / deb / AppImage / APK 附件齐全、Docker Hub 上 `:latest` 的 Pushed 时间**没有**跟着变、APK 能装能连(完整清单见第 7 节)。 + +**三处不能错**,每处错了都有明确症状: + +1. **六处版本号漏了后缀**(写成 `3.1.0`):比较是按 semver 做的,运行版本 `3.1.0` 对线上 tag `3.1.0-beta.1` 会判成「当前比线上新」,**beta 用户永远收不到更新**。六个文件都接受带后缀的版本号(Cargo / npm / tauri.conf.json 都认),照 tag 抄即可。APK 的 `versionCode` 由 semver 前三段折算、不带后缀(见 `android/variables.gradle`),beta 与同号正式版取同一个值,不构成冲突。 +2. **Release 漏标 pre-release**:标记就是开关的分界线,正式通道排除预发布。自动发布根据 semver 预发布后缀判定;应急脚本也会设置。若已有公开版本的标记不一致,自动流程明确失败,不擅自改写。 +3. **tag 打在 `main` 上**:beta 是集成分支,beta 版要的是它的快照。打在 `main` 上要么不含 beta 分支的新改动(白测一场),要么等于把还没发布的改动提前推进 `main`。 + +构建仓库 `.github/workflows/docker.yml` 发布 beta 时同时产出完整版本标签(如 `3.3.0-beta.3`)与滚动 `:beta`,**不移动 `:latest`**。Docker 用户用 `aimodcc/agent2api:beta` 跟随 beta,或用完整版本号固定版本;手动 dev 构建不会移动 `:beta`。 + +后续常见三件事: + +- **再发一个 beta**:核心号不变、递增序号(`3.1.0-beta.2`,六处一起改,上面那套命令照走)。预发布序号能分出先后(`beta.2` > `beta.1`),已经在 beta 上的人照样收到更新 —— 所以 beta 之间也要保持「tag 与版本号逐字一致」。 +- **转正**(发 `3.1.0` 正式版):按第 5 节在 `main` 上走,六处版本号去掉后缀。**beta 用户不会自动接回正式版**:开关开着的人只看预发布版(`pick_listed_release` 按「当前跑的是不是 beta 版」筛),转正的 `3.1.0` 对他们不算更新 —— 要留在 beta 频道的人等下一个 beta(`3.2.0-beta.1` 一出现就收得到);想回正式版由用户自己关开关,关掉后只在**版本号高于他手上那个 beta** 的正式版发布时才收到更新(`3.1.0` > `3.1.0-beta.1`,但更早的 `3.0.9` 不会把他降级)。 +- **「打开了却没有反应」**:仓库里一个预发布 Release 都没有时,打开这个开关也只会看到正式版 —— 第一次 beta 发出去之后才有效果。 + +## 7. 验收清单 + +以下 `gh` 命令都带 `--repo Agent2Builder/agent2api-releases` —— Release 与构建 run 都在构建仓库,不带这个参数会去查源码仓库然后什么都找不到(手动跑 gh 前先设代理,见第 8 节)。 + +- [ ] **构建仓库的 run 全绿**:`gh run list --repo Agent2Builder/agent2api-releases --limit 4` —— 应看到 `Release` 与 `docker` 两个 run,前者七个 job 全绿(含 `publish`); +- [ ] GitHub Release(在构建仓库):`gh release view vX.Y.Z --repo Agent2Builder/agent2api-releases` —— 正文日志齐全,桌面端附件都在:Windows `Agent2API_<版本>_x64-setup.exe`、macOS `Agent2API_<版本>_universal.dmg`、Linux `Agent2API_<版本>_amd64.deb` 与 `Agent2API_<版本>_amd64.AppImage`(另有两个 `agent2api-server-linux-*.tar.gz` + `.sha256`,那是容器内更新包,见第 5 节); +- [ ] **镜像到源码仓库**:`gh release view vX.Y.Z --repo aimod-cc/Agent2API` —— 附件数与构建仓库一致(默认 10 个:4 个桌面端 + 3 个服务端包 ×2 带校验 + 无手机端);正文与构建仓库那份逐字相同,**不带**指向构建仓库的附加说明; +- [ ] **手机端包不在 Release 里**:自动发布固定只挂十份桌面 / 服务端附件。APK / IPA 保留为构建 artifact;恢复公开发布必须先获用户确认,并同步修改自动发布附件清单与安装文档。 +- [ ] **插件包**:在独立插件构建仓库的对应 Release 中检查五个平台包及官方签名,不计入主程序十份附件。 +- [ ] Linux 桌面端:装 deb(或 `chmod +x` 后跑 AppImage)能启动,且面板「设置 → 软件更新」能检查到版本、给出对应包型的安装入口(deb → 系统安装器,AppImage → 换文件); +- [ ] 手机端 APK:构建 run 的 `android-apk` artifact 中有 `Agent2API-<版本>.apk` 与 `.sha256`,不公开挂 Release。 +- [ ] 手机端 iOS:构建 run 的 `ios-ipa` artifact 中有 `Agent2API-<版本>.ipa` 与 `.sha256`,不公开挂 Release;未签名包需自行重签。 +- [ ] Docker Hub:`aimodcc/agent2api` 的 Tags 页出现 `<版本>` 与 `latest`,Pushed 时间一致;GHCR:`ghcr.io/aimod-cc/agent2api:<版本>` 可拉(**新包默认私有**,首次要去包设置改 Public); +- [ ] **更新检查真的能查到**(这条最容易漏):`gh release view vX.Y.Z --repo Agent2Builder/agent2api-releases` 有内容,且索引仓库的 `app.json` 里 `app.repo` 指向同一个仓库。两者对不上时,用户面板会显示「已是最新」; +- [ ] 安装包「关于」页版本号与 tag 一致; +- [ ] 安装文档对得上实际产物:`docs/install.md` 与 5 份译文的产物名、端口、命令、环境变量跟这次 Release 一致(README 里那几张速查表同理); +- [ ] beta 版多一条:Release 显示 **Prerelease**;Docker Hub 上完整版本标签与 `beta` 的镜像 digest 一致,包含 amd64 / arm64,且 `latest` 没有跟着动。 + +## 8. 已知坑与排查 + +### 8.1 secrets 放在哪(2026-10 起) + +**所有构建用到的 secret 都在构建仓库 `Agent2Builder/agent2api-releases`**,源码仓库一个都不需要(除了下面那个派发令牌)。 + +| secret | 在哪 | 用途 | 缺了会怎样 | +|---|---|---|---| +| `AGENT2API_PLUGIN_KEY` | 构建仓库 | 插件包 ed25519 签名私钥 | 产物未签名,网关安装时标黄 | +| `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | 构建仓库 | APK 签名 | `android` job 直接红 | +| `IOS_CERTIFICATE_BASE64` / `IOS_CERTIFICATE_PASSWORD` / `IOS_PROVISIONING_PROFILE` / `IOS_TEAM_ID` | 构建仓库 | iOS 签名 | 产出未签名 IPA(可 AltStore 重签),不报错 | +| `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` | 构建仓库 | 推 Docker Hub | 镜像工作流明确失败 | +| `GHCR_TOKEN` | 构建仓库 | 推 GHCR。**必须是 aimod-cc 账号**、仅 `write:packages` 的 classic PAT(内置 `GITHUB_TOKEN` 只认构建账号自己的命名空间,跨账号会被拒) | 镜像工作流明确失败 | +| `SOURCE_RELEASE_TOKEN` | **构建仓库** | 同步主仓库 Release,限定源码仓库 Contents: write | `mirror` 明确失败,可单独重跑 | +| `BUILDER_DISPATCH_TOKEN` | **源码仓库** | 向构建仓库派发 `repository_dispatch` | tag 推上去后什么都不会发生 | + +**GitHub 的 secret 值读不回来**(设计如此),所以「把 secret 从 A 仓库搬到 B 仓库」只能由人重新填一遍,不能脚本复制。迁移构建仓库时按上表逐项重配。 + +`BUILDER_DISPATCH_TOKEN` 需要一个对构建仓库有 `contents: write` 权限的 token。用某个账号的 OAuth token 即可(`gh auth token --user <账号>`),也可以用细粒度 PAT。 + +### 8.2 镜像与 GHCR + +- **GHCR 新包默认私有**:首次推送后需到包设置手动改 Public,否则别人 `docker pull` 会 401(API 改不了这个默认值)。 +- **镜像名不能用 `${GITHUB_REPOSITORY}`**:工作流跑在构建仓库里,那个变量会解析成构建仓库的名字,镜像就推到构建账号的命名空间下去了。`docker.yml` 用 `GHCR_OWNER` / `GHCR_IMAGE` 两个 env 显式指定,换主仓库时连同 `SOURCE_URL`(`org.opencontainers.image.source` 的覆盖值)一起改这三处。 +- **包要关联主仓库**:`metadata-action` 默认把 `org.opencontainers.image.source` 填成「工作流所在仓库」(= 构建仓库),跨账号连不上。`docker.yml` 在 `labels:` 里显式重写为主仓库地址,这样 GHCR 的包页面才会指回 `aimod-cc/Agent2API`。 + +### 8.3 代理 + +- **gh 不读 git 的代理配置,跑 gh 前要先设代理环境变量**:`gh` 是 Go 程序,不读 `~/.gitconfig` 里的 `http(s).proxy`,也不读 Windows 系统代理(WinINET),只认 `HTTPS_PROXY` / `HTTP_PROXY` 环境变量。本机 GitHub 直连时通时不通,手动执行 gh 命令前先设: + + ```bash + export HTTPS_PROXY=$(git config --get-urlmatch http.proxy https://github.com || git config --get https.proxy) + ``` + + 只设 `HTTPS_PROXY` 就够(gh 的请求全是 HTTPS,实测不读 `HTTP_PROXY`)。`scripts/release.sh` 已内置这一步(从 git 配置读取后透传给 gh),用脚本发版无需手动设。代理没开时 gh 会立刻报 `proxyconnect tcp: ... connection refused` 而不回退直连 —— 与 `git push` 的表现一致。 + +### 8.4 重配 Android 签名(在构建仓库) + +- **APK 签名走四个 secret,缺一个 `android` job 直接红**。密钥本体(`*.jks`)与 `keystore.properties` 都在 `.gitignore` 里,只在打包机与构建仓库 secret 各存一份。来源是打包机 `desktop-tauri/mobile/android/` 下的 `agent2api-release.jks` 与 `keystore.properties`,keyAlias 为 `agent2api`。 +- 重配 / 补配的操作(在打包机上执行,值从本地文件读,不要手抄): + + ```bash + cd desktop-tauri/mobile/android + # base64 必须是单行(-w0),带换行会解出损坏的 jks + base64 -w0 agent2api-release.jks > /tmp/ks.b64 + gh secret set ANDROID_KEYSTORE_BASE64 --repo Agent2Builder/agent2api-releases < /tmp/ks.b64 + # 其余三个直接取 keystore.properties 的值,去掉键名与换行 + sed -n 's/^storePassword=//p' keystore.properties | tr -d '\n' | gh secret set ANDROID_KEYSTORE_PASSWORD --repo Agent2Builder/agent2api-releases + sed -n 's/^keyAlias=//p' keystore.properties | tr -d '\n' | gh secret set ANDROID_KEY_ALIAS --repo Agent2Builder/agent2api-releases + sed -n 's/^keyPassword=//p' keystore.properties | tr -d '\n' | gh secret set ANDROID_KEY_PASSWORD --repo Agent2Builder/agent2api-releases + rm -f /tmp/ks.b64 + gh secret list --repo Agent2Builder/agent2api-releases # 只显示名字与更新时间,值读不回来 + ``` + + 校验密钥本身是否可用:`keytool -list -keystore agent2api-release.jks -storepass `,应看到别名 `agent2api` 的 `PrivateKeyEntry`。**换密钥的后果**是已安装用户无法覆盖升级(签名不一致),不是重装一遍就好 —— 原密钥丢了等于断了 APK 的升级路径。 + +### 8.5 其它 + +- **APK 构建的两处环境差异**(构建仓库的 `release.yml` 已处理,改这块时别删):`android/gradle.properties` 里的 `org.gradle.java.home` 是本机 Windows 绝对路径,runner 上要删掉回落 `JAVA_HOME`;`gradle-wrapper.properties` 的 `distributionUrl` 指向腾讯云镜像,runner 在海外要换回官方源。 +- **APK 的产物名由 `mobile/package.json` 的 version 决定**:`Agent2API-<版本>.apk`,`release.sh` 原样挂上 Release。安装文档六份里写的也是这个名字,改命名规则要连文档一起改。 +- **推含工作流改动的提交会被拒**:报错 `refusing to allow an OAuth App to create or update workflow ... without workflow scope`。解决:`gh auth switch --user <账号>` 切到目标账号,再 `gh auth refresh --scopes workflow` 授权一次。注意 `gh auth refresh` **没有 `--user` 参数**,它只作用于当前活动账号;不先 switch 就会出现「浏览器里授权的是 A,refresh 的是 B」的报错。 +- **协议库按 tag 引用插件仓库**:`desktop-tauri/src-tauri/server/Cargo.toml` 里 `agent2api-provider-protocol` 指向 `ProvidersPlugin/Agent2Providers` 的某个 tag。插件新增协议字段时,要等插件仓库打了新 tag 并发布后,这里才能跟上;本地联调若需要临时改协议,用 `.cargo/config.toml` 的 `[patch]` 指向本地路径,**但绝不能把那段提交**。 diff --git a/.dockerignore b/.dockerignore index 6a3ef826e..6cad57674 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ .git .github Acankao +plugin assets prototype logs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..01edc5063 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# 钩子脚本必须以 LF 检出。 +# +# Windows 上 core.autocrlf=true 会把文本文件检出成 CRLF,而 CRLF 的 +# `#!/bin/sh` 会让 Git Bash 报 "bad interpreter",钩子直接失效(还不会 +# 报错提示,只是静默不跑)。只约束 .githooks/ 下的文件,仓库其余部分 +# 维持现状,不做全仓库行尾规范化。 +.githooks/** text eol=lf diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 000000000..0417089cd --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,89 @@ +#!/bin/sh +# +# pre-commit:本次提交若动了前端源码,就自动重建入库产物并一起提交。 +# +# ── 为什么需要它 ────────────────────────────────────────────── +# 本仓库把前端构建产物**入库**(ui/islands/ui.js、ui/islands/ui.css、 +# ui/i18n/**、ui/m/**、ui/sw.js),Tauri 打包(tauri.conf.json 的 +# frontendDist 指向 ui/)与 Docker 镜像(Dockerfile COPY ui/)都直接取 +# 这些字节 —— 仓库里那几份就是用户拿到的界面,没有任何构建兜底。 +# +# 于是「改了源码忘了重建」会一路进分支,而 review 看不出 1 MB 压缩产物 +# 里少了什么。CI 的 frontend-artifacts 闸能在推送后拦住,但那时代码已经 +# 进了远端;这道钩子把拦截提前到本地提交前 —— 忘了也交不上去。 +# +# ── 行为 ────────────────────────────────────────────────────── +# 检测暂存区里是否含前端源码改动,有则重建对应产物并 git add: +# ui-islands / ui-kit → desktop-tauri/ui/islands/{ui.js,ui.css} +# mobile → desktop-tauri/ui/m/** 与 desktop-tauri/ui/sw.js +# i18n → desktop-tauri/ui/i18n/** +# 任何一步构建失败都会中止提交(产物没更新,提交出去就与源码不同源)。 +# +# ── 逃生门 ──────────────────────────────────────────────────── +# 刻意想分开提交(例如先提源码再提产物)时: +# git commit --no-verify +# 关掉钩子(本机生效): +# git config --unset core.hooksPath + +set -e + +# 暂存区名单落到临时文件:管道里的 while 跑在子 shell 里,变量传不出来 +staged="$(mktemp)" +trap 'rm -f "$staged"' EXIT +git diff --cached --name-only --diff-filter=ACMR > "$staged" + +need_islands=0 +need_mobile=0 +need_i18n=0 + +while IFS= read -r f; do + [ -n "$f" ] || continue + case "$f" in + # ui-kit 以源码方式被两端引用:岛走 vite alias,手机端引它的样式 + desktop-tauri/ui-kit/* | desktop-tauri/ui-islands/*) + need_islands=1 + need_mobile=1 + ;; + desktop-tauri/i18n/*) + need_i18n=1 + ;; + # 手机端原生工程不参与 Web 产物,改了不用重建 + desktop-tauri/mobile/android/* | desktop-tauri/mobile/ios/* | desktop-tauri/mobile/android-www/*) + ;; + desktop-tauri/mobile/*) + need_mobile=1 + ;; + esac +done < "$staged" + +if [ "$need_islands" = 0 ] && [ "$need_mobile" = 0 ] && [ "$need_i18n" = 0 ]; then + exit 0 +fi + +if ! command -v npm > /dev/null 2>&1; then + echo "pre-commit: 检测到前端源码改动,但找不到 npm,无法重建产物。" >&2 + echo " 装好 Node 后重试,或用 git commit --no-verify 跳过(不推荐)。" >&2 + exit 1 +fi + +echo "pre-commit: 检测到前端源码改动,重建入库产物……" + +if [ "$need_islands" = 1 ]; then + npm --prefix desktop-tauri/ui-islands run build +fi +if [ "$need_mobile" = 1 ]; then + npm --prefix desktop-tauri/mobile run build +fi +if [ "$need_i18n" = 1 ]; then + npm --prefix desktop-tauri/i18n run build +fi + +# 只 add 产物所在目录:ui/ 根下的 app.js、css/ 等是手写文件,不归钩子管 +[ "$need_islands" = 1 ] && git add desktop-tauri/ui/islands +[ "$need_mobile" = 1 ] && git add desktop-tauri/ui/m desktop-tauri/ui/sw.js +[ "$need_i18n" = 1 ] && git add desktop-tauri/ui/i18n + +# 产物更新后可能在暂存区留下差异(也可能没有 —— 那说明本来就同源), +# 两种情况都放行;真正的把关交给构建是否成功 +echo "pre-commit: 产物已重建并加入本次提交" +exit 0 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index f7fb0c82c..75c77e4b3 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -7,4 +7,4 @@ # 注意:链接固定指向默认分支(main),文档合并到 main 之后按钮才可用。 # 将来接入其它平台(爱发电 / Buy Me a Coffee / GitHub Sponsors 等)时, # 在这里追加对应的键(如 buy_me_a_coffee)或往 custom 数组里再加链接即可(最多 4 条)。 -custom: ["https://github.com/aimod-cc/agent2api/blob/main/docs/SPONSOR.md"] +custom: ["https://github.com/aimod-cc/Agent2API/blob/main/docs/SPONSOR.md"] diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 000000000..f026d588b --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,21 @@ +# secret scanning / push protection 路径排除清单(GitHub 官方机制: +# 「Excluding folders and files from secret scanning」,键为 paths-ignore)。 +# +# 下列文件含有**第三方官方客户端自带**的公开凭证 —— 不是本仓库的机密, +# 也不能改写(改了对应提供商的登录链路就断了),因此按整文件排除, +# 避免历史告警反复出现、push protection 拦截触碰这些值的推送: +# +# 1. providers/antigravity/endpoints.rs —— Google 官方 Antigravity 客户端 +# 随安装包分发的公开 OAuth client_id / client_secret(全网开源实现原样 +# 公开;本仓文件头注释也写明「公开客户端凭证,不是用户机密」)。 +# 用于 Antigravity 提供商的 Google 账号网页登录(oauth.rs)。 +# 2. providers/orcaterm/login.rs —— 腾讯 OrcaTerm 官方自己的微信开放平台 +# AppID(其登录页 302 跳转 URL 里明文可见的公开标识符;微信的机密是 +# AppSecret,本仓没有)。该值在文件里仅出现在注释中。 +# +# 注意:此配置按仓库默认分支(main)读取,改动合入 main 后才生效; +# 命中的已有告警会被自动关闭(理由:ignored by configuration)。 +# 不要往本文件写入任何密钥明文 —— 本文件自身不在排除清单里,写了反而会触发告警。 +paths-ignore: + - "desktop-tauri/src-tauri/server/src/server/core/providers/antigravity/endpoints.rs" + - "desktop-tauri/src-tauri/server/src/server/core/providers/orcaterm/login.rs" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4fcef6f7f..3f01fdf67 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,17 +4,33 @@ name: build # # ── 为什么需要 CI 构建 ───────────────────────────────────────── # Tauri 的 macOS 产物(.app / .dmg)**只能在 macOS 上生成**,无法从 Windows -# 交叉编译。因此 Windows 上开发、又要出 Mac 包时,只能借助 CI 的 macOS runner。 +# 交叉编译;Linux 产物(deb / AppImage)同理 —— 它们链接 webkit2gtk / gtk3, +# 同样无法从 Windows 交叉。因此这两个平台的桌面端都交给 CI 的 runner 出。 # 本仓库是公开仓库,标准 GitHub-hosted runner(含 macOS)对公开仓库免费且 # 不计分钟数 —— macOS runner 是私有仓库里最贵的一档,公开仓库没有这个问题。 # +# ── 六个 job 的分工(名字相近,别看混)──────────────────────── +# macos → macOS 桌面端 dmg → artifact: macos-universal +# windows → Windows 桌面端 NSIS exe → artifact: windows-nsis +# linux-desktop → Linux 桌面端 deb+AppImage → artifact: linux-desktop +# linux-server → Linux **服务端**包(无 GUI)→ artifact: linux-server-{amd64,arm64} +# windows-server → Windows **服务端**包(无 GUI)→ artifact: windows-server-amd64 +# android → Android APK(Capacitor) → artifact: android-apk +# ios → iOS IPA(Capacitor) → artifact: ios-ipa +# +# 前三者是「桌面端安装包」(给用户装在自己机器上),中间两者是**服务器形态**: +# 无图形界面、命令行安装、注册成系统服务(Linux systemd / Windows SCM)。 +# linux-server 的产物同时还是容器内一键更新的载荷。 +# +# android / ios 是**手机端**:Capacitor 把 `desktop-tauri/mobile` 的界面打进 +# 安装包,与 PWA 形态共用同一份 UI 代码。它的 Web 形态(`ui/m/**`)不需要在 +# 这里构建 —— 那份产物**入库**,由 frontend-artifacts.yml 校验可复现,桌面端 / +# 服务端 / Docker 的 `ui/` 静态目录直接带上它,发版流程一行都不用改。这里只补 +# 「需要签名密钥、只能在打包机上出」的那一半。 +# # ── 触发方式 ────────────────────────────────────────────────── -# · 手动 workflow_dispatch(在 Actions 页面选分支触发): -# 三个平台一起构建,产物作为 workflow artifact 下载,不发布 Release -# · 推送 v* tag:与 docker.yml 同时触发,一次打 tag 安装包与镜像全出; -# Windows/macOS 包与 Linux 服务端包只挂 workflow artifact —— GitHub -# Release 由发版收尾脚本在本地挂载(scripts/release.sh):Release 本就 -# 不自动建,安装包只挂 artifact 让构建与发布解耦,发布那步重跑脚本即可 +# 本文件只做派发,实际构建见本文件下半部分「2026-10:编译搬到独立构建账号」。 +# 镜像构建(原 docker.yml)也已整体搬到构建仓库,源码仓库不再保留那份。 # # ── action 版本为什么都钉在大版本 v7(本次升级)───────────────── # 原先用 checkout@v4 / setup-node@v4 / upload-artifact@v4,三者都跑在 @@ -40,155 +56,112 @@ name: build # (甚至因为找不到 lock 文件而直接报错)。 # # ── 签名 ────────────────────────────────────────────────────── -# 默认**不签名**(ad-hoc)。macOS 上未签名的应用 Gatekeeper 会拦下, +# 桌面端默认**不签名**(ad-hoc)。macOS 上未签名的应用 Gatekeeper 会拦下, # 用户需要在「系统设置 → 隐私与安全性」里手动放行一次。要消除这一步需要 # Apple Developer 账号($99/年)做 Developer ID 签名 + 公证,届时在仓库里配 # APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_SIGNING_IDENTITY / # APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID 这几个 secret 即可, # tauri-action 与 tauri CLI 会自动完成签名与公证。 +# +# APK 则**必须签名**(Android 不接受未签名的 release 包),密钥与密码不入库 +# (见 .gitignore),由下面 android job 从四个 secret 还原: +# ANDROID_KEYSTORE_BASE64 agent2api-release.jks 的 base64(单行) +# ANDROID_KEYSTORE_PASSWORD keystore.properties 的 storePassword +# ANDROID_KEY_ALIAS keyAlias(当前是 agent2api) +# ANDROID_KEY_PASSWORD keyPassword +# ⚠️ 这四样换不得:换了密钥签名就不一致,已装的用户无法覆盖升级。原密钥 +# 除了打包机之外要另存一份,secret 只是它的第二份副本。 +# 生成 base64:base64 -w0 agent2api-release.jks(Windows 用 +# [Convert]::ToBase64String([IO.File]::ReadAllBytes('agent2api-release.jks'))) + + +# ── 2026-10:编译搬到独立构建账号 ───────────────────────────── +# 上面的 job 分工与签名说明仍然有效 —— 它们描述的是**构建仓库**里那份 +# 工作流(`Agent2Builder/agent2api-releases`)。本文件现在只做转发: +# +# · 本仓库推 `v*` tag → 发一条 `repository_dispatch` → 构建仓库开跑 +# · 七个平台 job(三套工具链 + Android/iOS 签名)不再占用本仓库的 Actions +# +# ── 为什么搬 ──────────────────────────────────────────────── +# 七个 job 里有两个 macOS、两个 Linux 容器、一个 Windows,还有 Android 与 +# iOS 的签名链路 —— 跑一次是实打实的重负载,而 tag 是发版动作、频率不可控。 +# 把「打一个版本标签」变成「自动兑换七台虚拟机」,最坏会被判定为 Actions +# 滥用。构建仓库挂在专用账号下,额度与负载都落在那边。 +# +# ── 密钥归属 ──────────────────────────────────────────────── +# 所有签名材料(Apple / Android / iOS)只存在**构建仓库**的 secrets 里, +# 本仓库没有、也不需要。本仓库唯一要配的是 `BUILDER_DISPATCH_TOKEN` —— +# 一个对构建仓库有 contents write 权限的 token,只用来派发事件。 +# +# ── 产物去哪 ──────────────────────────────────────────────── +# 各平台安装包仍是 workflow artifact,落在**构建仓库**的 run 页面上。 +# 本仓库的 `scripts/release.sh` 挂 Release 的流程不变:从构建仓库的 +# artifact(或 Releases)取包,再挂到本仓库的 Release。 + +# ── 2026-10:编译搬到独立构建账号 ───────────────────────────── +# 上面的 job 分工与签名说明描述的是**构建仓库**里那份工作流 +# (`Agent2Builder/agent2api-releases` 的 `release.yml`)。本文件现在只做转发: +# +# · 本仓库推 `v*` tag → 发一条 `repository_dispatch` → 构建仓库开跑 +# · 七个平台 job(三套工具链 + Android/iOS 签名)不再占用本仓库的 Actions +# +# ── 为什么搬 ──────────────────────────────────────────────── +# 七个 job 里有两个 macOS、两个 Linux、一个 Windows,还有 Android 与 iOS +# 的签名链路 —— 跑一次是实打实的重负载,而 tag 是发版动作、频率不可控。 +# 把「打一个版本标签」变成「自动兑换七台虚拟机」,最坏会被判定为 Actions +# 滥用。构建仓库挂在专用账号下,额度与负载都落在那边。 +# +# ── 密钥归属 ──────────────────────────────────────────────── +# 所有签名材料(插件包 / Android / iOS)与 Docker Hub 凭据只存在**构建 +# 仓库**的 secrets 里,本仓库没有、也不需要。本仓库唯一要配的是 +# `BUILDER_DISPATCH_TOKEN` —— 一个对构建仓库有 contents write 权限的 token, +# 只用来派发事件。 +# +# ── 产物去哪 ──────────────────────────────────────────────── +# 各平台安装包是**构建仓库** run 页面上的 workflow artifact;Release 由 +# `scripts/release.sh` 挂到构建仓库(脚本里的 `BUILD_REPO` 已指向那里)。 on: - workflow_dispatch: push: tags: - 'v*' - -env: - RUST_BACKTRACE: 1 + workflow_dispatch: + inputs: + version: + description: '要发布的版本号(不带 v 前缀;对应的 tag 必须已存在)' + required: true + type: string jobs: - # ── macOS ───────────────────────────────────────────────── - macos: - # macos-latest 是 Apple Silicon(M1)。这里用 universal 目标同时产出 - # x86_64 与 aarch64 两个架构并合并,Intel 与 M 芯片都能原生运行 —— - # 代价是 Rust 部分要编两遍,缓存命中时约十几分钟。 - runs-on: macos-latest - steps: - - uses: actions/checkout@v7 - - - name: 安装 Node - uses: actions/setup-node@v7 - with: - node-version: '22' - # 依赖在 desktop-tauri/ 下(根 package.json 没有依赖),必须显式指路 - cache: npm - cache-dependency-path: desktop-tauri/package-lock.json - - - name: 安装 Rust - uses: dtolnay/rust-toolchain@stable - with: - # universal 需要两个目标都装上(合并时两个架构各编一遍) - targets: aarch64-apple-darwin,x86_64-apple-darwin - - # 缓存 Rust 依赖:冷编译约 10 分钟,命中后大幅缩短。 - # key 里带上 Cargo.lock 的哈希,依赖变动时自动失效。 - # target 路径按 .cargo/config.toml 的 target-dir 指向仓库根的 target/ - - name: 缓存 Rust 依赖 - uses: Swatinem/rust-cache@v2 - with: - workspaces: desktop-tauri/src-tauri -> ../../target - - - name: 安装桌面端依赖 - run: npm --prefix desktop-tauri ci - - # 不走 tauri-action:它的默认脚本是 `npm run tauri build`,而本项目的 - # package.json 里那条脚本叫 `build`(见 desktop-tauri/package.json)。 - # 直接用 CLI 更直白,也少一层 action 的版本耦合。 - - name: 构建 macOS 安装包 - run: npm --prefix desktop-tauri run build -- --target universal-apple-darwin - - - name: 上传产物 - uses: actions/upload-artifact@v7 - with: - name: macos-universal - # 只取 .dmg:bundle 目录下还有 .app 与 .dSYM 等调试产物, - # 它们不是分发形态(用户要的是 dmg),传上去只会白占存储 - path: target/universal-apple-darwin/release/bundle/dmg/*.dmg - if-no-files-found: error - - # ── Windows ───────────────────────────────────────────────── - windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v7 - - - name: 安装 Node - uses: actions/setup-node@v7 - with: - node-version: '22' - # 同 macOS job:依赖在 desktop-tauri/ 下 - cache: npm - cache-dependency-path: desktop-tauri/package-lock.json - - - name: 安装 Rust - uses: dtolnay/rust-toolchain@stable - - - name: 缓存 Rust 依赖 - uses: Swatinem/rust-cache@v2 - with: - workspaces: desktop-tauri/src-tauri -> ../../target - - - name: 安装桌面端依赖 - run: npm --prefix desktop-tauri ci - - - name: 构建 Windows 安装包 - run: npm --prefix desktop-tauri run build - - - name: 上传产物 - uses: actions/upload-artifact@v7 - with: - name: windows-nsis - path: target/release/bundle/nsis/*.exe - if-no-files-found: error - - # ── Linux 服务端包(容器内一键更新的 Release 资产)────────── - # 面板在容器里「一键更新」时下载的就是这里的产物:包内是可执行文件 + - # ui/ 目录,由服务端自己替换后再重启(见 core/update/selfupdate.rs)。 - # 两个架构各出一个包,包名与后端挑资产的名字**必须逐字一致** - # (server_bundle_name():agent2api-server-linux-amd64 / -arm64.tar.gz)。 - # - # 为什么走 Dockerfile 而不是直接 cargo build: - # · 复用镜像那条已经验证过的编译路径(amd64 原生 / arm64 交叉), - # 不引入第二套工具链; - # · 产物与镜像里的可执行文件**同源同工具链**,glibc 版本天然对齐 - # (不变量见 Dockerfile 的 bundle 阶段); - # · bundle 阶段是 FROM scratch + 纯 COPY,导出时不进 QEMU。 - # 缓存与 docker.yml 共用同一个 gha scope:依赖桩层已在那边暖过。 - linux-server: + dispatch: runs-on: ubuntu-latest - strategy: - matrix: - arch: [amd64, arm64] steps: - - uses: actions/checkout@v7 - - - name: 设置 buildx - uses: docker/setup-buildx-action@v4 - - - name: 构建服务端包 - run: | - docker buildx build \ - --platform linux/${{ matrix.arch }} \ - --target bundle \ - --output type=local,dest=out \ - --cache-from type=gha \ - --cache-to type=gha,mode=max \ - . - - - name: 打包并生成校验和 + - name: 解析版本号与 ref + id: meta + env: + INPUT_VERSION: ${{ github.event.inputs.version }} run: | - name="agent2api-server-linux-${{ matrix.arch }}.tar.gz" - mkdir -p dist - # 包内布局是硬约定:根下 agent2api-server 与 ui/(解包方按名取) - tar -C out/bundle -czf "dist/$name" agent2api-server ui - # 校验文件与包同名 + .sha256:面板按这个名字找它,找不到就拒绝安装 - ( cd dist && sha256sum "$name" > "$name.sha256" ) - cat "dist/$name.sha256" - ls -lh dist - - - name: 上传产物 - uses: actions/upload-artifact@v7 + set -euo pipefail + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + REF="$GITHUB_REF_NAME" + VERSION="${REF#v}" + else + VERSION="$INPUT_VERSION" + REF="v$VERSION" + fi + # 换行或引号会往下面的 JSON 里注入字段 + [[ "$VERSION" =~ ^[0-9][0-9A-Za-z.+-]*$ ]] || { echo "版本号不合法: $VERSION" >&2; exit 1; } + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "ref=$REF" >> "$GITHUB_OUTPUT" + echo "派发构建:版本 $VERSION(ref: $REF)" + + - name: 派发到构建仓库 + uses: peter-evans/repository-dispatch@v3 with: - name: linux-server-${{ matrix.arch }} - path: dist/* - if-no-files-found: error + token: ${{ secrets.BUILDER_DISPATCH_TOKEN }} + repository: Agent2Builder/agent2api-releases + event-type: release + client-payload: '{"ref": "${{ steps.meta.outputs.ref }}", "version": "${{ steps.meta.outputs.version }}"}' + + - name: 提示去哪看进度 + run: echo "构建在 https://github.com/Agent2Builder/agent2api-releases/actions" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index a1992f11f..000000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: docker - -# 构建网关镜像(amd64 + arm64)并推送到镜像仓库。 -# -# ── 触发方式 ────────────────────────────────────────────────── -# · 推送 v* tag(如 v2.7.1):产出 <版本号> 与 :latest -# · 推送带预发布后缀的 v* tag(如 v3.1.0-beta.1,beta 版):只产出 -# <版本号>(3.1.0-beta.1),**不移动 :latest**(理由见下面生成 tag 那一步) -# · 手动 workflow_dispatch:产出 :dev(移动测试 tag,不碰正式 tag) -# 推 v* tag 时与 build.yml 同时触发 —— 一次打 tag,安装包与镜像一起出来 -# -# ── 推到哪里:两个仓库各自独立 ──────────────────────────────── -# · **GHCR(恒推,无需任何 secret)**:ghcr.io// -# (本仓库 → ghcr.io/coulsontl/agent2api)。用 Actions 自带的 -# GITHUB_TOKEN 登录,只要顶层 permissions 里给了 packages: write。 -# ⚠️ GHCR 的**新包默认私有**:首次推送后要到包的设置里手动改成 Public, -# 否则别人 docker pull 会 401(这一步只能人工点,API 改不了默认值)。 -# · **Docker Hub(配了凭据才推)**:/agent2api。 -# 镜像名**跟着凭据的用户名走**,不在文件里写死 —— 上游那份写的是 -# aimodcc/agent2api(上游作者的命名空间),在 fork 上照搬只会把镜像往 -# 别人家的仓库推(且必然 403)。两个 secret 都没配时,这一步整体跳过, -# 工作流照常绿(只推 GHCR)。 -# -# ── 为什么 runner 上不用装 QEMU ─────────────────────────────── -# Dockerfile 的 builder 固定跑在 $BUILDPLATFORM:runner 是 amd64,arm64 -# 走 aarch64 工具链交叉编译。本地与 CI 同一套交叉编译路径,无需 -# setup-qemu-action,也没有 QEMU 模拟下 Rust 编译慢几十倍的问题。 -# -# ── 缓存 ───────────────────────────────────────────────────── -# type=gha 把 BuildKit 缓存挂到 Actions cache:Dockerfile 的「桩依赖层」 -# 首次编完整棵依赖树后落缓存,之后每次构建直接命中,只剩源码层重编。 -# 两个 registry 在**同一次构建**里推送(不是构建两遍):镜像目标是一份 -# 清单,build-push 一次构建、给每个目标打同一套 tag。 -# -# ── action 版本 ────────────────────────────────────────────── -# 与 build.yml 同一标准:直接钉最新大版本。截至本次编写: -# setup-buildx v4 / login v4 / metadata v6 / build-push v7, -# login v4 起已默认 Node 24 运行时,无 Node 20 弃用问题。 -# -# ── secrets ────────────────────────────────────────────────── -# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN(Read & Write 权限的 Access Token) -# 两者都配齐才推 Docker Hub;GHCR 不需要任何 secret。 - -on: - workflow_dispatch: - push: - tags: - - 'v*' - -permissions: - contents: read - # 推 GHCR 需要(GITHUB_TOKEN 往本仓库所属的包命名空间写) - packages: write - -jobs: - image: - runs-on: ubuntu-latest - # 两个 Docker Hub 凭据放在 **job 级 env**:`steps.if` 读不到 secrets 上下文 - # (GitHub 的 context 可用性表里没有它),但读得到 env —— 于是「配没配」 - # 只判定一次,组装镜像目标与登录两步共用同一份值。没配就是空串。 - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - steps: - - uses: actions/checkout@v7 - - - name: 设置 buildx - uses: docker/setup-buildx-action@v4 - - # 镜像目标是一份**新行分隔**的清单:GHCR 恒在,Docker Hub 看凭据。 - # 组装成 step output 再交给 metadata-action(它的 images 本来就是这个形状), - # 于是 tag 规则(semver / latest / dev)只写一份、两个 registry 共用。 - - name: 组装镜像目标 - id: images - run: | - images=("ghcr.io/${GITHUB_REPOSITORY}") - if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then - images+=("${DOCKERHUB_USERNAME}/agent2api") - fi - { - echo "list<> "$GITHUB_OUTPUT" - echo "本次镜像目标:" - printf ' %s\n' "${images[@]}" - - - name: 登录 GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - # 没配 Docker Hub 凭据时整步跳过(工作流不会因此变红) - - name: 登录 Docker Hub - if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' - uses: docker/login-action@v4 - with: - username: ${{ env.DOCKERHUB_USERNAME }} - password: ${{ env.DOCKERHUB_TOKEN }} - - # tag 推送:v2.7.1 → 2.7.1 + latest;手动触发:只出 dev - # - # latest **只跟正式版走**:beta tag(v3.1.0-beta.1)带 `-`,不移动 latest —— - # 应用里的「加入 beta 版」是一次显式选择,而 `docker pull :latest` 的人没做 - # 这个选择;推一次 beta 就把 :latest 换掉等于绕过那道开关。beta 镜像照旧 - # 会出 3.1.0-beta.1 这个版本 tag,想尝鲜的按版本号拉即可。 - - name: 生成镜像 tag - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ steps.images.outputs.list }} - tags: | - type=semver,pattern={{version}} - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} - type=raw,value=dev,enable=${{ !startsWith(github.ref, 'refs/tags/v') }} - - - name: 构建并推送 - uses: docker/build-push-action@v7 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/frontend-artifacts.yml b/.github/workflows/frontend-artifacts.yml index 60fdc243b..4e306259e 100644 --- a/.github/workflows/frontend-artifacts.yml +++ b/.github/workflows/frontend-artifacts.yml @@ -3,10 +3,11 @@ name: frontend-artifacts # 校验「入库的前端构建产物」与源码同源。 # # ── 为什么需要单独一道闸 ────────────────────────────────────── -# 本仓库把构建产物**入库**(`ui/islands/ui.js`、`ui/islands/ui.css`、`ui/i18n/**`), -# 而 Tauri 打包的正是 `ui/` 这个静态目录(见 tauri.conf.json 的 frontendDist)—— -# 也就是说上线的字节就是仓库里那几份。于是「只改源码不重新构建」与「手改产物」 -# 都能一路合进 beta 并被打进安装包,而常规 review 看不出 1.1 MB 压缩文件里少了什么。 +# 本仓库把构建产物**入库**(`ui/islands/ui.js`、`ui/islands/ui.css`、 +# `ui/islands/login.js`、`ui/i18n/**`、`ui/m/**`),而 Tauri 打包的正是 `ui/` 这个静态目录(见 tauri.conf.json 的 +# frontendDist)—— 也就是说上线的字节就是仓库里那几份。于是「只改源码不重新构建」 +# 与「手改产物」都能一路合进 beta 并被打进安装包,而常规 review 看不出 1.1 MB +# 压缩文件里少了什么。 # # 2026-10 合并 #228 / #235 时踩到过这个面:两个 PR 各自重建过产物,在 `ui.js` 上 # 必然撞车,而**取任何一边都会静默回退另一边的 UI 改动**(那一次是靠人工核对 @@ -50,6 +51,7 @@ jobs: cache-dependency-path: | desktop-tauri/ui-kit/package-lock.json desktop-tauri/ui-islands/package-lock.json + desktop-tauri/mobile/package-lock.json desktop-tauri/i18n/package-lock.json # ui-kit 也必须装,不是可选项:岛构建以**源码**方式接入它(vite alias 指到 @@ -61,12 +63,17 @@ jobs: run: | npm --prefix desktop-tauri/ui-kit ci npm --prefix desktop-tauri/ui-islands ci + npm --prefix desktop-tauri/mobile ci npm --prefix desktop-tauri/i18n ci - name: 重建产物 run: | - # 岛产物:build 脚本是 `tsc --noEmit && vite build`,顺带做类型检查 + # 岛产物:build 脚本是 `tsc --noEmit && vite build && vite build --config + # vite.login.config.ts` —— 主面板 ui.js / ui.css 与登录页独立的 login.js + # 一次出齐,顺带做类型检查 npm --prefix desktop-tauri/ui-islands run build + # 手机端产物:同样带类型检查,产物落 ui/m/ + npm --prefix desktop-tauri/mobile run build # 多语言词典产物 npm --prefix desktop-tauri/i18n run build @@ -89,6 +96,7 @@ jobs: echo echo "本地修复方式:" echo " npm --prefix desktop-tauri/ui-islands run build" + echo " npm --prefix desktop-tauri/mobile run build" echo " node desktop-tauri/i18n/build.mjs" - echo "(不要手动编辑 ui/islands/ui.js 或 ui/i18n/** —— 它们是产物,重建即可)" + echo "(不要手动编辑 ui/islands/ui.js、ui/islands/login.js、ui/m/** 或 ui/i18n/** —— 它们是产物,重建即可)" exit 1 diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml new file mode 100644 index 000000000..a42ece790 --- /dev/null +++ b/.github/workflows/server-tests.yml @@ -0,0 +1,58 @@ +name: server-tests + +# 跑后端单元测试。 +# +# ── 为什么需要这道闸 ────────────────────────────────────────── +# 本 crate 有约 270 个测试属性(257 个 `#[test]` + 13 个 `#[tokio::test]`),但此前没有任何工作流执行 `cargo test`: +# `build.yml` 与 `docker.yml` 只在打 tag / 手动触发时构建安装包, +# `warm-cache.yml` 只预热依赖缓存,`frontend-artifacts.yml` 只校验前端产物同源。 +# 也就是说:**测试写了,但从头到尾没人跑** —— 合进 beta 的改动要等装到用户机器上 +# 才第一次真正被执行。选路、余额解析、错误码映射这几族逻辑恰恰是最怕静默回归的。 +# +# ── 判据:只测后端 crate,不碰桌面壳 ────────────────────────── +# 判据只有一条:`cargo test -p agent2api-server` 必须通过。 +# 后端测试全部在这一个 crate 里(桌面壳 `src/` 下没有测试), +# 所以「只测 server」不是偷懒的折中,而是**已经覆盖了全部现有测试**。 +# 为什么不顺手把壳也编一遍:壳是 Tauri,在 Linux 上要 webkit2gtk / gtk3 一整套 +# 系统库(`server/Cargo.toml` 自己写着拆分就是为了「headless 一个都不用」, +# 且其依赖里「刻意没有的:tauri 及其插件」)。测试 job 不该为 0 个测试背这个包。 +# 同理不加 `--all-features`:本仓库的 feature 组合没有既有的验证约定, +# 贸然打开可能引入需要系统库或网络的构建,那会让这道闸变成噪音而不是信号。 +# +# ── 与现有工作流的差异 ──────────────────────────────────────── +# 触发方式照抄 `frontend-artifacts.yml`(main / beta 的 push 与 pull_request), +# 因为它已经是这个仓库里「PR 上跑校验」的既成先例;构建工具链与缓存写法照抄 +# `build.yml`(同一个 rust-toolchain 动作、同一个 rust-cache 配置口径)。 +# 失败时输出就是 cargo 自己的编译与断言错误,最常见的原因按频率排: +# 1. 改动破坏了既有单测(看 `test result: FAILED` 上面那条 panic 的文件与行号); +# 2. 新增测试依赖了真实网络或本机路径(CI 上必挂,请改成打桩或用临时目录)。 + +on: + push: + branches: [main, beta] + pull_request: + branches: [main, beta] + workflow_dispatch: + +permissions: + contents: read + +jobs: + server-tests: + name: 后端测试(agent2api-server) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: 安装 Rust + uses: dtolnay/rust-toolchain@stable + + # 与 build.yml 同一口径:.cargo/config.toml 把 target-dir 指到仓库根的 target/ + - name: 缓存 Rust 依赖 + uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop-tauri/src-tauri -> ../../target + + - name: 跑测试 + working-directory: desktop-tauri/src-tauri + run: cargo test -p agent2api-server diff --git a/.gitignore b/.gitignore index f988fbbe1..15d642b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ desktop-tauri/node_modules/ desktop-tauri/src-tauri/target/ desktop-tauri/src-tauri/gen/schemas/ +desktop-tauri/target/ # cargo 编译产物(src-tauri/.cargo/config.toml 把 target-dir 指到了项目根) /target/ @@ -13,6 +14,9 @@ desktop-tauri/src-tauri/gen/schemas/ .zcode/ .verify/ prototype/ +.codeartsdoer/ +# 排查模型目录时导出的临时快照 +.tmp_catalog.json # ZCode 的忽略配置文件本身(本机工具配置,不入库) .zcodeignore @@ -24,6 +28,10 @@ desktop-tauri/web logs/ desktop-tauri/window-shot.png +# curl 调试残留(-D 响应头 / -c cookie jar),含会话令牌,不入库 +desktop-tauri/src-tauri/h1.txt +desktop-tauri/src-tauri/c1.txt + # 发布说明草稿只在本机留存,不入库;docs/ 下的其它文档正常入库 /docs/release-notes/ @@ -33,3 +41,44 @@ dist/ # Docker 部署:密钥与运行数据不入库 .env data/ + +# omarchy-agent2api.sh backup 的归档目录(停服 → 打包 ./data → 起服) +backups/ + +# ── 手机 APK(Capacitor)────────────────────────────────────── +# android/ 是 `npx cap add android` 生成的原生工程,需要入库: +# 图标、Manifest、网络安全策略都是手改过的,重新生成会丢。 +# 但下面这些是构建机本地状态,不该进仓库: +desktop-tauri/mobile/android/.gradle/ +desktop-tauri/mobile/android/build/ +desktop-tauri/mobile/android/app/build/ +desktop-tauri/mobile/android/local.properties +desktop-tauri/mobile/android/.idea/ +desktop-tauri/mobile/android/capacitor-cordova-android-plugins/build/ +# 构建中间产物(android-www 由 npm run build:apk 生成,再同步进 android/) +desktop-tauri/mobile/android-www/ + +# APK 签名密钥与配套密码:**不入库**。 +# 拿着密钥 + 密码就能签一个同包名的假 APK 覆盖安装;本仓库是公开的, +# 所以两者只留在打包机上。新机器打 release 包时按 +# desktop-tauri/mobile/android/keystore.properties.example 建一份 +# keystore.properties,再把密钥放回 android/ 目录。 +*.jks +*.keystore +desktop-tauri/mobile/android/keystore.properties + +# 本地测试跑出来的运行数据(数据库、加密密钥、日志) +.agenttest2/ + +# ── iOS(Capacitor)──────────────────────────────────────────── +# ios/ 是 `npx cap add ios` 生成的原生工程,需要入库: +# Info.plist(ATS / 本地网络用途)、AppIcon、Splash 都是手改过的, +# 重新生成会丢。 +# 构建机本地状态与生成物不入库(ios/.gitignore 已覆盖大部分,这里补两条 +# 全局的,避免换机器时把 Xcode 的派生数据带进来): +desktop-tauri/mobile/ios/App/build/ +desktop-tauri/mobile/ios/DerivedData/ + +# 提供商插件(独立仓库的本地开发目录,不得进入本仓库历史) +/agent2api-providers/ +/plugin/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..3ea0471cb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md + +> **涉及分支、发版、版本号的操作,一律按 Skill `release-flow` 执行:** +> **`.agents/skills/release-flow/SKILL.md`** —— 动手前先读它,细则一律以该文件为准,本文件只作入口指路。 +> +> 覆盖范围:切分支 / 给 PR 选 base / 合并 PR / 回复 Issue、PR / 改版本号 / 写更新日志 / +> 打 tag / 挂 GitHub Release / 发 beta 预发布版 / 发版验收 / 排查发版失败。 + +## 两条红线(先问人,不得自行推进) + +1. **发版**:改版本号、推 `main` / `beta`、打 tag、挂 Release 之前,先把版本号与「这次要发什么」讲清楚,等用户确认;**未经确认不得推任何分支或 tag** —— tag 一旦推出去就会触发构建、产出公开的安装包与镜像,收不回来。 +2. **更新日志**:写好的日志(= 发布提交信息 = Release 正文)必须先给用户逐条过目;按反馈改完**再确认一次**才能提交。 + +其余细则(分支落脚点、署名与 AI 落款、六处版本号、正式版 / beta 完整流程、验收清单、已知坑与排查)见上述 Skill。 + +## 反代提供商问题排查 + +收到反代提供商相关的问题反馈时,**优先使用网络搜索查找已有解决方案**,重点参考同类项目的 Issue、PR、修复记录和适配实现,了解其他项目是否遇到过相同问题、如何解决。**不要默认从零自行排查**:先核对已有方案与本项目的适用性,优先借鉴可靠的解决思路;未找到可靠方案、现有方案不适用或仍有未解决的问题时,再开展针对性的本地排查。 + +## 文档落点 + +安装 / 部署 / 升级 / 排错说明只在 `docs/install.md`(另有 5 份译文 `docs/install.{en,zh-Hant,ja,ko,pt-BR}.md`,六份一一对应)里维护;各语言 README 只留「快速开始」速查表 + 一个指向安装文档的链接,**不要再把安装细节写回 README**。产物名、端口、环境变量、安装脚本参数有改动时,六份一起改。 + +**README 语言落点:根 `README.md` 是英文**(GitHub 默认展示),简体中文在 `docs/README.zh-CN.md`,其余四份为 `docs/README.{zh-Hant,ja,ko,pt-BR}.md`。中文仍是文案源语言,改支持矩阵等内容时六份同步,别把中文写回根目录。 diff --git a/Dockerfile b/Dockerfile index f47149a0c..fbef4efd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # Agent2API headless 网关镜像(多阶段构建,amd64 + arm64) # # ── 为什么只构建 server crate ──────────────────────────────── @@ -5,11 +6,11 @@ # 与网关本体(server/,无 GUI 依赖)。容器里只需要网关本体 —— # `cargo build -p agent2api-server` 明确只编它。 # -# ── 多架构:交叉编译而不是 QEMU ───────────────────────────── +# ── 多架构:Rust 原生交叉编译 ──────────────────────────────── # builder 固定跑在构建机的原生架构($BUILDPLATFORM):buildx 构建 arm64 -# 时不进 QEMU 模拟(Rust 编译会慢几十倍),而是 rustup 加一个 aarch64 -# target + 装 aarch64 的 C 工具链交叉编译。转发链路全是 rustls(纯 Rust), -# 唯一的 C 代码是 rusqlite bundled 的 SQLite 源码,gcc-aarch64 工具链足够。 +# 时 Rust 不进 QEMU(会慢几十倍),而是 rustup 加一个 aarch64 target +# + 装 aarch64 的 C 工具链交叉编译。转发链路全是 rustls(纯 Rust), +# rusqlite bundled 的 SQLite 与 ring 的 C/汇编使用该交叉工具链。 # # ── 依赖缓存层 ────────────────────────────────────────────── # 先用空的 lib/bin 桩把全部依赖编一遍(首次几分钟),之后只改源码重新 @@ -17,7 +18,6 @@ # 编译时刻,cargo 的增量判断会误判「没变」而跳过重编,所以真源码层先 # find touch 再编。 -# syntax=docker/dockerfile:1 FROM --platform=$BUILDPLATFORM rust:1-bookworm AS builder WORKDIR /build @@ -32,6 +32,12 @@ ARG HTTPS_PROXY="" # arm64 分支永远不会命中,交叉编译退化成原生 amd64 编译,arm64 镜像里 # 装进 amd64 二进制(v2.7.1 及之前线上镜像的 bug 根因)。 ARG TARGETARCH +ARG BUILDARCH +# 保留现有 amd64 → arm64 主链;arm64 构建机可原生出 arm64,不伪装成 amd64。 +RUN case "$BUILDARCH/$TARGETARCH" in \ + amd64/amd64|amd64/arm64|arm64/arm64) ;; \ + *) printf '%s\n' "Rust 构建只支持 amd64 → amd64/arm64 或 arm64 → arm64,实际 $BUILDARCH → $TARGETARCH" >&2; exit 1 ;; \ + esac # arm64 需要 aarch64 的 C 工具链与 glibc 开发头文件(rusqlite bundled 的 # SQLite、ring 的 C/汇编源码用)。注意 libc6-dev-arm64-cross 只是 # gcc-aarch64-linux-gnu 的 Recommends,--no-install-recommends 下必须显式 @@ -67,12 +73,24 @@ RUN mkdir -p src-tauri/server/src/bin src-tauri/src \ && echo "fn main() {}" > src-tauri/server/src/bin/agent2api-server.rs \ && echo "" > src-tauri/src/lib.rs \ && echo "fn main() {}" > src-tauri/src/main.rs -# 桩依赖层:整棵依赖树编一遍,命中后成为之后每次构建的缓存底座 +# 整棵依赖树编一遍,命中后成为之后每次构建的缓存底座。 RUN /build/cargo-build.sh COPY desktop-tauri/src-tauri/server/src src-tauri/server/src +COPY desktop-tauri/src-tauri/server/build.rs src-tauri/server/ +# ── 为什么构建阶段也要 ui/ ────────────────────────────────── +# `server/src/server/api/plugin_theme.rs` 用 `include_str!` / +# `include_bytes!` 把几份静态资源编进二进制(宿主设计令牌、组件库产物、 +# 提供商图标),路径是相对该源码文件往上五层 —— 即 `desktop-tauri/ui/`。 +# 只 COPY `server/src` 时那些文件不存在,编译直接失败: +# error: couldn't read `.../ui/css/tokens.css`: No such file or directory +# +# 放在这一层而不是桩依赖层:这些文件与源码一起变,缓存失效的时机一致; +# 放进桩层会让「只改了一行 Rust」也把整棵依赖树重编一遍。 +COPY desktop-tauri/ui ui RUN cd src-tauri \ && find server/src -type f -exec touch {} + \ + && touch server/build.rs \ && /build/cargo-build.sh # ── 服务端更新包(CI 的 linux-server job 用它出 Release 资产)────── @@ -99,6 +117,8 @@ RUN cd src-tauri \ FROM scratch AS bundle COPY --from=builder /build/agent2api-server /bundle/agent2api-server COPY desktop-tauri/ui /bundle/ui +COPY LICENSE /bundle/LICENSE +COPY licenses/ /bundle/licenses/ # ── 运行时 ────────────────────────────────────────────────── # bookworm-slim + ca-certificates(出站 HTTPS)+ curl(HEALTHCHECK)。 @@ -110,6 +130,8 @@ RUN apt-get update \ COPY --from=builder /build/agent2api-server /usr/local/bin/agent2api-server COPY desktop-tauri/ui /app/ui +COPY LICENSE /app/LICENSE +COPY licenses/ /app/licenses/ # 容器内的默认形态:全网卡监听 + 数据落卷 + 自托管面板。 # 鉴权:面板需要管理员(登录页注册或 env 预置);未配置任何 API Key 时 diff --git a/LICENSE b/LICENSE index d55c36784..be3f7b28e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,91 +1,661 @@ -MIT License + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -Copyright (c) 2026 aimod-cc (https://github.com/aimod-cc) + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Preamble -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. -──────────────────────────────────────────────────────────────────────────── + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. -使用声明(Usage Notice) + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. -【这份声明的性质】上文的 MIT 条款授予你使用、修改、分发本项目的权利。本声明 -在 MIT 之上**追加限制**(主要是第 3 条:禁止商业用途与二次分发牟利),因此 -本项目**不是**纯粹的 MIT 项目 —— 完整授权 = MIT 条款 **加上** 本声明的全部内容。 -两者若对某一行为给出不同结论,以**更严格**的一方为准。这也是本仓库 -`desktop-tauri/src-tauri/Cargo.toml` 用 `license-file` 指向本文件、而不声明 -SPDX 的 `"MIT"` 的原因:那个标识会漏掉这里的附加限制。 + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. -以下内容同时说明本项目「是什么、用来干什么、有什么风险」。 + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. -1. 项目性质:仅供学习与交流 + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. - 本项目是一个用于学习 HTTP 反向代理、SSE 流式透传、多账号选路、内容脱敏与 - 桌面端打包(Tauri)等技术主题的实践项目,面向开发者之间的技术交流。 - 它不是官方产品,与任何被接入服务的提供方(包括但不限于腾讯 WorkBuddy / - CodeBuddy、美团 CatPaw、小浣熊、AutoClaw、Cline、Qoder 及其各自的运营主体) - 均无任何关联,未获得它们的授权、认可或赞助。 + The precise terms and conditions for copying, distribution and +modification follow. -2. 关于反向代理行为 + TERMS AND CONDITIONS - 本项目实现的是本地反向代理:在你自己的机器上复用你自己账号的登录态, - 把请求转发到官方上游网关。它不破解、不绕过任何付费或权限校验, - 使用的额度始终来自你自己账号本就拥有的配额。 + 0. Definitions. - 但需要明确的是,这种「以非官方客户端形态复用登录态」的转发方式, - 可能不符合上游服务的用户协议或使用条款。是否使用、由此产生的 - 一切后果(包括但不限于账号被限流、被风控、被冻结或封禁), - 均由使用者自行承担。 + "This License" refers to version 3 of the GNU Affero General Public License. -3. 禁止用途(本声明最主要的附加限制) + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. - 禁止将本项目用于:任何商业用途、二次分发牟利、批量账号运营、 - 绕过上游计费或配额限制、以及其他违反当地法律法规的活动。 - 如需在生产环境或商业场景中调用相关模型服务,请使用官方渠道与官方 API。 + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. - 这里的「商业用途」指以本项目本身或其衍生版本**直接获取经济收益**, - 包括打包出售、付费分发、作为付费服务或付费产品的组成部分等; - 「二次分发牟利」指二次分发时**以本项目为卖点收取费用**。 - 个人学习、研究、自用,以及不以牟利为目的的分享与交流,不受本条限制。 + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. -4. 凭证与数据风险 + A "covered work" means either the unmodified Program or a work based +on the Program. - 本项目会把账号凭证(accessToken / refreshToken 等)以**明文**形式 - 保存在本机配置目录(默认 `~/.agent2api/`;1.x 版本的旧目录名是 - `~/.workbuddy-proxy/`,升级时会自动拷贝过来)中,导出功能生成的 - 文件同样包含明文凭证。请自行妥善保管,切勿提交到公开仓库、 - 上传到网盘或分享给他人。因凭证泄露造成的损失由使用者自行承担。 + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. - 自「数据结构升级」版本起,这些数据(账号、日志、请求统计、调试报文、 - 脱敏词表、网关配置与桌面设置)统一存放在该目录下的单个 SQLite 数据库 - `agent2api.db` 里,不再各自是一个独立文件。备份时请注意 WAL 模式下 - 还有 `-wal` / `-shm` 两个附属文件,需要一起拷贝(或先正常退出程序 - 让数据落盘)。升级时旧文件不会被删除,只会在导入后改名为 - `*.migrated` 留在原处。 + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. -5. 无担保 + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. - 本项目按「现状」提供,作者不对其可用性、稳定性、安全性或 - 对特定用途的适用性作任何承诺。上游接口随时可能变更, - 本项目可能随时失效而不再维护。 + 1. Source Code. -6. 权利通知 + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. - 本项目中的接口形态、协议字段等信息来自对官方客户端公开网络通信的 - 观察与整理,相关商标与服务的权利归其各自所有者。若权利人认为 - 本项目存在不当之处,请联系作者,将及时调整或删除。 + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index a06edb0d2..4701ffd4d 100644 --- a/README.md +++ b/README.md @@ -2,260 +2,167 @@ # Agent2API -**把多家 AI 桌面客户端的登录态,变成一个本地 OpenAI 兼容 API 网关** +**An AI routing gateway with Agent client takeover, unifying model APIs and local client configuration** -[简体中文](./README.md) | [English](./docs/README.en.md) | [繁體中文](./docs/README.zh-Hant.md) | [日本語](./docs/README.ja.md) | [한국어](./docs/README.ko.md) | [Português (BR)](./docs/README.pt-BR.md) +**English** | [简体中文](./docs/README.zh-CN.md) | [繁體中文](./docs/README.zh-Hant.md) | [日本語](./docs/README.ja.md) | [한국어](./docs/README.ko.md) | [Português (BR)](./docs/README.pt-BR.md) -[![license](https://img.shields.io/badge/license-MIT%20%2B%20附加声明-green)](./LICENSE) -[![release](https://img.shields.io/github/v/release/aimod-cc/agent2api?include_prereleases)](https://github.com/aimod-cc/agent2api/releases/latest) +[![license](https://img.shields.io/badge/license-AGPL--3.0-green)](./LICENSE) +[![release](https://img.shields.io/github/v/release/aimod-cc/Agent2API?include_prereleases)](https://github.com/aimod-cc/Agent2API/releases/latest) [![docker pulls](https://img.shields.io/docker/pulls/aimodcc/agent2api)](https://hub.docker.com/r/aimodcc/agent2api) -[![stars](https://img.shields.io/github/stars/aimod-cc/agent2api?style=flat&color=yellow)](https://github.com/aimod-cc/agent2api/stargazers) -[![platform](https://img.shields.io/badge/platform-Windows%20%7C%20Docker-blue)](https://github.com/aimod-cc/agent2api/releases/latest) +[![stars](https://img.shields.io/github/stars/aimod-cc/Agent2API?style=flat&color=yellow)](https://github.com/aimod-cc/Agent2API/stargazers) +[![platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20Docker-blue)](https://github.com/aimod-cc/Agent2API/releases/latest) -[✨ 核心特性](#-核心特性) • [💬 社区与支持](#-社区与支持) • [🤝 支持的提供商](#-支持的提供商) • [🚀 快速开始](#-快速开始) • [🐳 Docker 部署](#-docker-部署) • [📸 界面预览](#-界面预览) • [🔧 开发与构建](#-开发与构建) • [📜 使用声明](#-使用声明) +[✨ Key Features](#-key-features) • [💬 Community & Support](#-community--support) • [🚀 Quick Start](#-quick-start) • [📦 Installation & deployment](./docs/install.en.md) • [📸 Screenshots](#-screenshots) • [🔧 Development & Build](#-development--build) • [📜 Usage Notice](#-usage-notice) --- -Agent2API 把多家 AI 桌面客户端里已登录的账号接到一起,共用一套 OpenAI 兼容接口。任何支持自定义 `base_url` 的客户端,填上 `http://127.0.0.1:3065/v1` 就能直接调用这些账号的模型额度 —— **不需要上游 API Key,也不需要改客户端**。 +Agent2API is an **AI routing gateway with Agent client takeover**. It exposes a unified model API for custom AI providers and centralizes account routing, model mapping, gateway keys and request reports. It also manages local Agent clients' connection profiles, including importing, switching and previewing configuration changes. -## ✨ 核心特性 +## ✨ Key Features -- 🔌 **OpenAI 兼容** —— 客户端填个 `base_url` 就能用,Chat / Responses / Messages 三种协议都支持 -- 🏪 **内置 17 家提供商** —— WorkBuddy、小浣熊、CatPaw、Qoder、Trae 等开箱即用,也支持自定义提供商 -- 🔁 **凭证自动续期** —— Token 到期自动换新,临期凭证可批量刷新,模型目录也不会过期 -- 🪂 **多账号轮转** —— 按优先级排队,429 自动换账号,限额冷却期内不空等 -- 🛡️ **出网代理** —— 每个账号可配独立出口,出站指纹按官方客户端伪装 -- 🔑 **网关安全** —— 多把网关 Key 可分权、登录有人机验证,没配 Key 时默认拒绝请求 -- 📊 **请求报表** —— 请求量、成功率、Token 消耗一览,还有缓存命中率与 365 天热力图 -- ⏰ **定时任务** —— 自动签到、凭证维护、模型目录刷新,开关与间隔在一页里管理 -- 💰 **余额与用量** —— 账号行内直接看余额与用量,支持跨账号并发查询 -- 🖥️ **两种部署** —— 桌面端托盘常驻、免装 Node;Docker 版面板与网关同端口,界面六语 +- 🔌 **Unified model APIs** — OpenAI Chat / Responses / Images and Anthropic Messages, with a unified model catalog; Raccoon, Loomy, Antigravity, ModelScope, WorkBuddy and Doubao Work supply image generation, while Raccoon, Loomy, Antigravity, ModelScope and WorkBuddy also support reference-image editing. Both are available as AI assistant tools +- 🧭 **AI routing** — model mapping, account priority, load balancing, session affinity and failover in one place +- 🤖 **Agent client takeover** — manage multiple connection profiles for local clients, import existing settings, preview writes and switch between the gateway and custom services +- 🧩 **Plugin extensions** — additional provider implementations are installed and updated separately, not bundled with the application; custom providers do not require them +- 🛡️ **Egress proxies** — select proxy-pool exits for accounts and update tasks +- 🔑 **Gateway security** — scoped gateway keys, optional human verification for panel sign-in, and model requests refused by default until a key is configured +- 📊 **Request reports** — request volume, success rate, token usage, cache hit rate and a 365-day heatmap +- ⏰ **Background maintenance** — model catalog refresh, credential maintenance and software update checks; daily check-in runs per provider on that provider's own window (Qoder 10:01, ZCode 19:01, others follow the schedule time), plus a catch-up round that only touches accounts not yet checked in; additional provider-specific tasks are supplied by plugins +- 🖥️ **Three deployment forms** — Windows / macOS / Linux desktop apps, headless Windows / Linux servers and Docker (amd64 / arm64); no Node runtime required, with a six-language interface -## ❤️ 赞助商 +## ❤️ Sponsors -ProxyLane(赞助位) +ProxyLane (sponsor) - - + +
ProxyLane感谢 ProxyLane 赞助了本项目!ProxyLane 提供 AI 账号专用的原生静态住宅 IP(Static Residential ISP):IP 来自真实家庭宽带运营商,Scamalytics 低欺诈评分,每个 IP 独享,不限流量,$3.90 起。在「网络代理」页给每个账号配一个出口即可。首次购买使用优惠码 AGENT2API25 享 75 折。ProxyLaneThanks to ProxyLane for sponsoring this project! ProxyLane provides native static residential ISP proxies for AI accounts (Static Residential ISP): IPs come from real home broadband operators with low Scamalytics fraud scores, each IP is dedicated to you, with unlimited traffic, from $3.90. Add one exit per account on the Network proxy page. Use code AGENT2API25 for 25% off your first purchase.
-## 💬 社区与支持 +## 💬 Community & Support -- **QQ 群**:Agent2API 交流群,群号 `1127656296` —— 在 QQ 里搜索群号即可加入,使用答疑与问题反馈都在这里。 -- **💖 赞赏**:如果 Agent2API 帮到了你,欢迎[请作者喝杯咖啡](docs/SPONSOR.md);赞赏完全自愿,不解锁任何功能。 -- **🤝 赞助 / 商务合作**:想赞助本项目,或想让你的产品出现在这里?发邮件到 [541675139@qq.com](mailto:541675139@qq.com)。 +- **QQ groups**: Agent2API chat groups — **group 1 (`1127656296`) and group 2 (`1130894674`) are almost full, please join group 3 (`1108276647`)**; search the number in QQ to join. +- **Telegram group**: +- **💖 Donate**: if Agent2API helps you, you are welcome to [buy the author a coffee](./docs/SPONSOR.md); donations are voluntary and unlock nothing. +- **🤝 Sponsorship / business**: want to sponsor the project, or feature your product here? Email [541675139@qq.com](mailto:541675139@qq.com). -## 🤝 支持的提供商 +## 🚀 Quick start -支持情况一览(✓ 支持 · ✗ 不支持 · — 无此概念): +Three forms — pick the one that matches your setup; **the complete install, upgrade, uninstall and troubleshooting guide is in [Installation & deployment](./docs/install.en.md)**. -| 平台 | LLM 请求 | Token 自动续期 | 模型列表 | 余额查询 | 签到 | 领取类 | -| --- | :--: | :--: | :--: | :--: | :--: | :--: | -| WorkBuddy 国内版 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| WorkBuddy 国际版 | ✓ | ✓ | ✓ | ✓ | ✗ | — | -| 小浣熊 | ✓ | ✓ | ✓ | ✓ | ✓ | — | -| CatPaw | ✓ | ✗ | ✓ | ✓ | ✗ | — | -| AutoClaw(国内版 / 国际版) | ✓ | ✓ | ✓ | ✓ | ✓ | — | -| Qoder | ✓ | ✓ | ✓ | ✓ | ✓ | — | -| Cline(Free / Pass) | ✓ | ✓ | ✓ | ✓ | — | — | -| Accio(国际版 / 国内版) | ✓ | ✓ | ✓ | ✓ | — | — | -| ZCode(国内版 / 国际版) | ✓ | ✗ | ✗ | ✓ | — | ✓ | -| CodeArts | ✓ | ✓ | ✓ | ✓ | — | ✓ | -| Trae | ✓ | ✓ | ✓ | ✓ | — | — | -| Loomy(讯飞) | ✓ | ✗ | ✓ | ✓ | ✓ | — | -| KukuAI(百度文库) | ✓ | ✗ | ✓ | ✓ | ✓ | — | -| MonkeyCode(长亭科技,国内版 / 国际版) | ✓ | ✗ | ✓ | — | — | — | -| Command Code | ✓ | ✗ | ✓ | — | — | — | -| Antigravity(Google,Gemini) | ✓ | ✓ | ✓ | — | — | — | -| OfficeAce(华为云果办 / OfficeClaw) | ✓ | ✓ | ✓ | ✓ | ✓ | — | -| 自定义提供商 | ✓ | — | ✓ | — | — | — | +| Form | Artifact | Interface | +| --- | --- | --- | +| **Desktop** (Windows / macOS / Linux) | `Agent2API__x64-setup.exe`, `_universal.dmg`, `_amd64.deb`, `_amd64.AppImage` | App window | +| **Server** (Windows Server / Linux, headless) | `agent2api-server-windows-amd64.zip`, `agent2api-server-linux-.tar.gz` | Browser panel | +| **Docker** | `aimodcc/agent2api:latest` (amd64 / arm64) | Browser panel | -> 注:模型列表的 ✓ 表示支持在线自动拉取,部分平台另有内置静态表兜底;ZCode 仅有内置静态表。Token 续期列 ✗ 表示该平台没有可用的续期接口(账号在有效期内不受影响)。Qoder 签到仅中国版可用。 +**Desktop**: add a custom provider and account, create a gateway key, then apply a connection profile on the Agent page or configure your client with `http://127.0.0.1:3065/v1` and that key. Additional provider capabilities require the corresponding plugin. -`/v1/chat/completions`、`/v1/responses`、`/v1/messages`(含 `count_tokens`)与 `/v1/models` 对所有平台一视同仁;模型映射、全局优先级队列、429 降级、出网代理、出站指纹脱敏与请求报表同样全平台通用。 - -> **本项目仅供学习与交流使用。** 它以非官方客户端形态复用你自己账号的登录态,可能不符合上游服务的使用条款,风险(含账号被风控、封禁)自负;禁止商用或绕过计费。详见[使用声明](#-使用声明)与 [LICENSE](./LICENSE)。 -> -> 本项目与各上游厂商及其官方产品均无关(名单见[使用声明](#-使用声明));接口形态来自对各家客户端通信的观察,上游随时可能调整。 - -## 🚀 快速开始 - -从 [Releases](https://github.com/aimod-cc/agent2api/releases/latest) 下载安装包(NSIS,简体中文,默认装到 `C:\Program Files\Agent2API`,安装时需要管理员授权),安装后启动即可,**无需安装 Node 或任何其它运行时**。 - -1. 首次启动即在应用进程内启动本机网关(端口 3065)并打开主窗口;若检测到旧版本的数据目录或数据文件,会弹窗提示迁移,按指引操作即可。 -2. 点「账号」页的「添加账号」,选提供商后按弹窗提示完成登录或填写凭证即可(方式以弹窗为准:网页登录 / 手机验证码 / 粘贴凭证 / 导入本机登录态)。 -3. 把 OpenAI 客户端的 `base_url` 填成 `http://127.0.0.1:3065/v1`,`api_key` 随便填(例如 `sk-local`,未启用鉴权时服务端不校验)。 - -关闭窗口默认最小化到托盘,网关继续在后台转发;彻底退出请在托盘图标右键选「退出」。 - -### 验证 - -网关起来后,用 curl 确认联通性(`model` 填 `GET /v1/models` 里任一实际存在的名字): +**Server** (no GUI; registered as a system service and started at boot): ```bash -curl http://127.0.0.1:3065/health -curl http://127.0.0.1:3065/v1/models - -curl http://127.0.0.1:3065/v1/chat/completions \ - -H 'Content-Type: application/json' \ - -d '{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"你好"}],"stream":true}' +# Linux +tar -xzf agent2api-server-linux-amd64.tar.gz && cd agent2api-server-linux-amd64 && sudo bash install.sh ``` -客户端接入示例(Python SDK): - -```python -from openai import OpenAI - -client = OpenAI(base_url="http://127.0.0.1:3065/v1", api_key="sk-local") -resp = client.chat.completions.create( - model="deepseek-v4.1-flash", # 名单里有哪家就是哪家,见 GET /v1/models - messages=[{"role": "user", "content": "你好"}], -) -print(resp.choices[0].message.content) +```powershell +# Windows Server (administrator PowerShell) +Expand-Archive agent2api-server-windows-amd64.zip -DestinationPath C:\agent2api-pkg +cd C:\agent2api-pkg; .\install-service.ps1 ``` -**浏览器页面**(自建 Web UI 等)用 `fetch` 直连会被跨源预检拒绝——网关默认不应答 CORS,预检落到 API Key 校验上得 401。两种解法:① 设置页「安全 → 网关跨域访问」打开(来源 `*`;此时任何网页都能借本机网关打上游,建议同时配「网关 Key」);② 页面与网关同源——本地静态服务托管页面并把 `/v1` 反代到 `127.0.0.1:3065`。 - -### 局域网访问 - -默认只监听 `127.0.0.1`。开启「设置 → 通用 → 局域网访问」后改听所有网卡,局域网设备把 API 地址指向本机 IP 即可共用(界面会给出完整地址)。出于安全,开启前必须注册面板管理员:管理接口要求管理员会话或网关 Key,无可用 Key 时转发接口同样拒绝服务(开启流程会自动补一把「默认」Key);也可选择同时开放网页面板(默认关闭)。改动重启生效。 - -## 🐳 Docker 部署 +**Docker**: ```bash -docker run -d --name agent2api --restart unless-stopped \ - -p 3065:3065 -v ./data:/data \ - aimodcc/agent2api:latest +docker run -d --name agent2api --restart unless-stopped -p 3065:3065 -v ./data:/data aimodcc/agent2api:latest ``` -浏览器打开 `http://<主机>:3065`,首次进入会引导**注册管理员账号**;登录后在「网关 Key」页建一把 API Key 给客户端用 —— `http://<主机>:3065/v1` 即 OpenAI 兼容端点(未建 Key 前拒绝转发)。状态(SQLite 库 / 配置 / 日志)都在 `./data` 一个卷里。 +> On Arch / Omarchy, a source-build setup hardened for local use (loopback-only by default) ships in `scripts/`: see [Installation & deployment → Docker](./docs/install.en.md#docker). -compose 用户(amd64 / arm64 都有镜像): +Whichever form you pick, open the panel afterwards (`http://127.0.0.1:3065/`; use the host IP for the server and Docker forms), register the admin account, then create a key on the "Gateway Keys" page. Connectivity check: `curl http://127.0.0.1:3065/health` -```yaml -services: - agent2api: - image: aimodcc/agent2api:latest - container_name: agent2api - restart: unless-stopped - ports: - - "3065:3065" - volumes: - - ./data:/data -``` - -环境变量(都可选): - -| 变量 | 说明 | -| --- | --- | -| `AGENT2API_ADMIN_USER` + `AGENT2API_ADMIN_PASSWORD` | 预置管理员账号密码(密码明文填,启动时自动转哈希);不填走面板注册 | -| `AGENT2API_PANEL_PORT` | 面板分端口:设后面板(界面 + `/api/*`)单独监听该端口,公网只映射主端口即可把管理面留在内网(面板端口绑回环,写 `127.0.0.1:3066:3066`) | -| `AGENT2API_HOST` / `AGENT2API_PROXY_PORT` | 监听地址(默认 `0.0.0.0`)/ 端口(默认 `3065`) | -| `AGENT2API_ALLOW_NO_KEY` | 置 `1` 关闭 fail-closed(未配 Key 也放行 `/v1`,仅限纯内网) | -| `AGENT2API_CAPTCHA_ENABLED` | 登录页人机验证:默认 `1` 开启,`0` 关闭 | -| `AGENT2API_SELF_UPDATE` | 容器内的更新入口:默认 `1` 开启(面板可「下载并更新」);置 `0` 关闭,只走手动升级 | -| `WORKBUDDY_UPDATE_REPO` + `WORKBUDDY_UPDATE_API_BASE` | 进阶:把「检查更新 / 下载更新」换成自建源。前者换仓库(默认跟上游 `aimod-cc/agent2api`,fork 后可改),后者换 API 根(默认 `https://api.github.com`,需返回与 GitHub 同形的 release JSON)。配了自建源后,下载白名单只额外放行**该源自己的域名**(按源的 scheme:配 `http://` 就允许明文下载,仅建议本地演练/内网镜像用)| - -从源码构建:克隆本仓库后 `docker compose up -d --build`(镜像里只有网关与面板,不含 Rust 工具链)。 - -**网页端功能差异**(源于「没有本机桌面客户端」):网页登录(WorkBuddy / Qoder / Cline)、手机验证码、粘贴凭证完全可用;需要本机回调的网页登录(AutoClaw / CatPaw / Accio / CodeArts / Trae)与「导入本机登录态」不可用,请改用粘贴凭证。 - -## 📸 界面预览 - -### 账号 +**Mobile**: open `http://:3065/m` in a browser (you can "Add to Home Screen" for a full-screen PWA). For a standalone icon, install `Agent2API-.apk` (Android) or `Agent2API-.ipa` (iOS, unsigned — re-sign it yourself) from the Release. Details: [Install & deploy → Mobile](./docs/install.en.md#mobile). -![账号管理页:全局队列、按模型限额冷却、有效期与余额](./assets/screenshots/accounts.png) +## 📸 Screenshots -添加账号: +### Accounts -![添加账号:选择提供商与版本,然后走网页登录](./assets/screenshots/add-account.png) +![Accounts page: global queue, per-model rate-limit cooldown, expiry and balance](./assets/screenshots/accounts.png) -### 模型管理 +Add account: -![模型管理页:按提供商查看模型清单、倍率、上下文与映射](./assets/screenshots/models.png) +![Add account: choose provider and edition, then sign in on the web](./assets/screenshots/add-account.png) -### 报表 +### Model management -概览:请求数 / 成功率 / Token 总量 / Top 模型与账号、提供商排名,附用量环图与 365 天活跃热力图: +![Model management: per-provider model catalog with rate, context and mappings](./assets/screenshots/models.png) -![报表概览:统计卡片、Top 账号 / 提供商、模型与提供商用量环形图](./assets/screenshots/report-overview.png) +### Report -趋势:24 小时**缓存命中率**(折线)与 **Token 消耗**(面积)双轴同图,底部按天 Token 柱状图: +Overview: requests / success rate / tokens / top model, with account and provider rankings, usage donuts and a 365-day activity heatmap: -![报表趋势:缓存命中率与 Token 消耗双轴趋势、按天 Token 柱状图](./assets/screenshots/report-trends.png) +![Report overview: stat cards, top accounts / providers, model and provider usage donuts](./assets/screenshots/report-overview.png) -### 签到中心 +Trends: 24-hour **cache hit rate** (line) and **token consumption** (area) on twin axes, with a per-day token bar chart below: -![签到中心页:签到总览与每日签到分组](./assets/screenshots/checkin-center.png) +![Report trends: dual-axis cache hit rate and token consumption, per-day token bars](./assets/screenshots/report-trends.png) -## 🔧 开发与构建 +## 🔧 Development & Build -### 环境要求 +### Requirements -- Rust >= 1.77 与 Tauri 2 工具链(编译桌面端本体;Windows 上还需 WebView2 运行时) -- Node.js >= 18.17(只用于 `npm run tauri:*` 与前端构建脚本,桌面端运行时不依赖 Node) +- Rust >= 1.77 and the Tauri 2 toolchain (to compile the desktop app itself; Windows also needs the WebView2 runtime) +- Node.js >= 18.17 (only to run `npm run tauri:*` and frontend build scripts; the desktop app does not depend on Node at runtime) +- Target platforms: Windows / macOS / Linux desktops, headless Windows / Linux servers, and a Docker server -### 常用脚本 +### Common scripts ```bash -npm run tauri:install # 安装桌面端依赖(等价 npm --prefix desktop-tauri install) -npm run tauri:dev # 开发模式调起桌面端(自动热重载) -npm run tauri:build # 构建桌面端安装包 +npm run tauri:install # Install desktop dependencies (same as npm --prefix desktop-tauri install) +npm run tauri:dev # Launch the desktop app in dev mode (with hot reload) +npm run tauri:build # Build the desktop installer -npm run build:icon # 生成图标源图(改图标设计后执行,再跑 tauri icon) +npm run build:icon # Generate the icon source image (run after changing the icon design, then run tauri icon) ``` -打包产物为 `target/release/bundle/nsis/Agent2API_<版本>_x64-setup.exe`(当前约 3.0 MB;`src-tauri/.cargo/config.toml` 把 cargo 的 `target-dir` 指到了项目根的 `target/`)。 - -## 📜 使用声明 - -### 仅供学习与交流 - -本项目是一个用于学习 HTTP 反向代理、SSE 流式透传、多上游协议适配与桌面端打包(Tauri)等技术主题的实践项目,**仅供个人学习与研究使用**。它不是官方产品,与腾讯公司及 WorkBuddy / CodeBuddy、美团及 CatPaw、商汤及小浣熊、智谱及 AutoClaw / autoglm、阿里巴巴及 Qoder / Accio、华为云及 CodeArts / OfficeAce、字节跳动及 Trae、科大讯飞及 Loomy、百度文库及 KukuAI、长亭科技及 MonkeyCode、Command Code、Google 及 Antigravity 均无任何关联,未获得其授权、认可或赞助。 - -### 关于反向代理行为 - -本项目实现的是本地反向代理:在你自己的机器上复用你自己账号的登录态,把请求转发到官方上游网关。它不破解、不绕过任何付费或权限校验,使用的额度始终来自你自己账号本就拥有的配额。但需要明确的是,这种「以非官方客户端形态复用登录态」的转发方式,**可能不符合上游服务的用户协议或使用条款**;是否使用、由此产生的一切后果(包括但不限于账号被限流、被风控、被冻结或封禁),均由使用者自行承担。 - -### 禁止用途 +The build artifact is `target/release/bundle/nsis/Agent2API__x64-setup.exe` (about 8.6 MB as of 3.0; `src-tauri/.cargo/config.toml` points cargo's `target-dir` at the project root's `target/`). -禁止将本项目用于任何商业用途、二次分发牟利、批量账号运营、绕过上游计费或配额限制、或其他违反当地法律法规的活动。如需在生产环境或商业场景中调用相关模型服务,请使用官方渠道与官方 API。 +The macOS `.dmg` **can only be built on macOS** (`npm --prefix desktop-tauri run build -- --target universal-apple-darwin`) — it cannot be cross-compiled from Windows, so the official artifact comes from the CI `build` workflow (`macos-latest`, two targets merged). Docker images and the server archives used for in-container updates come from the same workflow's `linux-server` job, which reuses the Dockerfile's compile path so the executable inside is built from the same toolchain as the image. -### 凭证与数据风险 +## 📜 Usage Notice -本项目会把账号凭证(`accessToken` / `refreshToken` 等)以**明文**形式保存在本机配置目录(默认 `~/.agent2api/`)中,导出功能生成的文件同样包含明文凭证。请自行妥善保管,切勿提交到公开仓库、上传到网盘或分享给他人。因凭证泄露造成的损失由使用者自行承担。 +### Credential and data security -### 无担保与权利通知 +Account credentials are encrypted in the local database, but exports, backups and key files may still contain sensitive data. Keep them private; do not upload them to public repositories or cloud storage, or share them with others. -本项目按「现状」提供,作者不对其可用性、稳定性、安全性或对特定用途的适用性作任何承诺。上游接口随时可能变更,本项目可能随时失效而不再维护。完整条款见 [LICENSE](./LICENSE)。本项目中的接口形态、协议字段等信息来自对各官方客户端公开网络通信的观察与整理,相关商标与服务的权利归其各自所有者;若权利方认为本项目存在不当之处,请联系作者,将及时调整或删除。 +### No warranty -## 📄 许可 +The project is provided "as is", without guarantees of availability, stability, security or suitability for a particular purpose. Trademarks and services belong to their respective owners. The complete license and warranty terms are in [LICENSE](./LICENSE). -本项目基于 [MIT License](./LICENSE),可自由使用、修改与分发,须保留版权声明。 +## 📄 License -需要留意的是:LICENSE 正文之后附有一份**使用声明**,其中第 3 条在 MIT 之上**追加了限制**(禁止商业用途、禁止二次分发牟利、禁止批量账号运营)。因此本项目**不是**纯粹的 MIT 项目——**MIT 条款与使用声明共同构成完整的授权与使用约定**,两者对同一行为给出不同结论时以更严格的一方为准。这也是 `Cargo.toml` 用 `license-file` 指向 LICENSE、而不声明 SPDX `"MIT"` 的原因。 +This project uses the same [GNU Affero General Public License v3.0 (AGPL-3.0)](./LICENSE) as the provider plugin repository. Use, modification and distribution, including commercial use, are permitted under the license. Preserve copyright and license notices and fulfill applicable source-code obligations. If you offer a modified version over a network, you must offer its Corresponding Source to those users. ## ⭐ Star History - + - - - Star History Chart + + + Star History Chart
-**[下载最新版](https://github.com/aimod-cc/agent2api/releases/latest)** • **[问题反馈](https://github.com/aimod-cc/agent2api/issues)** +**[Download the latest release](https://github.com/aimod-cc/Agent2API/releases/latest)** • **[Report an issue](https://github.com/aimod-cc/Agent2API/issues)** -如果这个项目对你有帮助,欢迎点一个 ⭐️ Star! +If this project helps you, a ⭐️ Star is appreciated!
diff --git a/agent.md b/agent.md deleted file mode 100644 index 13ce1ad0d..000000000 --- a/agent.md +++ /dev/null @@ -1,211 +0,0 @@ -# AGENT.md - -> 本文档面向维护者与 AI 代理:Agent2API(workbuddy)桌面端 + Docker 镜像的**标准发版流程**。 -> 所有发布动作都由 `.github/workflows/` 下的两个工作流自动完成,人工只负责「提交、打 tag、挂 GitHub Release、验收」。 -> beta 版(预发布)是同一套流程的变体,完整步骤见第 4 节末尾。 - -## 0. 两条人工红线(AI 代理必须停下等确认) - -这两件事**必须由人先审过、明确点头**,代理才能继续执行。代理可以准备草稿、可以列依据,但不能自己拍板往下走: - -1. **发版**:进入发版流程(改版本号、建发布提交、推 main / beta、打 tag、挂 Release)之前,先把版本号与「这次要发什么」讲清楚,等用户确认。**未经确认不得推任何分支或 tag** —— tag 一旦推出去就会触发两个工作流、产出公开的安装包与镜像,收不回来。 -2. **提交日志**:写好的更新日志(发布提交的提交信息,同时也是 GitHub Release 正文)必须先给用户逐条过目,等明确确认后才能提交。用户可以要求改措辞、增删条目、调整分类前缀,代理按反馈改完要**再确认一次**,不能因为「改得不多」就直接提交。 - -两条红线同时适用:一次发版里既要有版本号的确认,也要有日志的确认;用户说「继续」之后才动手。 - -## 1. 分支约定:改动一律先落 beta,发版才动 main - -本仓库有两条长期分支,**日常动作只碰 `beta`,`main` 只在发版时前进**: - -| 分支 | 角色 | 什么时候动它 | -|---|---|---| -| `beta` | 集成分支(默认落脚点) | 日常修改、合并 PR、解冲突 —— 一律先到这里 | -| `main` | 发版分支 | 只在构建发版时:切到 main、把 beta 的改动合进来、打 tag | - -- **合并 PR 时 base 选 `beta`**,不要选 `main`。PR 直接合进 `main`,等于把还没发布的改动提前塞进发版分支;之后还得反过来把 `main` 合回 `beta`,平白多一次合并提交、多一次冲突机会。已经把 PR 开成 base=main 的,先在 PR 页面上把 base 改成 `beta` 再合。 -- **本地改动也提交到 `beta`**(写代码、改文档、解冲突都算),推 `origin/beta`;合并结果的核对、构建自检都在 beta 上做完。 -- **只有正式发版才切到 `main`**:`git switch main` → 把 beta 合进来 → 按第 6、7 节写发布提交、推 main、打 tag;tag 必须落在 `main` 上。发完版后 beta 再把 main 合回来,两边保持一致。(beta 版是这条规则唯一的例外:不切 `main`,tag 落在 `beta` 分支自己身上 —— 见第 4 节末尾。) -- **代理不要自作主张选落脚分支**:用户没说要发版,就不要往 `main` 提交 / 推送,也不要把 PR 的 base 改成 `main`。 - -## 2. 合并 PR:保留原作者署名 - -**合并贡献者的 PR 时,提交作者(author)必须还是贡献者本人。** 这直接决定对方能不能进仓库首页的 Contributors 名单、贡献图里有没有这一笔 —— 署名是贡献者应得的东西,不该因为合并方式丢掉。 - -GitHub 的归属规则只有一条:**提交头里的 author 邮箱,出现在某个账号已验证的邮箱列表里,这次提交就算那个账号的**;否则记成匿名条目,不进 Contributors。补充两条口径:统计只认默认分支(本仓库 `main`),合并提交与空提交不计入。 - -- **用 GitHub 网页上的合并按钮**(`Merge pull request` / `Squash and merge` / `Rebase and merge`):三种都会保留原作者为 commit 的 author;squash 时 PR 里的多位作者会一并进 co-author 名单。这是默认做法。 -- **禁止「本地摘取重提」**:把对方的改动拷过来自己提交(本地 `git merge --squash` 后自己 commit、重抄一遍改动、或改了 author 再提交)都不行 —— 这样 author 是合并者,贡献者的署名在 GitHub 侧完全不可见。本仓库历史上出现过这种情况,别再来一次。 -- **确实要在本地处理时**(有冲突要解、要顺带调整),必须把署名带上:单个原作者用 `git commit --author="原作者 <原作者邮箱>"`;一个提交里有多个人的工作,在提交信息末尾加 trailer `Co-authored-by: 名字 <邮箱>`(GitHub 会把 co-author 一并计入贡献者)。 -- **AI 不计入贡献者署名(特例)**:贡献者提交信息末尾的 `Co-Authored-By: Claude Code <…>` 这类 AI trailer,合并时一律摘除 —— GitHub 会把它渲染成共同作者,进了 `main` 就出现在 Contributors 名单里,而 AI 不是贡献者。摘除只删 trailer 行本身,author 与其余提交信息逐字保留;正文里对 AI 产品的**技术性提及**(「支持 Claude Code 客户端」「Claude 兼容端点」之类)与此无关,不动。摘除动的是已合入分支的历史,趁合并后、发版前尽早做(`git filter-branch --msg-filter` 摘行后 force-push),拖到发版后就得重写 `main`。 -- **邮箱必须能对上账号**:用对方 GitHub 账号里**已验证**的地址;对方想保密就用 GitHub 给的 `ID+用户名@users.noreply.github.com`(在对方账号的 Emails 设置里查)。内网地址、`user@机器名` 这类邮箱永远关联不上账号,提交会被记成匿名。 - -合并后顺手核对一次:`git log --format='%an <%ae>' -1 <合并提交>`,或在 PR 页面看 commit 旁的头像是否指向原作者。 - -## 3. 回复 Issue / PR:标明 AI 来源 - -**由 AI 代理发出(含代笔)的每一条 Issue / PR 回复,末尾都要单独一行写明来源。** 收件人有权知道自己在跟谁说话 —— 这与第 2 节的署名是同一条道理:**谁说的写谁**,看得见落款才谈得上信任与追责。 - -固定落款,逐字照用(别改写成「机器人」「助手」这类含糊说法): - -> 🤖 本条回复由 AI Agent 生成 - -- **位置**:整条回复的最后一行,单独成段(前后各留一个空行);不要塞进正文段落、列表项、引用块或代码块里 —— 引用别人的话时,落款落在自己的话之后。 -- **英文场合**(外部贡献者的 PR / Issue)在中文那行下面再补一行 `Generated by an AI agent.`:说的是同一件事,两边都不省。 -- **代理自己新建的 Issue / PR**,正文末尾同样加。 -- **代笔也算**:人在对话里让代理转达(哪怕逐字)一段话,落款照加 —— 它描述的是「这条回复是谁发出去的」,不是「谁构思的」。 -- 只约束 Issue / PR **回复**(评论、review 意见、关闭 / 合并说明)。提交信息不追加这一行,署名口径见第 2 节。 - -## 4. 版本号约定 - -- 版本号形如 `X.Y.Z`(如 `2.7.2`),日常小版本「递增 0.01」= 末位 +1(如 `2.7.8` → `2.7.9`)。 -- **递增幅度以用户要求为准**:默认用上面的「递增 0.01」;用户另有要求时(如「这次递增 0.1」)一律听用户的,按用户指定的幅度算出新版本号,不要自作主张套用默认幅度。 -- **版本号要同步改 5 处**(缺一处会导致安装包与显示版本对不上): - 1. `package.json`(根) - 2. `desktop-tauri/package.json` - 3. `desktop-tauri/src-tauri/Cargo.toml` - 4. `desktop-tauri/src-tauri/tauri.conf.json` - 5. `desktop-tauri/src-tauri/server/Cargo.toml` -- 应用内「关于」与更新检查读的是 `env!("CARGO_PKG_VERSION")`(第 5 处),改完跑一次 `cargo check` 让 `Cargo.lock` 跟着刷新。 - -### beta 版(预发布):怎么发 - -应用里有「设置 → 软件更新 → 更新设置 → **加入 beta 版**」开关:**打开的人才收到 beta -更新,没打开的人只看正式发布。** beta 版不是另一条流水线,而是上面这套流程的一个变体 —— -唯一的结构差别是「**从 `beta` 分支打带预发布后缀的 tag**」,提交、tag、Release、验收逐字照旧。 - -第 0 节的两条红线同样适用:版本号与更新日志先给用户过目、确认了再动手。 - -```bash -# 1. 在 beta 分支上动手(beta 版不碰 main) -git switch beta && git pull -git status --short # 必须为空,有改动先提交或清理 - -# 2. 五处版本号一起改成「与 tag 逐字一致」的形态: -# tag 打 v3.1.0-beta.1 → 五处都写 3.1.0-beta.1(清单见上面) -cargo check # 顺带刷新 Cargo.lock - -# 3. 提交并推 beta 分支(提交信息 = 这次的 beta 更新日志,格式同第 6 节) -git commit -am "…beta 版日志…" -git push origin beta - -# 4. 打 tag 并推:tag 落在 **beta 分支** 的提交上(只有正式版才要求落 main) -git tag v3.1.0-beta.1 -git push origin v3.1.0-beta.1 - -# 5. 两个工作流跑完后挂 Release(脚本按 tag 里的 `-` 自动标 pre-release) -gh run list --limit 4 # 手动跑 gh 前先设代理,见第 10 节 -bash scripts/release.sh v3.1.0-beta.1 -``` - -验收就三条:Release 显示 **Prerelease**、exe / dmg 附件齐全、Docker Hub 上 `:latest` 的 -Pushed 时间**没有**跟着变(完整清单见第 9 节)。 - -**三处不能错**,每处错了都有明确症状: - -1. **五处版本号漏了后缀**(写成 `3.1.0`):比较是按 semver 做的,运行版本 `3.1.0` 对线上 - tag `3.1.0-beta.1` 会判成「当前比线上新」,**beta 用户永远收不到更新**。五个文件都接受 - 带后缀的版本号(Cargo / npm / tauri.conf.json 都认),照 tag 抄即可。 -2. **Release 漏标 pre-release**:标记就是开关的分界线 —— 正式通道走 GitHub 的 - `/releases/latest`,它排除预发布,只有开了开关的人从 `/releases` 列表里看到 beta。 - **漏标 = beta 被所有正式版用户当成正式更新收到。** `scripts/release.sh` 已按「tag 里带 - `-`」自动标记,重跑收尾时也会把标记纠正回来,所以这一条只要走脚本就不用操心。 -3. **tag 打在 `main` 上**:beta 是集成分支,beta 版要的是它的快照。打在 `main` 上要么不含 - beta 分支的新改动(白测一场),要么等于把还没发布的改动提前推进 `main` —— 后者正是第 1 - 节明确不做的事。 - -docker 的 `:latest` 不会被 beta tag 移动(`.github/workflows/docker.yml`),镜像仍出 -`3.1.0-beta.1` 那个版本 tag,想尝鲜的按版本号拉。 - -后续常见三件事: - -- **再发一个 beta**:核心号不变、递增序号(`3.1.0-beta.2`,五处一起改,上面那套命令照走)。 - 预发布序号能分出先后(`beta.2` > `beta.1`),已经在 beta 上的人照样收到更新 —— 所以 beta - 之间也要保持「tag 与版本号逐字一致」。 -- **转正**(发 `3.1.0` 正式版):按第 6、7 节在 `main` 上走,五处版本号去掉后缀。 - **beta 用户不会自动接回正式版**:开关开着的人只看预发布版(`pick_listed_release` - 按「当前跑的是不是 beta 版」筛),转正的 `3.1.0` 对他们不算更新 —— 要留在 beta 频道的人 - 等下一个 beta(`3.2.0-beta.1` 一出现就收得到);想回正式版由用户自己关开关,关掉后 - 只在**版本号高于他手上那个 beta** 的正式版发布时才收到更新(`3.1.0` > `3.1.0-beta.1`, - 但更早的 `3.0.9` 不会把他降级)。 -- **「打开了却没有反应」**:仓库里一个预发布 Release 都没有时,打开这个开关也只会看到 - 正式版 —— 第一次 beta 发出去之后才有效果(跑正式版的人打开开关时仍是「预发布一并 - 参与、取最高」,只是没有 beta 可拿)。 - -## 5. 提交:保证工作区干净 - -发版前必须把工作区收干净,tag 里的代码就是发出去的代码: - -```bash -git status --short # 必须为空;有改动就先提交或清理 -cargo check # 或前端有改动时做一次构建自检 -``` - -## 6. 发布提交:更新日志写进提交信息 - -**发布提交的提交信息 = 更新日志**。原因:发版收尾脚本创建 / 更新 GitHub Release 时,正文取「tag 所指提交的提交信息」(`git log -1 --format=%B`)。 - -- 格式沿用历史版本的编号清单,每行一条,带分类前缀:`新增:` / `优化:` / `修复:` / `更新:` / `移除:`,最后一条固定 `版本:X.Y.Z → X.Y.Z+1`; -- 内容取「上个 tag 以来的全部提交」综合整理(`git log vX.Y.Z..HEAD --oneline`),纯 CI/临时的调试提交归并成一条流程性描述即可; -- 本次没有代码改动时用空提交承载:`git commit --allow-empty -F 更新日志.txt`(历史发版两种做法都有先例)。 -- 更新日志草稿与历史发布说明统一写在 `docs/release-notes/<版本号>.md`(如 `docs/release-notes/2.9.2.md`),不要在项目根目录新建 `.release-notes-*.md` —— `docs/release-notes/` 已在忽略列表里(只忽略这个目录,`docs/` 下的其它文档正常入库),新增文件不必再单独加忽略规则。 - -## 7. 打 tag 并推送:一条 tag 触发全部构建 - -```bash -git push origin main -git tag vX.Y.Z -git push origin vX.Y.Z -``` - -(beta 版同法,只是 tag 带预发布后缀、落在 `beta` 分支的提交上:`git tag vX.Y.Z-beta.N` -→ `git push origin vX.Y.Z-beta.N`,见第 4 节末尾。) - -推 `v*` tag 会**同时触发两个工作流**(`.github/workflows/`): - -| 工作流 | 产出 | 说明 | -|---|---|---| -| `build.yml`(build) | Windows NSIS 安装包 + macOS universal dmg | `macos` / `windows` 两个 job 构建并上传 artifact(macOS 包**只能在 CI 构建**,无法从 Windows 交叉编译);安装包只挂 artifact,GitHub Release 由本地脚本挂载(见第 8 节) | -| `docker.yml`(docker) | Docker Hub `aimodcc/agent2api:<版本>` + `:latest`(amd64 / arm64 双架构) | 手动 `workflow_dispatch` 触发时只出 `:dev` 测试 tag,不碰正式 tag;beta tag(版本号带 `-`)只出 `<版本>`,**不移动 `:latest`** | - -跟踪进度(手动跑 gh 前要先设代理,见第 10 节): - -```bash -gh run list --limit 4 # 确认工作流都已触发 -gh run watch --exit-status -``` - -## 8. 发版收尾:挂 GitHub Release(本地脚本一条命令) - -GitHub Release **不由 CI 发布**(Release 本来就不会自动创建),由本地脚本 -一步挂载: - -```bash -bash scripts/release.sh vX.Y.Z # 自动找该 tag 的成功 build run -bash scripts/release.sh vX.Y.Z # 或显式指定 run -``` - -脚本做三件事:下载 build 的两个安装包 artifact 到 dist/ → 按 tag 提交信息 -(= 更新日志,见第 6 节)创建 / 更新 GitHub Release 并挂附件 → 打印验收 -提示。幂等可重跑(`--clobber` 覆盖附件)。 - -**beta tag(`v3.1.0-beta.1`)不用改任何参数**:脚本按「tag 里带 `-`」自动把 -Release 标成 pre-release(重跑时也会把标记纠正回来),验收提示里会写明这一点 —— -标记是应用内「加入 beta 版」开关的分界线(完整步骤见第 4 节末尾)。 - -## 9. 验收清单(三处核对) - -- [ ] GitHub Release:`gh release view vX.Y.Z`(手动跑 gh 前先设代理,见第 10 节)—— 正文日志齐全,exe / dmg 两个附件都在; -- [ ] Docker Hub:`aimodcc/agent2api` 的 Tags 页出现 `<版本>` 与 `latest`,Pushed 时间一致; -- [ ] 安装包「关于」页版本号与 tag 一致; -- [ ] 只有 beta 版多一条:Release 显示 **Prerelease**,且 Docker Hub 上 `latest` 没有跟着动(见第 4 节末尾的 beta 版流程)。 - -## 10. 已知坑与排查 - -- **GHCR 新包默认私有**:若以后镜像改推 GHCR,首次推送后需到包设置手动改 Public(当前推的是 Docker Hub,无此问题)。 -- **gh 不读 git 的代理配置,跑 gh 前要先设代理环境变量**:`gh` 是 Go 程序,不读 `~/.gitconfig` 里的 `http(s).proxy`,也不读 Windows 系统代理(WinINET),只认 `HTTPS_PROXY` / `HTTP_PROXY` 环境变量。本机 GitHub 直连时通时不通,手动执行 gh 命令前先设: - - ```bash - export HTTPS_PROXY=$(git config --get-urlmatch http.proxy https://github.com || git config --get https.proxy) - ``` - - 只设 `HTTPS_PROXY` 就够(gh 的请求全是 HTTPS,实测不读 `HTTP_PROXY`)。第 8 节的 `scripts/release.sh` 已内置这一步(从 git 配置读取后透传给 gh),用脚本发版无需手动设。代理没开时 gh 会立刻报 `proxyconnect tcp: ... connection refused` 而不回退直连 —— 与 `git push` 的表现一致。 diff --git a/assets/alipay-receive.jpg b/assets/alipay-receive.jpg new file mode 100644 index 000000000..f8b8b9644 Binary files /dev/null and b/assets/alipay-receive.jpg differ diff --git a/build/make-icon.mjs b/build/make-icon.mjs index b942693e2..03530baad 100644 --- a/build/make-icon.mjs +++ b/build/make-icon.mjs @@ -124,50 +124,91 @@ function shapeAlpha(nx, ny) { // ─── 光栅化(3×3 超采样)──────────────────────────────────── const SS = 3; -const pixels = Buffer.alloc(SIZE * SIZE * 4); /** 线性插值:目前只用于把纯白箭头按覆盖率混到纯色底上 */ function lerp(a, b, t) { return a + (b - a) * t; } -for (let y = 0; y < SIZE; y++) { - for (let x = 0; x < SIZE; x++) { - let rectHits = 0; - let arrowHits = 0; - for (let sy = 0; sy < SS; sy++) { - for (let sx = 0; sx < SS; sx++) { - const px = (x + (sx + 0.5) / SS) / SIZE - 0.5; - const py = (y + (sy + 0.5) / SS) / SIZE - 0.5; - const { rect, arrow } = shapeAlpha(px, py); - if (rect <= 0) rectHits++; - if (arrow <= 0) arrowHits++; +/** + * 把一块画布光栅化成 RGBA 像素。 + * + * `shape` 收归一化坐标([-0.5, 0.5]),返回 `{ rect, arrow }` 两个 SDF 值。 + * 分成「底」与「箭头」两层是为了让两者各自带抗锯齿覆盖率 —— 箭头压在底上, + * 边缘不能直接取整。 + */ +function rasterize(size, shape) { + const pixels = Buffer.alloc(size * size * 4); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + let rectHits = 0; + let arrowHits = 0; + for (let sy = 0; sy < SS; sy++) { + for (let sx = 0; sx < SS; sx++) { + const px = (x + (sx + 0.5) / SS) / size - 0.5; + const py = (y + (sy + 0.5) / SS) / size - 0.5; + const { rect, arrow } = shape(px, py); + if (rect <= 0) rectHits++; + if (arrow <= 0) arrowHits++; + } } + const total = SS * SS; + const rectA = rectHits / total; + const arrowA = arrowHits / total; + if (rectA === 0) continue; + + // 底色改为纯苹果蓝 #007AFF(与 iOS/macOS 系统蓝同族的观感),不再做对角渐变, + // 这样图标在浅色/深色壁纸和 Dock 里都保持同一个可辨识的品牌蓝。 + let r = 0x00; + let g = 0x7a; + let b = 0xff; + // 箭头为纯白,按覆盖率与底色混合 + if (arrowA > 0) { + r = lerp(r, 255, arrowA); + g = lerp(g, 255, arrowA); + b = lerp(b, 255, arrowA); + } + const i = (y * size + x) * 4; + pixels[i] = Math.round(r); + pixels[i + 1] = Math.round(g); + pixels[i + 2] = Math.round(b); + pixels[i + 3] = Math.round(rectA * 255); } - const total = SS * SS; - const rectA = rectHits / total; - const arrowA = arrowHits / total; - if (rectA === 0) continue; - - // 底色改为纯苹果蓝 #007AFF(与 iOS/macOS 系统蓝同族的观感),不再做对角渐变, - // 这样图标在浅色/深色壁纸和 Dock 里都保持同一个可辨识的品牌蓝。 - let r = 0x00; - let g = 0x7a; - let b = 0xff; - // 箭头为纯白,按覆盖率与底色混合 - if (arrowA > 0) { - r = lerp(r, 255, arrowA); - g = lerp(g, 255, arrowA); - b = lerp(b, 255, arrowA); - } - const i = (y * SIZE + x) * 4; - pixels[i] = Math.round(r); - pixels[i + 1] = Math.round(g); - pixels[i + 2] = Math.round(b); - pixels[i + 3] = Math.round(rectA * 255); } + return pixels; } +const pixels = rasterize(SIZE, shapeAlpha); + mkdirSync(dirname(OUT), { recursive: true }); writeFileSync(OUT, encodePng(SIZE, SIZE, pixels)); console.log(`[icon] 已生成 ${OUT}`); + +// ─── PWA maskable 图标 ────────────────────────────────────── +// +// 「maskable」是 Android 自适应图标用的那一档:系统会**按自己的形状裁剪** +// (圆形、方形、水滴…),并可能把图形放大到 1.33 倍。规范要求关键内容落在 +// 中心 80% 直径的圆内,否则会被裁掉。 +// +// 所以这一版与 source.png 有两处必须不同,不能直接复用同一张图: +// ① **满铺、不透明、无圆角** —— 带透明圆角的图被裁成圆形后,四角会露出 +// 底色(黑边),且规范明确要求 maskable 图标不能有透明像素; +// ② **箭头缩到 60%** —— 留出被裁剪的余量,放大 1.33 倍后仍完整。 +// +// 输出直接落到 `ui/assets/`(手机端静态资源目录),不进 tauri icons —— +// 那一套是桌面端安装包用的,与 PWA 无关。 +const MASKABLE_OUT = join(HERE, '..', 'desktop-tauri', 'ui', 'assets', 'app-icon-maskable-512.png'); +const MASKABLE_SIZE = 512; +/** 箭头缩放:0.6 时箭头横向跨度约 0.34,落在 80% 安全圆内绰绰有余 */ +const MASKABLE_SCALE = 0.6; + +const maskablePixels = rasterize(MASKABLE_SIZE, (nx, ny) => { + // 底:满铺整块画布(SDF 恒 <0),于是没有透明像素、也没有圆角 + const rect = -1; + // 箭头:坐标放大 = 图形缩小,居中不变 + const { arrow } = shapeAlpha(nx / MASKABLE_SCALE, ny / MASKABLE_SCALE); + return { rect, arrow }; +}); + +writeFileSync(MASKABLE_OUT, encodePng(MASKABLE_SIZE, MASKABLE_SIZE, maskablePixels)); +console.log(`[icon] 已生成 ${MASKABLE_OUT}`); diff --git a/desktop-tauri/i18n/build.mjs b/desktop-tauri/i18n/build.mjs index 359a418f8..4b440a66e 100644 --- a/desktop-tauri/i18n/build.mjs +++ b/desktop-tauri/i18n/build.mjs @@ -40,6 +40,8 @@ const OUT_DIR = path.join(DESKTOP, 'ui', 'i18n') /** 需要产出词典的语言(简体不需要:查不到就显示键本身,即简体原文) */ const OUTPUT_LOCALES = ['en', 'zh-Hant', 'ja', 'ko', 'pt-BR'] +const ONLY_DOMAIN = process.argv.find(arg => arg.startsWith('--domain='))?.slice('--domain='.length) +if (ONLY_DOMAIN && !DOMAIN_ORDER.includes(ONLY_DOMAIN)) throw new Error(`Unknown i18n domain: ${ONLY_DOMAIN}`) /* ─── 小工具 ───────────────────────────────── */ @@ -152,15 +154,19 @@ async function main() { const toHant = makeHantGenerator(converter, glossary) const { keys, values } = loadSources() - const produced = DOMAIN_ORDER.filter(domain => (keys.get(domain) || []).length > 0) + const produced = DOMAIN_ORDER.filter(domain => (!ONLY_DOMAIN || domain === ONLY_DOMAIN) && (keys.get(domain) || []).length > 0) // 先全部生成到内存并自校验,再落盘 —— 不写半套坏词典 const outputs = [] - outputs.push({ file: path.join(OUT_DIR, 'manifest.js'), content: manifestContent() }) + if (!ONLY_DOMAIN) outputs.push({ file: path.join(OUT_DIR, 'manifest.js'), content: manifestContent() }) let hantGenerated = 0 let hantManual = 0 for (const locale of OUTPUT_LOCALES) { + // 主面板走合并词典(一个文件装下该语言的全部域):原来 head 里要写 22 个 + // + + + + + +
+ + + + diff --git a/desktop-tauri/mobile/capacitor.config.ts b/desktop-tauri/mobile/capacitor.config.ts new file mode 100644 index 000000000..b9bfaccb8 --- /dev/null +++ b/desktop-tauri/mobile/capacitor.config.ts @@ -0,0 +1,74 @@ +import type { CapacitorConfig } from '@capacitor/cli' + +/** + * Capacitor 配置(Android APK + iOS IPA 共用一份 Web 产物)。 + * + * ── 为什么 androidScheme 是 https,而 cleartext 打开 ────────── + * 这两个不矛盾,各管一件事: + * · `androidScheme: 'https'` 决定**应用自己页面**的 origin(`https://localhost`)。 + * 它必须是安全上下文,否则 `crypto.subtle` 不可用 —— 登录页的人机验证 + * 要用它算 SHA-256。改成 http 会让验证退回纯 JS 实现,慢且没必要。 + * · `cleartext: true` + manifest 里的 `usesCleartextTraffic` 决定**能不能 + * 访问 http:// 的网关**。局域网网关都是 http(没有证书),不开这个 + * 所有请求会被 Android 9+ 直接拦掉。 + * + * 页面是 https、请求是 http —— 这是 mixed content,WebView 默认禁止。 + * 所以还要 `allowMixedContent: true`。它被官方标注「不用于生产」,但这个 + * 应用的场景就是「访问局域网里的 http 服务」,没有别的选择;且页面内容是 + * 我们自己打进包里的,不存在被第三方脚本注入的风险面。 + * + * ── iOS 这边为什么不能照抄 androidScheme ────────────────────── + * `iosScheme` 不允许是 `https` / `http` —— 那是 WKWebView 自己处理的协议, + * Capacitor 在 `InstanceDescriptor.normalize()` 里会把它判为非法并**静默退回** + * 默认值 `capacitor`(见 @capacitor/ios 的 CAPInstanceDescriptor.swift:169)。 + * 所以 iOS 的页面 origin 固定是 `capacitor://localhost`。 + * + * 代价是它不算「安全上下文」,`crypto.subtle` 在 iOS 上不可用 —— 登录页的 + * 人机验证会自动走 `src/api/auth.ts` 里的纯 JS SHA-256 兜底(那条路径本来 + * 就是为 http 局域网准备的,只是慢一点,结果一致)。 + * + * ── iOS 的明文 http 网关怎么放行 ────────────────────────────── + * 靠 `ios/App/App/Info.plist` 的 `NSAppTransportSecurity`(ATS)例外,不在 + * 这里配。网关是局域网 IP + http,iOS 17 起 ATS 默认连 IP 地址都拦,所以 + * 那边同时开了 `NSAllowsArbitraryLoads` 与 `NSAllowsLocalNetworking`。 + * + * ── 为什么不用 server.url 指向网关 ──────────────────────────── + * 那种做法把 App 变成一个去掉了地址栏的浏览器:切服务器要重载整个 WebView, + * 登录也还是服务器那份网页,而且离线时连界面都起不来。这里要的是「界面在 + * 包里 + 地址可配 + 多台切换」,所以走打包 + 跨源请求。 + */ +const config: CapacitorConfig = { + appId: 'com.agent2api.mobile', + appName: 'Agent2API', + // 构建产物目录(npm run build:native 的输出,Android / iOS 共用) + webDir: 'android-www', + android: { + // 局域网网关普遍是 http,不开这个请求全被拦 + allowMixedContent: true, + }, + ios: { + // 安全区由页面自己的 env(safe-area-inset-*) 接管:WKWebView 不再额外 + // 加一圈内边距,否则刘海 / 底部横条会被算两次,顶部多出一条空白。 + contentInset: 'never', + // 与 Android 一致:底色交给页面主题,避免深色模式下边缘闪白 + backgroundColor: '#f3f3f6', + }, + server: { + // 页面 origin 用 https://localhost:安全上下文,crypto.subtle 可用 + androidScheme: 'https', + // 允许访问明文 http 的网关 + cleartext: true, + }, + plugins: { + Keyboard: { + // iOS 默认的 native 模式会把整个 WebView 缩短,页面里的 fixed / flex + // 布局会跟着抖一下;body 模式只改 body 高度,配合 visualViewport 的 + // 键盘判断(Shell.tsx 的 useKeyboardOpen)更稳。 + resize: 'body', + // Android 全屏时键盘不会触发 resize,插件需要额外处理 + resizeOnFullScreen: true, + }, + }, +} + +export default config diff --git a/desktop-tauri/mobile/ios/.gitignore b/desktop-tauri/mobile/ios/.gitignore new file mode 100644 index 000000000..f47029973 --- /dev/null +++ b/desktop-tauri/mobile/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/desktop-tauri/mobile/ios/App/App.xcodeproj/project.pbxproj b/desktop-tauri/mobile/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 000000000..4cfc1556f --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,384 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; }; + A17C20D101C940E39C030001 /* ImageSaverPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17C20D101C940E39C030002 /* ImageSaverPlugin.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + A17C20D101C940E39C030002 /* ImageSaverPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaverPlugin.swift; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */, + A17C20D101C940E39C030002 /* ImageSaverPlugin.swift */, + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */, + A17C20D101C940E39C030001 /* ImageSaverPlugin.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.agent2api.mobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.agent2api.mobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/desktop-tauri/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/desktop-tauri/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/desktop-tauri/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/desktop-tauri/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme new file mode 100644 index 000000000..ab40088eb --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop-tauri/mobile/ios/App/App/AppDelegate.swift b/desktop-tauri/mobile/ios/App/App/AppDelegate.swift new file mode 100644 index 000000000..fec95acae --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App/AppDelegate.swift @@ -0,0 +1,44 @@ +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default Configuration", + sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } +} diff --git a/desktop-tauri/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 000000000..6767a5160 Binary files /dev/null and b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/desktop-tauri/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..9b7d382dc --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Contents.json b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 000000000..da4a164c9 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 000000000..d7d96a67c --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 000000000..7c59ff920 Binary files /dev/null and b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 000000000..7c59ff920 Binary files /dev/null and b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 000000000..7c59ff920 Binary files /dev/null and b/desktop-tauri/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/desktop-tauri/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard b/desktop-tauri/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000..e7ae5d780 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop-tauri/mobile/ios/App/App/Base.lproj/Main.storyboard b/desktop-tauri/mobile/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 000000000..ea814b392 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/desktop-tauri/mobile/ios/App/App/ImageSaverPlugin.swift b/desktop-tauri/mobile/ios/App/App/ImageSaverPlugin.swift new file mode 100644 index 000000000..3b1a5b559 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App/ImageSaverPlugin.swift @@ -0,0 +1,59 @@ +import UIKit +import Capacitor + +@objc(ImageSaverPlugin) +public class ImageSaverPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "ImageSaverPlugin" + public let jsName = "ImageSaver" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "save", returnType: CAPPluginReturnPromise) + ] + + @objc func save(_ call: CAPPluginCall) { + guard let encoded = call.getString("data"), !encoded.isEmpty, + encoded.utf8.count <= 40 * 1024 * 1024, + let data = Data(base64Encoded: encoded), + let name = call.getString("name"), + name.range(of: "^image-[a-f0-9]{8}\\.(png|jpg|webp)$", options: .regularExpression) != nil, + let mime = call.getString("mime"), + ["image/png", "image/jpeg", "image/webp"].contains(mime) else { + call.reject("图片参数无效") + return + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let file = directory.appendingPathComponent(name) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: file, options: .atomic) + } catch { + try? FileManager.default.removeItem(at: directory) + call.reject("保存图片失败:\(error.localizedDescription)") + return + } + DispatchQueue.main.async { + guard let controller = self.bridge?.viewController, controller.presentedViewController == nil else { + try? FileManager.default.removeItem(at: directory) + call.reject("当前无法打开图片保存窗口,请关闭其它窗口后重试") + return + } + let sheet = UIActivityViewController(activityItems: [file], applicationActivities: nil) + sheet.completionWithItemsHandler = { _, completed, _, error in + try? FileManager.default.removeItem(at: directory) + if let error = error { call.reject("保存图片失败:\(error.localizedDescription)") } + else { call.resolve(["saved": completed, "canceled": !completed]) } + } + if let popover = sheet.popoverPresentationController { + popover.sourceView = controller.view + popover.sourceRect = CGRect(x: controller.view.bounds.midX, y: controller.view.bounds.midY, width: 1, height: 1) + popover.permittedArrowDirections = [] + } + controller.present(sheet, animated: true) + } + } +} + +class AppBridgeViewController: CAPBridgeViewController { + override func capacitorDidLoad() { + bridge?.registerPluginInstance(ImageSaverPlugin()) + } +} diff --git a/desktop-tauri/mobile/ios/App/App/Info.plist b/desktop-tauri/mobile/ios/App/App/Info.plist new file mode 100644 index 000000000..9805ab981 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App/Info.plist @@ -0,0 +1,118 @@ + + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Agent2API + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + + + NSLocalNetworkUsageDescription + 用于连接你在局域网里运行的 Agent2API 网关。 + NSBonjourServices + + _http._tcp + _https._tcp + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + ITSAppUsesNonExemptEncryption + + + diff --git a/desktop-tauri/mobile/ios/App/App/SceneDelegate.swift b/desktop-tauri/mobile/ios/App/App/SceneDelegate.swift new file mode 100644 index 000000000..02ea17d12 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/App/SceneDelegate.swift @@ -0,0 +1,24 @@ +import UIKit +import Capacitor + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + window?.rootViewController = AppBridgeViewController() + window?.makeKeyAndVisible() + + SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + SceneDelegateProxy.shared.scene(scene, continue: userActivity) + } +} diff --git a/desktop-tauri/mobile/ios/App/CapApp-SPM/.gitignore b/desktop-tauri/mobile/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 000000000..3b2981208 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/desktop-tauri/mobile/ios/App/CapApp-SPM/Package.swift b/desktop-tauri/mobile/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 000000000..1f7742387 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.3"), + .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), + .package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard") + ] + ) + ] +) diff --git a/desktop-tauri/mobile/ios/App/CapApp-SPM/README.md b/desktop-tauri/mobile/ios/App/CapApp-SPM/README.md new file mode 100644 index 000000000..03964db90 --- /dev/null +++ b/desktop-tauri/mobile/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/desktop-tauri/mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/desktop-tauri/mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 000000000..945afec8c --- /dev/null +++ b/desktop-tauri/mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/desktop-tauri/mobile/ios/debug.xcconfig b/desktop-tauri/mobile/ios/debug.xcconfig new file mode 100644 index 000000000..53ce18dea --- /dev/null +++ b/desktop-tauri/mobile/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/desktop-tauri/mobile/package-lock.json b/desktop-tauri/mobile/package-lock.json new file mode 100644 index 000000000..d6564d631 --- /dev/null +++ b/desktop-tauri/mobile/package-lock.json @@ -0,0 +1,3918 @@ +{ + "name": "agent2api-mobile", + "version": "3.4.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agent2api-mobile", + "version": "3.4.2", + "dependencies": { + "@base-ui/react": "^1.8.0", + "@capacitor/android": "^8.5.3", + "@capacitor/app": "^8.1.2", + "@capacitor/core": "^8.5.3", + "@capacitor/ios": "^8.5.3", + "@capacitor/keyboard": "^8.0.6", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@capacitor/cli": "^8.5.3", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.6.3", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.0", + "vite": "^7.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "8.0.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-8.0.7.tgz", + "integrity": "sha512-k0hagDDq/6w4v+0Sp4wFvIz/yswH5O7Va6aRTnD7N62Tnborp4X+YB+H3XUcWVSR4aE47WPEXiIIZchBs0GmYg==", + "license": "MIT" + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.9.0", + "resolved": "https://registry.npmmirror.com/@base-ui/react/-/react-1.9.0.tgz", + "integrity": "sha512-S8CSYosASBKtpL8SWUfS78rFElfLijiCgA7oHOK22jCJ95FULsYyKwBNFOgudt0RA+/7yNc7F81bOvQnRgbBiQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@base-ui/utils": "0.5.0", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.7.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/@base-ui/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.3.0", + "use-sync-external-store": "^1.7.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@capacitor/android": { + "version": "8.5.3", + "resolved": "https://registry.npmmirror.com/@capacitor/android/-/android-8.5.3.tgz", + "integrity": "sha512-ilH4gSSz3DrketMkzDVAvY9JsWJ//rCQnECLTdcFRO7xXDogwAbUvQAnIAbiqefwV/D1xfjEL4Gw1MJXEcaA9g==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.5.0" + } + }, + "node_modules/@capacitor/app": { + "version": "8.1.2", + "resolved": "https://registry.npmmirror.com/@capacitor/app/-/app-8.1.2.tgz", + "integrity": "sha512-uKBlsrXH0IdvUn69e/KKYbZ32A06pcrUdG8LWuZ/6y33YMXZXLEEdNSwl+jKS/SrsTWqnePs8EqSWaiQM3mUGA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.5.3", + "resolved": "https://registry.npmmirror.com/@capacitor/cli/-/cli-8.5.3.tgz", + "integrity": "sha512-/G6xwna+p4GS3lthSdWN3OThCH2F85O0Ukdy8rv3oAXvgEMeyFgPEIA3PMAS++IkHwUuBYBAKOQJnZfcrh1n0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xcode": "^3.0.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@capacitor/core": { + "version": "8.5.3", + "resolved": "https://registry.npmmirror.com/@capacitor/core/-/core-8.5.3.tgz", + "integrity": "sha512-oBE1oGuYE4ZzYYrulC75OuHdSsD6zLUy7d5o1tFwDLG4govGoubY15cxpiI5l0+q87Lm5p0bhbehMdzJg4aFag==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "8.5.3", + "resolved": "https://registry.npmmirror.com/@capacitor/ios/-/ios-8.5.3.tgz", + "integrity": "sha512-HnnV92Jl8+3x0JUloXUMjFcUsQI8wHFVsKwkLn72DfRZXYGZW11tQbmNOWOnRTljaU18FdI1b9ipT09SzzPDNw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.5.0" + } + }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.6", + "resolved": "https://registry.npmmirror.com/@capacitor/keyboard/-/keyboard-8.0.6.tgz", + "integrity": "sha512-Bbe+9V0qaT6dzEFJEK7BUWR01l2+vZE8FM5ITF/WgKtx+G6eGZ6d5+YiSMRJBAK0VGjdO59v8yiQ10Doa5KyvQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmmirror.com/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.9.tgz", + "integrity": "sha512-qJmhckvOleXp8R+BaXiVW/S34/3M7pvN17/QcG90A74BDaSvKv8ffY+GYG+a/xU0vEoITzdMSiM7o9HZSQWbfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.9.tgz", + "integrity": "sha512-dqQgds39BNWGB2ughJ6n2LTQFXk8RAOwnXj6m387OHpygfD93QRS1ODIfpiWyAa9gsFB9l34pSqeLmA0Xk/VBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.9.tgz", + "integrity": "sha512-scrMx4SGikOSbWknLmPD0v6OeJ1Wih3kyRLdNZnlVbBeKVWp1nRTVcpM0IxDJdc3u8UjesPHTmnX649u8cQpBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.9.tgz", + "integrity": "sha512-fkwi2OQR+QWoViRFt08Bsca6G0e9pk/4z+JgmqHq6jSRTT0LtI27KRVK8bu++6L/CBtQ3OZePbqC3bcrwGzTqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.9.tgz", + "integrity": "sha512-8Apr0Khn0r2MUsDtcOMbp85fDdXsX/E+iLhO17WUjG24m3O8ChArqI0isI7CAdj9Ouq177PHtVtEUpMNE1C4Gw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.9.tgz", + "integrity": "sha512-H+upy4DhBcBZlUsCAMyr8wR0Dg9kj8sGloVZdZ7va2heJM/5jnROxI47dtXseJjlD/VoQe6MrXhY3b5ikOq/Vw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.9.tgz", + "integrity": "sha512-b50NS9TbAEK/b16vBoKrkeNwy+HvGBXmtBbzX4wcodWOh9dQZvRTN9rUyElIXa36QnwXhU9PAVVkZpqRzSi2Tg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.9.tgz", + "integrity": "sha512-SdqcudB/FzjLESsRMgXhV+FXGtf6usxXwlMoRW7D/XS3f9CZ4T4wycRBu2h9bVl2AOaPpunoA8ftAM/MF4altQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.9.tgz", + "integrity": "sha512-dg19FFRoovN+KZ+gMHBAq4Do8PgQmBcxpuhux57OuiR0QLavfn8uPBY8CP+UO50bO+xf0tK0GlqI/jld++5E6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.9.tgz", + "integrity": "sha512-+jyv5ofGmpAvtrzLjSr/e4syskeikEp5L82bUzB76auwOgK1VNVwZV/F2g1Pes9bhTznbejOefAqPglHSKYmTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.9.tgz", + "integrity": "sha512-MKFXxqmkzCbtNvx/vjiJdss44eRtVabhCtDWcCFD9NHGwP+YkqnM/slnEsv/3fsoq9ukTtudraAjGhptrUPCVg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.9.tgz", + "integrity": "sha512-lhARcOT6n7Ni9hpQRq8LZjbuoW+G/GXaW4Jfhx2MbmjnCj9XxLMRTceJIMpiMLQVWm+qxe7oq1sYvlIGOAW34Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.9.tgz", + "integrity": "sha512-oORQzGxZ1oHghJwLdwc8Ni0SY9otILAeXsWdiImTF4F5HkWJUwh5s/269PF5tYILNC1z/3RwFsrgcHPguBcb0g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.9.tgz", + "integrity": "sha512-J2JQHQt7oR1uutd2OSwbV2Qpb2LI5gggPolbkUwDe7vn/0dnduZ0w6EIbGnZUAw7uIKaMgfVylcH6V+Aov8kRA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.9.tgz", + "integrity": "sha512-bWaKLrfzeQ9mOzJJxXbPgw2RH6VAaTJrb/60Tqf7YSMxubG8780Hzm7Us+D6qVW8QRGJYl4VUvVXxnRB0o9zvQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.9.tgz", + "integrity": "sha512-TFJyaCB9NsvSyvVGQgTSRD9XgxsAq99ivDDkt9KAySlrNW5Obsd1kQpvoCvBCOLq+JlJTjmtroLH0wp8M8mEsA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.9.tgz", + "integrity": "sha512-DvmOM5xz/wDnUJFVjqP32ahalxn7BDuvO25bpuFOrb50AIFCCcFPN4lWWUDD1c8Ddv9sMO9SXyqtOet8G8u9cQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.9.tgz", + "integrity": "sha512-aFzokHK3yE00mzAvKm/PY76OsOOTjvoTGE82Vp89iBDyD2wUBlV4ZsR2tWkRECTEFk6TC4Yc3zaEWjPqq1vstQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.9.tgz", + "integrity": "sha512-t/yMdeaaQUuNNpmT7taBmH2VvHzgT4YA/IaKVE3qVr4PYQemn/kmJkL4uG4UvTLcWXQd0MSoOa/3URYSWGmUEg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.9.tgz", + "integrity": "sha512-4dnxKQ8GHARyjqAU87q5UWhywZTpIbR2VWhtACdC37GD1SGszmvb3TaBFVA4n+fnDHSKMut4ZJ63hVFwgnxVtg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.9.tgz", + "integrity": "sha512-1qdQj6IrNoR0xFvrCQHmhBXe9HMbBGNTFi+3n4jKMt2rTx4k0aeAdraZU4WDOMfL9tAwS8/TRSuquvNuPQ+Gpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.9.tgz", + "integrity": "sha512-yc1GSEHIYhKGN5WSaJc+eMy8bZvzcvAmflEgFh8L3xcy8dYxGmfrudyBnBFde9f6JzOa1kFqocF8BHaWybjQoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.9.tgz", + "integrity": "sha512-VzHGvosAkLIzrwqvSgJBPDmGFTF/yE8OtMT4H5z8pM1NA0NiPpvTYnUh+9xPd+d5oy/wqF317FmJ1I8Kk8pp0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.9.tgz", + "integrity": "sha512-FB77txy1PiAcJgG72nOLA6R0S3PRZahs8Spx0z2yseDUkoMmJcrQghiebJ2JHZ74cCOVlX/MYd4dUbJtJJ8fow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.9.tgz", + "integrity": "sha512-IoIgQLpK1DbOcQf1vX+i5oe5LXypsCVb1j/mU6BLAbOGmXH4E+j4kMfgZRwPYUohVBurNyVMgIKVM5dR6xA0kA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "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/baseline-browser-mapping": { + "version": "2.11.29", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", + "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.459", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", + "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", + "dev": true, + "license": "ISC" + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmmirror.com/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.59", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.64.9", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.64.9.tgz", + "integrity": "sha512-mnPSQINChN7PpT+a5CI0/+87hm/+6zxSKBK1XonrzQ0zFnglaTjhkTP2Br4yNCTcLHUEvrAfZffH57jGf+8xBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.9", + "@rollup/rollup-android-arm64": "4.64.9", + "@rollup/rollup-darwin-arm64": "4.64.9", + "@rollup/rollup-darwin-x64": "4.64.9", + "@rollup/rollup-freebsd-arm64": "4.64.9", + "@rollup/rollup-freebsd-x64": "4.64.9", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.9", + "@rollup/rollup-linux-arm-musleabihf": "4.64.9", + "@rollup/rollup-linux-arm64-gnu": "4.64.9", + "@rollup/rollup-linux-arm64-musl": "4.64.9", + "@rollup/rollup-linux-loong64-gnu": "4.64.9", + "@rollup/rollup-linux-loong64-musl": "4.64.9", + "@rollup/rollup-linux-ppc64-gnu": "4.64.9", + "@rollup/rollup-linux-ppc64-musl": "4.64.9", + "@rollup/rollup-linux-riscv64-gnu": "4.64.9", + "@rollup/rollup-linux-riscv64-musl": "4.64.9", + "@rollup/rollup-linux-s390x-gnu": "4.64.9", + "@rollup/rollup-linux-x64-gnu": "4.64.9", + "@rollup/rollup-linux-x64-musl": "4.64.9", + "@rollup/rollup-openbsd-x64": "4.64.9", + "@rollup/rollup-openharmony-arm64": "4.64.9", + "@rollup/rollup-win32-arm64-msvc": "4.64.9", + "@rollup/rollup-win32-ia32-msvc": "4.64.9", + "@rollup/rollup-win32-x64-gnu": "4.64.9", + "@rollup/rollup-win32-x64-msvc": "4.64.9", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "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/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/desktop-tauri/mobile/package.json b/desktop-tauri/mobile/package.json new file mode 100644 index 000000000..a70f210f6 --- /dev/null +++ b/desktop-tauri/mobile/package.json @@ -0,0 +1,38 @@ +{ + "name": "agent2api-mobile", + "version": "3.4.2", + "private": true, + "type": "module", + "description": "Agent2API 手机端 —— 响应式 Web / PWA,消费 ui-kit(Base UI + Tailwind v4),产物落 ui/m/", + "scripts": { + "build": "tsc --noEmit && vite build", + "build:native": "tsc --noEmit && vite build --mode apk", + "build:apk": "npm run build:native && cap sync android", + "build:ios": "npm run build:native && cap sync ios", + "watch": "vite build --watch", + "typecheck": "tsc --noEmit", + "dev": "vite", + "apk": "npm run build:apk && cd android && gradlew.bat assembleRelease" + }, + "dependencies": { + "@base-ui/react": "^1.8.0", + "@capacitor/android": "^8.5.3", + "@capacitor/app": "^8.1.2", + "@capacitor/core": "^8.5.3", + "@capacitor/ios": "^8.5.3", + "@capacitor/keyboard": "^8.0.6", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@capacitor/cli": "^8.5.3", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.6.3", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.0", + "vite": "^7.1.0" + } +} diff --git a/desktop-tauri/mobile/public/manifest.webmanifest b/desktop-tauri/mobile/public/manifest.webmanifest new file mode 100644 index 000000000..ba58ffe87 --- /dev/null +++ b/desktop-tauri/mobile/public/manifest.webmanifest @@ -0,0 +1,57 @@ +{ + "id": "/m.html", + "name": "Agent2API 手机端", + "short_name": "Agent2API", + "description": "多提供商本地 AI 网关的手机面板", + "start_url": "/m.html", + "scope": "/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone", "minimal-ui"], + "orientation": "portrait", + "background_color": "#f3f3f6", + "theme_color": "#f3f3f6", + "lang": "zh-CN", + "dir": "ltr", + "categories": ["productivity", "utilities"], + "prefer_related_applications": false, + "icons": [ + { + "src": "/assets/app-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/app-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/app-icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "签到中心", + "short_name": "签到", + "url": "/m.html#checkin", + "icons": [{ "src": "/assets/app-icon-192.png", "sizes": "192x192" }] + }, + { + "name": "账号", + "short_name": "账号", + "url": "/m.html#accounts", + "icons": [{ "src": "/assets/app-icon-192.png", "sizes": "192x192" }] + }, + { + "name": "请求日志", + "short_name": "日志", + "url": "/m.html#requests", + "icons": [{ "src": "/assets/app-icon-192.png", "sizes": "192x192" }] + } + ] +} diff --git a/desktop-tauri/mobile/public/sw.js b/desktop-tauri/mobile/public/sw.js new file mode 100644 index 000000000..b4c1eb6ce --- /dev/null +++ b/desktop-tauri/mobile/public/sw.js @@ -0,0 +1,189 @@ +/* + * Agent2API 手机端 Service Worker。 + * + * ── 为什么放在 `ui/` 根、而不是 `ui/m/` ────────────────────── + * SW 的默认 scope 上限是它自己所在的目录:`ui/m/sw.js` 最多只能控制 + * `/m/*`,而手机端的入口是 `/m.html` —— 它落在 scope 之外,注册成 `/` + * 会被浏览器拒绝(`The path of the provided scope is not under the max + * scope`),注册成 `/m/` 又管不到入口页。放在 `ui/` 根目录就没有这个 + * 矛盾:`/sw.js` 天然可以 scope 到 `/`。 + * + * 构建脚本(vite.config.ts 的 swBuildStamp)把 `mobile/public/sw.js` + * 复制到这里并写入时间戳,所以这个文件是产物 —— 改它请改源文件。 + * + * ── 只拦截手机端,桌面端一字未变 ───────────────────────────── + * scope 是 `/`,但下面的 fetch 处理只认 `/m` 与 `/m/*`,其余请求直接 + * return(浏览器按原样处理)。桌面面板与 `/api/*` 都不受影响。 + * + * ── 三条策略,按资源性质分 ──────────────────────────────────── + * · **入口页**(`/m`、`/m.html`):network-first + 离线回退缓存。 + * 不能 stale-while-revalidate —— 入口页决定加载哪个版本的 JS, + * 先给旧的再在后台更新会让「新 JS + 旧 HTML」错配一轮。 + * · **外壳**(mobile.js / mobile.css / manifest / 图标):stale-while-revalidate。 + * 首屏秒开,后台拉新版留给下次打开。 + * · **数据**(`/api/*`、`/v1/*`):**完全不碰**。网关数据必须实时, + * 缓存一份只会让用户看到过期读数却以为是真的。 + * + * ── 版本 ──────────────────────────────────────────────────── + * SW 的更新判据是文件内容。产物变了但 sw.js 不变时浏览器不会重装 SW, + * 旧缓存会一直生效 —— 时间戳让每次构建都必然更新。 + * 新 SW 装好后不立刻 skipWaiting,而是等页面发消息 —— 否则用户正在 + * 填的表单会被一次自动刷新清掉。页面侧在设置页给「重启以更新」按钮。 + */ + +const CACHE = 'agent2api-mobile-__BUILD__' + +/** 预缓存的外壳文件(相对 SW 所在目录,即 `ui/` 根) */ +const SHELL = [ + './m.html', + './m/mobile.js', + './m/mobile.css', + './m/manifest.webmanifest', + './i18n.js', + './aliyun-captcha.js', + './assets/app-icon-192.png', + './assets/app-icon-512.png', + './assets/app-icon-maskable-512.png', +] + +/** 这个请求属不属于手机端(决定要不要拦) */ +function isMobileAsset(pathname) { + if (pathname === '/m' || pathname === '/m.html') return true + return pathname.startsWith('/m/') || pathname === '/i18n.js' || pathname === '/aliyun-captcha.js' || pathname.startsWith('/i18n/') +} + +/** 是不是入口页导航(`/m` 或 `/m.html`,含 query / hash 之外的路径部分) */ +function isEntryNavigation(request, url) { + if (request.mode !== 'navigate') return false + return url.pathname === '/m' || url.pathname === '/m.html' +} + +/** + * 预缓存一个文件。 + * + * 不用 `cache.add` 是因为它会**跟随重定向**:未登录时 `/m.html` 会 302 到 + * `/login`,`cache.add` 会把那张登录页存成入口页 —— 之后离线打开就是登录页。 + * 这里显式取一次,跟随了重定向的响应直接丢弃(留给下次在线访问时再存)。 + */ +async function precache(cache, item) { + try { + const response = await fetch(item, { cache: 'no-store' }) + if (response.ok && !response.redirected) await cache.put(item, response) + } catch { + /* 单个文件失败不影响其余(比如图标缺失) */ + } +} + +self.addEventListener('install', event => { + event.waitUntil( + caches + .open(CACHE) + // 逐个而不是 addAll:任何一个文件缺失不该让整次预缓存失败 + .then(cache => Promise.all(SHELL.map(item => precache(cache, item)))) + .then(() => { + // 不在这里 skipWaiting:等页面决定何时切换(见 message 分支) + }), + ) +}) + +self.addEventListener('activate', event => { + event.waitUntil( + caches + .keys() + .then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))) + .then(() => self.clients.claim()), + ) +}) + +/** + * 页面发来的消息。 + * + * `SKIP_WAITING`:用户点了「重启以更新」。新 SW 立刻接管,页面随后自己 + * reload 拿到新外壳。 + */ +self.addEventListener('message', event => { + const data = event.data + if (data && data.type === 'SKIP_WAITING') { + void self.skipWaiting() + } +}) + +self.addEventListener('fetch', event => { + const request = event.request + if (request.method !== 'GET') return + + const url = new URL(request.url) + if (url.origin !== self.location.origin) return + // 桌面面板与接口一律不碰 + if (!isMobileAsset(url.pathname)) return + + // 入口页:network-first,离线回退缓存 + if (isEntryNavigation(request, url)) { + event.respondWith( + fetch(request) + .then(response => { + // 只缓存「直接命中」的响应。会话过期时服务器会 302 到 /login, + // fetch 跟随重定向后拿到的是一张 200 的登录页 —— 存进入口页缓存 + // 的话,下次离线打开看到的就是登录页(而离线根本登不了)。 + if (response.ok && !response.redirected) { + const copy = response.clone() + void caches + .open(CACHE) + .then(cache => cache.put('./m.html', copy)) + .catch(() => undefined) + } + return response + }) + .catch(async () => { + const cached = await caches.match('./m.html') + if (cached) return cached + // 连外壳都没有(首次访问就断网):给一句可读的话,别让浏览器报网络错误 + return new Response( + '' + + '' + + '

离线

' + + '

连不上网关。确认手机与网关在同一局域网,然后刷新。

' + + '', + { headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + ) + }), + ) + return + } + + const isShell = SHELL.some(item => url.pathname.endsWith(item.slice(1))) + if (isShell) { + // 外壳:stale-while-revalidate —— 首屏秒开,后台拉新版留给下次 + event.respondWith( + caches.open(CACHE).then(async cache => { + const cached = await cache.match(request) + const network = fetch(request) + .then(response => { + if (response.ok) void cache.put(request, response.clone()) + return response + }) + .catch(() => cached) + return cached || network + }), + ) + return + } + + // 手机端其余资源:network-first,离线时回退缓存 + event.respondWith( + caches.open(CACHE).then(async cache => { + try { + const response = await fetch(request) + if (response.ok && !response.redirected) { + const copy = response.clone() + event.waitUntil(cache.put(request, copy).catch(() => undefined)) + } + return response + } catch { + const cached = await cache.match(request) + if (cached) return cached + throw new Error('offline') + } + }), + ) +}) diff --git a/desktop-tauri/mobile/src/App.tsx b/desktop-tauri/mobile/src/App.tsx new file mode 100644 index 000000000..ed1fbc4dd --- /dev/null +++ b/desktop-tauri/mobile/src/App.tsx @@ -0,0 +1,176 @@ +import React, { useEffect } from 'react' +import { AppFrame } from '@ui' +import { store, useStore, bootstrapTheme } from './state/store' +import { TabBar, Toast, AuthGate, ThemeSync } from './components/Shell' +import { PwaNotice } from './components/PwaNotice' +import { usePwa } from './lib/usePwa' +import { HomePage } from './pages/HomePage' +import { ReportPage } from './pages/ReportPage' +import { AccountsPage } from './pages/AccountsPage' +import { AssistantPage } from './pages/AssistantPage' +import { MorePage } from './pages/MorePage' +import { ModelsPage } from './pages/ModelsPage' +import { KeysPage } from './pages/KeysPage' +import { ProxiesPage } from './pages/ProxiesPage' +import { TasksPage } from './pages/TasksPage' +import { RequestsPage } from './pages/RequestsPage' +import { LogsPage } from './pages/LogsPage' +import { SettingsPage } from './pages/SettingsPage' +import { DocsPage } from './pages/DocsPage' +import { AgentPage } from './pages/AgentPage' +import { ToolsPage } from './pages/ToolsPage' +import { ServersPage } from './pages/ServersPage' +import { CheckinPage } from './pages/CheckinPage' +import { PluginsPage } from './pages/PluginsPage' +import type { ScreenId } from './lib/routes' +import { NavBar } from './components/NavBar' +import { t } from './i18n' +import { RequiredUpdateGate } from './components/RequiredUpdateGate' + +/** + * 路由表。 + * + * 一级页(`primary: true`)显示底部标签栏;二级页显示返回栏。 + * 与桌面端不同:桌面端 13 个一级菜单在手机上收成 5 个标签,其余进 + * 「更多」的宫格 —— 层级只有两层,不再有第三层。 + */ +type ScreenDef = { + title: string + component: () => React.ReactElement + primary?: boolean +} + +const SCREENS: Record = { + home: { title: t('首页'), component: HomePage, primary: true }, + report: { title: t('报表'), component: ReportPage, primary: true }, + accounts: { title: t('账号'), component: AccountsPage, primary: true }, + assistant: { title: t('AI 助手'), component: AssistantPage, primary: true }, + more: { title: t('更多'), component: MorePage, primary: true }, + models: { title: t('模型管理'), component: ModelsPage }, + keys: { title: t('网关 Key'), component: KeysPage }, + proxies: { title: t('网络代理'), component: ProxiesPage }, + tasks: { title: t('定时任务'), component: TasksPage }, + requests: { title: t('请求日志'), component: RequestsPage }, + logs: { title: t('运行日志'), component: LogsPage }, + settings: { title: t('设置'), component: SettingsPage }, + docs: { title: t('接口文档'), component: DocsPage }, + agent: { title: t('Agent 配置'), component: AgentPage }, + tools: { title: t('工具服务'), component: ToolsPage }, + servers: { title: t('服务器'), component: ServersPage }, + checkin: { title: t('签到中心'), component: CheckinPage }, + plugins: { title: t('网关插件'), component: PluginsPage }, +} + +export function App() { + const state = useStore() + const pwa = usePwa() + const def = SCREENS[state.route.screen] + const Page = def.component + const isPrimary = Boolean(def.primary) + // 助手页自己滚动对话区,外层不再创建第二个滚动容器。 + const fillHeight = state.route.screen === 'assistant' + /** + * APK 首次启动、一台服务器都没配:**没有地址就什么都拉不到**,所以直接 + * 顶掉整个界面(不显示标签栏 —— 切到别的标签也是空的)。配好一台之后 + * 这里自然放行。 + */ + const needsServer = state.packaged && !state.server + + useEffect(() => { + bootstrapTheme() + store.restoreFromHash() + // 没有可用服务器时不要发请求:那会以「无法连接网关」的形式刷一屏错误, + // 而真实原因是「还没填地址」。 + if (!state.packaged || state.server) { + void store.refresh(true) + } + // 浏览器前进 / 后退(PWA 与 APK 的 WebView 都会触发)。走 store 的 + // handlePopState 而不是直接 restoreFromHash:前者会同步弹掉二级栈, + // 后者只看 hash,会把「返回」变成「重进当前页」。 + const onPop = () => store.handlePopState() + window.addEventListener('popstate', onPop) + return () => window.removeEventListener('popstate', onPop) + // 只在挂载时跑一次:后续的 refresh 由各页面与写操作自己触发 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + /** + * 安卓物理返回键。 + * + * 不处理的话 Capacitor 的默认行为是直接 finish 掉 Activity —— 用户在 + * 任何二级页按一下都会回到桌面,而不是退回上一页。 + * + * 优先级:先让已打开的弹层自己关(Sheet / Dialog 走各自的关闭逻辑), + * 再交给 store 退栈。首页且无弹层时才退出应用 —— 这是安卓的通行预期。 + */ + useEffect(() => { + if (!state.packaged) return + let dispose: (() => void) | undefined + let cancelled = false + void import('@capacitor/app') + .then(({ App: CapApp }) => { + if (cancelled) return + void CapApp.addListener('backButton', () => { + // 弹层开着时先关弹层。Base UI 的弹层挂 data-open,且关闭按钮带 + // data-slot='sheet-close' / 'dialog-close';点它等价于用户按关闭。 + const popup = document.querySelector( + '[data-slot="sheet-content"][data-open], [data-slot="dialog-content"][data-open]', + ) + if (popup) { + const closer = popup.querySelector('[data-slot="sheet-close"], [data-slot="dialog-close"]') + closer?.click() + return + } + if (!store.handleHardwareBack()) void CapApp.exitApp() + }).then(handle => { + if (cancelled) void handle.remove() + else dispose = () => void handle.remove() + }) + }) + .catch(() => { + /* 插件缺失(例如浏览器形态)时什么都不做:浏览器自己有返回键 */ + }) + return () => { + cancelled = true + dispose?.() + } + // packaged 在应用生命周期内不变 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + // 页面标题跟着当前页走(PWA 里影响任务切换器的显示) + document.title = needsServer ? `${t('服务器')} · Agent2API` : `${def.title} · Agent2API` + }, [def.title, needsServer]) + + if (needsServer) { + return ( + + +
+ +
+ +
+ ) + } + + return ( + + + + {isPrimary ? null : store.back()} />} +
+ +
+ + + + +
+ ) +} diff --git a/desktop-tauri/mobile/src/api/add-account-dynamic.ts b/desktop-tauri/mobile/src/api/add-account-dynamic.ts new file mode 100644 index 000000000..32148c887 --- /dev/null +++ b/desktop-tauri/mobile/src/api/add-account-dynamic.ts @@ -0,0 +1,93 @@ +/** 只使用现有 methods/start/wait/callback/cancel 契约;不猜提供商协议。 */ +import { apiCall, ApiError } from './client' +import { serverIdentity } from '../lib/servers' +import { isSuspendedProvider } from '../../../ui-shared/provider-availability' +import { t } from '../i18n' + +export type DynamicLoginStart = { + state?: string + flow?: string + provider?: string + url?: string + authUrl?: string + instructions?: string + manualCallback?: boolean + callbackInput?: 'url' | 'code' + localOnly?: boolean + pollIntervalMs?: number +} + +export type DynamicLoginWait = { + pending?: boolean + done?: boolean + error?: string + instructions?: string + hint?: string + session?: { accountUid?: string; accountId?: string; nickname?: string; provider?: string } | null +} + +export type LoginInput = { provider: string; methodIndex: number; inputs: Record; proxy?: unknown } + +function requireAvailableProvider(provider: string): void { + if (isSuspendedProvider(provider)) { + throw new Error(t('该提供商已不在运行时目录中,请返回列表重新选择')) + } +} + +export function readDynamicMethods(provider: string, signal?: AbortSignal): Promise { + requireAvailableProvider(provider) + return apiCall(`/api/session/login/methods?provider=${encodeURIComponent(provider)}`, { signal }) +} + +export function startDynamicLogin(input: LoginInput): Promise { + requireAvailableProvider(input.provider) + return apiCall('/api/session/login/start', { method: 'POST', body: input }) +} + +export function waitDynamicLogin(state: string): Promise { + return apiCall(`/api/session/login/wait?state=${encodeURIComponent(state)}`) +} + +export async function cancelDynamicLogin(state: string): Promise { + const result = await apiCall<{ canceled?: boolean }>('/api/session/login/cancel', { method: 'POST', body: { state } }) + if (result?.canceled !== true) throw new Error(t('登录取消未确认,请刷新状态后重试。')) +} + +export function submitDynamicCallback(state: string, callbackUrl: string): Promise<{ accountId?: string; accepted?: boolean; continue?: boolean; nextUrl?: string }> { + // URL / 裸 code 原文必须保留,不能 trim、拆参数或重建 state。 + return apiCall('/api/session/login/callback', { method: 'POST', body: { state, callbackUrl } }) +} + +export function dynamicLoginError(error: unknown): string { + return error instanceof Error ? t(error.message) : t('未知错误') +} + +export async function addDynamicApiAccount(input: LoginInput & { name?: string }): Promise { + requireAvailableProvider(input.provider) + const identity = serverIdentity() + const result = await apiCall<{ account?: { id?: string; provider?: string }; proxySaved?: boolean; warning?: string }>( + '/api/accounts', { method: 'POST', body: input }, + ) + if (serverIdentity() !== identity) throw new DOMException('服务器已切换', 'AbortError') + if (!result?.account?.id || result.account.provider !== input.provider) { + throw new Error(t('登录桥未返回已完成的账号或会话,请重试')) + } + const warning = typeof result.warning === 'string' ? t(result.warning) : '' + if (result.proxySaved === true) return warning + // 新网关同次落库成功后不重复 PATCH;旧网关仍尝试兼容保存并保留告警。 + // 失败不能撤销已经添加的账号,也不能假称代理已保存。 + let proxyWarning = t('旧网关未确认代理同次保存,已使用兼容接口保存;请在账号设置中确认代理选择。') + try { + await apiCall(`/api/accounts/${encodeURIComponent(result.account.id)}`, { + method: 'PATCH', body: { proxy: input.proxy ?? { source: 'direct' } }, + }) + } catch (error) { + if (serverIdentity() !== identity) throw error + proxyWarning = t('账号已添加,但代理选择未确认保存;请在账号设置中重新检查。') + } + return [warning, proxyWarning].filter(Boolean).join('\n') +} + +export function methodsUnavailable(error: unknown): boolean { + return error instanceof ApiError && error.status === 501 +} diff --git a/desktop-tauri/mobile/src/api/assistant-images.ts b/desktop-tauri/mobile/src/api/assistant-images.ts new file mode 100644 index 000000000..0a983f72b --- /dev/null +++ b/desktop-tauri/mobile/src/api/assistant-images.ts @@ -0,0 +1,13 @@ +import { ApiError, authHeaders, refreshPanelAuthIfExpiring, resolveUrl } from './client' +import { serverIdentity } from '../lib/servers' + +export async function loadAssistantImage(id: string, signal?: AbortSignal): Promise { + const identity = serverIdentity() + await refreshPanelAuthIfExpiring() + if (serverIdentity() !== identity) throw new DOMException('Aborted', 'AbortError') + const response = await fetch(resolveUrl(`/api/assistant/images/files/${encodeURIComponent(id)}`), { headers: authHeaders(), signal }) + if (!response.ok) throw new ApiError(`HTTP ${response.status}`, response.status) + const blob = await response.blob() + if (serverIdentity() !== identity) throw new DOMException('Aborted', 'AbortError') + return blob +} diff --git a/desktop-tauri/mobile/src/api/assistant.ts b/desktop-tauri/mobile/src/api/assistant.ts new file mode 100644 index 000000000..1e7dc1496 --- /dev/null +++ b/desktop-tauri/mobile/src/api/assistant.ts @@ -0,0 +1,205 @@ +/** + * AI 助手的对话流(SSE)。 + * + * ── 请求走哪条路 ────────────────────────────────────────────── + * `POST /api/assistant/chat`,**由网关转发到上游**。手机端只负责把消息发出去、 + * 把流回来的 delta 画出来 —— 模型路由、账号选取、429 换号、协议翻译、记账 + * 全在网关那一侧(见 `api::assistant` 的模块头)。手机不持有任何上游凭证, + * 也不直连任何模型服务。 + * + * ── 为什么不复用 apiCall ────────────────────────────────────── + * `apiCall` 会把响应体整个读成文本再解 `{success, data}` 信封;对话是 SSE, + * 要边读边渲染。所以这里单独发一次 fetch,只共用 client.ts 的地址拼接、 + * 鉴权头与 401 续期(那三件事必须与其它请求同一份实现,否则「登录过期」 + * 在对话页会表现成另一种样子)。 + * + * ── 帧格式 ──────────────────────────────────────────────────── + * 与 OpenAI 流式一致:`data: {choices:[{delta:{content, tool_calls}}]}`, + * 以 `data: [DONE]` 收尾。正文按 delta 累加;`tool_calls` 按 `index` 聚合 + * (同一只工具的参数会被拆成多片,不能逐片当独立调用)。 + */ + +import { ApiError, apiCall, authHeaders, refreshPanelAuth, refreshPanelAuthIfExpiring, resolveUrl } from '../api/client' +import type { ChatMessage } from '../../../ui-shared/assistant-history' +import { parseAssistantMessages, assistantTitle } from '../../../ui-shared/assistant-history' +export type { ChatMessage } from '../../../ui-shared/assistant-history' +import { readAssistantStream, type ChatEvent, type ToolCall } from '../../../ui-shared/assistant-stream' +import { userContentWithFiles } from '../../../ui-shared/assistant-files' +import { t } from '../i18n' +import { serverIdentity, apiBase } from '../lib/servers' +import { readModelApiKeySelection, rememberModelApiKeySelection } from '../../../ui-shared/model-api-key-memory' +export type { ChatEvent, ToolCall } from '../../../ui-shared/assistant-stream' + +export type ChatRole = 'user' | 'assistant' | 'tool' + +/** 助手配置(只读用得到的几项) */ +export type AssistantConfig = { + model?: string | null + apiKeyId?: string | null + reasoning?: string | null + mode?: 'confirm' | 'full' + systemPromptEnabled?: boolean + systemPrompt?: string + titleModel?: string | null + titlePrompt?: string + /** 工具勾选表:tool id → 是否勾选(后端已把缺省值展开) */ + tools?: Record +} + +export async function fetchAssistantConfig(): Promise { + const identity = serverIdentity() + const scope = apiBase() + const payload = await apiCall<{ config?: AssistantConfig }>('/api/assistant/config') + if (serverIdentity() !== identity) throw new DOMException('服务器已切换', 'AbortError') + if (!payload.config) throw new Error(t('配置响应不完整')) + const apiKeyId = typeof payload.config.apiKeyId === 'string' && payload.config.apiKeyId.trim() ? payload.config.apiKeyId.trim() + : readModelApiKeySelection(scope, 'assistant') + if (apiKeyId) rememberModelApiKeySelection(scope, 'assistant', apiKeyId) + return { ...payload.config, apiKeyId } +} + +/** + * 发一轮对话并把流式事件回调出去。 + * + * `signal` 用来实现「停止」:abort 之后读取循环以 AbortError 结束 —— 那是 + * 用户的意图,不是错误(调用方按 `signal.aborted` 区分)。 + */ +export async function streamAssistantChat(options: { + apiKey: string + messages: ChatMessage[] + /** 勾选过的工具表(OpenAI tools 形态);空数组 = 纯聊天 */ + tools?: Array> + model?: string | null + reasoning?: string | null + signal?: AbortSignal + onEvent: (event: ChatEvent) => void +}): Promise { + const body: Record = { + messages: options.messages.map(wireMessage), + } + // 不传 model 时由网关用「助手设置」里存的默认模型兜底 + if (options.model) body.model = options.model + if (options.reasoning) body.reasoning = options.reasoning + // 工具表为空时**不要发空数组**:后端按「有没有 tools」决定注不注入系统提示词 + // (见 config::AssistantConfig::system_prompt),空数组与不发不是一回事 + if (options.tools && options.tools.length) body.tools = options.tools + + const identity = serverIdentity() + const target = resolveUrl('/api/assistant/chat') + const send = async (): Promise => { + if (serverIdentity() !== identity) throw new DOMException('服务器已切换', 'AbortError') + return fetch(target, { + method: 'POST', + headers: { ...authHeaders(), 'x-api-key': options.apiKey, Authorization: `Bearer ${options.apiKey}`, 'Content-Type': 'application/json', Accept: 'text/event-stream' }, + body: JSON.stringify(body), + signal: options.signal, + }) + } + + // 到期前先换令牌:这条不走 apiCall,不补这一步就会在 access 过期那一刻 + // 产生一条 401(见 client.ts 的 refreshPanelAuthIfExpiring) + await refreshPanelAuthIfExpiring() + let response = await send() + // 401 静默续期一次(与 apiCall 同一语义):续上了就重发,续不上交给界面 + if (response.status === 401 && (await refreshPanelAuth())) { + response = await send() + } + + if (!response.ok || !response.body) { + const text = await response.text().catch(() => '') + let message = text.trim() + try { + const parsed = JSON.parse(text) + message = String(parsed?.error?.message || parsed?.error || message) + } catch { + /* 非 JSON 错误体:原样用文本 */ + } + if (response.status === 404 && message === 'Not found: POST /api/assistant/chat') { + throw new ApiError(t('这一版网关还没有 AI 助手接口(HTTP 404),请升级网关后重试。'), 404) + } + throw new ApiError(message || `HTTP ${response.status}`, response.status) + } + + await readAssistantStream(response, { + onEvent: options.onEvent, + signal: options.signal, + incompleteMessage: t('模型响应未完整结束,请重试;未执行其中的工具调用。'), + }) +} + +/** + * 一条消息 → 发给上游的 wire 形态。 + * + * 与 OpenAI 的约定逐字对齐:assistant 带工具调用时要有 `tool_calls`, + * 工具结果用 `role: "tool"` + `tool_call_id` 回灌 —— 少任何一个字段, + * 上游都会拒掉整轮请求(或在下一轮丢掉上下文)。 + */ +function wireMessage(message: ChatMessage): Record { + if (message.role === 'tool') { + return { role: 'tool', tool_call_id: message.toolCallId, content: message.content } + } + if (message.role === 'assistant') { + const wire: Record = { role: 'assistant', content: message.content || '' } + if (message.toolCalls?.length) { + wire.tool_calls = message.toolCalls.map(call => ({ + id: call.id, + type: 'function', + function: { name: call.name, arguments: call.arguments }, + })) + } + return wire + } + const images = (message.images || []).filter((image): image is string => typeof image === 'string') + const text = userContentWithFiles(message.content, message.files || []) + if (!images.length) return { role: 'user', content: text } + return { role: 'user', content: [ + { type: 'text', text }, + ...images.map(url => ({ type: 'image_url', image_url: { url } })), + ] } +} + +export type SessionSummary = { id: string; title: string; model: string | null; reasoning: string | null; createdAt: number; updatedAt: number; titleGenerated: boolean } +export type SessionDetail = SessionSummary & { messages: ChatMessage[] } + +export async function saveAssistantSession(id: string, messages: ChatMessage[], model?: string | null, title?: string): Promise { + const data = await apiCall<{ session: SessionSummary }>('/api/assistant/sessions', { method: 'POST', body: { id, title: title ?? assistantTitle(messages), messages, model: model || null } }) + return data.session +} + +export async function listAssistantSessions(): Promise { + const data = await apiCall<{ sessions: SessionSummary[] }>('/api/assistant/sessions') + return data.sessions +} + +export async function getAssistantSession(id: string): Promise { + const data = await apiCall<{ session: SessionDetail }>(`/api/assistant/sessions/${encodeURIComponent(id)}`) + return { ...data.session, messages: parseAssistantMessages(data.session.messages) } +} + +export async function forkAssistantSession(id: string, messageId: string): Promise { + const data = await apiCall<{ session: SessionDetail }>(`/api/assistant/sessions/${encodeURIComponent(id)}/fork`, { method: 'POST', body: { id: crypto.randomUUID(), messageId } }) + return { ...data.session, messages: parseAssistantMessages(data.session.messages) } +} + +export async function summarizeAssistantSession(id: string, apiKey: string): Promise { + const data = await apiCall<{ session: SessionSummary }>(`/api/assistant/sessions/${encodeURIComponent(id)}/title`, { method: 'POST', apiKey }) + return data.session +} + +export async function saveAssistantConfig(config: AssistantConfig): Promise { + const identity = serverIdentity() + const scope = apiBase() + rememberModelApiKeySelection(scope, 'assistant', config.apiKeyId) + const data = await apiCall<{ config: AssistantConfig }>('/api/assistant/config', { method: 'PUT', body: { config } }) + if (serverIdentity() !== identity) throw new DOMException('服务器已切换', 'AbortError') + rememberModelApiKeySelection(scope, 'assistant', data.config.apiKeyId) + return data.config +} + +export async function saveAssistantTitleConfig(config: AssistantConfig, titleModel: string | null, titlePrompt: string): Promise { + return saveAssistantConfig({ ...config, titleModel, titlePrompt }) +} + +export async function deleteAssistantSession(id: string): Promise { + await apiCall(`/api/assistant/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} diff --git a/desktop-tauri/mobile/src/api/auth.ts b/desktop-tauri/mobile/src/api/auth.ts new file mode 100644 index 000000000..21cb034af --- /dev/null +++ b/desktop-tauri/mobile/src/api/auth.ts @@ -0,0 +1,288 @@ +/** + * 面板认证:登录 / 首次注册 / 人机验证。 + * + * ── 为什么要自己实现一遍 ALTCHA ─────────────────────────────── + * 桌面网页端用的是 `ui/panel-captcha.js`(自定义元素 ``), + * 那是给 `login.html` 准备的经典脚本,APK 里没有那个页面、也不该为了它去 + * 加载桌面端的整套资源。协议本身很简单(服务端见 `server::altcha`): + * + * 领题 → `{algorithm, challenge, salt, maxnumber, signature}` + * 枚举 n 使 `sha256(salt + n) === challenge` + * 提交 base64(JSON({algorithm, challenge, number, salt, signature})) + * + * 这里把求解实现成 Promise,不做成自定义元素 —— APK 的登录页是 React 组件, + * 用状态驱动比 DOM 事件更自然。 + * + * ── 为什么不用 crypto.subtle 一把梭 ──────────────────────────── + * `crypto.subtle` 只在安全上下文提供。APK 里页面 origin 是 `http://localhost`, + * **算安全上下文**(localhost 被视为可信),所以这里可以放心用原生实现 —— + * 与网页端那份「http 局域网要纯 JS 兜底」的顾虑不同。 + * 但为了在浏览器里调试时不炸,仍然保留了纯 JS 兜底。 + */ + +import { apiCall, url, type PanelStatus } from './client' +import { activeServerId, readServerAuth, serverIdentity, storeServerAuth } from '../lib/servers' + +/** 题面(服务端 `altcha::challenge()` 的原样输出) */ +type Challenge = { + algorithm: string + challenge: string + salt: string + maxnumber: number + signature: string +} + +/** 求解进度回调(0–1),用于界面上给一点反馈 */ +export type SolveProgress = (ratio: number) => void + +/* ── SHA-256 ─────────────────────────────────────────────── */ + +const K = new Int32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]) + +function rotr(value: number, bits: number): number { + return (value >>> bits) | (value << (32 - bits)) +} + +/** 纯 JS SHA-256(UTF-8 输入 → 32 字节摘要) */ +function sha256Bytes(bytes: Uint8Array): Uint8Array { + const H = new Int32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]) + const length = bytes.length + // 补位:0x80 + 若干 0 + 64 位长度 + const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6) + padded.set(bytes) + padded[length] = 0x80 + const bitLength = length * 8 + // 长度只有低 32 位可能非零(题面几十字节,够用) + const view = new DataView(padded.buffer) + view.setUint32(padded.length - 4, bitLength >>> 0, false) + view.setUint32(padded.length - 8, Math.floor(bitLength / 0x100000000), false) + + const w = new Int32Array(64) + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getInt32(offset + i * 4, false) + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3) + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10) + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0 + } + let a = H[0] + let b = H[1] + let c = H[2] + let d = H[3] + let e = H[4] + let f = H[5] + let g = H[6] + let h = H[7] + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25) + const ch = (e & f) ^ (~e & g) + const t1 = (h + S1 + ch + K[i] + w[i]) | 0 + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22) + const maj = (a & b) ^ (a & c) ^ (b & c) + const t2 = (S0 + maj) | 0 + h = g + g = f + f = e + e = (d + t1) | 0 + d = c + c = b + b = a + a = (t1 + t2) | 0 + } + H[0] = (H[0] + a) | 0 + H[1] = (H[1] + b) | 0 + H[2] = (H[2] + c) | 0 + H[3] = (H[3] + d) | 0 + H[4] = (H[4] + e) | 0 + H[5] = (H[5] + f) | 0 + H[6] = (H[6] + g) | 0 + H[7] = (H[7] + h) | 0 + } + + const out = new Uint8Array(32) + const outView = new DataView(out.buffer) + for (let i = 0; i < 8; i++) outView.setInt32(i * 4, H[i], false) + return out +} + +function toHex(bytes: Uint8Array): string { + let text = '' + for (const byte of bytes) text += byte.toString(16).padStart(2, '0') + return text +} + +function hasNativeCrypto(): boolean { + return typeof crypto !== 'undefined' && Boolean(crypto.subtle) && typeof crypto.subtle.digest === 'function' +} + +const encoder = new TextEncoder() + +/** 一次 sha256 的 hex 结果(优先原生实现) */ +async function sha256Hex(text: string): Promise { + if (hasNativeCrypto()) { + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(text)) + return toHex(new Uint8Array(digest)) + } + return toHex(sha256Bytes(encoder.encode(text))) +} + +/* ── 求解 ─────────────────────────────────────────────────── */ + +/** + * 枚举 number 使 `sha256(salt + number) === challenge`。 + * + * 每 2000 次让出一次主线程:纯 JS 路径的 await 只进微任务队列,不让出的话 + * 界面会冻住。原生路径每次 digest 本身就是异步的,天然让出。 + * 服务端上限 5–10 万次,平均命中在一半。 + */ +async function solve(challenge: Challenge, onProgress?: SolveProgress): Promise { + const target = String(challenge.challenge || '').toLowerCase() + const salt = String(challenge.salt || '') + const max = Number(challenge.maxnumber) || 0 + if (!target || !salt) throw new Error('题面不完整') + for (let n = 0; n <= max; n++) { + if ((await sha256Hex(salt + n)) === target) return n + if (n % 2000 === 1999) { + onProgress?.(Math.min(1, n / Math.max(1, max))) + await new Promise(resolve => setTimeout(resolve, 0)) + } + } + throw new Error('未找到答案') +} + +/** + * 领题并求解,返回可直接放进登录请求体的 payload。 + * + * 服务端关闭校验时领题回 400 —— 这时返回 `null`,调用方提交时不带 captcha 字段。 + */ +export async function solveCaptcha(onProgress?: SolveProgress): Promise { + let challenge: Challenge + try { + const response = await fetch(url('/api/panel/captcha'), { headers: { Accept: 'application/json' } }) + if (!response.ok) return null + challenge = (await response.json()) as Challenge + } catch { + // 网关不可达:交给登录请求自己报错,不在这里把「验证失败」当结论 + return null + } + const number = await solve(challenge, onProgress) + // 服务端要的是 base64(JSON)(std 与 url-safe 都认,btoa 给的是 std) + return btoa( + JSON.stringify({ + algorithm: challenge.algorithm, + challenge: challenge.challenge, + number, + salt: challenge.salt, + signature: challenge.signature, + }), + ) +} + +/* ── 登录 / 注册 ─────────────────────────────────────────── */ + +export type AuthResult = { access: string; refresh: string } | { error: string } + +/** + * 登录或首次注册。 + * + * 两者共用一条路径:服务端 `/api/panel/status` 说没注册过就走 setup, + * 否则走 login。成功后令牌一定在响应体里 —— 请求带了 `x-panel-auth-mode: + * body`,服务端据此把令牌放进 body(见 `tokens_in_body`)。 + * + * 为什么必须要求令牌进 body:APK 的页面 origin 与网关不同源,服务端种的 + * HttpOnly cookie 属于网关那个 origin,WebView 的 fetch 跨源时不会带上它 + * (`Allow-Origin: *` 下浏览器拒绝带 cookie)。只有拿 body 里的令牌走请求头 + * 这条路。 + */ +export async function authenticate(input: { + mode: 'login' | 'setup' + username: string + password: string + captcha: string | null +}): Promise { + const identity = serverIdentity() + const serverId = activeServerId() + const body: Record = { + username: input.username, + password: input.password, + } + if (input.captcha) body.captcha = input.captcha + + const path = input.mode === 'setup' ? '/api/panel/setup' : '/api/panel/login' + try { + const response = await fetch(url(path), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + // 令牌进响应体(两条通道都给:有的中转会剥自定义头) + 'x-panel-auth-mode': 'body', + }, + body: JSON.stringify(body), + }) + const payload = (await response.json().catch(() => null)) as + | { + success?: boolean + data?: { accessToken?: string; refreshToken?: string; expiresIn?: number } + error?: unknown + } + | null + if (!response.ok) { + return { error: readError(payload?.error) || `HTTP ${response.status}` } + } + const data = payload?.data + if (!data?.accessToken || !data?.refreshToken) { + // 服务端没按标记给令牌(老版本网关):告诉用户升级,而不是让他反复试 + return { error: '网关没有返回登录令牌,请确认网关版本(需要支持 x-panel-auth-mode 的版本)' } + } + if (serverIdentity() !== identity) return { error: '服务器已切换,请在当前服务器重新登录' } + storeServerAuth(serverId, data.accessToken, data.refreshToken, data.expiresIn) + return { access: data.accessToken, refresh: data.refreshToken } + } catch (error) { + return { error: `无法连接网关:${error instanceof Error ? error.message : String(error)}` } + } +} + +function readError(error: unknown): string { + if (typeof error === 'string') return error + if (error && typeof error === 'object') { + const message = (error as { message?: unknown }).message + if (typeof message === 'string') return message + } + return '' +} + +/** 退出登录:撤掉服务端会话(失败也无所谓),再清本地 */ +export async function logout(): Promise { + const auth = readServerAuth(activeServerId()) + const headers: Record = { + Accept: 'application/json', + 'x-panel-auth-mode': 'body', + } + if (auth?.access) { + headers['x-panel-token'] = auth.access + headers.Authorization = `Bearer ${auth.access}` + } + if (auth?.refresh) headers['x-panel-refresh'] = auth.refresh + try { + await fetch(url('/api/panel/logout'), { method: 'POST', headers, body: '{}' }) + } catch { + /* 网关不可达也照样清本地 */ + } +} + +/** 读当前面板状态(未登录也能调) */ +export async function fetchPanelStatus(): Promise { + return apiCall('/api/panel/status', { quiet: true }) +} diff --git a/desktop-tauri/mobile/src/api/checkin.ts b/desktop-tauri/mobile/src/api/checkin.ts new file mode 100644 index 000000000..2d621719c --- /dev/null +++ b/desktop-tauri/mobile/src/api/checkin.ts @@ -0,0 +1,179 @@ +import { apiCall } from './client' +import type { + AutoState, CheckinCenter, CheckinResult, CheckinResultRow, KeepaliveState, OnboardingTask, + RecordsPage, TravelRunResult, TravelState, +} from '../../../ui-shared/checkin' + +export type { + AutoState, CheckinAccount, CheckinCenter, CheckinResult, CheckinSection, GrowthEntry, + KeepaliveState, OnboardingEntry, OnboardingTask, PlanEntry, ProviderGroup, + RecordRow, RecordsPage, ScheduleEntry, TravelRunResult, TravelState, +} from '../../../ui-shared/checkin' +export { CHECKIN_SECTION_OPTIONS, modesOf } from '../../../ui-shared/checkin' + +export type CheckinRun = CheckinResult +export type ActivityResult = CheckinResultRow + +export type SchedulePatch = { + id?: string + label?: string + enabled: boolean + time: string + timeEnd?: string + providers: string[] + /** 逐家的时刻覆盖(不传 = 保持原样;空对象 = 全部撤销) */ + providerTimes?: Record + /** 补签轮时刻(空串 = 关掉补签轮) */ + retryTime?: string + /** 中性未领取的重试间隔(分钟;0 = 不重试) */ + retryGapMinutes?: number + /** 每账号每天的重试次数上限(0 = 不重试) */ + retryLimit?: number +} + +export type RecordFilters = { + outcome?: string + /** 签到类型:manual / schedule / makeup */ + kind?: string + provider?: string + keyword?: string + start?: number + end?: number +} + +function queryString(params: Record): string { + const query = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') query.set(key, String(value)) + } + const text = query.toString() + return text ? `?${text}` : '' +} + +const accountPath = (id: string) => `/api/accounts/${encodeURIComponent(id)}` + +export function getCheckinCenter(signal?: AbortSignal): Promise { + return apiCall('/api/checkin-center', { signal }) +} + +export function runCheckin(id?: string): Promise { + return apiCall('/api/accounts/checkin', { method: 'POST', body: id ? { id } : {} }) +} + +export function runActivity(id: string, mode: string): Promise { + return apiCall('/api/checkin-activity', { method: 'POST', body: { id, mode } }) +} + +export function saveAutoCheckin(enabled: boolean): Promise { + return apiCall('/api/auto-checkin', { method: 'POST', body: { enabled } }) +} + +export function saveCheckinSchedule(body: SchedulePatch): Promise { + return apiCall('/api/checkin-schedules', { method: 'POST', body }) +} + +export function deleteCheckinSchedule(id: string): Promise { + return apiCall(`/api/checkin-schedules/${encodeURIComponent(id)}/delete`, { method: 'POST' }) +} + +export function runCheckinSchedule(id: string): Promise { + return apiCall(`/api/checkin-schedules/${encodeURIComponent(id)}/run`, { method: 'POST' }) +} + +export function saveKeepalive(models: string[], apiKeyId?: string | null): Promise { + return apiCall('/api/checkin-keepalive', { method: 'POST', body: { models, apiKeyId } }) +} + +export function saveTravel(body: { enabled?: boolean; times?: string[] }): Promise { + return apiCall('/api/travel-schedule', { method: 'POST', body }) +} + +export function runTravel(): Promise { + return apiCall('/api/travel-schedule/run', { method: 'POST' }) +} + +export function getCheckinRecords( + filters: RecordFilters & { offset: number; limit: number }, + signal?: AbortSignal, +): Promise { + return apiCall(`/api/checkin-records${queryString(filters)}`, { signal }) +} + +export function clearCheckinRecords(filters: RecordFilters, all = false): Promise<{ removed: number }> { + return apiCall(`/api/checkin-records${queryString(all ? { all: 1 } : filters)}`, { method: 'DELETE' }) +} + +export type OnboardingResult = { + tasks?: OnboardingTask[] + earned?: number + total?: number + unclaimed?: number + settled?: boolean + settledAt?: number | null + claimed?: number + failed?: number + claimedPoints?: number + message?: string +} + +export function getOnboarding(id: string, refresh = false): Promise { + return apiCall(`${accountPath(id)}/onboarding${refresh ? '?refresh=1' : ''}`) +} + +export function claimOnboarding(id: string): Promise { + return apiCall(`${accountPath(id)}/onboarding/claim`, { method: 'POST' }) +} + +export type GrowthTask = { + code: string + title: string + description?: string + status?: string + current?: number + target?: number + locked?: boolean + supported?: boolean + unforgeable?: boolean +} + +export type GrowthResult = { + energy?: number | null + streak?: number | null + tasks?: { tasks?: GrowthTask[]; error?: string } + travel?: Record | null + school?: Record | null +} + +export type GrowthRun = { + code?: string + outcome?: string + message?: string + error?: string + credit?: number +} + +export function getGrowth(id: string): Promise { + return apiCall(`${accountPath(id)}/growth`) +} + +export function runGrowthTask(id: string, code: string, mode: 'full' | 'claim'): Promise { + return apiCall(`${accountPath(id)}/growth/task`, { method: 'POST', body: { code, mode } }) +} + +export function runGrowthTravel(id: string): Promise { + return apiCall(`${accountPath(id)}/growth/travel`, { method: 'POST' }) +} + +export function runGrowthSchool(id: string, lottery: boolean): Promise { + return apiCall(`${accountPath(id)}/growth/school`, { method: 'POST', body: { lottery } }) +} + +export type UmidState = { available?: boolean; installing?: boolean; installSupported?: boolean; source?: string } + +export function getUmid(): Promise { + return apiCall('/api/qoder-umid') +} + +export function installUmid(): Promise { + return apiCall('/api/qoder-umid/install', { method: 'POST' }) +} diff --git a/desktop-tauri/mobile/src/api/client-identification.ts b/desktop-tauri/mobile/src/api/client-identification.ts new file mode 100644 index 000000000..5165e1e0d --- /dev/null +++ b/desktop-tauri/mobile/src/api/client-identification.ts @@ -0,0 +1,8 @@ +import { apiCall } from './client' +import type { ClientIdentificationApi } from '../../../ui-shared/client-identification' + +export const clientIdentificationApi: ClientIdentificationApi = { + load: () => apiCall('/api/client-identification'), + save: payload => apiCall('/api/client-identification', { method: 'PUT', body: payload }), + preview: payload => apiCall('/api/client-identification/preview', { method: 'POST', body: payload }), +} diff --git a/desktop-tauri/mobile/src/api/client.ts b/desktop-tauri/mobile/src/api/client.ts new file mode 100644 index 000000000..1e325ecd8 --- /dev/null +++ b/desktop-tauri/mobile/src/api/client.ts @@ -0,0 +1,335 @@ +/** + * 移动端与网关之间的 HTTP 客户端。 + * + * ── 两种运行形态,一套代码 ──────────────────────────────────── + * · **PWA / 浏览器**:页面由网关出,`/api/*` 是同源相对路径,服务端用 + * HttpOnly cookie 认会话 —— 与桌面网页端完全一致。 + * · **原生 App**:界面打进包里,页面 origin 是原生壳的本地地址,请求网关 + * 必然跨源。跨源请求浏览器不会带 cookie,所以必须显式把令牌放进请求头 + * (`x-panel-token` / `x-panel-refresh`),并且登录时要让服务端把令牌 + * 放进响应体(`x-panel-auth-mode: body`,见服务端 `tokens_in_body`)。 + * + * 两条路径的差别只在「地址怎么拼」与「令牌从哪来」,收敛在 `request()` + * 一处,业务层不必区分。 + * + * ── 凭证按服务器隔离 ────────────────────────────────────────── + * 见 lib/servers.ts:令牌是按服务器签发的,混着存会让切换后立刻 401。 + * + * ── 401 的两种语义 ──────────────────────────────────────────── + * · `error.type === 'panel_login_required'` → 管理员已注册但会话没了: + * 先静默 refresh,失败才交给界面弹登录; + * · 其余 401 → 兼容模式没配 Key:交给界面提示输入。 + */ + +import { + activeServerId, + apiBase, + clearServerAuth, + isPackaged, + readServerAuth, + readServerKey, + serverIdentity, + storeServerAuth, + storeServerKey, + type PanelAuth, +} from '../lib/servers' + +/** 管理 API 的统一错误 */ +export class ApiError extends Error { + status: number + /** 后端 `error.type`(`panel_login_required` 之类),没有则为空串 */ + type: string + constructor(message: string, status: number, type = '') { + super(message) + this.name = 'ApiError' + this.status = status + this.type = type + } +} + +/** 把相对路径拼成完整地址(PWA 下原样返回,交给浏览器按同源处理) */ +export function url(path: string): string { + if (!isPackaged()) return path + const base = apiBase() + if (!base) throw new ApiError('还没有配置服务器地址', 0) + return `${base}${path}` +} + +/** 把相对路径拼成完整地址(PWA 下原样返回,交给浏览器按同源处理) */ +export function resolveUrl(path: string): string { + return url(path) +} + +/** + * 当前会话的鉴权头(给「不能走 apiCall」的调用方用,例如 SSE 流)。 + * + * 与 {@link apiCall} 内部 `buildHeaders` 是同一份实现 —— 两处各写一份的话, + * 「登录过期」在不同页面上会表现成不同的样子。 + */ +export function authHeaders(extra?: Record): Record { + return buildHeaders(extra) +} + +/** 静默续期(对话流 401 时用)。成功返回 true */ +export function refreshPanelAuth(): Promise { + return tryRefresh() +} + +/** + * 到期前主动续期(给不走 `apiCall` 的调用方用,例如 SSE 对话流与文件上传)。 + * + * `apiCall` 内部已经调过它;这些直连 fetch 的路径没有,所以要在发请求前自己 + * 调一次,否则它们仍会在 access 过期那一刻产生一条 401 —— 而那正是本次要 + * 消掉的噪音(见 `ensureFreshSession` 的说明)。永不抛错。 + */ +export function refreshPanelAuthIfExpiring(): Promise { + return ensureFreshSession() +} + +export function readApiKey(): string { + return readServerKey(activeServerId()) +} + +export function storeApiKey(value: string): void { + storeServerKey(activeServerId(), value) +} + +export function readPanelAuth(): PanelAuth | null { + return readServerAuth(activeServerId()) +} + +export function storePanelTokens(access: string, refresh: string, expiresIn?: number): void { + storeServerAuth(activeServerId(), access, refresh, expiresIn) +} + +export function clearPanelAuth(): void { + clearServerAuth(activeServerId()) +} + +/** 会话是否还有效(只用于界面提示,不阻塞渲染) */ +export function hasStoredCredential(): boolean { + const auth = readPanelAuth() + return Boolean(readApiKey() || auth?.access) +} + +/** 401 时由界面接管(弹登录 / 输入 Key);客户端自己不做跳转 */ +let onAuthLost: ((mode: 'login' | 'key') => void) | null = null +export function setAuthLostHandler(handler: ((mode: 'login' | 'key') => void) | null): void { + onAuthLost = handler +} + +function buildHeaders(extra?: Record): Record { + const headers: Record = { Accept: 'application/json', ...extra } + const apiKey = readApiKey() + if (apiKey) headers['x-api-key'] = apiKey + const auth = readPanelAuth() + if (auth?.access) { + headers['x-panel-token'] = auth.access + // Authorization 是标准头:连自定义头都剥的中转也会放它过去 + headers.Authorization = `Bearer ${auth.access}` + } + return headers +} + +/** 续期按服务器隔离;换服务器后不能沿用上一台的在途令牌轮换。 */ +const refreshInFlight = new Map>() + +function requireCurrentServer(identity: string): void { + if (serverIdentity() !== identity) throw new DOMException('服务器已切换', 'AbortError') +} + +/** + * 静默续期,并发 401 单飞:access 过期那一刻页面会同时收到多个 401, + * 各自去换的话,后到的带着同一把(刚被轮换的)refresh 上行,只能靠服务端 + * 的宽限窗兜底(`access::ROTATION_GRACE_MS`)。这里同一时刻只放行一次轮换 + * 请求,其余等同一个结果 —— 先到者换到的新令牌直接被所有等待者共用。 + * 成功返回 true + */ +async function tryRefresh(): Promise { + const identity = serverIdentity() + const pending = refreshInFlight.get(identity) + if (pending) return pending + const request = doRefresh(identity).finally(() => refreshInFlight.delete(identity)) + refreshInFlight.set(identity, request) + return request +} + +async function doRefresh(identity: string): Promise { + const serverId = activeServerId() + const auth = readServerAuth(serverId) + const headers = buildHeaders({ 'Content-Type': 'application/json' }) + if (auth?.refresh) { + headers['x-panel-refresh'] = auth.refresh + // 要求令牌进响应体:APK 下没有可用的 cookie 通道,只认 body + headers['x-panel-auth-mode'] = 'body' + } + try { + const response = await fetch(url('/api/panel/refresh'), { + method: 'POST', + headers, + body: '{}', + }) + if (!response.ok) return false + const payload = (await response.json().catch(() => null)) as + | { data?: { accessToken?: string; refreshToken?: string; expiresIn?: number } } + | null + if (serverIdentity() !== identity) return false + const data = payload?.data + if (data?.accessToken && data?.refreshToken) { + storeServerAuth(serverId, data.accessToken, data.refreshToken, data.expiresIn) + } + return true + } catch { + return false + } +} + +/** + * 主动续期的提前量(毫秒):到期前这么久就把令牌换掉。 + * + * 两分钟够覆盖「这一轮请求刚好在到期瞬间发出」的抖动,又远小于 2 小时的寿命 —— + * 不会因为时钟偏差或一次慢请求就提前换掉刚签发的令牌(那会白耗一次轮换)。 + */ +const REFRESH_AHEAD_MS = 2 * 60 * 1000 + +/** + * 到期前主动续期:把「access 过期 → 401 → 续期」提前到过期之前。 + * + * 只靠 401 被动续期时,**每次**过期都必然产生一条 401(服务端按「refresh + * 还活着」已把它降成 verbose,但请求本身仍是一次多余往返)。手机端挂着不动 + * 时也一样会撞上,这里从源头把那条请求消掉。 + * + * 失败一律静默放行,让请求照常发出去:`doRefresh` 返回 false 可能是会话链 + * 真没了(该走 401 → 弹登录),也可能只是网络抖动。真正的判据只有服务端知道, + * 交回给 `apiCall` 里既有的 401 分支处理。 + */ +async function ensureFreshSession(): Promise { + const auth = readPanelAuth() + const expiresAt = Number(auth?.expiresAt) + if (!Number.isFinite(expiresAt) || expiresAt <= 0) return + if (Date.now() < expiresAt - REFRESH_AHEAD_MS) return + await tryRefresh() +} + +type RequestInitLite = { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + body?: unknown + /** 不触发 401 接管(探活类请求用) */ + quiet?: boolean + signal?: AbortSignal + apiKey?: string +} + +/** + * 发一条管理 API 请求并解开 `{success, data}` 信封。 + * + * 返回值是 `data` 本身;失败一律抛 {@link ApiError}。 + */ +export function apiCall(path: string, init: RequestInitLite = {}): Promise { + return requestApi(path, init, serverIdentity(), true) +} + +async function requestApi(path: string, init: RequestInitLite, identity: string, retry: boolean): Promise { + requireCurrentServer(identity) + if (init.signal?.aborted) throw new DOMException('请求已取消', 'AbortError') + await ensureFreshSession() + requireCurrentServer(identity) + const method = init.method || 'GET' + const headers = buildHeaders() + if (init.apiKey !== undefined) { + headers['x-api-key'] = init.apiKey + headers.Authorization = `Bearer ${init.apiKey}` + } + const requestInit: RequestInit = { method, headers, signal: init.signal } + if (init.body !== undefined || method !== 'GET') { + headers['Content-Type'] = 'application/json' + requestInit.body = JSON.stringify(init.body ?? {}) + } + + let response: Response + try { + response = await fetch(url(path), requestInit) + } catch (error) { + requireCurrentServer(identity) + if (init.signal?.aborted || (error instanceof DOMException && error.name === 'AbortError')) throw error + throw new ApiError(`无法连接网关:${error instanceof Error ? error.message : String(error)}`, 0) + } + + const text = await response.text() + requireCurrentServer(identity) + let payload: unknown = null + try { + payload = text ? JSON.parse(text) : null + } catch { + throw new ApiError(text.trim() || `网关返回了空响应(HTTP ${response.status})`, response.status) + } + + if (response.status === 401 && !init.quiet) { + const type = readErrorType(payload) + if (type === 'panel_login_required') { + const refreshed = retry && !path.startsWith('/api/assistant/images/') && await tryRefresh() + requireCurrentServer(identity) + if (refreshed) return requestApi(path, init, identity, false) + clearPanelAuth() + onAuthLost?.('login') + } else if (init.apiKey === undefined) { + onAuthLost?.('key') + } + } + + const envelope = payload as { success?: boolean; data?: unknown; error?: unknown } | null + const ok = response.status >= 200 && response.status < 300 + const failed = envelope?.success === false + if (!ok || failed) { + throw new ApiError(errorMessage(envelope?.error, response.status), response.status, readErrorType(payload)) + } + return (envelope && 'data' in envelope ? envelope.data : envelope) as T +} + +function readErrorType(payload: unknown): string { + const error = (payload as { error?: unknown } | null)?.error + if (error && typeof error === 'object' && 'type' in error) { + const type = (error as { type?: unknown }).type + return typeof type === 'string' ? type : '' + } + return '' +} + +function errorMessage(error: unknown, status: number): string { + if (typeof error === 'string' && error) return error + if (error && typeof error === 'object') { + const message = (error as { message?: unknown }).message + if (typeof message === 'string' && message) return message + } + return `HTTP ${status}` +} + +/** 探活:不带凭证也能打(`/health` 免鉴权) */ +export async function ping(): Promise { + try { + const response = await fetch(url('/health'), { headers: { Accept: 'application/json' } }) + return response.ok + } catch { + return false + } +} + +/** 探活并带回版本信息(配置服务器时用,能顺便显示是不是本产品) */ +export async function probe(base: string): Promise<{ ok: boolean; version?: string }> { + try { + const response = await fetch(`${base}/health`, { headers: { Accept: 'application/json' } }) + if (!response.ok) return { ok: false } + const payload = (await response.json().catch(() => null)) as { version?: string; data?: { version?: string } } | null + return { ok: true, version: payload?.version || payload?.data?.version } + } catch { + return { ok: false } + } +} + +/** 面板登录状态(决定「登录」还是「创建管理员」) */ +export type PanelStatus = { registered?: boolean; captchaEnabled?: boolean } +export async function panelStatus(): Promise { + const response = await fetch(url('/api/panel/status'), { headers: { Accept: 'application/json' } }) + const payload = (await response.json().catch(() => null)) as { data?: PanelStatus } | null + return payload?.data || {} +} diff --git a/desktop-tauri/mobile/src/api/gateway.ts b/desktop-tauri/mobile/src/api/gateway.ts new file mode 100644 index 000000000..8c73538bd --- /dev/null +++ b/desktop-tauri/mobile/src/api/gateway.ts @@ -0,0 +1,551 @@ +/** + * 网关 API 的类型与薄封装。 + * + * 只声明**移动端真正读到**的字段(后端还会给更多,这里按 unknown 收)。 + * 字段名一律照后端契约(`record.rs` / `session.rs` / `checkin_center.rs`), + * 不在前端另起一套命名 —— 改口径要能一眼看出改的是哪一边。 + */ + +import { apiCall } from './client' +import type { ProxyUsage } from '../../../ui-shared/proxy-usage' +import type { CheckinCenter } from '../../../ui-shared/checkin' +import type { HiddenAccountsSummary } from '../../../ui-shared/plugins' +import type { AccountSorting } from '../../../ui-shared/account-sorting' +import { logQueryString, type LogPage, type LogQuery } from '../../../ui-shared/logs' + +export type { LogEntry, LogPage } from '../../../ui-shared/logs' + +export type { CheckinAccount, CheckinCenter, ProviderGroup as CheckinProviderRow } from '../../../ui-shared/checkin' + +/* ─── 会话 / 账号 ─────────────────────────────── */ + +export type ProviderSummary = { id?: string; label?: string; count?: number; category?: 'agent' | 'web'; source?: 'builtin' | 'plugin' } + +export type RateLimitInfo = { + status?: number | string + code?: string | number + resetAt?: number + message?: string +} + +export type Account = { + id: string + provider?: string + name?: string + displayName?: string + nameCustom?: boolean + nickname?: string + email?: string + enabled?: boolean + available?: boolean + hasCredentials?: boolean + priority?: number + edition?: string + editionLabel?: string + tokenExpiresAt?: number + expiresAt?: number + hasAccessToken?: boolean + accessTokenExpiresAt?: number | null + hasRefreshToken?: boolean + refreshTokenLongLived?: boolean + refreshExpiresAt?: number | null + refreshTokenExpiresAt?: number | null + needsRelogin?: boolean + needsReloginReason?: string + /** 智谱清言:这条账号是不是访客(后端只对本家注入;访客没有积分体系,不查余额) */ + isGuest?: boolean + /** 按模型记的限流记录 */ + rateLimits?: Record + upstreamCooldownUntil?: number | null + upstreamBlockedReason?: string + [key: string]: unknown +} + +export type SessionPayload = { + health?: { + upstreamConfigured?: boolean + upstreamBaseUrl?: string | null + authSource?: string | null + unavailableReason?: string | null + } + session?: unknown + accounts?: { + currentAccountId?: string | null + currentAccountIds?: Record + accounts?: Account[] + providers?: ProviderSummary[] + hiddenAccounts?: HiddenAccountsSummary + sorting?: AccountSorting + } + accountFeatures?: Record + lastRequestModel?: string | null + routedAccountId?: string | null + defaultModel?: string | null + proxies?: { optionCount?: number; clashAvailable?: boolean; clashError?: string | null } + models?: Array<{ id?: string; name?: string; provider?: string; providerLabel?: string }> +} + +export function fetchSession(signal?: AbortSignal): Promise { + return apiCall('/api/session', { signal }) +} + +/** 账号列表(`GET /api/accounts` 返回的就是快照形状) */ +export function fetchAccounts(signal?: AbortSignal): Promise<{ + accounts?: Account[] + providers?: ProviderSummary[] + hiddenAccounts?: HiddenAccountsSummary + sorting?: AccountSorting + currentAccountId?: string | null + currentAccountIds?: Record +}> { + return apiCall('/api/accounts', { signal }) +} + +export function setAccountEnabled(id: string, enabled: boolean): Promise { + return apiCall(`/api/accounts/${encodeURIComponent(id)}`, { method: 'PATCH', body: { enabled } }) +} + +export function deleteAccount(id: string): Promise { + return apiCall(`/api/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + +export function refreshAccount(id: string): Promise { + return apiCall('/api/accounts/refresh', { method: 'POST', body: { id } }) +} + +export function refreshExpiringAccounts(): Promise { + return apiCall('/api/accounts/refresh-expiring', { method: 'POST' }) +} + +/* ─── 报表 ─────────────────────────────────────── */ + +export type StatsOverview = { + requests?: number + successful?: number + tokens?: number + activeDays?: number + streak?: number + topModel?: { model?: string; tokens?: number; percentage?: number } | null +} + +export type StatsDimension = { + id?: string + label?: string + requests?: number + success?: number + failures?: number + totalTokens?: number + tokens?: number + percentage?: number + model?: string + successRate?: number | null + promptTokens?: number | null + completionTokens?: number | null + cacheReadTokens?: number | null + cacheRate?: number | null + lastUsedAt?: number | null + tokenDetailsComplete?: boolean + source?: string + filterValue?: string + state?: 'enabled' | 'disabled' | 'deleted' +} + +export type StatsDay = { + date?: string; tokens?: number; requests?: number + models?: StatsDimension[]; clients?: StatsDimension[]; apiKeys?: StatsDimension[] +} + +export type StatsSummary = { + range?: string + startDate?: string + endDate?: string + overview?: StatsOverview + providers?: StatsDimension[] + accounts?: StatsDimension[] + models?: StatsDimension[] + clients?: StatsDimension[] + apiKeys?: StatsDimension[] + apiKeyDetail?: { filterValue?: string; label?: string; requests?: number; totalTokens?: number; detailsComplete?: boolean; clients?: StatsDimension[]; models?: StatsDimension[] } | null + cacheRates?: Record + dailyTrend?: StatsDay[] + cacheTrend24h?: unknown +} + +export type StatsRange = 'today' | 'yesterday' | '7' | '30' | 'month' | 'all' | 'custom' + +export function fetchStatsSummary(range: StatsRange, signal?: AbortSignal, options?: { + apiKey?: string; startDate?: string; endDate?: string +}): Promise { + const query = new URLSearchParams({ range }) + for (const [key, value] of Object.entries(options || {})) if (value) query.set(key, value) + return apiCall(`/api/stats/summary?${query}`, { signal }) +} + +/* ─── 请求日志 ─────────────────────────────────── */ + +export type AttemptDetail = { provider?: string; status?: number; error?: string } + +export type RequestRow = { + clientLabel?: string + clientEvidence?: import('../../../ui-shared/client-identification').ClientEvidence + ts?: number + id?: string + model?: string + accountId?: string + accountName?: string + status?: number + durationMs?: number + firstResponseMs?: number | null + attempts?: number + error?: string | null + promptTokens?: number + completionTokens?: number + totalTokens?: number + cacheReadTokens?: number + provider?: string + providerLabel?: string + clientModel?: string + upstreamModel?: string + attemptDetails?: AttemptDetail[] + sensitiveHits?: Array<{ word?: string; count?: number }> + route?: unknown + client?: string + isTest?: boolean + apiKeyId?: string; apiKeyName?: string; apiKeySource?: string +} + +export type RequestPage = { + entries?: RequestRow[] + total?: number + matched?: number + running?: number +} + +export function fetchRequests( + params: { + offset?: number + limit?: number + /** 后端只认这三个值,其余忽略(见 report::normalize_status_filter) */ + status?: 'ok' | 'error' | 'running' + model?: string + provider?: string + client?: string + apiKey?: string + startDate?: string + endDate?: string + productionOnly?: string + completedOnly?: string + }, + signal?: AbortSignal, +): Promise { + const query = new URLSearchParams() + if (params.offset) query.set('offset', String(params.offset)) + if (params.limit) query.set('limit', String(params.limit)) + if (params.status) query.set('status', params.status) + if (params.model) query.set('model', params.model) + if (params.provider) query.set('provider', params.provider) + if (params.client !== undefined) query.set('client', params.client) + for (const key of ['apiKey', 'startDate', 'endDate', 'productionOnly', 'completedOnly'] as const) { + if (params[key] !== undefined) query.set(key, params[key] as string) + } + const suffix = query.toString() + return apiCall(`/api/stats/requests${suffix ? `?${suffix}` : ''}`, { signal }) +} + +/* ─── 运行日志 ─────────────────────────────────── */ + +export function fetchLogs(params: LogQuery, signal?: AbortSignal): Promise { + return apiCall(`/api/logs?${logQueryString({ limit: 60, ...params })}`, { signal }) +} + +/* ─── 签到中心 ─────────────────────────────────── */ + +export function fetchCheckinCenter(signal?: AbortSignal): Promise { + return apiCall('/api/checkin-center', { signal }) +} + +/* ─── 模型管理 ─────────────────────────────────── */ + +export type ManageModel = { + id?: string + name?: string + provider?: string + providerLabel?: string + source?: string + kind?: string + supports_image_generation?: boolean + supports_image_editing?: boolean + enabled?: boolean + aliases?: string[] + visibility?: string | null + upstreamStatus?: 'undeployed' | null + capabilities?: unknown + reasoningLevels?: unknown + reasoningDefaultLevel?: unknown + reasoningFixedLevel?: unknown + reasoningBindingSupported?: unknown + reasoningSource?: unknown + refreshedAt?: unknown +} + +export type ManageMapping = { + alias?: string + target?: string + provider?: string + enabled?: boolean + isDefault?: boolean + /** + * 这条映射**挂不到任何模型行**(目标上游不在清单里 = `dangling`, + * `carried: false` 时说明该上游已下线 / 改了 ID)。见桌面端 + * `models-dangling.tsx` 的说明 —— 手机端只在模型页顶部提示 + 清理。 + */ + dangling?: boolean + carried?: boolean +} +export type ManageView = { models?: ManageModel[]; mappings?: ManageMapping[] } + +export function fetchManage(signal?: AbortSignal): Promise { + return apiCall('/api/models/manage', { signal }) +} + +export function setModelEnabled(payload: { + id: string + provider: string + enabled: boolean +}): Promise { + return apiCall('/api/models/state', { method: 'POST', body: payload }) +} + +/** + * 删除一条映射(三元组定位)。手机端只用于清理「未挂载映射」—— + * 常规的映射编辑仍在桌面端「模型管理」里做。 + */ +export function removeModelMapping(payload: { + alias: string + target: string + provider?: string +}): Promise { + return apiCall('/api/models/mappings/remove', { method: 'POST', body: payload }) +} + +export function refreshModels(): Promise { + return apiCall('/api/models/refresh', { method: 'POST' }) +} + +/* ─── 网关 Key ─────────────────────────────────── */ + +export type GatewayKey = { + id?: string + name?: string + key?: string + masked?: string + enabled?: boolean + createdAt?: number + allowedProviders?: string[] + allowedModels?: string[] +} + +export type KeysPayload = { + keys?: GatewayKey[] + authRequired?: boolean + providers?: ProviderSummary[] + modelsByProvider?: Record +} + +export function fetchKeys(signal?: AbortSignal): Promise { + return apiCall('/api/keys', { signal }) +} + +export function setKeyEnabled(id: string, enabled: boolean): Promise { + return apiCall(`/api/keys/${encodeURIComponent(id)}`, { method: 'PATCH', body: { enabled } }) +} + +export function deleteKey(id: string): Promise { + return apiCall(`/api/keys/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + +/* ─── 网络代理 ─────────────────────────────────── */ + +export type PoolItem = ProxyUsage & { + id: string + name: string + source: string + enabled: boolean + sourceEnabled?: boolean + localDisabled?: boolean + listenerUid?: string + protocol: string + host: string + port: number | null + username: string + password: string + resolved?: { protocol?: string; host?: string; port?: number | null; label?: string } | null + resolveError?: string | null + lastTest?: { at?: number; success?: boolean; ip?: string; error?: string; durationMs?: number } | null +} + +export type PoolPayload = { items?: PoolItem[]; clash?: import('../../../ui-shared/clash-proxy').ClashProxySnapshot; changes?: number; syncError?: string | null } +export type ProxyDraft = Pick +export type ProxyTestResult = PoolPayload & { + success?: boolean + error?: string + ip?: string + durationMs?: number + saveError?: string +} +export type ProxySettings = { testUrl?: string; defaults?: { testUrl?: string } } + +export function fetchProxyPool(signal?: AbortSignal): Promise { + return apiCall('/api/proxies/pool', { signal }) +} + +export function syncClashProxies(): Promise { + return apiCall('/api/proxies/pool/sync-clash', { method: 'POST' }) +} + +export function saveProxyItem(draft: ProxyDraft, id?: string): Promise { + return apiCall(id ? '/api/proxies/pool/update' : '/api/proxies/pool', { + method: 'POST', body: { ...draft, source: 'manual', ...(id ? { id } : {}) }, + }) +} + +export function removeProxyItem(id: string): Promise { + return apiCall('/api/proxies/pool/remove', { method: 'POST', body: { id } }) +} + +export function setProxyEnabled(id: string, enabled: boolean): Promise { + return apiCall('/api/proxies/pool/enable', { method: 'POST', body: { id, enabled } }) +} + +export function testProxy(id: string): Promise { + return apiCall('/api/proxies/pool/test', { method: 'POST', body: { id } }) +} + +export function testProxyDraft(draft: ProxyDraft): Promise { + return apiCall('/api/proxies/test', { method: 'POST', body: { proxy: { ...draft, source: 'custom' } } }) +} + +export function fetchProxySettings(): Promise { + return apiCall('/api/proxies/settings') +} + +export function saveProxySettings(testUrl: string): Promise { + return apiCall('/api/proxies/settings', { method: 'POST', body: { testUrl } }) +} + +/* ─── 定时任务 ─────────────────────────────────── */ + +export type ScheduledTask = { + id?: string + label?: string + description?: string + unit?: string + runner?: 'backend' | 'frontend' + enabled?: boolean + interval?: number + min?: number + max?: number + defaultInterval?: number + running?: boolean + lastRunAt?: number | null + lastSuccessAt?: number | null + lastResult?: string | null + lastError?: string | null + nextRunAt?: number | null + canRun?: boolean +} + +export function fetchScheduledTasks(signal?: AbortSignal): Promise<{ tasks?: ScheduledTask[] }> { + return apiCall<{ tasks?: ScheduledTask[] }>('/api/scheduled-tasks', { signal }) +} + +export function patchScheduledTask( + id: string, + patch: { enabled?: boolean; interval?: number }, +): Promise { + return apiCall(`/api/scheduled-tasks/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch }) +} + +export function runScheduledTask(id: string): Promise { + return apiCall(`/api/scheduled-tasks/${encodeURIComponent(id)}/run`, { method: 'POST' }) +} + +/* ─── Agent 接管 ───────────────────────────────── */ + +export type AgentClient = { + resources?: import('../../../ui-shared/agent-resources').ResourceCapabilities + id?: string + name?: string + resolvedPath?: string + configPath?: string + viaGateway?: boolean + configMode?: string + installed?: boolean + readError?: string | null + needsRestart?: boolean + detectionState?: string + supportNote?: string + applied?: { canUndo?: boolean; canDisconnect?: boolean; modifiedExternally?: boolean } + [key: string]: unknown +} + +export type AgentRestorePreview = { + token: string + warnings: string[] + files: Array<{ path: string; diff: Array<{ kind: 'same' | 'added' | 'removed'; text: string }> }> +} + +export function fetchAgents(signal?: AbortSignal): Promise<{ clients?: AgentClient[] }> { + return apiCall<{ clients?: AgentClient[] }>('/api/agents', { signal }) +} + +export function previewAgentRestore(clientId: string): Promise { + return apiCall('/api/agents/preview', { method: 'POST', body: { clientId, operation: 'restore' } }) +} + +export function restoreAgentClient(clientId: string, token: string): Promise { + return apiCall('/api/agents/restore', { method: 'POST', body: { clientId, operation: 'restore', token } }) +} + +/* ─── 账号余额 ─────────────────────────────── */ + +export type UsageRow = import('../../../ui-shared/usage-query-state').UsageQueryRow + +export type UsageSnapshot = { + sorting?: AccountSorting + /** 全部行里最新的结论时刻 */ + at?: number + results?: UsageRow[] + skipped?: number +} + +/** 最近一次查询结果的快照(只读记录表,不触发上游查询) */ +export function fetchUsageSnapshot(signal?: AbortSignal): Promise { + return apiCall('/api/accounts/usage/snapshot', { signal }) +} + +/** 手动查询单个账号余额(会真的打上游,可能慢) */ +export function fetchAccountUsage(id: string): Promise<{ results?: UsageRow[]; skipped?: number }> { + return apiCall<{ results?: UsageRow[]; skipped?: number }>( + `/api/accounts/usage?id=${encodeURIComponent(id)}`, + ) +} + +/** 改备注名(后端 PATCH 支持 name 字段,空串会被 400 拒绝) */ +export function renameAccount(id: string, name: string): Promise { + return apiCall(`/api/accounts/${encodeURIComponent(id)}`, { method: 'PATCH', body: { name } }) +} + +/** 不传 model 时清除账号冷却与全部模型限流标记。 */ +export function clearAccountRateLimits(id: string, model?: string): Promise { + return apiCall(`/api/accounts/${encodeURIComponent(id)}/rate-limits/clear`, { + method: 'POST', + body: model ? { model } : {}, + }) +} + +/** 把账号设为全局队列第一位(设为首选) */ +export function promoteAccount(id: string): Promise { + return apiCall('/api/accounts/current', { method: 'POST', body: { id } }) +} + diff --git a/desktop-tauri/mobile/src/api/plugins.ts b/desktop-tauri/mobile/src/api/plugins.ts new file mode 100644 index 000000000..259830a4d --- /dev/null +++ b/desktop-tauri/mobile/src/api/plugins.ts @@ -0,0 +1,54 @@ +import { apiCall } from './client' +import { githubErrorMessage } from '../../../ui-shared/updates' +import { t } from '../i18n' +import type { + PluginMutation, PluginOperation, PluginSettings, PluginsPayload, PluginUpdate, ReleaseState, +} from '../../../ui-shared/plugins' + +export type { + PackProvider, PackSummary, PluginMutation, PluginOperation, PluginSettings, + PluginsPayload, PluginUpdate, StorePlugin, +} from '../../../ui-shared/plugins' + +export function pluginErrorMessage(error: unknown): string { + return githubErrorMessage(error, t, 'plugin') +} + +export function getPlugins(signal?: AbortSignal): Promise { + return apiCall('/api/plugins', { signal }) +} + +export function refreshPluginCatalog(): Promise { + return apiCall('/api/plugins/release') +} + +export function checkPluginUpdates(): Promise<{ error?: boolean; reason?: string; updates?: PluginUpdate[] }> { + return apiCall('/api/plugins/update') +} + +export function startPluginInstall(id: string, target: string, update = false): Promise<{ operation: PluginOperation }> { + return apiCall(update ? '/api/plugins/update' : '/api/plugins/install-from-release', { + method: 'POST', + body: { id, target }, + }) +} + +export function setPluginEnabled(id: string, enabled: boolean): Promise { + return apiCall('/api/plugins/enable', { method: 'POST', body: { id, enabled } }) +} + +export function setPluginProvider(id: string, provider: string, enabled: boolean): Promise { + return apiCall('/api/plugins/provider', { method: 'POST', body: { id, provider, enabled } }) +} + +export function uninstallPlugin(id: string): Promise { + return apiCall('/api/plugins/uninstall', { method: 'POST', body: { id } }) +} + +export function getPluginSettings(signal?: AbortSignal): Promise { + return apiCall('/api/plugins/settings', { signal }) +} + +export function savePluginSettings(body: Partial): Promise { + return apiCall('/api/plugins/settings', { method: 'PATCH', body }) +} diff --git a/desktop-tauri/mobile/src/api/updates.ts b/desktop-tauri/mobile/src/api/updates.ts new file mode 100644 index 000000000..35b60532a --- /dev/null +++ b/desktop-tauri/mobile/src/api/updates.ts @@ -0,0 +1,24 @@ +import { apiCall } from './client' +import { t } from '../i18n' +import type { UpdateChannels, UpdateCheckPlan } from '../../../ui-shared/updates' + +export type { UpdateChannels, UpdateCheckPlan } from '../../../ui-shared/updates' + +export function getUpdateChannels(signal?: AbortSignal): Promise { + return apiCall('/api/update/channels', { signal }) +} + +export function saveUpdateChannels(body: Partial>): Promise { + return apiCall('/api/update/channels', { method: 'POST', body }) +} + +export async function getUpdateCheckPlan(signal?: AbortSignal): Promise { + const payload = await apiCall<{ tasks?: Array }>('/api/scheduled-tasks', { signal }) + const task = payload.tasks?.find(task => task.id === 'updateCheck') + if (!task) throw new Error(t('软件版本检查设置不可用')) + return task +} + +export function saveUpdateCheckPlan(body: Partial>): Promise { + return apiCall('/api/scheduled-tasks/updateCheck', { method: 'PATCH', body }) +} diff --git a/desktop-tauri/mobile/src/api/zcode.ts b/desktop-tauri/mobile/src/api/zcode.ts new file mode 100644 index 000000000..0c58dd165 --- /dev/null +++ b/desktop-tauri/mobile/src/api/zcode.ts @@ -0,0 +1,113 @@ +import { apiCall } from './client' +import { serverIdentity } from '../lib/servers' +import { t } from '../i18n' + +export type { Entitlement, PreviewPlan, OwnedPlan, PreviewResult, ClaimResult } from '../../../ui-shared/zcode-plans' +import type { PreviewResult, ClaimResult } from '../../../ui-shared/zcode-plans' + +export type CaptchaPhase = 'preparing' | 'verifying' | 'submitting' +export type CaptchaPoolStats = { + ready?: number; target?: number; required?: boolean; needsTokens?: boolean + demandActive?: boolean; startPlanAccounts?: number; configError?: string; error?: string +} +type CaptchaConfig = { enabled?: boolean; region?: string; prefix?: string; sceneId?: string; skipModelRequest?: boolean } +type CaptchaApi = { + solve(config: CaptchaConfig, request: (param: string) => Promise<{ captchaResult: boolean; bizResult: boolean; result: T }>, signal?: AbortSignal, + options?: { protocol: 'v3'; onPhase?: (phase: CaptchaPhase) => void }): Promise<{ result: T }> + requestToken(config: CaptchaConfig, signal?: AbortSignal, options?: { onPhase?: (phase: CaptchaPhase) => void }): Promise<{ param: string }> + cancel(): boolean + isBusy(): boolean +} + +const captchaApi = () => (window as Window & { wbAliyunCaptcha?: CaptchaApi }).wbAliyunCaptcha +let captchaLoading: Promise | null = null + +/** 只复用主程序的验证码实现,原生包也携带这份脚本,不从插件加载页面。 */ +function loadCaptcha(): Promise { + const api = captchaApi() + if (api) return Promise.resolve(api) + if (captchaLoading) return captchaLoading + captchaLoading = new Promise((resolve, reject) => { + const script = document.createElement('script') + const timer = window.setTimeout(() => finish(new Error(t('验证码组件加载超时,请重试'))), 15_000) + const finish = (error?: Error) => { + window.clearTimeout(timer) + script.onload = null + script.onerror = null + const loaded = captchaApi() + if (error || !loaded) { script.remove(); reject(error || new Error(t('验证码组件未加载,请重试'))) } + else resolve(loaded) + } + script.src = new URL('aliyun-captcha.js', document.baseURI).href + script.onload = () => finish() + script.onerror = () => finish(new Error(t('验证码组件加载失败,请重试'))) + document.head.appendChild(script) + }).catch(error => { captchaLoading = null; throw error }) + return captchaLoading +} + +const accountPath = (id: string) => `/api/accounts/${encodeURIComponent(id)}` + +export function previewPlans(id: string): Promise { + return apiCall(`${accountPath(id)}/zcode-claim/preview`, { method: 'POST' }) +} + +export async function claimPlan(id: string, planId: string, signal?: AbortSignal, onPhase?: (phase: CaptchaPhase) => void): Promise { + const identity = serverIdentity() + const requireCurrent = () => { + if (signal?.aborted || serverIdentity() !== identity) throw new DOMException('请求已取消', 'AbortError') + } + requireCurrent() + onPhase?.('preparing') + const config = await apiCall(`${accountPath(id)}/zcode-claim/captcha-config`, { method: 'POST', signal }) + requireCurrent() + if (!config || typeof config.enabled !== 'boolean') throw new Error(t('风控验证配置不完整,请稍后重试')) + const claim = (captchaVerifyParam: string) => { + requireCurrent() + onPhase?.('submitting') + return apiCall(`${accountPath(id)}/zcode-claim`, { + method: 'POST', body: { planId, captchaVerifyParam, captchaRegion: config.region || '' }, + }) + } + if (!config.enabled) return claim('') + const captcha = await loadCaptcha() + requireCurrent() + const response = await captcha.solve({ ...config, region: config.region || 'cn' }, async param => { + const result = await claim(param) + return { captchaResult: result.failure !== 'captcha', bizResult: Boolean(result.ok), result } + }, signal, { protocol: 'v3', onPhase }) + requireCurrent() + return response.result +} + +export function captchaPoolStats(signal?: AbortSignal): Promise { + return apiCall('/api/zcode/captcha', { signal }) +} + +export async function verifyPlanChannel(id: string, signal?: AbortSignal, onPhase?: (phase: CaptchaPhase) => void): Promise { + const identity = serverIdentity() + const requireCurrent = () => { + if (signal?.aborted || serverIdentity() !== identity) throw new DOMException('请求已取消', 'AbortError') + } + requireCurrent() + onPhase?.('preparing') + const config = await apiCall(`${accountPath(id)}/zcode-claim/captcha-config`, { method: 'POST', signal }) + requireCurrent() + const stats = await captchaPoolStats(signal) + requireCurrent() + if (stats.required === false) return false + const captcha = await loadCaptcha() + requireCurrent() + const token = await captcha.requestToken(config, signal, { onPhase }) + requireCurrent() + onPhase?.('submitting') + const result = await apiCall<{ accepted?: number }>('/api/zcode/captcha', { + method: 'POST', body: { tokens: [{ param: token.param, region: config.region || '' }] }, + }) + if (result.accepted !== 1) throw new Error(t('验证码令牌未全部入池')) + return true +} + +export function cancelPlanCaptcha(): void { + captchaApi()?.cancel() +} diff --git a/desktop-tauri/mobile/src/components/AccountsFilterSheet.tsx b/desktop-tauri/mobile/src/components/AccountsFilterSheet.tsx new file mode 100644 index 000000000..bb6c7dbf5 --- /dev/null +++ b/desktop-tauri/mobile/src/components/AccountsFilterSheet.tsx @@ -0,0 +1,254 @@ +import { Sheet, SheetContent, SheetHeader, SheetBody, SheetFooter, SheetTitle , Button } from '@ui' +import { Icon } from './Icon' +import { ProviderLogo } from './ProviderLogo' +import { providerLabel } from '../lib/providers' +import { t } from '../i18n' +import { Toggle as UiToggle } from '@ui' + +/** + * 账号筛选面板(账号页右上角那颗「筛选」按钮打开)。 + * + * ── 为什么从横排 chip 换成侧边面板 ──────────────────────────── + * 原来只有「全部 / 启用 / 限流 / 禁用」四颗横排 chip。四颗还放得下,但账号 + * 页真正要筛的维度不止这一个:提供商、有效期、余额状态各自都是一组。全都 + * 横排会变成一条要左右滑动的长条 —— 手机上滑条本身比内容还难操作,而且 + * 用户看不出「现在到底筛了什么」。 + * + * 面板里每个维度占一行、各自单选(提供商多选),底部常驻「重置」与「完成」。 + * 条件**即时生效**:关掉面板就能看到结果,不需要先点应用再看 —— 手机上多 + * 一步确认只会让人怀疑自己点没点对。 + * + * ── 为什么是右侧而不是底部 ──────────────────────────────────── + * 底部抽屉会盖住列表的下半部分,而筛选恰恰是「边看结果边调条件」的动作。 + * 右侧抽屉只占 340px(窄屏 86vw),左边还能看到列表在变。 + */ + +export type StatusFilter = 'all' | 'enabled' | 'disabled' | 'limited' | 'relogin' | 'expired' +export type ExpiryFilter = 'all' | 'soon' | 'expired' | 'longlived' +export type UsageFilter = 'all' | 'has' | 'unqueried' | 'failed' + +export type AccountFilters = { + status: StatusFilter + /** 选中的提供商 id;空数组 = 不限制 */ + providers: string[] + expiry: ExpiryFilter + usage: UsageFilter +} + +export const DEFAULT_FILTERS: AccountFilters = { + status: 'all', + providers: [], + expiry: 'all', + usage: 'all', +} + +/** 生效中的筛选维度个数(按钮上的角标;提供商按一个维度算) */ +export function activeFilterCount(filters: AccountFilters): number { + let count = 0 + if (filters.status !== 'all') count++ + if (filters.providers.length) count++ + if (filters.expiry !== 'all') count++ + if (filters.usage !== 'all') count++ + return count +} + +export function AccountsFilterSheet({ + open, + onClose, + filters, + onChange, + providers, + counts, +}: { + open: boolean + onClose: () => void + filters: AccountFilters + onChange: (next: AccountFilters) => void + /** 当前账号里出现过的提供商(id + 展示名) */ + providers: Array<{ id: string; label: string }> + /** 各状态的账号数,显示在选项右侧 */ + counts: { + status: Record + expiry: Record + usage: Record + } +}) { + const patch = (next: Partial) => onChange({ ...filters, ...next }) + + const toggleProvider = (id: string) => { + const picked = filters.providers.includes(id) + ? filters.providers.filter(item => item !== id) + : [...filters.providers, id] + patch({ providers: picked }) + } + + return ( + { if (!next) onClose() }}> + + + {t('筛选')} + + +
+ patch({ status: 'all' })} + /> + patch({ status: 'enabled' })} + /> + patch({ status: 'disabled' })} + /> + patch({ status: 'limited' })} + /> + patch({ status: 'relogin' })} + /> + patch({ status: 'expired' })} + /> +
+ + {providers.length > 1 ? ( +
+ {providers.map(item => ( + toggleProvider(item.id)} + style={{ gap: 6 }} + > + + {item.label} + + ))} +
+ ) : null} + +
+ patch({ expiry: 'all' })} + /> + patch({ expiry: 'soon' })} + /> + patch({ expiry: 'expired' })} + /> + patch({ expiry: 'longlived' })} + /> +
+ +
+ patch({ usage: 'all' })} + /> + patch({ usage: 'has' })} + /> + patch({ usage: 'unqueried' })} + /> + patch({ usage: 'failed' })} + /> +
+ +

+ {t('条件即时生效,关掉面板就能看到结果。')} +

+
+ + + + +
+
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+ {title} +
+
{children}
+
+ ) +} + +function Chip({ + label, + count, + on, + onClick, +}: { + label: string + count: number + on: boolean + onClick: () => void +}) { + return ( + + {label} + {count} + + ) +} diff --git a/desktop-tauri/mobile/src/components/AppUpdatePanel.tsx b/desktop-tauri/mobile/src/components/AppUpdatePanel.tsx new file mode 100644 index 000000000..bb8a47b07 --- /dev/null +++ b/desktop-tauri/mobile/src/components/AppUpdatePanel.tsx @@ -0,0 +1,385 @@ +import { Progress as UiProgress } from '@ui' +import { useEffect, useRef, useState } from 'react' +import { SectionHead } from './ui' +import { Icon } from './Icon' +import { store } from '../state/store' +import { + APP_VERSION, + autoCheckEnabled, + autoCheckIntervalMs, + canInstallApk, + canSelfUpdate, + cancelDownload, + checkApkUpdate, + downloadApk, + hasDownloadedApk, + installApk, + openInstallPermission, + setAutoCheckEnabled, + type ApkUpdateInfo, + type DownloadProgress, +} from '../lib/appUpdate' +import { GithubAccessSettingsPanel } from './GithubAccessSettings' +import { AppUpdateSettings } from './AppUpdateSettings' +import { openExternal } from '../lib/community' +import { t } from '../i18n' +import { githubErrorMessage, assetMissingReason } from '../../../ui-shared/updates' +import { Card, Button } from '@ui' +import { Switch as UiSwitch } from '@ui' + +/** + * 应用内更新面板(Android / iOS 共用,行为按平台分叉)。 + * + * ── 状态机 ────────────────────────────────────────────────────── + * idle → checking → (up-to-date | available) → downloading → downloaded + * 任一步失败回落到 idle 并给错误提示。 + * + * ── iOS 为什么只有「去下载」 ──────────────────────────────────── + * 苹果不允许 App 自行下载并安装可执行代码,IPA 只能由系统装(App Store / + * TestFlight / 侧载工具)。所以 iOS 上这个面板到「发现新版本」为止,动作 + * 按钮改成打开 GitHub Release 页面,由用户自己选渠道 —— 不渲染下载进度条 + * 与「安装」按钮,避免给一个必然失败的承诺。 + * + * ── 自动检查 ──────────────────────────────────────────────────── + * 挂载时按电脑端配置的间隔轮询检查(间隔读 `/api/scheduled-tasks` 的 + * `updateCheck` 条目,见 lib/appUpdate)。页面不可见时停表 —— 与各列表页的 + * 轮询同一套可见性规则,手机上不该在后台耗流量。 + * + * ── 为什么「已下载」要单独一档 ────────────────────────────────── + * 用户下完包、跳去授权、再回来时组件会重新挂载 —— 如果只按内存状态判断, + * 界面会回到「有新版本」让他重下一遍(几十 MB)。所以挂载时问一次原生 + * `downloaded()`,有包就直接进「待安装」。 + * + * ── 授权顺序 ──────────────────────────────────────────────────── + * 先下载再要授权:下载不需要任何权限,而授权会跳走系统设置页 —— 先要权限 + * 再下载,用户跳回来时下载还没开始,体验是断的。 + */ +type Phase = 'idle' | 'checking' | 'latest' | 'available' | 'downloading' | 'downloaded' + +export function AppUpdatePanel() { + const [phase, setPhase] = useState('idle') + const [info, setInfo] = useState(null) + const [progress, setProgress] = useState(null) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [auto, setAuto] = useState(autoCheckEnabled) + const [settingsRevision, setSettingsRevision] = useState(0) + const channelRevision = useRef(0) + const checkInFlight = useRef(false) + /** 自动检查的间隔(0 = 网关关了这条任务) */ + const [interval, setIntervalMs] = useState(0) + /** 当前壳能不能自装安装包:只有 Android 能(见 lib/appUpdate) */ + const selfUpdate = canSelfUpdate() + + // 挂载时先看缓存里有没有上次下好的包(iOS 上恒为 false) + useEffect(() => { + let cancelled = false + void hasDownloadedApk().then(result => { + if (!cancelled && result.exists) setPhase('downloaded') + }) + return () => { + cancelled = true + } + }, []) + + /** + * 自动检查:间隔读电脑端配置,页面不可见时停表。 + * + * 为什么把 interval 放进依赖:用户在电脑端改了间隔,手机端下次进来就该 + * 按新间隔走 —— 组件重挂时重新读一次即可(设置页每次进入都会重挂)。 + */ + useEffect(() => { + let disposed = false + let effectiveInterval = 0 + let timer: number | null = null + + const clear = () => { + if (timer !== null) { + window.clearInterval(timer) + timer = null + } + } + const start = (ms: number) => { + clear() + if (disposed || ms <= 0) return + timer = window.setInterval(() => { + if (document.visibilityState === 'visible') void runCheck(true) + }, ms) + } + + void (async () => { + const ms = await autoCheckIntervalMs() + if (disposed) return + setIntervalMs(ms) + effectiveInterval = ms + if (!auto || ms <= 0) return + await runCheck(true) + start(ms) + })() + + const onVisibility = () => { + if (auto && document.visibilityState === 'visible' && effectiveInterval > 0) void runCheck(true) + } + document.addEventListener('visibilitychange', onVisibility) + + return () => { + disposed = true + clear() + document.removeEventListener('visibilitychange', onVisibility) + } + // 设置变更后重读全局检查计划;本机开关只决定是否启动轮询。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auto, settingsRevision]) + + // 避免自动检查覆盖用户正在进行的下载 / 已下载待安装 + const phaseRef = useRef(phase) + phaseRef.current = phase + + /** `silent` = 自动检查:失败不弹错误、不打断当前状态 */ + const runCheck = async (silent = false) => { + const current = phaseRef.current + if (current === 'downloading' || current === 'downloaded' || checkInFlight.current) return + const revision = channelRevision.current + checkInFlight.current = true + if (!silent) { + setBusy(true) + setPhase('checking') + } + if (!silent) setError('') + try { + const result = await checkApkUpdate() + if (revision !== channelRevision.current) return + setInfo(result) + // hasUpdate 为 null = 版本无法比较(上游 tag 不是 semver); + // 这时只要挂了 APK 就允许手动更新,而不是卡在「未知」上 + const canUpdate = result.hasUpdate === true || (result.hasUpdate === null && result.asset !== null) + setPhase(prev => (prev === 'downloading' || prev === 'downloaded' ? prev : canUpdate ? 'available' : 'latest')) + } catch (failure) { + if (!silent && revision === channelRevision.current) { + setError(githubErrorMessage(failure, t, 'update')) + setPhase('idle') + } + } finally { + checkInFlight.current = false + if (!silent) setBusy(false) + } + } + + const download = async () => { + const url = info?.asset?.url + if (!url) { + // 手机端要的包固定是 `.apk`(后端按后缀挑,见 pick_android_apk)—— + // 原因与桌面端同一处口径,只是「本机要什么」这一项换成 apk + setError(t('这一版没有提供 Android 安装包:{reason}', { + reason: assetMissingReason(info?.assetProbe, { fallbackSuffix: '.apk' }, t), + })) + return + } + setBusy(true) + setError('') + setProgress({ received: 0, total: 0, percent: -1 }) + setPhase('downloading') + try { + await downloadApk(url, setProgress) + setPhase('downloaded') + } catch (failure) { + setError(githubErrorMessage(failure, t, 'update')) + setPhase('available') + } finally { + setBusy(false) + setProgress(null) + } + } + + const install = async () => { + setBusy(true) + setError('') + try { + // 先确认授权:没有的话跳到系统设置页,回来后用户再点一次安装 + if (!(await canInstallApk())) { + store.toast(t('请允许安装未知来源应用后重试')) + await openInstallPermission() + return + } + await installApk() + store.toast(t('已启动安装器')) + } catch (failure) { + setError(githubErrorMessage(failure, t, 'update')) + } finally { + setBusy(false) + } + } + + const stop = async () => { + await cancelDownload() + setPhase('available') + setProgress(null) + setBusy(false) + } + + /** + * iOS 的「更新」只能跳转:打开 GitHub Release 页面,由用户自己选渠道 + * (TestFlight / App Store / 侧载)。`pageUrl` 是网关从 GitHub API 拿到的 + * release 页面地址,没有它时退回**源码仓库**的 releases 列表 —— 构建账号 + * (安装包实际存放处)不是项目地址,不能拿来当兜底。 + */ + const openReleasePage = () => { + const target = info?.pageUrl || 'https://github.com/aimod-cc/Agent2API/releases' + openExternal(target) + } + + const toggleAuto = () => { + const next = !auto + setAutoCheckEnabled(next) + setAuto(next) + store.toast(next ? t('已开启自动检查') : t('已关闭自动检查')) + } + + const percent = progress && progress.percent >= 0 ? progress.percent : null + const sizeText = (bytes?: number) => { + if (!bytes) return '' + const mb = bytes / 1024 / 1024 + return `${mb >= 100 ? mb.toFixed(0) : mb.toFixed(1)} MB` + } + + return ( +
+ + {info?.forceUpdate?.required ?
{t('需要强制更新')} · {info.forceUpdate.message}
: null} + +
+ + + +
+
+ {phase === 'latest' + ? t('已是最新版本') + : phase === 'available' + ? t('发现新版本 {version}', { version: info?.latestVersion || '' }) + : phase === 'downloading' + ? progress?.phase === 'selecting' ? t('正在选择下载通道…') : t('正在下载…') + : phase === 'downloaded' + ? t('已下载,待安装') + : phase === 'checking' + ? t('正在检查…') + : t('当前版本 {version}', { version: APP_VERSION })} +
+
+ {phase === 'available' && !selfUpdate + ? t('请在 Release 页面下载后通过 TestFlight 或 App Store 安装') + : phase === 'available' && info?.asset + ? t('{size} · 下载后可直接安装,无需跳转浏览器', { size: sizeText(info.asset.size) }) + : phase === 'downloaded' + ? t('安装包已就绪,点「安装」即可完成更新') + : phase === 'downloading' && percent !== null + ? t('已完成 {percent}%', { percent }) + : phase === 'latest' + ? t('当前版本 {version}', { version: APP_VERSION }) + : selfUpdate + ? t('版本 {version} · 按 GitHub 访问设置下载安装包', { version: APP_VERSION }) + : t('版本 {version} · 更新包需从 Release 页面获取', { version: APP_VERSION })} +
+ + {phase === 'downloading' && progress?.source ?
+ {t('当前下载通道:{source}', { source: progress.source })} +
: null} + + {/* 进度条:总长度未知(percent = null)时不画,只显示已下载量 */} + {phase === 'downloading' ? ( +
+ + {progress ? ( +
+ {sizeText(progress.received)} + {progress.total > 0 ? ` / ${sizeText(progress.total)}` : ''} +
+ ) : null} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} +
+ + {/* 动作按钮:按状态给唯一的主动作,避免一屏好几个可点的东西 */} + {phase === 'downloading' ? ( + + ) : phase === 'downloaded' ? ( + + ) : phase === 'available' && !selfUpdate ? ( + // iOS:只能跳出去,由系统渠道安装 + + ) : phase === 'available' ? ( + + ) : ( + + )} +
+ + {/* 更新说明:有内容才展开,避免空卡片 */} + {info?.notes && (phase === 'available' || phase === 'downloaded') ? ( +
+ {info.notes} +
+ ) : null} + + {/* 自动检查开关:间隔跟电脑端一致,这里只控制「这台手机查不查」 */} +
+ + + +
+
{t('允许本机自动检查')}
+
+ {interval > 0 + ? t('与电脑端一致:每 {n} 分钟', { n: Math.round(interval / 60_000) }) + : t('网关已关闭自动检查,此处只做手动检查')} +
+
+ toggleAuto()} /> +
+ { + if (channelsChanged) channelRevision.current += 1 + if (channelsChanged && phaseRef.current !== 'downloading' && phaseRef.current !== 'downloaded') { + setInfo(null) + setPhase('idle') + setError('') + } + setSettingsRevision(revision => revision + 1) + }} /> + +
+
+ ) +} diff --git a/desktop-tauri/mobile/src/components/AppUpdateSettings.tsx b/desktop-tauri/mobile/src/components/AppUpdateSettings.tsx new file mode 100644 index 000000000..0dabb1ae6 --- /dev/null +++ b/desktop-tauri/mobile/src/components/AppUpdateSettings.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react' +import { Button, Input, Switch } from '@ui' +import { getUpdateChannels, getUpdateCheckPlan, saveUpdateChannels, saveUpdateCheckPlan, type UpdateChannels, type UpdateCheckPlan } from '../api/updates' +import { serverIdentity } from '../lib/servers' +import { store } from '../state/store' +import { t } from '../i18n' +import { Alert } from '@ui' + +export function AppUpdateSettings({ onChanged }: { onChanged: (channelsChanged: boolean) => void }) { + const [channels, setChannels] = useState(null) + const [plan, setPlan] = useState(null) + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const identity = serverIdentity() + + useEffect(() => { + const controller = new AbortController() + void getUpdateChannels(controller.signal).then(value => { + if (!controller.signal.aborted) setChannels(value) + }).catch(cause => { if (!controller.signal.aborted) setError(String(cause)) }) + void getUpdateCheckPlan(controller.signal).then(value => { + if (!controller.signal.aborted) { setPlan(value); setDraft(String(value.interval)) } + }).catch(cause => { if (!controller.signal.aborted) setError(String(cause)) }) + return () => controller.abort() + }, []) + + async function saveChannel(patch: Partial>) { + if (busy || !channels) return + const next = { ...channels, ...patch } + if (!next.stable && !next.beta) { + setError(t('至少需要关注正式版或 beta 版中的一个通道')) + return + } + setBusy(true) + setError('') + try { + const result = await saveUpdateChannels(patch) + if (serverIdentity() !== identity) return + setChannels(result) + store.toast(result.saved === false ? t('通道已切换,但写入磁盘失败(重启后会回到原设置)') : t('更新通道已保存'), result.saved === false ? 'err' : 'ok') + onChanged(true) + } catch (cause) { + if (serverIdentity() === identity) setError(cause instanceof Error ? cause.message : String(cause)) + } finally { setBusy(false) } + } + + async function savePlan(patch: Partial>) { + if (busy) return + setBusy(true) + setError('') + try { + const result = await saveUpdateCheckPlan(patch) + if (serverIdentity() !== identity) return + setPlan(result) + setDraft(String(result.interval)) + store.toast(result.saved === false ? t('设置已生效,但写入磁盘失败(重启后会恢复原设置)') : t('✅ 自动检查更新已保存'), result.saved === false ? 'err' : 'ok') + onChanged(false) + } catch (cause) { + if (serverIdentity() === identity) setError(cause instanceof Error ? cause.message : String(cause)) + } finally { setBusy(false) } + } + + function saveInterval() { + const interval = Number(draft) + if (!Number.isInteger(interval) || interval < 1 || interval > 1440) { + setError(t('检查间隔必须是 {min} ~ {max} 分钟', { min: 1, max: 1440 })) + return + } + void savePlan({ interval }) + } + + return
+
{t('更新设置')}
+

{t('以下设置属于所连接网关,不是手机本地设置。')}

+
+ {t('自动检查更新')} + void savePlan({ enabled: value === true })} /> +
+
+ setDraft(event.currentTarget.value)} aria-label={t('检查间隔(分钟)')} /> + {t('分钟({min} ~ {max})', { min: 1, max: 1440 })} + +
+
+ {t('关注正式版更新')} + void saveChannel({ stable: value === true })} /> +
+

{t('默认开启;即使当前使用 beta 版,也可收到版本号更高的正式版更新。')}

+
+ {t('关注 beta 版更新')} + void saveChannel({ beta: value === true })} /> +
+

{t('beta 版可能不稳定。两项同时开启时,按版本号选择最高版本,不会降级;安装到正式版或 beta 版后仍保留关注选择。')}

+

{t('插件默认跟随这里的更新通道、自动检查和间隔;可在插件设置中单独设置。')}

+ {error ? {error} : null} +
+} diff --git a/desktop-tauri/mobile/src/components/AssistantModelPicker.tsx b/desktop-tauri/mobile/src/components/AssistantModelPicker.tsx new file mode 100644 index 000000000..bda418330 --- /dev/null +++ b/desktop-tauri/mobile/src/components/AssistantModelPicker.tsx @@ -0,0 +1,95 @@ +import { useEffect, useMemo, useState } from 'react' +import { Alert, Button, Empty, InputGroup, InputGroupAddon, InputGroupInput, Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle } from '@ui' +import { selectedModel, type ModelOption } from '../../../ui-shared/assistant-models' +import { Icon } from './Icon' +import { t } from '../i18n' + +type Props = { + open: boolean + onClose: () => void + models: ModelOption[] + current: string + defaultModel: string + followingDefault: boolean + ready: boolean + loading: boolean + warning: string + onRefresh: () => void + onPick: (value: string) => void +} + +export function AssistantModelPicker({ + open, onClose, models, current, defaultModel, followingDefault, ready, loading, warning, onRefresh, onPick, +}: Props) { + const [query, setQuery] = useState('') + useEffect(() => { if (!open) setQuery('') }, [open]) + const filtered = useMemo(() => { + const keyword = query.trim().toLowerCase() + return keyword ? models.filter(item => item.name.toLowerCase().includes(keyword)) : models + }, [models, query]) + const currentOption = selectedModel(models, current) + const defaultAvailable = Boolean(selectedModel(models, defaultModel)) + + return ( + { if (!next) onClose() }}> + + + {t('选择对话模型')} + + + + + setQuery(event.currentTarget.value)} + placeholder={t('搜索模型…')} aria-label={t('搜索模型…')} /> + + + {warning && !loading ? {warning} : null} + + {filtered.length ? filtered.map(item => { + const picked = !followingDefault && currentOption?.name === item.name + return ( + + ) + }) : + {loading ? t('正在读取可用模型…') : query.trim() ? t('没有匹配的模型') : t('没有可用的模型')} + } +

+ {t('这里只改本次会话用的模型,不会改网关里保存的默认值。')} +

+
+
+
+ ) +} diff --git a/desktop-tauri/mobile/src/components/CommunitySheet.tsx b/desktop-tauri/mobile/src/components/CommunitySheet.tsx new file mode 100644 index 000000000..9e3c9fc17 --- /dev/null +++ b/desktop-tauri/mobile/src/components/CommunitySheet.tsx @@ -0,0 +1,145 @@ +import { Sheet, SheetContent, SheetHeader, SheetBody, SheetTitle, SheetFooter , Button } from '@ui' +import { Icon } from './Icon' +import { Badge, copyWithToast } from './ui' +import { COMMUNITY_LINKS, QQ_GROUPS, openExternal } from '../lib/community' +import { t } from '../i18n' + +/** + * 「社区与支持」面板(设置页底部那一行点开)。 + * + * ── 为什么是抽屉而不是二级页 ────────────────────────────────── + * 里面只有四张卡(三个 QQ 群 + Telegram),没有需要记住的状态、 + * 也没有下一步。做成二级页会多一层返回栏、多一条浏览器历史,用户点完就退。 + * + * ── QQ 卡片是「复制群号」,另一张是「打开链接」─────────────── + * 群号本身就是用户要拿走的东西(去 QQ 里搜),所以点一下复制;Telegram + * 是 https 链接,交给系统浏览器。两类卡片的副标题把这个差别写出来, + * 不用用户猜。 + * + * ── 为什么不再摆二维码 ──────────────────────────────────────── + * 与桌面端同一理由:二维码是打进包里的静态图,换群 / 换码得跟着发版。 + */ +export function CommunitySheet({ + open, + onClose, +}: { + open: boolean + onClose: () => void +}) { + return ( + { if (!next) onClose() }}> + + + {t('社区与支持')} + + +

+ {t('遇到问题或有使用心得,欢迎到群里交流。')} +

+ + {QQ_GROUPS.map((group, index) => ( + + ))} + + {COMMUNITY_LINKS.map(link => ( + + ))} + +

+ {t('在 QQ 里搜索群号即可加入。')} +

+
+ + + +
+
+ ) +} + +/** + * 品牌标(QQ / Telegram)。 + * + * 路径取自 simple-icons(与桌面端 `ui/icons.js` 的 brand 集同一来源、同一份 + * 路径),填充色写在 path 上 —— 这两个品牌有官方色,跟着文字色走反而不像。 + */ +function BrandGlyph({ id }: { id: 'qq' | 'telegram' }) { + const path = + id === 'qq' + ? 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673' + : 'M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z' + return ( + + ) +} diff --git a/desktop-tauri/mobile/src/components/DateRangeFilter.tsx b/desktop-tauri/mobile/src/components/DateRangeFilter.tsx new file mode 100644 index 000000000..9d8f0f268 --- /dev/null +++ b/desktop-tauri/mobile/src/components/DateRangeFilter.tsx @@ -0,0 +1,36 @@ +import { Input, Toggle } from '@ui' +import { DATE_RANGES, dayKey, updateCustomRange, type CustomDates, type DateRange } from '../../../ui-shared/date-range' +import { t } from '../i18n' + +const LABELS: Record = { + today: t('今天'), yesterday: t('昨天'), 7: t('7 天'), 30: t('30 天'), + month: t('本月'), all: t('全部'), custom: t('自定义'), +} + +export function DateRangeFilter({ range, dates, onRangeChange, onDatesChange, label }: { + range: DateRange; dates: CustomDates; onRangeChange: (range: DateRange) => void + onDatesChange: (dates: CustomDates) => void; label: string +}) { + const today = dayKey(new Date()) + const change = (patch: Partial) => { + const next = updateCustomRange(dates, patch) + if (next !== dates) onDatesChange(next) + } + return <> +
+ {DATE_RANGES.map(value => onRangeChange(value)}> + {LABELS[value]} + )} +
+ {range === 'custom' ?
+ change({ start: event.currentTarget.value })} /> + + change({ end: event.currentTarget.value })} /> +
: null} + +} diff --git a/desktop-tauri/mobile/src/components/GithubAccessSettings.tsx b/desktop-tauri/mobile/src/components/GithubAccessSettings.tsx new file mode 100644 index 000000000..9af5fd25e --- /dev/null +++ b/desktop-tauri/mobile/src/components/GithubAccessSettings.tsx @@ -0,0 +1,15 @@ +import { GithubAccessEditor } from '../../../ui-shared/github-access-editor' +import type { GithubAccessPatch, GithubAccessSettings } from '../../../ui-shared/github-access' +import { apiCall } from '../api/client' +import { store } from '../state/store' +import { t } from '../i18n' + +const load = () => apiCall('/api/update/github') +const persist = (patch: GithubAccessPatch) => apiCall('/api/update/github', { method: 'POST', body: patch }) +const notify = (message: string) => store.toast(message) + +export function GithubAccessSettingsPanel() { + return
+ +
+} diff --git a/desktop-tauri/mobile/src/components/HiddenAccountsNotice.tsx b/desktop-tauri/mobile/src/components/HiddenAccountsNotice.tsx new file mode 100644 index 000000000..919c8f09a --- /dev/null +++ b/desktop-tauri/mobile/src/components/HiddenAccountsNotice.tsx @@ -0,0 +1,36 @@ +import { Button , Card } from '@ui' +import { Badge } from './ui' +import type { HiddenAccountReason, HiddenAccountsSummary } from '../../../ui-shared/plugins' +import { store } from '../state/store' +import { t } from '../i18n' + +function reasonLabel(reason: HiddenAccountReason): string { + switch (reason) { + case 'not_installed': return t('插件未安装') + case 'pack_disabled': return t('插件包已停用') + case 'provider_disabled': return t('提供商已停用') + case 'not_loaded': return t('插件未加载') + default: return t('插件未就绪') + } +} + +export function HiddenAccountsNotice({ hidden }: { hidden?: HiddenAccountsSummary }) { + if (!hidden?.total) return null + return ( +
+ +

+ {t('有 {n} 个账号因提供商插件未就绪,暂未显示', { n: hidden.total })} +

+ {hidden.providers?.map(provider =>
+ {t(provider.label)} ×{provider.count}{reasonLabel(provider.reason)} +
)} +

+ {t('账号数据仍然保留,无需重新添加。安装或启用对应插件并成功加载后,账号会自动恢复显示。')} +

+ +
+
+ ) +} diff --git a/desktop-tauri/mobile/src/components/Icon.tsx b/desktop-tauri/mobile/src/components/Icon.tsx new file mode 100644 index 000000000..a05730996 --- /dev/null +++ b/desktop-tauri/mobile/src/components/Icon.tsx @@ -0,0 +1,124 @@ +import type { SVGProps } from 'react' + +/** + * 手机端图标集。 + * + * 与 `ui/icons.js` 同风格(24×24 画布、currentColor、描边 1.8),但只挑手机端 + * 真正用到的那批:桌面端那套有 60 多个图标,移动端菜单只有 5+9 个入口, + * 全量搬过来只会让 bundle 白胖一圈。 + * + * 用法:``,尺寸由外层控制(默认 24)。 + */ + +const PATHS: Record = { + home: '', + chart: '', + users: + '', + grid: + '', + back: '', + chev: '', + plus: '', + search: '', + close: '', + check: '', + copy: + '', + refresh: '', + play: '', + bell: '', + settings: + '', + spark: + '', + checkin: + '', + model: + '', + proxy: + '', + key: '', + doc: '', + log: '', + request: + '', + task: '', + agent: + '', + tools: + '', + wallet: + '', + shield: + '', + download: '', + trash: + '', + edit: '', + filter: '', + link: + '', + qr: + '', + moon: '', + info: '', + clock: '', + up: '', + bolt: '', + globe: '', + eye: + '', + heart: + '', + gift: + '', + alert: '', + logout: + '', + pause: '', + install: + '', + share: + '', + server: + '', + plugin: + '', +} + +export type IconName = keyof typeof PATHS + +type IconProps = SVGProps & { name: IconName } + +export function Icon({ name, ...props }: IconProps) { + const inner = PATHS[name] || PATHS.info + return ( + + ) +} + +/** 品牌标:与桌面端 icons.js 的 brand 同一造型(蓝底双向箭头) */ +export function BrandMark({ size = 32 }: { size?: number }) { + return ( + + ) +} diff --git a/desktop-tauri/mobile/src/components/MarkdownLite.tsx b/desktop-tauri/mobile/src/components/MarkdownLite.tsx new file mode 100644 index 000000000..c41161883 --- /dev/null +++ b/desktop-tauri/mobile/src/components/MarkdownLite.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react' + +/** + * 极简 Markdown 渲染(只服务助手的回答气泡)。 + * + * ── 为什么需要它 ────────────────────────────────────────────── + * 模型输出天然带 Markdown:`**账号名**`、`` `模型ID` ``、`- 列表项`。直接当 + * 纯文本铺在气泡里,用户看到的是满屏星号和反引号 —— 尤其是账号名 / 模型名 + * 这类本来就该用等宽字显示的内容,反而被符号糊住了。 + * + * ── 为什么不上一个 Markdown 库 ──────────────────────────────── + * 手机上只需要三样:粗体、行内代码、列表。引 markdown-it / react-markdown + * 会给包体加几十 KB,还要处理 HTML 白名单(模型输出是不可信内容,直接 + * `dangerouslySetInnerHTML` 等于开一个 XSS 口子)。这里用 React 元素拼, + * 天然转义,没有注入面。 + * + * 代码块(```)只按普通段落显示 —— 手机上长代码块本来就该去桌面端看, + * 为它做一套横向滚动 + 语法高亮不值。 + */ +export function MarkdownLite({ text }: { text: string }) { + const lines = String(text || '').split('\n') + const blocks: ReactNode[] = [] + let list: string[] = [] + + const flushList = (key: string) => { + if (!list.length) return + blocks.push( +
    + {list.map((item, index) => ( +
  • + {inline(item)} +
  • + ))} +
, + ) + list = [] + } + + lines.forEach((line, index) => { + const trimmed = line.trim() + // 代码围栏:整行丢掉(内容按普通行渲染,见文件头) + if (trimmed.startsWith('```')) return + const bullet = /^[-*+]\s+(.*)$/.exec(trimmed) + const numbered = /^\d+[.)]\s+(.*)$/.exec(trimmed) + if (bullet) { + list.push(bullet[1]) + return + } + if (numbered) { + list.push(numbered[1]) + return + } + flushList(`list-${index}`) + if (!trimmed) { + blocks.push(
) + return + } + // 标题(## / ###)当成加粗段落:手机气泡里字号层级够多了 + const heading = /^#{1,6}\s+(.*)$/.exec(trimmed) + blocks.push( +
+ {inline(heading ? heading[1] : trimmed)} +
, + ) + }) + flushList('list-tail') + + return <>{blocks} +} + +/** 行内:**粗体** 与 `代码`,其余原样 */ +function inline(text: string): ReactNode[] { + const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g) + return parts.filter(Boolean).map((part, index) => { + if (part.startsWith('**') && part.endsWith('**') && part.length > 4) { + return {part.slice(2, -2)} + } + if (part.startsWith('`') && part.endsWith('`') && part.length > 2) { + return ( + + {part.slice(1, -1)} + + ) + } + return {part} + }) +} diff --git a/desktop-tauri/mobile/src/components/MobileReportTrend.tsx b/desktop-tauri/mobile/src/components/MobileReportTrend.tsx new file mode 100644 index 000000000..106ca5b46 --- /dev/null +++ b/desktop-tauri/mobile/src/components/MobileReportTrend.tsx @@ -0,0 +1,57 @@ +import { useMemo, useState } from 'react' +import { Card, Empty, SegmentedControl, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ui' +import type { StatsSummary } from '../api/gateway' +import { formatInt, formatTokens } from '../lib/format' +import { reportSourceName } from './SourceUsageSection' +import { t } from '../i18n' + +export function MobileReportTrend({ summary }: { summary: StatsSummary }) { + const [dimension, setDimension] = useState<'models' | 'clients' | 'apiKeys'>('models') + const [metric, setMetric] = useState<'tokens' | 'requests'>('tokens') + const [selected, setSelected] = useState('__all__') + const options = useMemo(() => [{ value: '__all__', label: t('全部') }, ...(summary[dimension] || []).map(row => ({ + value: dimension === 'apiKeys' ? row.filterValue || 'unknown' : row.id || row.model || '__unknown__', + label: dimension === 'models' ? row.label || row.model || t('未知模型') : reportSourceName(row, dimension === 'apiKeys'), + }))], [summary, dimension]) + const displayedOptions = options.some(option => option.value === selected) ? options : [{ value: selected, label: selected }, ...options] + const trend = (summary.dailyTrend || []).map(day => ({ + date: day.date || '', + value: selected === '__all__' ? Number(day[metric]) || 0 : (day[dimension] || []).reduce((sum, row) => { + const id = row.id || row.model || '__unknown__' + return sum + (id === selected ? Number(row[metric]) || 0 : 0) + }, 0), + })) + const max = Math.max(...trend.map(row => row.value), 1) + const width = 300 + const height = 84 + const gap = trend.length > 100 ? 0 : 2 + const barWidth = Math.max(0.1, (width - gap * (trend.length - 1)) / Math.max(1, trend.length)) + return
+ {metric === 'tokens' ? t('按天 Token 趋势') : t('按天请求趋势')} +
+ { + setDimension(value as typeof dimension); setSelected('__all__') + }} options={[{ value: 'models', label: t('模型') }, { value: 'clients', label: t('客户端') }, { value: 'apiKeys', label: t('API Key') }]} /> + setMetric(value as typeof metric)} + options={[{ value: 'tokens', label: t('Token 用量') }, { value: 'requests', label: t('请求数') }]} /> + +
+ {!trend.length ? {t('暂无趋势数据')} : ( + <> + {trend.map((row, index) => { + const size = row.value / max * height + return + {row.date} · {metric === 'tokens' ? formatTokens(row.value) : formatInt(row.value)} + + })} +
+ {trend[0]?.date}{trend[trend.length - 1]?.date} +
+ )} +
+} diff --git a/desktop-tauri/mobile/src/components/NavBar.tsx b/desktop-tauri/mobile/src/components/NavBar.tsx new file mode 100644 index 000000000..0cbdd0d1c --- /dev/null +++ b/desktop-tauri/mobile/src/components/NavBar.tsx @@ -0,0 +1,31 @@ +import { Icon } from './Icon' +import { t } from '../i18n' +import { Button, PageHeader } from '@ui' + +/** + * 二级页顶部返回栏。 + * + * 结构与桌面端的移动版设置页返回栏同形(返回按钮 + 居中标题 + 右侧动作位), + * 但这里做成通用组件,所有二级页共用,避免每页各写一遍。 + * + * 右侧 `actions` 由页面自己给(刷新、新建…),没有就不占位。 + */ +export function NavBar({ + title, + onBack, + actions, +}: { + title: string + onBack: () => void + actions?: React.ReactNode +}) { + return ( + + + {title} + {actions} + + ) +} diff --git a/desktop-tauri/mobile/src/components/ProviderLogo.tsx b/desktop-tauri/mobile/src/components/ProviderLogo.tsx new file mode 100644 index 000000000..c7c576a21 --- /dev/null +++ b/desktop-tauri/mobile/src/components/ProviderLogo.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react' +import { Icon } from './Icon' +import { providerIcon, providerLabel } from '../lib/providers' + +/** + * 提供商图标块:有图用图,没图用首字。 + * + * 尺寸档与 `.m-logo` 一致(34px / `lg` 40px),这里只负责内容。 + */ +export function ProviderLogo({ + id, + label, + size = 'default', +}: { + id?: string + label?: string + size?: 'default' | 'lg' +}) { + const icon = providerIcon(id) + const [failedIcon, setFailedIcon] = useState('') + const text = providerLabel(id, label) + return ( + + {icon && failedIcon !== icon ? ( + setFailedIcon(icon)} /> + ) : ( + {text.slice(0, 1)} + )} + + ) +} + +/** 通用图标块:给「不是提供商」的入口用(签到、Key、代理…) */ +export function GlyphLogo({ + name, + size = 'default', +}: { + name: Parameters[0]['name'] + size?: 'default' | 'lg' +}) { + return ( + + + + ) +} diff --git a/desktop-tauri/mobile/src/components/PullToRefresh.tsx b/desktop-tauri/mobile/src/components/PullToRefresh.tsx new file mode 100644 index 000000000..7d7c60fff --- /dev/null +++ b/desktop-tauri/mobile/src/components/PullToRefresh.tsx @@ -0,0 +1,223 @@ +/** + * 下拉刷新基础设施。 + * + * 只提供 hook + 指示器,不负责接线:调用方把 `bindRef` 绑到页面根节点、 + * 把 `` 放进页面即可。 + * + * 定位前提:`PullIndicator` 用 `position: absolute` 贴住滚动容器顶部, + * 它的父级必须是定位上下文。mobile.css 末尾已给 `.m-page` 追加 + * `position: relative`,所以把指示器放在 `.m-page` 内即可。 + */ +import { useEffect, useRef, useState } from 'react' +import type { ReactElement, RefObject } from 'react' +import { t } from '../i18n' +import { Icon } from './Icon' + +type PullToRefreshOptions = { + /** 下拉超过这个距离(px)才触发刷新,默认 64 */ + threshold?: number + /** 刷新回调;返回 Promise 时组件会等它结束再收起 */ + onRefresh: () => Promise | unknown + /** 滚动容器。不传时自动找最近的 '.m-scroll' 或 '.m-body' 祖先 */ + scrollRef?: RefObject +} + +type PullToRefreshResult = { + /** 绑到页面根节点上(自动找滚动容器时用) */ + bindRef: RefObject + /** 当前下拉距离(px),给指示器用 */ + pull: number + /** 是否正在刷新 */ + refreshing: boolean +} + +/** 阻尼系数:手指移动 2px,指示器只长 1px */ +const DAMPING = 0.5 +/** 指示器最多展开到阈值的 1.8 倍,避免越拉越长 */ +const MAX_RATIO = 1.8 + +/** + * 找真正滚动的容器。 + * + * 本应用有两种滚动形态(见 mobile.css): + * 1. 整页滚:滚动发生在外层 `.m-scroll`; + * 2. 固定头 + 独立列表:`.m-scroll` 被 `overflow: hidden` 锁住,真正滚的是 + * 页面里的 `.m-body`。 + * + * 所以第 2 种必须优先判断:页面根节点是 `.m-page.m-fixed` 时,祖先 + * `.m-scroll` 虽然存在却不滚动,直接返回内部的 `.m-body`,绝不能落到 + * `.m-scroll` 上。 + */ +function resolveScrollContainer(bind: HTMLElement | null): HTMLElement | null { + if (!bind) return null + const fixedPage = bind.classList.contains('m-fixed') + ? bind + : bind.closest('.m-page.m-fixed') + if (fixedPage) { + const body = fixedPage.querySelector('.m-body') + if (body) return body + } + const scroller = bind.closest('.m-scroll') + if (scroller) return scroller + return bind.querySelector('.m-body') +} + +export function usePullToRefresh(options: PullToRefreshOptions): PullToRefreshResult { + const { threshold = 64, onRefresh, scrollRef } = options + const bindRef = useRef(null) + const [pull, setPull] = useState(0) + const [refreshing, setRefreshing] = useState(false) + + // 手势过程中的即时值放 ref:原生监听里读 state 会拿到旧闭包 + const startYRef = useRef(0) + const activeRef = useRef(false) + const pullRef = useRef(0) + const refreshingRef = useRef(false) + + // onRefresh 通常写成内联箭头函数,每次 render 都是新引用;放进 ref, + // 避免它变化时反复解绑 / 重绑触摸监听 + const onRefreshRef = useRef(onRefresh) + useEffect(() => { + onRefreshRef.current = onRefresh + }, [onRefresh]) + + useEffect(() => { + const el = scrollRef?.current ?? resolveScrollContainer(bindRef.current) + // 找不到滚动容器就静默不绑定,不报错 + if (!el) return + let alive = true + + const updatePull = (value: number) => { + pullRef.current = value + if (alive) setPull(value) + } + + const onTouchStart = (event: TouchEvent) => { + // 刷新中不接新手势 + if (refreshingRef.current) { + activeRef.current = false + return + } + const touch = event.touches[0] + if (!touch) { + activeRef.current = false + return + } + // 只有停在顶部才可能是下拉刷新;列表中间滑动交给原生滚动 + if (el.scrollTop > 0) { + activeRef.current = false + return + } + startYRef.current = touch.clientY + activeRef.current = true + } + + const onTouchMove = (event: TouchEvent) => { + if (!activeRef.current || refreshingRef.current) return + const touch = event.touches[0] + if (!touch) return + const deltaY = touch.clientY - startYRef.current + // 向上滑不归下拉刷新管,顺手把已经拉出来的距离收回去 + if (deltaY <= 0) { + updatePull(0) + return + } + // 起始时在顶部、滑动过程中又被滚下去了(例如惯性),本次手势作废 + if (el.scrollTop > 0) { + activeRef.current = false + updatePull(0) + return + } + // 只有确定是下拉才拦原生 overscroll;否则会吃掉正常滚动 + event.preventDefault() + updatePull(Math.min(deltaY * DAMPING, threshold * MAX_RATIO)) + } + + const onTouchEnd = () => { + if (!activeRef.current) return + activeRef.current = false + if (refreshingRef.current) return + // 没到阈值:直接收起,不触发刷新 + if (pullRef.current < threshold) { + updatePull(0) + return + } + refreshingRef.current = true + setRefreshing(true) + // 停在触发位置,让指示器不跟着手指回弹 + updatePull(threshold) + void (async () => { + try { + await onRefreshRef.current() + } catch { + // 失败提示由调用方负责,这里只保证指示器一定收起 + } finally { + refreshingRef.current = false + if (!alive) return + setRefreshing(false) + updatePull(0) + } + })() + } + + // 手势被系统打断(来电、通知中心等):收起,但不触发刷新 + const onTouchCancel = () => { + activeRef.current = false + if (!refreshingRef.current) updatePull(0) + } + + el.addEventListener('touchstart', onTouchStart, { passive: false }) + el.addEventListener('touchmove', onTouchMove, { passive: false }) + el.addEventListener('touchend', onTouchEnd, { passive: false }) + el.addEventListener('touchcancel', onTouchCancel, { passive: false }) + + return () => { + alive = false + el.removeEventListener('touchstart', onTouchStart) + el.removeEventListener('touchmove', onTouchMove) + el.removeEventListener('touchend', onTouchEnd) + el.removeEventListener('touchcancel', onTouchCancel) + // 容器切换 / 卸载时收起指示器 + pullRef.current = 0 + setPull(0) + } + // 不把 bindRef.current 放进依赖:ref 在 commit 阶段就挂好了(早于 passive + // effect),effect 首次运行时拿得到节点;若把它写进依赖,首次 setPull 触发的 + // 重渲染会让依赖从 null 变成节点,effect 重建并把指示器复位 —— 下拉手势会被 + // 打断一次。页面根节点在组件生命周期内不变,无需重绑。 + }, [scrollRef, threshold]) + + return { bindRef, pull, refreshing } +} + +/** 下拉 / 松开 / 刷新中的提示条,绝对定位在滚动容器顶部 */ +export function PullIndicator(props: { + pull: number + refreshing: boolean + threshold?: number +}): ReactElement | null { + const { pull, refreshing, threshold = 64 } = props + if (pull === 0 && !refreshing) return null + + const ready = pull >= threshold + const text = refreshing ? t('正在刷新…') : ready ? t('松开刷新') : t('下拉刷新') + // 未触发刷新时让图标跟着下拉角度转,到位后交给 .m-ptr-spin 匀速转 + const tilt = refreshing + ? undefined + : { transform: `rotate(${Math.min(pull / threshold, 1) * 180}deg)` } + + return ( +
+
+ + {text} +
+
+ ) +} diff --git a/desktop-tauri/mobile/src/components/PwaNotice.tsx b/desktop-tauri/mobile/src/components/PwaNotice.tsx new file mode 100644 index 000000000..39c01b912 --- /dev/null +++ b/desktop-tauri/mobile/src/components/PwaNotice.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react' +import { Icon } from './Icon' +import { usePwa } from '../lib/usePwa' +import { useStore } from '../state/store' +import { applyUpdate } from '../lib/pwa' +import { t } from '../i18n' +import { Alert, Button } from '@ui' + +/** + * PWA 状态条。 + * + * 两件事,位置不同: + * · **离线**:常驻顶部细条。它解释的是「为什么页面上的数字不动了」, + * 放在内容上方、一眼能看到,且不抢操作。 + * · **有新版本**:底部浮条。它需要一个明确的动作(重启), + * 放在拇指够得到的地方;同时给「稍后」,不打断当前操作。 + * + * 两者都是 `.m-app` 的 flex 子项 / 浮层,不遮挡导航:离线条把内容整体 + * 往下推,更新条浮在标签栏之上。 + * + * 设置页不弹更新浮条:那一页的「应用状态」卡片常驻同样的按钮, + * 两处一起出现只是重复。 + */ +export function PwaNotice() { + const pwa = usePwa() + const { route } = useStore() + const [busy, setBusy] = useState(false) + // 收起只对本次会话有效:新 SW 仍在等待,下次打开会再提示一次 + const [snoozed, setSnoozed] = useState(false) + const showUpdate = pwa.updateReady && !snoozed && route.screen !== 'settings' + + return ( + <> + {pwa.offline ? ( + + + {t('已离线 · 显示的是缓存内容')} + + ) : null} + + {showUpdate ? ( + + {t('有新版本,重启后生效')} + + + + ) : null} + + ) +} diff --git a/desktop-tauri/mobile/src/components/RequiredUpdateGate.tsx b/desktop-tauri/mobile/src/components/RequiredUpdateGate.tsx new file mode 100644 index 000000000..0ed568ce9 --- /dev/null +++ b/desktop-tauri/mobile/src/components/RequiredUpdateGate.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react' +import { Alert, Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody, DialogFooter } from '@ui' +import { apiCall } from '../api/client' +import type { ForceUpdatePolicy } from '../../../ui-shared/updates' +import { store, useStore } from '../state/store' +import { APP_VERSION } from '../lib/appUpdate' +import { t } from '../i18n' + +export function RequiredUpdateGate() { + const state = useStore() + const [policy, setPolicy] = useState(null) + const [current, setCurrent] = useState('') + const [updating, setUpdating] = useState(false) + useEffect(() => { + let disposed = false + setPolicy(null) + setUpdating(false) + if (state.packaged && !state.server) return + const load = () => { void apiCall<{ forceUpdate: ForceUpdatePolicy | null; currentVersion: string }>(`/api/update/policy${state.packaged ? `?current=${encodeURIComponent(APP_VERSION)}` : ''}`) + .then(result => { if (!disposed) { setPolicy(result.forceUpdate); setCurrent(result.currentVersion) } }).catch(() => {}) } + load() + const timer = setInterval(load, 60_000) + return () => { disposed = true; clearInterval(timer) } + }, [state.serverRevision, state.packaged, state.server]) + if (!policy?.required || (updating && state.route.screen === 'settings')) return null + return {}}> + + {t('需要强制更新')} + {policy.message} +

{t('当前版本 {current},最低可用版本 {minimum}。', { current, minimum: policy.minimumVersion })}

+

{t('更新前无法继续调用模型;账号、配置和历史记录不会被删除。')}

+
+ +
+
+} diff --git a/desktop-tauri/mobile/src/components/Shell.tsx b/desktop-tauri/mobile/src/components/Shell.tsx new file mode 100644 index 000000000..554edd520 --- /dev/null +++ b/desktop-tauri/mobile/src/components/Shell.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from 'react' +import { Icon, type IconName } from './Icon' +import { useStore, store } from '../state/store' +import { LoginPage } from '../pages/LoginPage' +import { isRateLimited } from '../lib/format' +import { t } from '../i18n' +import { BottomNavigation, BottomNavigationItem, Button, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, StatusDot, Toaster, createToastManager } from '@ui' + +const feedbackManager = createToastManager() + +/** + * 应用外壳:页面容器 + 底部标签栏 + 轻提示 + 凭证缺失层。 + * + * ── 标签栏的显示规则 ───────────────────────────────────────── + * 一级页显示,二级页隐藏(把高度让给内容)。助手页要撑满视口,所以它的 + * 滚动容器自己管(见 pages/AssistantPage 的 as-mode 处理)。 + */ + +export type TabDef = { id: string; label: string; icon: IconName } + +const TABS: TabDef[] = [ + { id: 'home', label: t('首页'), icon: 'home' }, + { id: 'report', label: t('报表'), icon: 'chart' }, + { id: 'accounts', label: t('账号'), icon: 'users' }, + { id: 'assistant', label: t('助手'), icon: 'spark' }, + { id: 'more', label: t('更多'), icon: 'grid' }, +] + +/** 一级页集合:这些 screen 显示底部标签栏 */ +export const PRIMARY_SCREENS = new Set(TABS.map(tab => tab.id)) + +/** + * 软键盘是否弹起。 + * + * ── 为什么需要这个判断 ──────────────────────────────────────── + * Android 的 `adjustResize` 会让 WebView 随键盘一起缩小,标签栏是 flex 布局 + * 的最后一个子项,于是它被顶到键盘正上方 —— 用户看到的是「输入框 + 一条 + * 导航栏 + 键盘」,而他要的是「输入框 + 键盘」。聊天类应用的通行做法就是 + * 键盘弹起时把标签栏收起来(WhatsApp / Telegram 都是这样)。 + * + * ── 怎么判 ──────────────────────────────────────────────────── + * `visualViewport` 的高度比 `innerHeight` 小出一大截(> 120px)就是键盘在 + * 占位。阈值取 120 而不是 0:移动浏览器的地址栏收起 / 展开也会让两者差几十 + * 像素,那是正常滚动,不该把标签栏闪一下。 + * + * 没有 `visualViewport` 的老 WebView 直接返回 false —— 标签栏常驻,行为与 + * 改动前一致,不会因为拿不到 API 就少一条导航。 + */ +export function useKeyboardOpen(): boolean { + const [open, setOpen] = useState(false) + + useEffect(() => { + const viewport = window.visualViewport + if (!viewport) return + const threshold = 120 + const check = () => { + const covered = window.innerHeight - viewport.height - viewport.offsetTop + setOpen(covered > threshold) + } + viewport.addEventListener('resize', check) + viewport.addEventListener('scroll', check) + check() + return () => { + viewport.removeEventListener('resize', check) + viewport.removeEventListener('scroll', check) + } + }, []) + + return open +} + +export function TabBar() { + const state = useStore() + const keyboardOpen = useKeyboardOpen() + const active = state.stack.length === 0 ? state.route.screen : '' + if (!PRIMARY_SCREENS.has(state.route.screen) || state.stack.length > 0) return null + // 键盘弹起时让位:那一条高度留给输入框,而不是导航 + if (keyboardOpen) return null + const limited = state.session?.accounts?.accounts?.filter(a => a.enabled && isRateLimited(a)).length || 0 + return ( + + {TABS.map(tab => ( + store.go(tab.id)} + > + + {tab.label} + {tab.id === 'more' && limited > 0 ? : null} + + ))} + + ) +} + +/** 轻提示 */ +export function Toast() { + const { toast } = useStore() + useEffect(() => { + if (toast) feedbackManager.add({ id: 'mobile-feedback', title: toast.text, type: toast.tone === 'err' ? 'error' : toast.tone === 'warn' ? 'warning' : 'success', timeout: 3500 }) + }, [toast]) + return +} + +/** + * 凭证缺失层。 + * + * 两种来路,文案与动作都不同: + * · `login` —— 管理员已注册、会话过期:APK 弹应用内登录表单; + * 浏览器(PWA)跳服务端登录页,带回跳。 + * · `key` —— 兼容模式(没注册管理员)没配 Key:就地输入,存 localStorage + * + * 为什么 APK 不能跳 `/login`:那个页面由网关出,而 APK 的界面在包里 —— + * 跳过去等于离开应用、进一个风格不一致的网页,而且跨源之后服务端种的 + * cookie 也带不回来(见 lib/servers.ts 的说明)。 + */ +export function AuthGate() { + const state = useStore() + const [key, setKey] = useState('') + const [busy, setBusy] = useState(false) + if (!state.authLost) return null + + /** + * APK 里登录页是整屏的,用户被登出后看不到「设置 → 服务器」。 + * 登录页给了「换一台服务器」入口,它把用户压进服务器页 —— 这一屏必须让开, + * 否则登录页盖住整个界面,列表点不到。authLost 故意不清:用户取消返回后 + * 登录页会重新盖上来,换到新服务器也会按新地址重新弹。 + */ + if (state.packaged && state.route.screen === 'servers') return null + + // 打包形态走整屏的应用内登录页(login / key 两种模式它都覆盖) + if (state.packaged) return + + const submit = async () => { + const value = key.trim() + if (!value) return + setBusy(true) + const { storeApiKey } = await import('../api/client') + storeApiKey(value) + store.setAuthLost(null) + await store.refresh(true) + setBusy(false) + } + + return + + {state.authLost === 'login' ? t('需要重新登录') : t('需要网关 API Key')} + +

{state.authLost === 'login' + ? t('会话已过期。重新登录后会自动回到手机端。') + : t('这个面板没有管理员,用一把已启用的 API Key 访问(在桌面端「网关 Key」页创建)。')}

+ {state.authLost === 'key' && setKey(event.target.value)} placeholder='sk-…' autoComplete='off' />} +
+ + {state.authLost === 'login' ? + : } + +
+
+} + +/** + * 主题同步:把 store 的主题写到 。 + * + * 单独一个组件而不是在 store 构造时做,是为了让 React 的首帧与 DOM 属性 + * 一致 —— 首帧闪烁(先浅色再切深色)在手机上是可见的。 + */ +export function ThemeSync() { + const { theme } = useStore() + useEffect(() => { + document.documentElement.dataset.theme = theme + }, [theme]) + return null +} diff --git a/desktop-tauri/mobile/src/components/SourceUsageSection.tsx b/desktop-tauri/mobile/src/components/SourceUsageSection.tsx new file mode 100644 index 000000000..bdb8197da --- /dev/null +++ b/desktop-tauri/mobile/src/components/SourceUsageSection.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react' +import { Badge, Button, Card, Empty, SegmentedControl, Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle } from '@ui' +import { fetchStatsSummary, type StatsDimension, type StatsSummary } from '../api/gateway' +import { store } from '../state/store' +import { formatInt, formatPercent, formatTokens } from '../lib/format' +import { t } from '../i18n' + +export function reportSourceName(row: StatsDimension, key: boolean): string { + if (!key) return !row.id ? t('未记录客户端') : row.id === 'other' ? t('其它') : String(row.label || row.id) + if (row.source === 'none') return t('免鉴权调用') + if (row.source === 'env') return t('环境变量 Key') + if (row.source !== 'key') return t('历史未记录 Key') + return row.label || row.id || t('未命名') +} + +function value(amount: number | null | undefined): string { + return typeof amount === 'number' ? formatTokens(amount) : '—' +} + +export function openMobileRequestLogs(summary: StatsSummary, query: Record): void { + store.push('requests', { ...query, startDate: summary.startDate || '', endDate: summary.endDate || '', productionOnly: '1', completedOnly: '1' }) +} + +export function SourceUsageSection({ summary, apiKeys = false }: { summary: StatsSummary; apiKeys?: boolean }) { + const [sort, setSort] = useState('tokens') + const [expanded, setExpanded] = useState(false) + const [target, setTarget] = useState(null) + const [detail, setDetail] = useState(null) + const [error, setError] = useState('') + const list = [...(apiKeys ? summary.apiKeys || [] : summary.clients || [])].sort((left, right) => { + const key = sort === 'tokens' ? 'totalTokens' : 'requests' + return (Number(right[key]) || 0) - (Number(left[key]) || 0) + }) + useEffect(() => { + if (!target) return + const controller = new AbortController() + setDetail(null) + setError('') + void fetchStatsSummary('custom', controller.signal, { + apiKey: target.filterValue || 'unknown', startDate: summary.startDate, endDate: summary.endDate, + }).then(setDetail).catch(failure => { if (!controller.signal.aborted) setError(String(failure)) }) + return () => controller.abort() + }, [target, summary.startDate, summary.endDate]) + const query = (row: StatsDimension): Record => apiKeys ? { apiKey: row.filterValue || 'unknown' } : { client: row.id || '', clientLabel: row.label || '' } + return ( +
+
+ {apiKeys ? t('API Key 统计') : t('客户端统计')} + +
+ + {!list.length ? {t('所选范围内暂无请求')} : (expanded ? list : list.slice(0, 5)).map(row => ( +
+
+ + {apiKeys && row.source === 'key' ? + {row.state === 'enabled' ? t('启用') : row.state === 'disabled' ? t('停用') : t('已删除')} + : null} + {formatTokens(row.totalTokens || 0)} +
+
+ {t('{n} 次请求', { n: formatInt(row.requests || 0) })} · {t('成功 / 失败')} {formatInt(row.success || 0)} / {formatInt(row.failures || 0)} +
{t('成功率')} {typeof row.successRate === 'number' ? formatPercent(row.successRate) : '—'} · {t('占比')} {summary.overview?.tokens ? formatPercent((row.totalTokens || 0) / summary.overview.tokens) : '—'} +
{t('输入 / 输出')} {value(row.promptTokens)} / {value(row.completionTokens)} +
{t('缓存读取')} {value(row.cacheReadTokens)} · {t('缓存命中率')} {typeof row.cacheRate === 'number' ? formatPercent(row.cacheRate) : '—'} + {apiKeys ? <>
{t('最近调用')} {row.lastUsedAt ? new Date(row.lastUsedAt).toLocaleString() : '—'} : null} +
+ {apiKeys ? : null} +
+ ))} + {list.length > 5 ? : null} +
+ { if (!open) setTarget(null) }}> + {t('API Key 用量详情')} · {target && reportSourceName(target, true)} + + {error ? {error} : !detail ? {t('正在加载…')} : ( + <>{(['clients', 'models'] as const).map(section => ( +
+ {section === 'clients' ? t('客户端分布') : t('模型分布')} + {detail.apiKeyDetail?.[section]?.length ? detail.apiKeyDetail[section]?.map((row, index) => ( + + )) : {t('没有可恢复的维度明细')}} +
+ ))} + )} +
+
+
+
+ ) +} diff --git a/desktop-tauri/mobile/src/components/SystemInfoSheet.tsx b/desktop-tauri/mobile/src/components/SystemInfoSheet.tsx new file mode 100644 index 000000000..56c9eb983 --- /dev/null +++ b/desktop-tauri/mobile/src/components/SystemInfoSheet.tsx @@ -0,0 +1,330 @@ +import { useEffect, useState } from 'react' +import { Sheet, SheetContent, SheetHeader, SheetBody, SheetTitle, SheetFooter , Card, Button } from '@ui' +import { apiCall } from '../api/client' +import { APP_VERSION } from '../lib/appUpdate' +import { isPackaged, nativePlatform } from '../lib/servers' +import { Badge, copyWithToast } from './ui' +import { Icon } from './Icon' +import { t } from '../i18n' + +/** + * 「版本信息」抽屉(手机端设置页底部那一行点开)。 + * + * ── 为什么手机端也要这一块 ────────────────────────────────── + * 手机端原来只在设置页底部显示**自己的** `v{APP_VERSION}`,用户连的是哪台 + * 网关、那台网关是什么形态 / 什么版本,一概看不到。而手机端出问题时, + * 第一个要问的恰恰是「网关那边是哪一版、跑在什么环境」—— 让用户再跑到 + * 电脑前翻一遍设置页不现实。 + * + * ── 两段版本号分开显示 ────────────────────────────────────── + * · **本机**:APK 的 versionName(构建期注入)/ PWA 的界面版本。它来自 + * 安装包,与网关无关; + * · **网关**:`GET /api/system-info` 里那份(版本 / 形态 / 系统 / 路径)。 + * 两者可能不同(手机装 3.1.0、网关还是 3.0.9),混成一个「版本」会让人 + * 拿着手机版本去比对网关的更新日志。 + * + * ── 复用桌面端的接口与脱敏口径 ────────────────────────────── + * 同一个 `/api/system-info`,同样的 `?hideUsername=1`(后端脱敏,见 + * `api::system_info` 的模块头)。手机端不单独造一份后端契约。 + */ + +type GatewayInfo = { + app?: { + version?: string | null + channel?: string | null + schemaVersion?: number | null + dbSchemaVersion?: number | null + } + runtime?: { + deployment?: string | null + os?: string | null + osVersion?: string | null + arch?: string | null + webviewVersion?: string | null + uptimeSeconds?: number | null + debugBuild?: boolean | null + } + deployment?: { + host?: string | null + port?: number | null + panelPort?: number | null + configDir?: string | null + dbFile?: string | null + dbBytes?: number | null + logFile?: string | null + exePath?: string | null + } + security?: { + adminRegistered?: boolean | null + apiKeyCount?: number | null + v1FailClosed?: boolean | null + } + stats?: { + accounts?: number | null + enabledAccounts?: number | null + models?: number | null + inFlight?: number | null + } +} + +/** 运行形态 → 展示文案(与桌面端 system-info-pane 同一份取值表) */ +const DEPLOYMENT_LABEL: Record = { + desktop: t('桌面端'), + 'windows-service': t('Windows 服务'), + docker: t('Docker 容器'), + systemd: t('systemd 服务'), + foreground: t('前台运行'), +} + +const OS_LABEL: Record = { + windows: 'Windows', + macos: 'macOS', + linux: 'Linux', +} + +function text(value: unknown): string { + if (value === null || value === undefined) return '—' + const result = String(value).trim() + return result || '—' +} + +/** + * 系统名 + 版本拼成一句。 + * + * Windows 的 `ProductName` 自带 "Windows"、Linux 的 `PRETTY_NAME` 自带发行版名, + * 直接加前缀会变成「Windows Windows 11 Pro」。与桌面端 system-info-pane.tsx + * 的同名函数同一口径(两处各写一份:手机端不 import 桌面端源码)。 + */ +function formatOs(os: string | null | undefined, version: string | null | undefined): string { + const name = OS_LABEL[String(os)] || text(os) + const detail = text(version) + if (detail === '—') return name + if (name !== '—' && detail.toLowerCase().startsWith(name.toLowerCase())) return detail + return `${name} ${detail}` +} + +function formatBytes(bytes: number | null | undefined): string { + const value = Number(bytes) + if (!Number.isFinite(value) || value <= 0) return '0 B' + if (value < 1024) return `${value} B` + const kb = value / 1024 + if (kb < 1024) return `${kb.toFixed(1)} KB` + const mb = kb / 1024 + if (mb < 1024) return `${mb.toFixed(1)} MB` + return `${(mb / 1024).toFixed(2)} GB` +} + +function formatUptime(seconds: number | null | undefined): string { + const total = Number(seconds) + if (!Number.isFinite(total) || total <= 0) return '—' + const days = Math.floor(total / 86400) + const hours = Math.floor((total % 86400) / 3600) + const minutes = Math.floor((total % 3600) / 60) + if (days > 0) return t('{d} 天 {h} 小时', { d: days, h: hours }) + if (hours > 0) return t('{h} 小时 {m} 分钟', { h: hours, m: minutes }) + return t('{m} 分钟', { m: minutes }) +} + +/** 一行「键 —— 值」 */ +function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
+
{label}
+
+ {value} +
+
+
+ ) +} + +export function SystemInfoSheet({ open, onClose }: { open: boolean; onClose: () => void }) { + const [info, setInfo] = useState(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [hideUsername, setHideUsername] = useState(false) + + const load = async (hide: boolean) => { + setLoading(true) + try { + const result = await apiCall( + `/api/system-info${hide ? '?hideUsername=1' : ''}`, + ) + setInfo(result || null) + setError('') + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)) + } finally { + setLoading(false) + } + } + + // 每次打开都重拉:运行时长、在途请求这些读数会随时间变 + useEffect(() => { + if (open) void load(hideUsername) + // hideUsername 变化由下面的开关自己触发,不放进依赖(否则拨开关会双拉) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const toggleHide = () => { + const next = !hideUsername + setHideUsername(next) + void load(next) + } + + const app = info?.app || {} + const runtime = info?.runtime || {} + const deployment = info?.deployment || {} + const security = info?.security || {} + const stats = info?.stats || {} + const deploymentLabel = DEPLOYMENT_LABEL[String(runtime.deployment)] || text(runtime.deployment) + const osFull = formatOs(runtime.os, runtime.osVersion) + // 原生壳里「本机版本」是包版本;PWA / 浏览器里是构建期注入的界面版本 + const platform = nativePlatform() + const localLabel = isPackaged() ? t('本机应用版本') : t('本机界面版本') + const formLabel = + platform === 'android' ? t('Android 应用(APK)') : platform === 'ios' ? t('iOS 应用') : t('浏览器 / PWA') + + const copyAll = () => { + const lines = [ + `[${t('本机')}]`, + `${localLabel}: ${APP_VERSION}`, + `platform: ${platform === 'android' ? 'android-apk' : platform === 'ios' ? 'ios-ipa' : 'browser'}`, + '', + `[${t('网关')}]`, + `${t('版本')}: ${text(app.version)}`, + `${t('更新通道')}: ${app.channel === 'both' ? t('正式版与 beta 版') : app.channel === 'beta' ? t('仅 beta 版') : t('仅正式版')}`, + `${t('运行形态')}: ${deploymentLabel}`, + `${t('操作系统')}: ${osFull} (${text(runtime.arch)})`, + `${t('监听地址')}: ${text(deployment.host)}:${text(deployment.port)}`, + `${t('已运行')}: ${formatUptime(runtime.uptimeSeconds)}`, + `${t('数据目录')}: ${text(deployment.configDir)}`, + `${t('日志文件')}: ${text(deployment.logFile)}`, + `${t('账号')}: ${text(stats.enabledAccounts)} / ${text(stats.accounts)}`, + `${t('模型')}: ${text(stats.models)}`, + `${t('网关 Key 数量')}: ${text(security.apiKeyCount)}`, + ] + void copyWithToast(lines.join('\n'), t('版本信息')) + } + + return ( + { if (!next) onClose() }}> + + + {t('版本信息')} + + + {/* 本机:与网关无关的那一半 */} +
+ {t('本机')} +
+ + + + + + {/* 网关:连的那台服务器的读数 */} +
+ {t('网关')} +
+ {error ? ( + + {t('读取网关信息失败:{message}', { message: error })} + + ) : ( + + + + + + + + + + + + )} + + {/* 路径:只在拿得到时显示(读不到就不占版面) */} + {deployment.configDir || deployment.logFile ? ( + <> +
+ {t('位置')} +
+ + {deployment.configDir ? ( + + ) : null} + {deployment.dbFile ? ( + + ) : null} + {deployment.logFile ? ( + + ) : null} + + + ) : null} + + {/* 隐藏用户名:与桌面端同一个后端参数 */} + + + {loading ? ( +
+ {t('读取中…')} +
+ ) : null} +
+ + + +
+
+ ) +} diff --git a/desktop-tauri/mobile/src/components/ui.tsx b/desktop-tauri/mobile/src/components/ui.tsx new file mode 100644 index 000000000..430e07575 --- /dev/null +++ b/desktop-tauri/mobile/src/components/ui.tsx @@ -0,0 +1,209 @@ +import { Icon, type IconName } from './Icon' +import { t } from '../i18n' +import { store } from '../state/store' +import { Alert, Badge as UiBadge, BadgeDot, Button, Empty as UiEmpty, PageHeader, PageTitle, PageDescription, Skeleton, Card } from '@ui' + +/** 通用页头(一级页):大标题 + 副标题 + 右侧动作 */ +export function PageHead({ + title, + subtitle, + actions, +}: { + title: string + subtitle?: string + actions?: React.ReactNode +}) { + return ( + +
+ {title} + {subtitle ? {subtitle} : null} +
+ {actions ?
{actions}
: null} +
+ ) +} + +/** + * 二级页的页内说明条。 + * + * 二级页的标题已经由顶部返回栏(NavBar)承载,页内再写一遍大标题就是重复。 + * 但副标题(统计读数 / 时间范围)有信息量,保留成一行小字。右侧动作放这里, + * 因为它属于页面内容而不是导航。 + */ +export function PageIntro({ + subtitle, + actions, +}: { + subtitle?: string + actions?: React.ReactNode +}) { + if (!subtitle && !actions) return null + return ( +
+ {subtitle} + {actions ?
{actions}
: null} +
+ ) +} + +/** 圆形图标按钮(页头 / 返回栏右侧都用它) */ +export function IconButton({ + name, + label, + onClick, + primary, +}: { + name: IconName + label: string + onClick?: () => void + primary?: boolean +}) { + return ( + + ) +} + +/** 区块标题:左侧标题 + 右侧可选动作 */ +export function SectionHead({ + title, + action, +}: { + title: string + action?: { label: string; onClick: () => void } +}) { + return ( +
+

{title}

+ {action ? ( + + ) : null} +
+ ) +} + +/** 徽章:语义色与桌面端一致(绿=正常、黄=告警、红=失效、灰=中性、蓝=待办) */ +export function Badge({ + tone, + dot, + children, +}: { + tone: 'ok' | 'warn' | 'bad' | 'mute' | 'brand' + dot?: boolean + children: React.ReactNode +}) { + const variants = { ok: 'success', warn: 'warning', bad: 'destructive', mute: 'secondary', brand: 'brand' } as const + return + {dot ? : null}{children} + +} + +/** 空态 */ +export function Empty({ text }: { text: string }) { + return {text} +} + +/** 错误条(页内,不弹窗) */ +export function ErrorNote({ text }: { text: string }) { + return {text} +} + +/** 加载骨架:给列表页首屏用,避免「空白 → 突然出现内容」的跳动 */ +export function SkeletonList({ rows = 4, height = 64 }: { rows?: number; height?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, index) => ( + + ))} +
+ ) +} + +/** 复制到剪贴板(失败时退回 execCommand,老 WebView 也能用) */ +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text) + } catch { + const area = document.createElement('textarea') + area.value = text + area.style.position = 'fixed' + area.style.opacity = '0' + document.body.appendChild(area) + area.select() + try { + document.execCommand('copy') + } catch { + /* 复制失败:提示由调用方给 */ + } + area.remove() + } +} + +/** 复制 + 提示的常用组合 */ +export async function copyWithToast(text: string, label?: string): Promise { + await copyText(text) + store.toast(t('已复制{label}', { label: label ? `:${label}` : '' })) +} + +/** 数值卡片(报表 / 首页共用) */ +export function StatCard({ + label, + value, + sub, + tone, +}: { + label: string + value: string + sub?: string + tone?: 'up' | 'ok' +}) { + return ( + +
{label}
+
+ {value} +
+ {sub ? ( +
+ {sub} +
+ ) : null} +
+ ) +} diff --git a/desktop-tauri/mobile/src/i18n.ts b/desktop-tauri/mobile/src/i18n.ts new file mode 100644 index 000000000..090bfd32f --- /dev/null +++ b/desktop-tauri/mobile/src/i18n.ts @@ -0,0 +1,82 @@ +/** + * 移动端 i18n 垫片。 + * + * 与 ui-islands/src/i18n.ts 同一套机制:词典由 `ui/i18n.js` 在页面 head 阶段 + * 注入到 `window.wbI18nDict`,本模块只查表、不打包词典。中文即键,缺译回落 + * 简体原文。手机端与桌面端共用同一份词典,重叠文案(账号、签到、模型…) + * 自动就有译文,不必另起一套 ARB。 + */ + +type TParams = Record + +type WbI18nApi = { + t(key: string, params?: TParams): string + locale(): string + rawLocale(): string + setLocale(value: string): void + sweep(root?: ParentNode): void + LOCALES: ReadonlyArray<{ code: string; label: string; nameKey: string }> +} + +declare global { + interface Window { + wbI18n?: WbI18nApi + wbI18nDict?: Record + __WB_I18N_DOMAINS?: string[] + } +} + +/** 取键的译文;params 把 {name} 形参替换成值。缺译 / 无运行时都回落键本身 */ +export function t(key: string, params?: TParams): string { + const api = typeof window !== 'undefined' ? window.wbI18n : undefined + if (api && typeof api.t === 'function') return api.t(key, params) + // 运行时缺失时自己查一次词典:页面把词典脚本排在本脚本之前加载, + // 极端时序下(i18n.js 未就位)也能出正确译文 + const dict = typeof window !== 'undefined' ? window.wbI18nDict : undefined + const hit = dict?.[key] + let text = typeof hit === 'string' && hit ? hit : key + if (params) { + for (const [name, value] of Object.entries(params)) { + text = text.split(`{${name}}`).join(String(value)) + } + } + return text +} + +/** 当前界面语言代码(`zh-Hans` / `en` / …) */ +export function locale(): string { + const api = typeof window !== 'undefined' ? window.wbI18n : undefined + if (api && typeof api.locale === 'function') return api.locale() + return 'zh-Hans' +} + +export type LocaleOption = { code: string; label: string; nameKey: string } + +/** 可切换的语言清单(语言自名,不翻译) */ +export function localeOptions(): readonly LocaleOption[] { + const api = typeof window !== 'undefined' ? window.wbI18n : undefined + if (api && Array.isArray(api.LOCALES)) return api.LOCALES + return [ + { code: 'zh-Hans', label: '简体中文', nameKey: '简体中文' }, + { code: 'zh-Hant', label: '繁體中文', nameKey: '繁体中文' }, + { code: 'en', label: 'English', nameKey: '英语' }, + { code: 'ja', label: '日本語', nameKey: '日语' }, + { code: 'ko', label: '한국어', nameKey: '韩语' }, + { code: 'pt-BR', label: 'Português (BR)', nameKey: '葡萄牙语(巴西)' }, + ] +} + +/** 切语言 = 整页刷新(与桌面端同一策略,见 ui/i18n.js 的说明) */ +export function setLocale(code: string): void { + const api = typeof window !== 'undefined' ? window.wbI18n : undefined + if (api && typeof api.setLocale === 'function') { + api.setLocale(code) + return + } + try { + localStorage.setItem('workbuddy-desktop-locale', code) + } catch { + /* 存储被禁:刷新后回到自动判定 */ + } + window.location.reload() +} diff --git a/desktop-tauri/mobile/src/lib/appUpdate.ts b/desktop-tauri/mobile/src/lib/appUpdate.ts new file mode 100644 index 000000000..89a1db12f --- /dev/null +++ b/desktop-tauri/mobile/src/lib/appUpdate.ts @@ -0,0 +1,323 @@ +/** + * 手机端应用更新。 + * + * ── 三种形态,三条路 ──────────────────────────────────────────── + * · **PWA / 浏览器**:更新走 Service Worker(见 lib/pwa.ts),这里不参与。 + * 浏览器里的「新版本」是网关托管的 `ui/m/*` 变了,刷新即可。 + * · **APK**:界面打在包里,必须换安装包。这条路是这里实现的: + * 1. 调网关 `GET /api/update/check` 拿最新版本与 `androidAsset`; + * 2. 向网关领取下载方案,手机按候选源下载并校验 APK; + * 3. 调起系统安装器。 + * 下载字节不经过网关中转,GitHub 令牌也不传给手机或加速站。 + * · **iOS(IPA)**:苹果不允许 App 自行下载并安装可执行代码,所以这里 + * **只能跳转** —— 拿 `pageUrl` 打开 GitHub Release 页面,由用户自己走 + * TestFlight / App Store / AltStore。`downloadApk` / `installApk` 在 iOS + * 上直接报错,界面也据此不渲染那几个按钮(见 AppUpdatePanel)。 + * + * ── 为什么下载走原生而不是 fetch ──────────────────────────────── + * APK 有几十 MB,`fetch` + `blob` 会把整包读进 WebView 内存;`CapacitorHttp` + * 也一样(它把响应体整个序列化回来)。原生插件用 HttpURLConnection 边读边 + * 写文件,内存占用只有缓冲区大小,还能实时上报进度。 + * + * ── 版本号从哪来 ──────────────────────────────────────────────── + * `__APP_VERSION__` 是构建期由 vite 从 `mobile/package.json` 注入的常量 + * (见 vite.config.ts),与 APK 的 `versionName`(android/variables.gradle + * 读同一个字段)永远一致;iOS 的 `MARKETING_VERSION` 由 CI 从同一个字段写进 + * xcodebuild 参数,所以两边不会漂。 + * + * ── 自动检查 ──────────────────────────────────────────────────── + * 间隔与开关**读电脑端的「软件版本检查」任务配置**(`/api/scheduled-tasks` + * 里的 `updateCheck` 条目),不另起一套设置 —— 用户在一处配好,两端一致。 + * 手机端本地只留一个「本机不自动检查」的开关(存在 localStorage), + * 用于「我不想让手机耗流量」这种纯本地偏好。 + */ + +import { Capacitor, registerPlugin } from '@capacitor/core' +import { ApiError, apiCall } from '../api/client' +import { activeServerId, nativePlatform } from '../lib/servers' +import { t } from '../i18n' +import type { AssetProbe, ForceUpdatePolicy } from '../../../ui-shared/updates' + +/** 构建期注入的本工程版本号 */ +declare const __APP_VERSION__: string + +export const APP_VERSION: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : '0.0.0' + +/** 更新检查结果里手机端关心的那部分 */ +export type ApkUpdateInfo = { + forceUpdate?: ForceUpdatePolicy | null + currentVersion: string + latestVersion: string | null + hasUpdate: boolean | null + notes: string + publishedAt: string + pageUrl: string + prerelease: boolean + /** 这一版挂的 APK 资产;null = 该版本没提供 Android 包 */ + asset: { name?: string; size?: number; url?: string } | null + /** 这一版到底传了哪些文件 —— 没有 APK 时界面据此说明原因(见 assetMissingReason) */ + assetProbe?: AssetProbe | null +} + +type RawCheckResult = { + forceUpdate?: ForceUpdatePolicy | null + currentVersion?: string | null + latestVersion?: string | null + hasUpdate?: boolean | null + notes?: string | null + publishedAt?: string | null + pageUrl?: string | null + prerelease?: boolean + androidAsset?: { name?: string; size?: number; url?: string } | null + assetProbe?: AssetProbe | null + checked?: boolean + channelChanged?: boolean +} + +/** + * 检查更新。 + * + * 传本机版本号而不是让服务端猜:网关可能跑在电脑上、版本与手机端不同, + * 「手机端有没有新版本」只有手机自己知道当前是哪个版本。 + * + * 走网关的 `/api/update/check` 而不是手机直接打 GitHub API:这样能**复用 + * 电脑端已配置的 GitHub 令牌**(匿名限额 60 次/小时,带令牌 5000), + * 也不必让手机再存一份凭证。注意这里只复用「查询」,下载仍是手机直连。 + */ +export async function checkApkUpdate(): Promise { + const raw = await apiCall( + `/api/update/check?current=${encodeURIComponent(APP_VERSION)}`, + ) + if (raw.channelChanged && raw.checked === false) throw new Error(t('更新通道已改变,请重新检查更新。')) + const asset = raw.androidAsset && raw.androidAsset.url ? raw.androidAsset : null + return { + forceUpdate: raw.forceUpdate, + currentVersion: APP_VERSION, + latestVersion: raw.latestVersion || null, + // 有新版但没挂 APK 时,对手机端来说等于「没有可更新的包」: + // 不谎报「有更新」把用户引到一个点不动的按钮上。 + hasUpdate: raw.hasUpdate === true && asset !== null ? true : raw.hasUpdate === false ? false : null, + notes: raw.notes || '', + publishedAt: raw.publishedAt || '', + pageUrl: raw.pageUrl || '', + prerelease: Boolean(raw.prerelease), + asset, + assetProbe: raw.assetProbe ? { ...raw.assetProbe, expectedSuffixes: ['.apk'], expectedName: null } : null, + } +} + +/* ─── 自动检查的配置(读电脑端) ─────────────────────────────── */ + +/** 手机端本地的「不自动检查」开关(纯本地偏好,不上报) */ +const AUTO_CHECK_KEY = 'agent2api.mobile.updateAutoCheck' + +/** 本机是否允许自动检查(默认允许) */ +export function autoCheckEnabled(): boolean { + try { + return localStorage.getItem(AUTO_CHECK_KEY) !== 'off' + } catch { + return true + } +} + +export function setAutoCheckEnabled(enabled: boolean): void { + try { + if (enabled) localStorage.removeItem(AUTO_CHECK_KEY) + else localStorage.setItem(AUTO_CHECK_KEY, 'off') + } catch { + /* 隐私模式:只在本页生效 */ + } +} + +/** + * 从电脑端的「软件版本检查」任务读检查间隔(毫秒)。 + * + * 电脑端把这条任务藏在「更新设置」弹窗里(定时任务页不渲染它),但配置本身 + * 仍由 `/api/scheduled-tasks` 返回 —— 手机端直接读同一个来源,不另做设置页。 + * 任务被关掉时返回 0(= 不自动检查),与电脑端语义一致。 + * + * 读不到(老网关 / 请求失败)时退回 20 分钟:那是电脑端的缺省值 + * (`DEFAULT_UPDATE_CHECK_MINUTES`),行为与「没配过」一致。 + */ +export async function autoCheckIntervalMs(): Promise { + try { + const payload = await apiCall<{ + tasks?: Array<{ id?: string; enabled?: boolean; interval?: number; unit?: string }> + }>('/api/scheduled-tasks') + const task = (payload.tasks || []).find(item => item.id === 'updateCheck') + if (!task) return 20 * 60_000 + if (task.enabled === false) return 0 + const interval = Number(task.interval) || 0 + if (interval <= 0) return 20 * 60_000 + // 电脑端这条任务的单位是分钟(见 scheduled_tasks.rs 的 TaskDef) + return interval * 60_000 + } catch { + return 20 * 60_000 + } +} + +/* ─── 原生插件(只在 APK 里存在) ─────────────────────────────── */ + +type PluginListenerHandle = { remove: () => Promise } + +type ApkUpdaterPlugin = { + sourceSelectionSupported(): Promise<{ supported: boolean }> + selectSources(options: { urls: string[]; sha256?: string; scope: string }): Promise<{ urls: string[]; selected: string; elapsedMs: number; cached: boolean }> + download(options: { url: string; sha256?: string; size?: number }): Promise<{ path: string; size: number }> + cancel(): Promise + canInstall(): Promise<{ allowed: boolean }> + openInstallPermissionSettings(): Promise + install(): Promise + downloaded(): Promise<{ exists: boolean; size: number }> + addListener( + event: 'downloadProgress', + handler: (data: { received: number; total: number; percent: number }) => void, + ): Promise +} + +const ApkUpdater = registerPlugin('ApkUpdater') + +/** 取原生插件;不在 APK 里(或插件没注册)时返回 null */ +function plugin(): ApkUpdaterPlugin | null { + if (nativePlatform() !== 'android' || !Capacitor.isPluginAvailable('ApkUpdater')) return null + return ApkUpdater +} + +/** 当前壳能不能在应用内下载并安装安装包(只有 Android 能) */ +export function canSelfUpdate(): boolean { + return nativePlatform() === 'android' && plugin() !== null +} + +export type DownloadProgress = { + received: number + total: number + /** 0–100;总长度未知时为 -1 */ + percent: number + phase?: 'selecting' | 'downloading' + source?: string +} + +let downloadCanceled = false +let downloadTask: Promise<{ path: string; size: number }> | null = null +let nativeSelection: Promise | null = null + +function sourceSelectionSupported(api: ApkUpdaterPlugin): Promise { + nativeSelection ??= api.sourceSelectionSupported().then(value => value.supported === true).catch(() => false) + return nativeSelection +} + +type DownloadPlan = { urls: string[]; sha256?: string | null; size?: number; mode?: 'auto' | 'official' | 'accelerated' } + +/** APK 字节由手机下载;网关只提供候选源和经过官方 API 确认的校验值。 */ +export async function downloadApk( + url: string, + onProgress?: (progress: DownloadProgress) => void, +): Promise<{ path: string; size: number }> { + if (downloadTask) throw new Error('下载或探测正在进行') + downloadCanceled = false + const task = runApkDownload(url, onProgress) + downloadTask = task + try { return await task } + finally { if (downloadTask === task) downloadTask = null } +} + +async function runApkDownload( + url: string, + onProgress?: (progress: DownloadProgress) => void, +): Promise<{ path: string; size: number }> { + const api = plugin() + if (!api) { + // iOS 与浏览器都会走到这里:两者都不允许应用内自装安装包 + throw new Error( + nativePlatform() === 'ios' + ? 'iOS 不支持应用内安装,请到 GitHub Release 页面下载' + : '当前不是 APK 形态,无法在应用内更新', + ) + } + if (!url) throw new Error('缺少下载地址') + + let plan: DownloadPlan + try { + plan = await apiCall('/api/update/download-plan', { method: 'POST', body: { url } }) + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 404) throw error + plan = { urls: [url] } + } + if (!plan.urls?.length) throw new Error('没有可用的下载源') + if (downloadCanceled) throw new Error('已取消下载') + const candidates = [...plan.urls] + const hasNativeSelection = await sourceSelectionSupported(api) + if (plan.mode === 'auto' && candidates.length > 1 && hasNativeSelection) { + if (downloadCanceled) throw new Error('已取消下载') + onProgress?.({ received: 0, total: 0, percent: -1, phase: 'selecting' }) + const selected = await api.selectSources({ urls: candidates, sha256: plan.sha256 || undefined, scope: activeServerId() || 'local' }) + if (downloadCanceled) throw new Error('已取消下载') + const valid = selected.urls.filter(candidate => candidates.includes(candidate)) + candidates.splice(0, candidates.length, ...new Set([...valid, ...candidates])) + } + let handle: PluginListenerHandle | null = null + let currentSource = '' + if (onProgress) { + handle = await api.addListener('downloadProgress', data => onProgress({ ...data, phase: 'downloading', source: currentSource })) + } + try { + let lastError: unknown = new Error('没有可用的下载源') + for (const candidate of candidates) { + if (downloadCanceled) throw new Error('已取消下载') + try { + currentSource = new URL(candidate).host + onProgress?.({ received: 0, total: plan.size || 0, percent: -1, phase: 'downloading', source: currentSource }) + const result = await api.download({ url: candidate, sha256: plan.sha256 || undefined, size: plan.size }) + return result + } catch (error) { + if (downloadCanceled) throw new Error('已取消下载') + const message = error instanceof Error ? error.message : String(error) + if (/限流|HTTP 429\b/.test(message) || (!hasNativeSelection && /HTTP 403\b/.test(message))) throw error + lastError = error + } + } + throw lastError + } finally { + if (handle) void handle.remove() + } +} + +export async function cancelDownload(): Promise { + downloadCanceled = true + const task = downloadTask + await plugin()?.cancel() + await task?.catch(() => undefined) +} + +/** 是否已获「安装未知来源应用」授权 */ +export async function canInstallApk(): Promise { + const api = plugin() + if (!api) return false + const result = await api.canInstall() + return Boolean(result.allowed) +} + +/** 跳到系统的「安装未知应用」授权页 */ +export async function openInstallPermission(): Promise { + await plugin()?.openInstallPermissionSettings() +} + +/** 调起系统安装器(APK 已下载时) */ +export async function installApk(): Promise { + const api = plugin() + if (!api) { + throw new Error( + nativePlatform() === 'ios' + ? 'iOS 不支持应用内安装,请到 GitHub Release 页面下载' + : '当前不是 APK 形态,无法安装', + ) + } + await api.install() +} + +/** 缓存里是否已有下载好的安装包(重进设置页时显示「已下载,待安装」) */ +export async function hasDownloadedApk(): Promise<{ exists: boolean; size: number }> { + const api = plugin() + if (!api) return { exists: false, size: 0 } + return api.downloaded() +} diff --git a/desktop-tauri/mobile/src/lib/assistant-attachments.ts b/desktop-tauri/mobile/src/lib/assistant-attachments.ts new file mode 100644 index 000000000..f2519cbee --- /dev/null +++ b/desktop-tauri/mobile/src/lib/assistant-attachments.ts @@ -0,0 +1,84 @@ +import { t } from '../i18n' +import { + MAX_TEXT_FILES, + MAX_TEXT_FILE_BYTES, + type AssistantTextFile, +} from '../../../ui-shared/assistant-files' + +export const MAX_ATTACHMENTS = 6 + +function readAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result || '')) + reader.onerror = () => reject(new Error(t('读取图片失败'))) + reader.readAsDataURL(file) + }) +} + +async function prepareImage(file: File): Promise { + if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) throw new Error(t('参考图仅支持 PNG、JPEG、WebP')) + if (file.size > 10 * 1024 * 1024) throw new Error(t('单张参考图不能超过 10 MiB')) + const raw = await readAsDataUrl(file) + const image = new Image() + await new Promise((resolve, reject) => { + image.onload = () => resolve() + image.onerror = () => reject(new Error(t('读取图片失败'))) + image.src = raw + }) + if (Math.max(image.naturalWidth, image.naturalHeight) <= 1600 && raw.length <= 1_200_000) return raw + const scale = Math.min(1, 1600 / Math.max(image.naturalWidth, image.naturalHeight)) + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)) + canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)) + const context = canvas.getContext('2d') + if (!context) throw new Error(t('读取图片失败')) + context.drawImage(image, 0, 0, canvas.width, canvas.height) + const encoded = canvas.toDataURL('image/webp', 0.86) + return encoded.length < raw.length ? encoded : raw +} + +/** Windows 上 .json 的 MIME 经常为空,所以扩展名也认 */ +function isTextCredentialFile(file: File): boolean { + return /\.(json|info)$/i.test(file.name) || file.type === 'application/json' +} + +/** + * 一次读取选中的图片与 JSON / .info 文件。 + * + * 与桌面端同一口径:图片按张数、文件按个数分别限额;文本文件读原文,二进制 + * 或空文件直接给出可读原因(不做静默丢弃)。 + */ +export async function readMobileAttachments( + files: FileList | File[], + counts: { images: number; files: number }, +): Promise<{ images: string[]; files: AssistantTextFile[] }> { + const list = Array.from(files) + const imageFiles = list.filter(file => file.type.startsWith('image/')) + const textFiles = list.filter(file => !file.type.startsWith('image/') && isTextCredentialFile(file)) + if (imageFiles.length + textFiles.length !== list.length) { + throw new Error(t('只支持图片和 JSON 凭证文件(.json / .info)')) + } + if (imageFiles.length + counts.images > MAX_ATTACHMENTS) { + throw new Error(t('一次最多 {n} 张图片。', { n: MAX_ATTACHMENTS })) + } + if (textFiles.length + counts.files > MAX_TEXT_FILES) { + throw new Error(t('一次最多 {n} 个文件。', { n: MAX_TEXT_FILES })) + } + const images: string[] = [] + for (const file of imageFiles) images.push(await prepareImage(file)) + const result: AssistantTextFile[] = [] + for (const file of textFiles) { + if (file.size > MAX_TEXT_FILE_BYTES) { + throw new Error(t('「{name}」超过 {n} MB,请换一个小一点的 JSON 文件。', { + name: file.name, + n: MAX_TEXT_FILE_BYTES / 1024 / 1024, + })) + } + const text = (await file.text()).replace(/^\uFEFF/, '') + if (!text.trim()) throw new Error(t('「{name}」是空文件。', { name: file.name })) + if (text.includes('\u0000')) throw new Error(t('「{name}」看起来是二进制文件,助手只接收 JSON 文本。', { name: file.name })) + result.push({ name: file.name, size: file.size, text }) + } + return { images, files: result } +} diff --git a/desktop-tauri/mobile/src/lib/assistant-custom-models.ts b/desktop-tauri/mobile/src/lib/assistant-custom-models.ts new file mode 100644 index 000000000..83247a3ab --- /dev/null +++ b/desktop-tauri/mobile/src/lib/assistant-custom-models.ts @@ -0,0 +1,94 @@ +import { apiCall } from '../api/client' +import { t } from '../i18n' +import type { ImageToolContext } from '../../../ui-shared/assistant-image-tools' +import { checkToolSignal } from '../../../ui-shared/assistant-tool-execution' + +type Model = { id: string; enabled?: boolean; reasoning?: string; capabilities?: Record } +type Mapping = { alias: string; target: string; enabled?: boolean; reasoning?: string } +type Provider = { id: string; name?: string; models?: Model[]; mappings?: Mapping[] } +type Draft = { models: Model[]; mappings: Mapping[] } + +const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase() +export const isCustomModelProvider = (id: string) => id.startsWith('custom-') + +function draftOf(provider: Provider): Draft { + const models = (provider.models || []).map(model => ({ ...model, enabled: model.enabled !== false })) + const mappings = (provider.mappings || []).map(mapping => ({ ...mapping, enabled: mapping.enabled !== false })) + for (const model of models) { + const legacy = mappings.find(mapping => same(mapping.alias, model.id) && same(mapping.target, model.id)) + if (!legacy) continue + model.enabled = model.enabled && legacy.enabled + model.reasoning = legacy.reasoning || model.reasoning + } + return { models, mappings: mappings.filter(mapping => !(same(mapping.alias, mapping.target) && models.some(model => same(model.id, mapping.target)))) } +} + +async function providers(): Promise { + const data = await apiCall<{ providers?: Provider[] }>('/api/custom-providers') + if (!Array.isArray(data.providers)) throw new Error(t('读取模型清单失败')) + return data.providers +} + +export async function readCustomModelView() { + const list = await providers() + const models: Array> = [] + const mappings: Array> = [] + for (const provider of list) { + const draft = draftOf(provider) + for (const model of draft.models) { + models.push({ ...model, provider: provider.id, providerLabel: provider.name || provider.id, + kind: 'chat', aliases: draft.mappings.filter(mapping => same(mapping.target, model.id)).map(mapping => mapping.alias) }) + mappings.push({ alias: model.id, target: model.id, provider: provider.id, enabled: model.enabled, + reasoning: model.reasoning || '', isDefault: true, dangling: false }) + } + for (const mapping of draft.mappings) mappings.push({ ...mapping, provider: provider.id, + isDefault: false, dangling: !draft.models.some(model => same(model.id, mapping.target)) }) + } + return { models, mappings } +} + +async function update(id: string, mutate: (draft: Draft) => void, context?: ImageToolContext): Promise { + checkToolSignal(context) + const provider = (await providers()).find(provider => provider.id === id) + if (!provider) throw new Error(t('该自定义提供商已不存在(可能已被删除),请刷新后重试')) + const draft = draftOf(provider) + mutate(draft) + checkToolSignal(context) + await apiCall('/api/custom-providers/models', { method: 'POST', body: { providerId: id, ...draft } }) +} + +export function setCustomBinding(provider: string, alias: string, target: string, enabled: boolean, context?: ImageToolContext): Promise { + return update(provider, draft => { + if (same(alias, target)) { + const model = draft.models.find(model => same(model.id, target)) + if (!model) throw new Error(t('该提供商的清单里没有模型「{name}」', { name: target })) + model.enabled = enabled + return + } + const mapping = draft.mappings.find(mapping => same(mapping.alias, alias) && same(mapping.target, target)) + if (mapping) mapping.enabled = enabled + else draft.mappings.push({ alias, target, enabled, reasoning: '' }) + }, context) +} + +export function removeCustomMapping(provider: string, alias: string, target: string, context?: ImageToolContext): Promise { + return update(provider, draft => { + if (same(alias, target)) throw new Error(t('原始 ID 的默认绑定不能删除,请关闭它的开关')) + const index = draft.mappings.findIndex(mapping => same(mapping.alias, alias) && same(mapping.target, target)) + if (index < 0) throw new Error(t('映射「{alias} → {target}」不存在', { alias, target })) + draft.mappings.splice(index, 1) + }, context) +} + +export function setCustomCapabilities(provider: string, id: string, patch: Record, context?: ImageToolContext): Promise { + return update(provider, draft => { + const model = draft.models.find(model => same(model.id, id)) + if (!model) throw new Error(t('该提供商的清单里没有模型「{name}」', { name: id })) + const capabilities = { ...model.capabilities } + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete capabilities[key] + else capabilities[key] = value + } + model.capabilities = capabilities + }, context) +} diff --git a/desktop-tauri/mobile/src/lib/assistant-image-save.ts b/desktop-tauri/mobile/src/lib/assistant-image-save.ts new file mode 100644 index 000000000..09fda6300 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/assistant-image-save.ts @@ -0,0 +1,22 @@ +import { Capacitor, registerPlugin } from '@capacitor/core' +import { saveAssistantImageFile, type ImageFileSaver } from '../../../ui-shared/assistant-image-save' +import { t } from '../i18n' + +type ImageSaverPlugin = { + save(options: { data: string; name: string; mime: string }): Promise<{ saved: boolean }> +} + +const ImageSaver = registerPlugin('ImageSaver') + +export const saveMobileAssistantImage: ImageFileSaver = async (image, blob) => { + if (!Capacitor.isNativePlatform()) { await saveAssistantImageFile(image, blob); return } + if (!Capacitor.isPluginAvailable('ImageSaver')) throw new Error(t('当前安装包未包含原生图片保存能力,请更新应用。')) + const data = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(new Error(t('无法读取图片'))) + reader.onload = () => resolve(String(reader.result).split(',')[1] || '') + reader.readAsDataURL(blob) + }) + const extension = image.mime === 'image/jpeg' ? 'jpg' : image.mime === 'image/webp' ? 'webp' : 'png' + await ImageSaver.save({ data, name: `image-${image.id.slice(0, 8)}.${extension}`, mime: image.mime }) +} diff --git a/desktop-tauri/mobile/src/lib/assistant-image-tools.ts b/desktop-tauri/mobile/src/lib/assistant-image-tools.ts new file mode 100644 index 000000000..9057f79b3 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/assistant-image-tools.ts @@ -0,0 +1,7 @@ +import { apiCall } from '../api/client' +import { t } from '../i18n' +import { createAssistantImageTools } from '../../../ui-shared/assistant-image-tools' + +export const ASSISTANT_IMAGE_TOOLS = createAssistantImageTools( + (path, body, signal, apiKey) => apiCall(path, { method: 'POST', body, signal, apiKey }), t, +) diff --git a/desktop-tauri/mobile/src/lib/assistant-models.ts b/desktop-tauri/mobile/src/lib/assistant-models.ts new file mode 100644 index 000000000..fb5831385 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/assistant-models.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { fetchSession } from '../api/gateway' +import { apiCall } from '../api/client' +import { modelOptionsFrom, type ModelOption } from '../../../ui-shared/assistant-models' +import { serverIdentity } from './servers' +import { t } from '../i18n' + +export function unavailableModelMessage(name: string): string { + return t('模型「{model}」当前不在可用列表中,请重新选择;也可到「模型管理」或「插件」页检查是否已启用。', { model: name }) +} + +export function useAssistantModels(apiKey?: string) { + const [models, setModels] = useState([]) + const [ready, setReady] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const mounted = useRef(true) + const taskRef = useRef<{ promise: Promise; controller: AbortController } | null>(null) + const identity = serverIdentity() + + const refresh = useCallback((): Promise => { + if (!mounted.current || serverIdentity() !== identity) return Promise.reject(new DOMException('请求已取消', 'AbortError')) + if (taskRef.current && !taskRef.current.controller.signal.aborted) return taskRef.current.promise + const controller = new AbortController() + setLoading(true) + const source = apiKey + ? apiCall<{ data: unknown[] }>('/api/model-access/models', { signal: controller.signal, apiKey }).then(data => ({ models: data.data })) + : fetchSession(controller.signal) + const promise = source.then(session => { + if (controller.signal.aborted || !mounted.current || serverIdentity() !== identity) throw new DOMException('请求已取消', 'AbortError') + if (!Array.isArray(session.models)) throw new Error(t('读取模型清单失败')) + const options = modelOptionsFrom(session.models) + if (mounted.current && serverIdentity() === identity) { + setModels(options) + setReady(true) + setError('') + } + return options + }).catch(failure => { + if (mounted.current && !controller.signal.aborted && serverIdentity() === identity) { + setReady(false) + setError(failure instanceof Error ? failure.message : String(failure)) + } + throw failure + }).finally(() => { + if (taskRef.current?.controller === controller) { + taskRef.current = null + if (mounted.current && serverIdentity() === identity) setLoading(false) + } + }) + taskRef.current = { promise, controller } + return promise + }, [identity, apiKey]) + + useEffect(() => { + mounted.current = true + void refresh().catch(() => {}) + const refreshVisible = () => { + if (!document.hidden) void refresh().catch(() => {}) + } + document.addEventListener('visibilitychange', refreshVisible) + return () => { + mounted.current = false + taskRef.current?.controller.abort() + document.removeEventListener('visibilitychange', refreshVisible) + } + }, [refresh]) + + return { models, ready, loading, error, refresh } +} diff --git a/desktop-tauri/mobile/src/lib/assistant-read-tools.ts b/desktop-tauri/mobile/src/lib/assistant-read-tools.ts new file mode 100644 index 000000000..85833342f --- /dev/null +++ b/desktop-tauri/mobile/src/lib/assistant-read-tools.ts @@ -0,0 +1,26 @@ +import { apiCall } from '../api/client' +import { fetchAccounts, fetchUsageSnapshot } from '../api/gateway' +import { t } from '../i18n' +import { createAssistantReadTools } from '../../../ui-shared/assistant-read-tools' +import type { AssistantReadApi, QueryParams } from '../../../ui-shared/assistant-read-types' + +function queryString(query: QueryParams): string { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) params.set(key, String(value)) + return `?${params.toString()}` +} + +const api: AssistantReadApi = { + accounts: () => fetchAccounts(), + usageSnapshot: () => fetchUsageSnapshot(), + connections: () => apiCall('/api/accounts/connections'), + routingQueue: model => apiCall('/api/routing/queue' + (model ? queryString({ model }) : '')), + usageReport: (range, dates, options) => apiCall('/api/stats/summary' + queryString({ + range, ...(dates ? { startDate: dates.start, endDate: dates.end } : {}), + ...(options?.apiKey ? { apiKey: options.apiKey } : {}), + })), + requestLogs: query => apiCall('/api/stats/requests' + queryString(query)), + runtimeLogs: query => apiCall('/api/logs' + queryString(query)), +} + +export const ASSISTANT_READ_TOOLS = createAssistantReadTools(api, t) diff --git a/desktop-tauri/mobile/src/lib/assistantTools.ts b/desktop-tauri/mobile/src/lib/assistantTools.ts new file mode 100644 index 000000000..0e119ac19 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/assistantTools.ts @@ -0,0 +1,564 @@ +/** + * AI 助手的工具集(手机端)。 + * + * ── 为什么手机端也要有工具表 ────────────────────────────────── + * 后端的 `/api/assistant/chat` 有一条产品口径(见 `config::AssistantConfig:: + * system_prompt`):**一只工具都没勾 → 不注入系统提示词**。而系统提示词正是 + * 告诉模型「你是 Agent2API 的内置助手,管的是本机模型转发配置」的那段话。 + * 手机端早先只发 messages、不带 tools,于是模型拿到的是一个没有身份设定的 + * 通用对话 —— 用户问「各账号余额」,它答成了银行账户余额。 + * + * 原有九只操作/查询工具、共享的六只诊断工具、图片工具,以及附件读取 / 账号 + * 文件导入工具都走**网关的管理 API**(手机端本来就在用那些接口),不经过桌面 + * 壳的桥。 + * + * ── 与桌面端的差异 ──────────────────────────────────────────── + * · 执行层从 `window.workbuddyDesktop` 换成 `api/gateway` 的 HTTP 调用; + * · 自定义提供商(`custom-` 前缀)的读写走 `/api/custom-providers`; + * · 写操作与额度消耗遵守同一份 confirm / full 配置,手机端用确认弹窗展示参数; + * 未勾选的工具不进入请求,执行入口仍须再次核对权限。 + */ + +import { + fetchAccounts, + fetchAccountUsage, + fetchManage, + fetchUsageSnapshot, + setAccountEnabled, + setModelEnabled, + type Account, + type ManageModel, +} from '../api/gateway' +import { apiCall } from '../api/client' +import { accountName } from '../lib/format' +import { createBalanceTool } from '../../../ui-shared/assistant-read-balance' +import { t } from '../i18n' +import { ASSISTANT_READ_TOOLS } from './assistant-read-tools' +import { ASSISTANT_IMAGE_TOOLS } from './assistant-image-tools' +import { + createAccountImportTool, + createAttachmentReadTool, +} from '../../../ui-shared/assistant-account-import-tools' +import type { ImageAsset, ImageToolContext } from '../../../ui-shared/assistant-image-tools' +import { checkToolSignal, providerGroups, runToolBatch } from '../../../ui-shared/assistant-tool-execution' +import { safeText } from '../../../ui-shared/assistant-read-data' +import { isCustomModelProvider, readCustomModelView, removeCustomMapping, setCustomBinding, setCustomCapabilities } from './assistant-custom-models' + +export type ToolResult = { text: string; summary: string; images?: ImageAsset[] } + +export type AssistantTool = { + id: string + label: string + kind: 'read' | 'write' | 'run' + desc: string + params: Record + plan?: (args: Record) => { lead: string; table?: { head: string[]; rows: string[][] }; note?: string } + redactArgs?: (args: Record) => Record + run: (args: Record, context?: ImageToolContext) => Promise +} + +/* ─── 入参读取(模型给的东西不一定守规矩,坏值一律当缺)── */ + +function str(value: unknown, max = 256): string { + return typeof value === 'string' ? value.trim().slice(0, max) : '' +} +function bool(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} +function num(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} +function rows(value: unknown): Array> { + return Array.isArray(value) + ? value.filter((item): item is Record => Boolean(item) && typeof item === 'object') + : [] +} + +/** 分页:模型与映射共用(一页塞太多会撑爆上下文窗口) */ +const PAGE_DEFAULT = 100 +const PAGE_MAX = 500 + +function paginate(items: T[], offset: number, limit: number) { + const start = Math.min(offset, items.length) + const end = Math.min(start + limit, items.length) + return { + meta: { + total: items.length, + offset: start, + limit, + returned: end - start, + hasMore: end < items.length, + ...(end < items.length ? { nextOffset: end } : {}), + }, + items: items.slice(start, end), + } +} + +/** 账号点名:id 精确 > 名字精确 > 名字子串(唯一命中才算) */ +function findAccount(accounts: Account[], ref: string): Account { + const needle = ref.trim() + if (!needle) throw new Error(t('没给账号名')) + const byId = accounts.find(row => row.id === needle) + if (byId) return byId + const exact = accounts.filter(row => accountName(row) === needle) + if (exact.length === 1) return exact[0] + const fuzzy = accounts.filter(row => accountName(row).includes(needle)) + if (fuzzy.length === 1) return fuzzy[0] + const candidates = (exact.length ? exact : fuzzy).map(accountName).slice(0, 6) + if (!candidates.length) throw new Error(t('没有找到账号「{name}」', { name: needle })) + throw new Error( + t('「{name}」匹配到多个账号:{list},请用完整账号名重试', { + name: needle, + list: candidates.join('、'), + }), + ) +} + +async function readAccounts(): Promise { + const data = await fetchAccounts() + // 全局队列 = priority 升序(后端 priority 越小越靠前) + return (data.accounts || []) + .slice() + .sort((a, b) => Number(a.priority ?? 0) - Number(b.priority ?? 0)) +} + +async function readModels(): Promise<{ models: ManageModel[]; mappings: Record[] }> { + const [view, custom] = await Promise.all([fetchManage(), readCustomModelView()]) + if (!Array.isArray(view.models)) throw new Error(t('读取模型清单失败')) + const mappings = (view as { mappings?: Record[] }).mappings + return { models: [...view.models, ...custom.models] as ManageModel[], mappings: [...(mappings || []), ...custom.mappings] } +} + +/* ─── 原有工具(诊断读取由 assistant-read-tools 共享)── */ + +/** 1 · 获取模型列表(只读,分页) */ +const listModels: AssistantTool = { + id: 'list_models', + label: t('获取模型列表'), + kind: 'read', + desc: '分页读取各提供商的模型清单(模型 ID、所属提供商、是否启用、已挂的映射名)。回答「有哪些模型」「哪些模型在多家重复」「某个名字挂在哪」这类问题之前先调用它。\n\n结果分页:响应里带 total / offset / limit / returned / hasMore / nextOffset。hasMore 为 true 时用 nextOffset 继续取,直到取完;不要只看第一页就下结论。', + params: { + type: 'object', + properties: { + provider: { type: 'string', description: '只看某一家(提供商 id);不填 = 全部' }, + keyword: { type: 'string', description: '按模型 ID 或映射名做子串过滤;不填 = 不过滤' }, + enabledOnly: { type: 'boolean', description: 'true = 只看已启用的模型' }, + section: { type: 'string', enum: ['models', 'mappings', 'both'], description: '默认 both;模型与映射各自分页' }, + offset: { type: 'number', description: '本页起点(从 0 开始);翻页时传上一页的 nextOffset' }, + limit: { type: 'number', description: `每页条数(默认 ${PAGE_DEFAULT},最多 ${PAGE_MAX})` }, + }, + }, + run: async args => { + const provider = str(args.provider, 64).toLowerCase() + const keyword = str(args.keyword, 128).toLowerCase() + const enabledOnly = bool(args.enabledOnly) === true + const offset = Math.max(0, Math.round(num(args.offset) ?? 0)) + const limit = Math.max(1, Math.min(PAGE_MAX, Math.round(num(args.limit) ?? PAGE_DEFAULT))) + + const section = str(args.section) || 'both' + if (!['models', 'mappings', 'both'].includes(section)) throw new Error(t('section 只能是 models / mappings / both')) + const data = await readModels() + const filtered = data.models.filter(row => { + if (provider && str(row.provider).toLowerCase() !== provider) return false + if (enabledOnly && row.enabled !== true) return false + if (!keyword) return true + const hay = [str(row.id), str(row.name), ...(row.aliases || [])].join(' ').toLowerCase() + return hay.includes(keyword) + }) + const page = paginate( + filtered.map(row => ({ + provider: str(row.provider), + providerLabel: str(row.providerLabel) || str(row.provider), + id: str(row.id), + enabled: row.enabled === true, + kind: row.kind || 'chat', + supports_image_generation: row.supports_image_generation === true, + supports_image_editing: row.supports_image_editing === true, + aliases: Array.isArray(row.aliases) ? row.aliases : [], + })), + offset, + limit, + ) + const mappings = data.mappings.filter(row => (!provider || str(row.provider).toLowerCase() === provider) + && (!enabledOnly || row.enabled === true) + && (!keyword || [str(row.alias), str(row.target)].join(' ').toLowerCase().includes(keyword))) + const mappingPage = paginate(mappings.map(row => ({ + provider: str(row.provider), alias: str(row.alias), target: str(row.target), + enabled: row.enabled === true, isDefault: row.isDefault === true, dangling: row.dangling === true, + })), offset, limit) + return { + text: JSON.stringify({ + ...(section !== 'mappings' ? { models: { ...page.meta, items: page.items } } : {}), + ...(section !== 'models' ? { mappings: { ...mappingPage.meta, items: mappingPage.items } } : {}), + }), + summary: section === 'mappings' ? t('{n} 条映射', { n: mappingPage.meta.total }) : t('{n} 个模型', { n: page.meta.total }), + } + }, +} + +const listAccountBalance: AssistantTool = createBalanceTool({ + accounts: () => fetchAccounts(), + usageSnapshot: () => fetchUsageSnapshot(), + refreshBalance: id => fetchAccountUsage(id), +}, t) + +/** 3 · 启用 / 禁用模型(改动) */ +const setModelEnabledTool: AssistantTool = { + id: 'set_model_enabled', + label: t('启用 / 禁用模型'), + kind: 'write', + desc: '开启或关闭某几个模型在网关上的可用性(粒度是「提供商 × 模型」)。禁用后下游请求不再路由到它。一次可以改多个。', + params: { + type: 'object', + properties: { + items: { + type: 'array', + description: '要改的模型列表', + items: { + type: 'object', + properties: { + provider: { type: 'string', description: '提供商 id' }, + id: { type: 'string', description: '模型 ID' }, + enabled: { type: 'boolean', description: 'true = 启用,false = 禁用' }, + }, + required: ['provider', 'id', 'enabled'], + }, + }, + }, + required: ['items'], + }, + run: async (args, context) => runToolBatch(rows(args.items), context, async item => { + const provider = str(item.provider) + const id = str(item.id) + const enabled = bool(item.enabled) + if (!provider || !id || enabled === undefined) throw new Error(t('参数不完整(需要 provider / id / enabled)')) + if (isCustomModelProvider(provider)) await setCustomBinding(provider, id, id, enabled, context) + else await setModelEnabled({ provider, id, enabled }) + return `${enabled ? '启用' : '禁用'} ${provider}/${id}` + }, t), +} + +/** 4 · 设置模型映射 ID(改动) */ +const setModelMapping: AssistantTool = { + id: 'set_model_mapping', + label: t('设置模型映射 ID'), + kind: 'write', + desc: '给模型新增或删除映射名(对外名)。同一对外名可以在多个提供商各挂一条:下游用同一个名字请求,网关按账号优先级主备切换;也可以给不同家的同名模型各加前缀来精确指定某一家。一次可以改多条。删掉的映射名只是不再对外可用,不删任何模型。', + params: { + type: 'object', + properties: { + items: { + type: 'array', + description: '要改的映射列表', + items: { + type: 'object', + properties: { + provider: { type: 'string', description: '提供商 id' }, + id: { type: 'string', description: '上游模型 ID' }, + alias: { type: 'string', description: '要新增 / 删除的映射名(对外名)' }, + action: { type: 'string', enum: ['add', 'remove'], description: 'add = 新增,remove = 删除' }, + }, + required: ['provider', 'id', 'alias', 'action'], + }, + }, + }, + required: ['items'], + }, + run: async (args, context) => runToolBatch(rows(args.items), context, async item => { + const provider = str(item.provider) + const target = str(item.id) + const alias = str(item.alias, 128) + const action = str(item.action) + if (!provider || !target || !alias || !['add', 'remove'].includes(action)) throw new Error(t('参数不完整(需要 provider / id / alias / action)')) + if (isCustomModelProvider(provider)) { + if (action === 'remove') await removeCustomMapping(provider, alias, target, context) + else await setCustomBinding(provider, alias, target, true, context) + } else { + await apiCall(`/api/models/mappings${action === 'remove' ? '/remove' : ''}`, { method: 'POST', body: { provider, target, alias } }) + } + return `${action === 'remove' ? '删除' : '新增'} ${alias} → ${provider}/${target}` + }, t), +} + +/** 5 · 修改模型能力(改动) */ +const CAPABILITY_KEYS = [ + 'maxInputTokens', + 'maxOutputTokens', + 'supportsToolCall', + 'supportsImages', + 'supportsVideo', + 'supportsReasoning', +] as const + +const CAPABILITY_LABELS: Record = { + maxInputTokens: t('上下文窗口'), + maxOutputTokens: t('最大输出 Token'), + supportsToolCall: t('工具调用'), + supportsImages: t('图片识别'), + supportsVideo: t('视频识别'), + supportsReasoning: t('深度推理'), +} + +const setModelCapability: AssistantTool = { + id: 'set_model_capability', + label: t('修改模型能力'), + kind: 'write', + desc: '修改某个模型对下游声明的能力位:上下文窗口、最大输出 Token、工具调用 / 图片 / 视频 / 深度推理四个布尔位。只传要改的键;布尔位给 null 表示恢复上游原值。', + params: { + type: 'object', + properties: { + provider: { type: 'string', description: '提供商 id' }, + id: { type: 'string', description: '模型 ID' }, + capabilities: { + type: 'object', + description: '要改的能力位(只写要改的键)', + properties: { + maxInputTokens: { type: ['number', 'null'], description: '上下文窗口(token 数)' }, + maxOutputTokens: { type: ['number', 'null'], description: '最大输出 token 数' }, + supportsToolCall: { type: ['boolean', 'null'], description: '是否支持工具调用' }, + supportsImages: { type: ['boolean', 'null'], description: '是否支持图片识别' }, + supportsVideo: { type: ['boolean', 'null'], description: '是否支持视频识别' }, + supportsReasoning: { type: ['boolean', 'null'], description: '是否支持深度推理' }, + }, + }, + }, + required: ['provider', 'id', 'capabilities'], + }, + run: async (args, context) => { + checkToolSignal(context) + const provider = str(args.provider) + const id = str(args.id) + const raw = (args.capabilities && typeof args.capabilities === 'object' + ? args.capabilities + : {}) as Record + const caps: Record = {} + for (const key of CAPABILITY_KEYS) { + if (!(key in raw)) continue + const value = raw[key] + if (value === null) { + caps[key] = null + continue + } + if (key === 'maxInputTokens' || key === 'maxOutputTokens') { + const value2 = num(value) + if (value2 === undefined) throw new Error(t('{key} 需要数字', { key: CAPABILITY_LABELS[key] })) + caps[key] = Math.round(value2) + } else { + const value2 = bool(value) + if (value2 === undefined) { + throw new Error(t('{key} 需要 true / false / null', { key: CAPABILITY_LABELS[key] })) + } + caps[key] = value2 + } + } + if (!provider || !id || !Object.keys(caps).length) { + throw new Error(t('参数不完整(需要 provider / id / capabilities)')) + } + if (isCustomModelProvider(provider)) await setCustomCapabilities(provider, id, caps, context) + else await apiCall('/api/models/capabilities', { method: 'POST', body: { provider, id, capabilities: caps } }) + return { + text: JSON.stringify({ ok: true, provider, id, capabilities: caps }), + summary: t('已修改 {n} 项能力', { n: Object.keys(caps).length }), + } + }, +} + +/** 6 · 发起模型测试(消耗额度) */ +const testModel: AssistantTool = { + id: 'test_model', + label: t('发起模型测试'), + kind: 'run', + desc: '对某个模型发一次真实的对话请求,回报连通性、耗时与首字延迟。会消耗该账号的额度。回答「这个模型通不通 / 快不快」时用它。', + params: { + type: 'object', + properties: { + provider: { type: 'string', description: '提供商 id' }, + id: { type: 'string', description: '模型 ID' }, + prompt: { type: 'string', description: '测试用的用户提示词;不填 = 默认一句问候' }, + }, + required: ['provider', 'id'], + }, + run: async (args, context) => { + checkToolSignal(context) + if (!context?.apiKey) throw new Error(t('请先选择一把 API Key')) + const testId = crypto.randomUUID() + const payload: Record = { provider: str(args.provider), model: str(args.id), test_id: testId } + const prompt = str(args.prompt, 4000) + if (prompt) payload.prompt = prompt + const cancel = () => { void apiCall(`/api/stats/requests/terminate?id=${encodeURIComponent(testId)}`, { method: 'POST' }).catch(() => {}) } + context?.signal?.addEventListener('abort', cancel, { once: true }) + let result: Record | undefined + try { + result = await apiCall('/api/models/test', { method: 'POST', body: payload, apiKey: context.apiKey }) as Record | undefined + } finally { + context?.signal?.removeEventListener('abort', cancel) + } + const status = String(result?.status ?? '') + const ok = result?.success === true + const slim = { + ok, + status, + error: result?.error ? safeText(result.error, 3000) : null, + reply: safeText(result?.reply, 1500), + duration_ms: result?.duration_ms ?? null, + ttfb_ms: result?.ttfb_ms ?? null, + account: result?.account_name ?? null, + } + return { + text: JSON.stringify(slim), + summary: ok + ? t('连通 · {ms}ms', { ms: String(slim.duration_ms ?? '') }) + : t('失败 · {status}', { status: status || '—' }), + } + }, +} + +/** 7 · 调整提供商顺位(改动)—— 整组移动 */ +const setProviderOrder: AssistantTool = { + id: 'set_provider_order', + label: t('调整提供商顺位'), + kind: 'write', + desc: '调整某个提供商在账号全局队列里的**整组顺位**(第几组),决定路由时先试哪一家。这一家的所有账号会作为一整块移动。位置从 1 开始数。', + params: { + type: 'object', + properties: { + provider: { type: 'string', description: '提供商 id' }, + position: { type: 'number', description: '目标组顺位(1 = 最前)' }, + }, + required: ['provider', 'position'], + }, + run: async (args, context) => { + checkToolSignal(context) + const provider = str(args.provider).toLowerCase() + const position = num(args.position) + if (!provider || position === undefined) { + throw new Error(t('参数不完整(需要 provider / position)')) + } + const accounts = await readAccounts() + const groups = providerGroups(accounts) + const from = groups.findIndex(group => group.provider === provider) + if (from < 0) throw new Error(t('没有找到提供商「{provider}」的账号', { provider })) + const to = Math.max(0, Math.min(groups.length - 1, Math.round(position) - 1)) + const [moved] = groups.splice(from, 1) + groups.splice(to, 0, moved) + checkToolSignal(context) + await apiCall('/api/accounts/reorder', { + method: 'POST', + body: { queue: groups.flatMap(group => group.ids) }, + }) + return { + text: JSON.stringify({ ok: true, provider, from: from + 1, to: to + 1, moved: moved.ids.length }), + summary: t('已移到第 {n} 顺位', { n: to + 1 }), + } + }, +} + +/** 8 · 调整账号优先级(改动) */ +const setAccountOrder: AssistantTool = { + id: 'set_account_order', + label: t('调整账号优先级'), + kind: 'write', + desc: '把某个账号移动到账号全局队列的第 N 顺位(1 = 最前)。位置决定主备与切换顺序:越靠前越先被使用。', + params: { + type: 'object', + properties: { + account: { type: 'string', description: '账号名或账号 id' }, + position: { type: 'number', description: '目标顺位(1 = 最前)' }, + }, + required: ['account', 'position'], + }, + run: async (args, context) => { + checkToolSignal(context) + const ref = str(args.account, 128) + const position = num(args.position) + if (!ref || position === undefined) { + throw new Error(t('参数不完整(需要 account / position)')) + } + const accounts = await readAccounts() + const target = findAccount(accounts, ref) + const ids = accounts.map(row => row.id).filter(Boolean) + const from = ids.indexOf(target.id) + if (from < 0) throw new Error(t('账号不在当前队列里')) + ids.splice(from, 1) + const to = Math.max(0, Math.min(ids.length, Math.round(position) - 1)) + ids.splice(to, 0, target.id) + checkToolSignal(context) + await apiCall('/api/accounts/reorder', { method: 'POST', body: { queue: ids } }) + return { + text: JSON.stringify({ ok: true, account: accountName(target), from: from + 1, to: to + 1 }), + summary: t('已移到第 {n} 顺位', { n: to + 1 }), + } + }, +} + +/** 9 · 启用 / 禁用账号(高危) */ +const setAccountEnabledTool: AssistantTool = { + id: 'set_account_enabled', + label: t('启用 / 禁用账号'), + kind: 'write', + desc: '开启或关闭某几个账号的转发资格。禁用只是把它移出队列(余额仍可查),但如果某条线路只剩它,禁用会直接断供。一次可以改多个。', + params: { + type: 'object', + properties: { + items: { + type: 'array', + description: '要改的账号列表', + items: { + type: 'object', + properties: { + account: { type: 'string', description: '账号名或账号 id' }, + enabled: { type: 'boolean', description: 'true = 启用,false = 禁用' }, + }, + required: ['account', 'enabled'], + }, + }, + }, + required: ['items'], + }, + run: async (args, context) => { + checkToolSignal(context) + const accounts = await readAccounts() + return runToolBatch(rows(args.items), context, async item => { + const enabled = bool(item.enabled) + if (enabled === undefined) throw new Error(t('参数不完整(需要 account / enabled)')) + const target = findAccount(accounts, str(item.account, 128)) + await setAccountEnabled(target.id, enabled) + return `${enabled ? '启用' : '禁用'} ${accountName(target)}` + }, t) + }, +} + +/** 附件读取与账号文件导入:与桌面端共用同一份定义,注入手机端的 HTTP 调用。 */ +const readAttachment = createAttachmentReadTool(t) +const importAccount = createAccountImportTool({ + post: (path, body, signal) => apiCall(path, { method: 'POST', body, signal }), +}, t) + +/** 全部工具(顺序 = 设置页清单顺序:只读在前,改动在后,高危最后) */ +export const ASSISTANT_TOOLS: AssistantTool[] = [ + ...ASSISTANT_READ_TOOLS, + readAttachment, + listModels, + listAccountBalance, + setModelEnabledTool, + setModelMapping, + setModelCapability, + testModel, + ...ASSISTANT_IMAGE_TOOLS, + setProviderOrder, + setAccountOrder, + setAccountEnabledTool, + importAccount, +] + +export function toolById(id: string): AssistantTool | undefined { + return ASSISTANT_TOOLS.find(tool => tool.id === id) +} + +/** 按勾选表组装 OpenAI tools 数组(顺序固定 = ASSISTANT_TOOLS 的顺序) */ +export function toolsPayload(enabled: Record): Array> { + return ASSISTANT_TOOLS.filter(tool => enabled[tool.id]).map(tool => ({ + type: 'function', + function: { name: tool.id, description: tool.desc, parameters: tool.params }, + })) +} diff --git a/desktop-tauri/mobile/src/lib/checkin.ts b/desktop-tauri/mobile/src/lib/checkin.ts new file mode 100644 index 000000000..7e6634c15 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/checkin.ts @@ -0,0 +1,59 @@ +import type { ActivityResult, CheckinRun } from '../api/checkin' +import { beijingDayKey } from './format' +import { t } from '../i18n' +import { rowCompleted, rowPending, rowMessage } from '../../../ui-shared/checkin-result' + +export function checkinSummary(result: CheckinRun, single = false): { text: string; tone: 'ok' | 'err' | 'warn' } { + const rows = result.results ?? [] + if (single && rows.length === 1) return activitySummary(rows[0]) + const failedRows = rows.filter(row => !rowCompleted(row) && !rowPending(row)) + const failed = result.failedCount ?? (Array.isArray(result.failed) ? result.failed.length : result.failed) ?? failedRows.length + const text = t('签到完成:{success}/{total} 成功,保活 {active},失败 {failed}', { + success: Number(result.succeeded) || 0, + total: Number(result.total) || rows.length, + active: Number(result.active) || 0, + failed, + }) + const failures = Array.isArray(result.failed) ? result.failed : failedRows.map(rowMessage).filter(Boolean) + return { + text: failures.length > 0 ? `${text}(${failures.slice(0, 5).join(';')})` : text, + tone: failed > 0 ? 'err' : Number(result.succeeded) > 0 || Number(result.active) > 0 ? 'ok' : 'warn', + } +} + +export function activitySummary(result: ActivityResult): { text: string; tone: 'ok' | 'err' | 'warn' } { + const completed = rowCompleted(result) + return { + text: rowMessage(result) || (completed ? t('执行完成') : t('未完成,请查看签到记录')), + tone: completed ? 'ok' : rowPending(result) ? 'warn' : 'err', + } +} + +export function checkinModeLabel(mode: string): string { + switch (mode) { + case 'checkin': return t('签到') + case 'keepalive': return t('保活') + case 'claim': return t('领取') + case 'full': return t('保活 + 领取') + default: return mode + } +} + +export function claimedPlansToday(ledger: Record | null | undefined): string[] { + if (!ledger || typeof ledger !== 'object' || Array.isArray(ledger)) return [] + const today = beijingDayKey() + return Object.entries(ledger).filter(([, raw]) => { + const at = Number(raw) + return Number.isFinite(at) && at > 0 && at < 8.64e15 - 8 * 3_600_000 && beijingDayKey(at) === today + }).map(([id]) => id) +} + +export function recordRangeStart(range: string): number | undefined { + if (range === 'all') return undefined + const beijing = new Date(Date.now() + 8 * 3_600_000) + const midnight = Date.UTC(beijing.getUTCFullYear(), beijing.getUTCMonth(), beijing.getUTCDate()) - 8 * 3_600_000 + if (range === 'today') return midnight + if (range === 'month') return Date.UTC(beijing.getUTCFullYear(), beijing.getUTCMonth(), 1) - 8 * 3_600_000 + const days = Number(range) + return Number.isFinite(days) && days > 0 ? midnight - (days - 1) * 86_400_000 : undefined +} diff --git a/desktop-tauri/mobile/src/lib/community.ts b/desktop-tauri/mobile/src/lib/community.ts new file mode 100644 index 000000000..0355daae5 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/community.ts @@ -0,0 +1,52 @@ +/** + * 社区入口常量。 + * + * ── 为什么单独一份 ──────────────────────────────────────────── + * 桌面端同一份数据在 `ui-islands/src/islands/update-shared.ts` 的 + * `QQ_GROUPS` / `COMMUNITY_LINKS`。手机端是独立工程(自己的 Vite 构建), + * 不能 import 那个文件(它依赖桌面壳的桥)。**改群号 / 换链接时两处一起改** + * —— 这里与那边是同一份事实的两个落点。 + * + * ── 为什么只给群号,不给二维码 ──────────────────────────────── + * 与桌面端同一理由(见那边 `QQ_GROUPS` 的注释):二维码是打进包里的静态图, + * 换群 / 换码就得跟着发一次版;群号 + 复制按钮改一个常量即可。 + */ + +export type QqGroup = { no: string; nearlyFull: boolean } + +export const QQ_GROUPS: readonly QqGroup[] = [ + { no: '1127656296', nearlyFull: true }, + { no: '1130894674', nearlyFull: true }, + { no: '1108276647', nearlyFull: false }, +] + +export type CommunityLink = { id: 'telegram'; url: string } + +export const COMMUNITY_LINKS: readonly CommunityLink[] = [ + { id: 'telegram', url: 'https://t.me/+9gM8YPvgRL02NGRl' }, +] + +/** + * 用系统浏览器打开一个外链。 + * + * ── 为什么 APK 里是「同窗跳转」而不是 window.open ───────────── + * Capacitor 的 WebView 没开多窗口支持,`window.open` 会被直接忽略(返回 + * null,什么也不发生)。而主框架导航会走 `BridgeWebViewClient` 的 + * `shouldOverrideUrlLoading` → `bridge.launchIntent`:目标不是应用自己的 + * origin 时,它抛一个 `ACTION_VIEW` Intent 给系统,由默认浏览器接管, + * WebView 自己停在原地。所以这里用 `location.href`。 + * + * PWA 里没有那层壳,`window.open` 才是对的(新标签页,不丢当前页面)。 + */ +export function openExternal(url: string): void { + const target = String(url || '').trim() + if (!/^https?:\/\//i.test(target)) return + const packaged = Boolean( + (window as Window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(), + ) + if (packaged) { + window.location.href = target + return + } + window.open(target, '_blank', 'noopener') +} diff --git a/desktop-tauri/mobile/src/lib/format.ts b/desktop-tauri/mobile/src/lib/format.ts new file mode 100644 index 000000000..9c7ef2fb4 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/format.ts @@ -0,0 +1,152 @@ +/** 通用格式化与少量纯函数工具(无 React 依赖,页面与数据层共用) */ + +import { upstreamCooldownOf } from '../../../ui-shared/account-upstream-state' + +/** 紧凑数字:1.2 万 / 3.4 亿;小于一万保留原样 */ +export function formatCompact(value: number): string { + if (!Number.isFinite(value)) return '—' + const abs = Math.abs(value) + if (abs >= 1e8) return `${(value / 1e8).toFixed(abs >= 1e9 ? 0 : 2)} 亿` + if (abs >= 1e4) return `${(value / 1e4).toFixed(abs >= 1e6 ? 0 : 1)} 万` + return String(Math.round(value)) +} + +/** 千分位整数 */ +export function formatInt(value: number): string { + if (!Number.isFinite(value)) return '—' + return Math.round(value).toLocaleString('zh-CN') +} + +/** Token 数:走紧凑口径(报表与日志都用这一套,避免两页读数不一致) */ +export function formatTokens(value: number): string { + return formatCompact(value) +} + +/** 百分比:输入 0~1 的比值或 0~100 的百分数由 `asRatio` 决定 */ +export function formatPercent(value: number | null | undefined, digits = 1, asRatio = true): string { + if (value === null || value === undefined || !Number.isFinite(value)) return '—' + const percent = asRatio ? value * 100 : value + return `${percent.toFixed(digits)}%` +} + +/** 毫秒 → 人类可读时长(1.2s / 860ms / 2m 05s) */ +export function formatDuration(ms: number | null | undefined): string { + if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) return '—' + if (ms < 1000) return `${Math.round(ms)}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s` + const total = Math.floor(ms / 1000) + return `${Math.floor(total / 60)}m ${String(total % 60).padStart(2, '0')}s` +} + +/** 时间戳 → HH:MM:SS */ +export function formatClock(ts: number | null | undefined): string { + if (!ts) return '—' + const date = new Date(ts) + if (Number.isNaN(date.getTime())) return '—' + return date.toLocaleTimeString('zh-CN', { hour12: false }) +} + +/** 时间戳 → MM-DD HH:MM */ +export function formatStamp(ts: number | null | undefined): string { + if (!ts) return '—' + const date = new Date(ts) + if (Number.isNaN(date.getTime())) return '—' + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const time = date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }) + return `${month}-${day} ${time}` +} + +/** 相对时间:3 分钟后 / 2 小时后 / 4 天后 */ +export function formatRelative(ts: number | null | undefined): string { + if (!ts) return '—' + const diff = ts - Date.now() + const abs = Math.abs(diff) + const suffix = diff >= 0 ? '后' : '前' + if (abs < 60_000) return diff >= 0 ? '即将' : '刚刚' + if (abs < 3_600_000) return `${Math.round(abs / 60_000)} 分钟${suffix}` + if (abs < 86_400_000) return `${Math.round(abs / 3_600_000)} 小时${suffix}` + return `${Math.round(abs / 86_400_000)} 天${suffix}` +} + +/** + * 有效期读数:把「绝对时间戳」翻成手机上更好读的相对量。 + * 已过期返回「已过期」;缺失返回「—」。 + */ +export function formatExpiry(ts: number | null | undefined): string { + if (!ts) return '—' + const diff = ts - Date.now() + if (diff <= 0) return '已过期' + if (diff < 3_600_000) return `${Math.max(1, Math.round(diff / 60_000))} 分钟后` + if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)} 小时后` + return `${Math.round(diff / 86_400_000)} 天后` +} + +/** 北京时间自然日的键(与后端签到日界同一口径) */ +export function beijingDayKey(ts = Date.now()): string { + const date = new Date(ts + 8 * 3_600_000) + return date.toISOString().slice(0, 10) +} + +/** 今天是否已经过某个时间戳(北京时间自然日) */ +export function isToday(ts: number | null | undefined): boolean { + if (!ts) return false + return beijingDayKey(ts) === beijingDayKey() +} + +/** 展示名优先采用网关快照,旧网关缺字段时回落原来的名字链。 */ +export function accountName(account: { + displayName?: string + name?: string + nickname?: string + email?: string + id?: string +}): string { + return account.displayName || account.name || account.nickname || account.email || account.id || '未命名' +} + +/** 脱敏:保留首尾各 3 个字符,中间用 • 代替 */ +export function maskMiddle(text: string, keep = 3): string { + const value = String(text || '') + if (value.length <= keep * 2) return value + return `${value.slice(0, keep)}${'•'.repeat(Math.min(8, value.length - keep * 2))}${value.slice(-keep)}` +} + +/** 模型名太长时保留尾部(同名模型常靠尾缀区分) */ +export function ellipsisMiddle(text: string, max = 26): string { + const value = String(text || '') + if (value.length <= max) return value + const head = Math.ceil((max - 1) / 2) + const tail = Math.floor((max - 1) / 2) + return `${value.slice(0, head)}…${value.slice(-tail)}` +} + +/** + * 账号**当前生效**的限流记录(`rateLimits[model]`,按恢复时间升序)。 + * + * 口径与后端 `routing::rate_limit_reset_at`、桌面端 `activeLimits()` 一致: + * 只有 `resetAt` 在**未来**才算限流;记录还在但已过期 = 冷却自然结束, + * 不该再显示成「限流」。后端从不主动清理过期记录,所以这里必须自己过滤 —— + * 只判断「有没有键」会把所有历史限流过的账号永远标红。 + */ +export function activeLimits(account: { + rateLimits?: Record +}): Array<{ model: string; resetAt: number; status?: number | string; code?: string | number; message?: string }> { + const limits = account.rateLimits + if (!limits || typeof limits !== 'object') return [] + const now = Date.now() + return Object.entries(limits) + .map(([model, info]) => ({ model, resetAt: Number(info?.resetAt) || 0, ...(info || {}) })) + .filter(item => item.resetAt > now) + .sort((a, b) => a.resetAt - b.resetAt) +} + +/** 账号此刻是否处于限流中(与 {@link activeLimits} 同一口径) */ +export function isRateLimited(account: { + upstreamCooldownUntil?: number | null + upstreamBlockedReason?: string + rateLimits?: Record +}): boolean { + return upstreamCooldownOf(account) !== null || activeLimits(account).length > 0 +} + diff --git a/desktop-tauri/mobile/src/lib/providerFacts.ts b/desktop-tauri/mobile/src/lib/providerFacts.ts new file mode 100644 index 000000000..240f706ed --- /dev/null +++ b/desktop-tauri/mobile/src/lib/providerFacts.ts @@ -0,0 +1,177 @@ +/** + * 按提供商区分的事实(有效期字段 / 标识字段 / 长期有效 / 余额能力)。 + * + * ── 事实来源:插件申报,不是客户端查表 ────────────────────── + * 「有效期落在哪个键」按家不同:WorkBuddy 写 `expiresAt`,小浣熊写 + * `tokenExpiresAt`,OrcaTerm 写 `tokenExpireAt`(少一个 s),KukuAI 压根没有 + * 这个字段。这些**全是上游事实**,原先在桌面端与手机端各存一份手写表 —— + * 手机端那份还是从桌面端摘抄的(见本文件的历史注释),摘错一次就是「有效期 + * 一直显示 —」。 + * + * 现在的事实来源是**插件在握手时申报的 `AccountFeatures`**,由后端挂在 + * `/api/session` 的 `accountFeatures` 上一起下发。手机端只需要「收下 + 查」, + * 新插件装上就自动正确,不必再改客户端。 + * + * ── 兜底 ──────────────────────────────────────────────────── + * 旧插件不申报(`accountFeatures` 里没有这一家)时走 `FALLBACK`: + * 两个键都看、取先有值的那个。宁可兜底,也不要因为漏登记而少一行读数。 + */ + +/** 一个提供商的账号行参数(与协议里 `AccountFeatures` 同形,只取手机端用到的) */ +export type ProviderFacts = { + /** 有效期字段名的**候选列表**(按顺序取第一个能解析出正数的);空 = 没有这个概念 */ + expiry: readonly string[] + /** 账号标识字段名的候选列表 */ + identifier: readonly string[] + /** 主凭证长期有效(没有固定到期时刻) */ + longLived: boolean + /** 这家有没有余额查询能力 */ + usage: boolean +} + +/** + * 插件申报表(`provider id → 申报`),由 `/api/session` 的 `accountFeatures` 带回。 + * + * 模块级缓存而不是 React state:读它的地方都在渲染函数里(列表行、详情页), + * 而它每次会话刷新才变一次 —— 换一份新 Map 就够。 + */ +let declared: ReadonlyMap = new Map() +/** 上一次归一用的原始对象(引用比较:`/api/session` 换一份才重归一) */ +let declaredSource: unknown = null + +/** + * 收下后端带回的申报表。 + * + * 形状是协议里 `AccountFeatures` 的 serde 输出(snake_case)。字段缺省按 + * `FALLBACK` 填 —— 这样消费侧不必到处判「键在不在」。 + */ +export function adoptProviderFacts(raw: unknown): void { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + declared = new Map() + return + } + const next = new Map() + for (const [id, value] of Object.entries(raw as Record)) { + if (!id || !value || typeof value !== 'object' || Array.isArray(value)) continue + next.set(id, normalize(value as Record)) + } + declared = next +} + +/** 把协议形态归一成手机端形态。 */ +function normalize(raw: Record): ProviderFacts { + return { + expiry: keyList(raw.expiry), + identifier: keyList(raw.identifier, FALLBACK.identifier), + longLived: raw.long_lived === true, + usage: raw.usage === true, + } +} + +/** 键名候选列表:数组原样取字符串项,单个字符串包成一项,其余走兜底。 */ +function keyList(value: unknown, fallback: readonly string[] = []): readonly string[] { + if (typeof value === 'string') return value ? [value] : [] + if (!Array.isArray(value)) return fallback + return value.filter((item): item is string => typeof item === 'string' && item.length > 0) +} + +const FALLBACK: ProviderFacts = { + expiry: ['expiresAt', 'tokenExpiresAt'], + identifier: ['userId'], + longLived: false, + usage: false, +} + +/** + * 某个提供商的账号行参数。查询顺序:**插件申报 → 通用兜底**。 + * + * `session` 参数可选:调用方手里有会话快照时传进来,函数会按引用比较决定 + * 要不要重新归一(`/api/session` 每次刷新都换一份新对象)。 + */ +export function providerFacts( + id: string | undefined, + session?: { accountFeatures?: Record } | null, +): ProviderFacts { + syncDeclared(session) + return (id && declared.get(id)) || FALLBACK +} + +/** 会话快照变了才重新归一(引用比较;没有会话时保持现状) */ +function syncDeclared(session?: { accountFeatures?: Record } | null): void { + if (!session) return + const raw = session.accountFeatures + if (raw === declaredSource) return + declaredSource = raw + adoptProviderFacts(raw) +} + +/** 当前申报表(排障用;界面请走 `providerFacts`) */ +export function declaredProviderFacts(): ReadonlyMap { + return declared +} + +/** + * 这家账号能不能查余额:自定义提供商看 balanceQuery.scheme,插件家看申报。 + * + * 访客账号(`isGuest`)一律 false:智谱清言的访客没有积分体系(上游接口 + * 一律 401),查询只会得到恒定的失败。判据与桌面端 `accounts-domain` 的同名 + * 函数、以及后端 `usage_query::supports_usage` 同口径 —— 三处必须一致, + * 否则界面算的目标集合与后端返回的行会对不上。 + */ +export function supportsUsage(account: { + provider?: string + balanceQuery?: unknown + isGuest?: boolean +}): boolean { + if (account.isGuest === true) return false + const id = String(account.provider || '') + if (id.startsWith('custom-')) { + const scheme = (account.balanceQuery as { scheme?: unknown } | undefined)?.scheme + return Boolean(scheme) + } + return providerFacts(id).usage +} + +/** 把值归一到毫秒(秒与毫秒在库里都出现过,见桌面端 accounts-domain 的同名函数) */ +function expiryMillis(value: unknown): number { + const raw = Number(value) || 0 + if (raw <= 0) return 0 + return raw < 1e11 ? raw * 1000 : raw +} + +/** + * 取账号的有效期时间戳(毫秒,0 = 记录里没有)。 + * + * 按申报的候选键**顺序取第一个能解析出正数**的:插件新落的记录与存量旧投影的 + * 键名不同(小浣熊是 `expiresAt` / `tokenExpiresAt`),两套都列在申报里。 + * 判据是「能解析出正数」而不是「有值」—— 一个 `null` 不该把后面有值的键挡掉。 + */ +export function expiryOf(account: { + provider?: string + expiresAt?: number + tokenExpiresAt?: number + [key: string]: unknown +}, session?: { accountFeatures?: Record } | null): number { + const facts = providerFacts(account.provider, session) + for (const key of facts.expiry) { + const value = expiryMillis(account[key]) + if (value > 0) return value + } + return 0 +} + +/** 这家有没有「有效期」这个概念(决定详情里渲不渲染那一格) */ +export function hasExpiry(provider: string | undefined): boolean { + const facts = providerFacts(provider) + return facts.expiry.length > 0 || facts.longLived +} + +/** + * 这家的主凭证是不是**长期有效**(没有固定到期时刻)。 + * + * 有它的家照样渲染「有效期」那一格,只是值写「长期有效」而不是「—」: + * 「—」读作「记录里没有到期时间」,长期有效读作「本来就不会到期」。 + */ +export function isLongLivedCredential(provider: string | undefined): boolean { + return providerFacts(provider).longLived +} diff --git a/desktop-tauri/mobile/src/lib/providers.ts b/desktop-tauri/mobile/src/lib/providers.ts new file mode 100644 index 000000000..049bee439 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/providers.ts @@ -0,0 +1,145 @@ +/** + * 提供商图标与展示名。 + * + * ── 图标为什么用 而不是内联 SVG ──────────────────────── + * 提供商图标是一批彩色 PNG(`ui/assets/providers/*.png`),由桌面端同一套 + * 资源提供。移动端页面与它们同源(都在 `ui/` 下),直接引相对路径即可, + * 不必再打包一份。 + * + * ── 为什么没有「第二份提供商清单」───────────────────────────── + * 项目里明确禁止前端维护写死的 provider 列表(见 account_store 模块头): + * 加一家内置提供商时,后端注册表是唯一事实来源。这里的映射只解决一件 + * 后端不管的事 —— **图标文件叫什么、中文展示名怎么念**。认不出的 id + * 一律回落到首字徽章,界面不会空一块。 + */ + +import { t } from '../i18n' + +/** 提供商 id → 图标文件(相对 `ui/` 根,页面在 `ui/m.html` 所以前缀 `m/` 不用加) */ +const ICONS: Record = { + workbuddy: 'assets/providers/workbuddy.png', + 'workbuddy-intl': 'assets/providers/workbuddy.png', + raccoon: 'assets/providers/raccoon.png', + catpaw: 'assets/providers/catpaw.png', + autoclaw: 'assets/providers/autoclaw.png', + 'autoclaw-intl': 'assets/providers/autoclaw.png', + qoder: 'assets/providers/qoder.png', + 'qoder-intl': 'assets/providers/qoder.png', + 'cline-free': 'assets/providers/cline.png', + cline: 'assets/providers/cline.png', + 'cline-pass': 'assets/providers/cline.png', + accio: 'assets/providers/accio.png', + 'accio-cn': 'assets/providers/accio.png', + 'accio-intl': 'assets/providers/accio.png', + zcode: 'assets/providers/zcode.png', + 'zcode-intl': 'assets/providers/zcode.png', + codearts: 'assets/providers/codearts.png', + trae: 'assets/providers/trae.png', + loomy: 'assets/providers/loomy.png', + kuku: 'assets/providers/kuku.png', + monkeycode: 'assets/providers/monkeycode.png', + 'monkeycode-intl': 'assets/providers/monkeycode.png', + commandcode: 'assets/providers/commandcode.png', + antigravity: 'assets/providers/antigravity.png', + officeace: 'assets/providers/officeace.png', + orcaterm: 'assets/providers/orcaterm.png', + chatglm: 'assets/providers/chatglm.png', + 'deepseek-web': 'assets/providers/deepseek.png', + tabbit: 'assets/providers/tabbit.png', + 'tabbit-intl': 'assets/providers/tabbit.png', + modelscope: 'assets/providers/modelscope.png', + 'modelscope-intl': 'assets/providers/modelscope.png', + atomcode: 'assets/providers/atomcode.png', + lobsterai: 'assets/providers/lobsterai.png', + doubaowork: 'assets/providers/doubaowork.png', + cursor: 'assets/providers/cursor.svg', + kiro: 'assets/providers/kiro.svg', + astudio: 'assets/providers/astudio.png', + devin: 'assets/providers/devin.svg', + factory: 'assets/providers/factory.svg', + zed: 'assets/providers/zed.svg', + grok: 'assets/providers/grok.svg', + 'minimax-code': 'assets/providers/minimax-code.svg', + 'minimax-code-global': 'assets/providers/minimax-code.svg', + qwenwork: 'assets/providers/qwenwork.svg', + 'qwenwork-intl': 'assets/providers/qwenwork.svg', + 'qwenwork-enterprise': 'assets/providers/qwenwork.svg', + 'kimi-work': 'assets/providers/kimi-work.svg', + opendesign: 'assets/providers/opendesign.svg', + 'nous-portal': 'assets/providers/nous-portal.svg', + 'claude-subscription': 'assets/providers/anthropic.png', + 'codex-subscription': 'assets/providers/openai.png', + 'chatgpt-subscription': 'assets/providers/openai.png', + 'copilot-subscription': 'assets/providers/copilot.svg', + 'gemini-subscription': 'assets/providers/gemini-cli.svg', +} + +/** 后端没给 label 时的兜底展示名(认不出的 id 原样显示) */ +const LABELS: Record = { + astudio: 'AStudio', + workbuddy: 'WorkBuddy 国内版', + 'workbuddy-intl': 'WorkBuddy 国际版', + raccoon: '小浣熊', + catpaw: 'CatPaw', + autoclaw: 'AutoClaw 国内版', + 'autoclaw-intl': 'AutoClaw 国际版', + qoder: 'Qoder 中国版', + 'qoder-intl': 'Qoder 国际版', + 'cline-free': 'Cline 免费池', + cline: 'Cline Free', + 'cline-pass': 'Cline Pass', + accio: 'Accio 国际版', + 'accio-cn': 'Accio 国内版', + 'accio-intl': 'Accio 国内版', + zcode: 'ZCode 国内版', + 'zcode-intl': 'ZCode 国际版', + codearts: 'CodeArts', + trae: 'Trae', + loomy: 'Loomy', + kuku: 'KukuAI', + monkeycode: 'MonkeyCode 国内版', + 'monkeycode-intl': 'MonkeyCode 国际版', + commandcode: 'Command Code', + antigravity: 'Antigravity', + officeace: 'OfficeAce 果办', + orcaterm: 'OrcaTerm', + chatglm: '智谱清言', + tabbit: 'Tabbit 国内版', + 'tabbit-intl': 'Tabbit 国际版', + modelscope: 'ModelScope 国内版', + 'modelscope-intl': 'ModelScope 国际版', + atomcode: 'AtomCode', + lobsterai: 'LobsterAI', + doubaowork: '豆包工作', + cursor: 'Cursor', + kiro: 'Kiro', + devin: 'Devin', + factory: 'Factory', + zed: 'Zed', + grok: 'Grok (SuperGrok)', + 'minimax-code': 'MiniMax Code 国内版', + 'minimax-code-global': 'MiniMax Code 国际版', + qwenwork: '千问办公 国内版', + 'qwenwork-intl': '千问办公 国际版', + 'qwenwork-enterprise': 'QwenWork 千问办公企业版', + 'kimi-work': 'Kimi Work', + opendesign: 'Open Design', + 'nous-portal': 'Nous Portal', + 'claude-subscription': 'Claude 订阅', + 'codex-subscription': 'Codex 订阅', + 'chatgpt-subscription': 'ChatGPT API 订阅', + 'copilot-subscription': 'GitHub Copilot', + 'gemini-subscription': 'Gemini CLI', +} + +/** 图标地址;认不出的 id 返回空串(调用方画首字徽章) */ +export function providerIcon(id: string | undefined): string { + return (id && ICONS[id]) || '' +} + +/** 展示名:优先用后端给的 label,其次本地映射,最后原样回显 id */ +export function providerLabel(id: string | undefined, fallback?: string): string { + if (fallback) return t(fallback) + if (!id) return t('未知') + return t(LABELS[id] || id) +} diff --git a/desktop-tauri/mobile/src/lib/pwa.ts b/desktop-tauri/mobile/src/lib/pwa.ts new file mode 100644 index 000000000..fdf5a9cd4 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/pwa.ts @@ -0,0 +1,281 @@ +/** + * PWA 运行时:安装引导、更新通知、离线状态、独立窗口检测。 + * + * ── 为什么要自己包一层 ──────────────────────────────────────── + * 这几件事都有平台差异,散在页面里写会变成一堆 if: + * · **安装**:Chrome / Edge 有 `beforeinstallprompt`,可以弹原生安装框; + * iOS Safari 没有这个事件,只能教用户「分享 → 添加到主屏幕」; + * 已经在独立窗口里运行时两者都不该出现。 + * · **更新**:新 Service Worker 装好后要等页面发消息才接管(见 sw.js), + * 这里负责检测「有新版在等」并通知界面。 + * · **离线**:`navigator.onLine` 只说明有没有网卡连接,不代表网关可达 —— + * 这里只把它当作「可以提示」的信号,真正的可达性仍由接口请求判。 + * + * ── 为什么状态放模块级而不是 React context ──────────────────── + * `beforeinstallprompt` 可能在 React 挂载之前就触发(页面加载极快时), + * 事件对象只能捕获一次,错过就没有了。所以监听器必须在模块加载时就挂上, + * 状态再通过订阅分发给组件(见 lib/usePwa.ts)。 + */ + +export type PwaState = { + /** 可以弹原生安装框(Chrome / Edge 且未安装) */ + canInstall: boolean + /** iOS Safari:没有原生安装框,只能给「添加到主屏幕」的操作指引 */ + showIosHint: boolean + /** 已在独立窗口(standalone)里运行 */ + standalone: boolean + /** 有新版 Service Worker 在等待接管 */ + updateReady: boolean + /** 浏览器报告离线(网卡层,不代表网关可达) */ + offline: boolean + /** + * Service Worker 的状态。 + * · `checking` —— 注册还没返回,界面不该据此下结论; + * · `ready` —— 已注册,离线外壳可用; + * · `unsupported` —— 非安全上下文(局域网 http)或注册失败。 + */ + swState: 'checking' | 'ready' | 'unsupported' +} + +/** `beforeinstallprompt` 事件(TS 标准库没有,这里补最小形状) */ +type InstallPromptEvent = Event & { + prompt: () => Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +let deferredPrompt: InstallPromptEvent | null = null +let started = false + +const listeners = new Set<() => void>() + +const state: PwaState = { + canInstall: false, + showIosHint: false, + standalone: false, + updateReady: false, + offline: false, + swState: 'checking', +} + +/** 缓存的快照对象:useSyncExternalStore 要求同一状态返回同一引用 */ +let snapshot: PwaState = { ...state } + +function emit(): void { + snapshot = { ...state } + listeners.forEach(listener => listener()) +} + +function set(patch: Partial): void { + let changed = false + for (const key of Object.keys(patch) as Array) { + if (state[key] !== patch[key]) { + state[key] = patch[key] as never + changed = true + } + } + if (changed) emit() +} + +/** 是否在独立窗口里运行(主屏图标启动 / 桌面 PWA 窗口) */ +function detectStandalone(): boolean { + if (typeof window === 'undefined') return false + // display-mode 是标准判据;iOS 早期只认 navigator.standalone + const queries = ['(display-mode: standalone)', '(display-mode: fullscreen)', '(display-mode: minimal-ui)'] + for (const query of queries) { + if (window.matchMedia(query).matches) return true + } + const legacy = (window.navigator as Navigator & { standalone?: boolean }).standalone + return legacy === true +} + +/** iOS / iPadOS 上的 Safari(含 iPadOS 13+ 伪装成 macOS 的情况) */ +function isIosSafari(): boolean { + if (typeof navigator === 'undefined') return false + const ua = navigator.userAgent + const isIos = /iPad|iPhone|iPod/.test(ua) || (ua.includes('Macintosh') && 'ontouchend' in document) + if (!isIos) return false + // Chrome / Firefox / Edge 在 iOS 上都是 WebKit 壳,但安装入口不同 —— + // 只有 Safari 有「添加到主屏幕」,其余浏览器打开时提示去 Safari + return /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua) +} + +/** + * 初始化。幂等:重复调用直接返回(模块级调用一次即可)。 + * + * 返回清理函数,主要是给「页面要卸载」的场景用;单页应用整个生命周期 + * 只调一次,不调清理也没问题。 + */ +export function initPwa(): () => void { + if (typeof window === 'undefined' || started) return () => undefined + started = true + + set({ standalone: detectStandalone() }) + + // ── 安装提示 ────────────────────────────────────────────── + const onBeforeInstall = (event: Event) => { + // 阻止默认的迷你信息条:我们要自己决定在设置页里什么时候展示 + event.preventDefault() + deferredPrompt = event as InstallPromptEvent + set({ canInstall: true, showIosHint: false }) + } + const onInstalled = () => { + deferredPrompt = null + set({ canInstall: false, showIosHint: false, standalone: true }) + } + window.addEventListener('beforeinstallprompt', onBeforeInstall) + window.addEventListener('appinstalled', onInstalled) + + // iOS 没有 beforeinstallprompt:用「未安装 + iOS Safari」推出操作指引 + if (!detectStandalone() && isIosSafari()) { + set({ showIosHint: true }) + } + + // display-mode 变化:从浏览器标签页切到独立窗口(安装后立刻发生) + const mediaQueries = [ + '(display-mode: standalone)', + '(display-mode: fullscreen)', + '(display-mode: minimal-ui)', + ].map(query => window.matchMedia(query)) + const onDisplayMode = () => { + const standalone = detectStandalone() + set({ standalone, showIosHint: standalone ? false : state.showIosHint }) + } + mediaQueries.forEach(mq => { + if (typeof mq.addEventListener === 'function') mq.addEventListener('change', onDisplayMode) + else mq.addListener(onDisplayMode) + }) + + // ── 离线状态 ────────────────────────────────────────────── + const onOnline = () => set({ offline: false }) + const onOffline = () => set({ offline: true }) + set({ offline: !navigator.onLine }) + window.addEventListener('online', onOnline) + window.addEventListener('offline', onOffline) + + // ── Service Worker 更新 ─────────────────────────────────── + let swCleanup: (() => void) | null = null + let disposed = false + + // 只在安全上下文里注册:局域网 http:// 下浏览器不提供 SW + if ('serviceWorker' in navigator && window.isSecureContext) { + void navigator.serviceWorker + // updateViaCache: 'none' —— 让浏览器每次都直接问服务器要 sw.js, + // 不走 HTTP 缓存。网关的静态文件带缓存头,默认策略下浏览器可能 + // 拿缓存里的旧脚本做更新比对,于是「有新版本」永远检测不到。 + .register('sw.js', { scope: '/', updateViaCache: 'none' }) + .then(registration => { + if (disposed) return + set({ swState: 'ready' }) + swCleanup = watchUpdates(registration) + }) + .catch(() => { + // 注册失败不影响使用(只是没有离线外壳) + set({ swState: 'unsupported' }) + }) + } else { + set({ swState: 'unsupported' }) + } + + return () => { + disposed = true + window.removeEventListener('beforeinstallprompt', onBeforeInstall) + window.removeEventListener('appinstalled', onInstalled) + mediaQueries.forEach(mq => { + if (typeof mq.removeEventListener === 'function') mq.removeEventListener('change', onDisplayMode) + else mq.removeListener(onDisplayMode) + }) + window.removeEventListener('online', onOnline) + window.removeEventListener('offline', onOffline) + swCleanup?.() + } +} + +/** + * 盯着 SW 的更新。 + * + * 两个入口: + * · `updatefound` —— 页面打开期间浏览器发现新版本并开始安装; + * · 页面加载时已经有一个 `waiting` 的 SW(上次访问时下好了但没接管)。 + * 两者都归结为「有新版在等」,界面给一个「重启以更新」。 + */ +function watchUpdates(registration: ServiceWorkerRegistration): () => void { + const markReady = () => set({ updateReady: true }) + + if (registration.waiting && navigator.serviceWorker.controller) { + markReady() + } + + const onUpdateFound = () => { + const installing = registration.installing + if (!installing) return + installing.addEventListener('statechange', () => { + // `installed` 且已有 controller = 有旧版在跑,新的在等 + if (installing.state === 'installed' && navigator.serviceWorker.controller) { + markReady() + } + }) + } + registration.addEventListener('updatefound', onUpdateFound) + + // 页面重新可见时主动查一次更新(手机上切回前台是常见时机) + const onVisible = () => { + if (document.visibilityState === 'visible') void registration.update().catch(() => undefined) + } + document.addEventListener('visibilitychange', onVisible) + + return () => { + registration.removeEventListener('updatefound', onUpdateFound) + document.removeEventListener('visibilitychange', onVisible) + } +} + +/** 订阅状态变化(配 React 的 useSyncExternalStore 用) */ +export function subscribePwa(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +/** 读当前快照(同一状态返回同一引用,满足 useSyncExternalStore 的要求) */ +export function getPwaSnapshot(): PwaState { + return snapshot +} + +/** + * 弹原生安装框。返回用户的选择;没有可用事件时返回 `'unavailable'` + * (调用方据此展示手动指引)。 + */ +export async function promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'> { + if (!deferredPrompt) return 'unavailable' + const prompt = deferredPrompt + deferredPrompt = null + set({ canInstall: false }) + try { + await prompt.prompt() + const choice = await prompt.userChoice + return choice.outcome + } catch { + return 'dismissed' + } +} + +/** 让等待中的新 SW 立刻接管(页面随后自己 reload) */ +export async function applyUpdate(): Promise { + const registration = await navigator.serviceWorker.getRegistration() + registration?.waiting?.postMessage({ type: 'SKIP_WAITING' }) + // 等 controller 换人再刷新,避免刷新后还是旧 SW 在服务 + await new Promise(resolve => { + let done = false + const finish = () => { + if (done) return + done = true + navigator.serviceWorker.removeEventListener('controllerchange', finish) + resolve() + } + navigator.serviceWorker.addEventListener('controllerchange', finish) + // 兜底:600ms 内没换人也刷新(比如 SW 已被回收) + window.setTimeout(finish, 600) + }) + window.location.reload() +} diff --git a/desktop-tauri/mobile/src/lib/routes.ts b/desktop-tauri/mobile/src/lib/routes.ts new file mode 100644 index 000000000..521ff01ef --- /dev/null +++ b/desktop-tauri/mobile/src/lib/routes.ts @@ -0,0 +1,25 @@ +export const SCREEN_IDS = [ + 'home', 'report', 'accounts', 'assistant', 'more', 'models', 'keys', 'proxies', + 'tasks', 'requests', 'logs', 'settings', 'docs', 'agent', 'tools', 'servers', 'checkin', 'plugins', +] as const + +export type ScreenId = typeof SCREEN_IDS[number] +export type Route = { screen: ScreenId; params?: Record } + +/** 旧插件签到地址只作路由兼容,不再加载插件提供的 HTML。 */ +export function normalizeRoute(screen: string, params?: Record): Route { + if (screen === 'checkin-records') return { screen: 'checkin', params: { ...params, tab: 'records' } } + if (screen.startsWith('plugin:')) { + return { screen: screen.endsWith(':checkin') ? 'checkin' : 'plugins', params } + } + if ((SCREEN_IDS as readonly string[]).includes(screen)) return { screen: screen as ScreenId, params } + return { screen: 'home' } +} + +export function routeFromHash(hash: string): Route { + const [screen, value] = hash.replace(/^#/, '').trim().split('/') + if (screen === 'checkin' && (value === 'records' || value === 'schedule')) { + return { screen, params: { tab: value } } + } + return normalizeRoute(screen || 'home', value ? { id: value } : undefined) +} diff --git a/desktop-tauri/mobile/src/lib/servers.ts b/desktop-tauri/mobile/src/lib/servers.ts new file mode 100644 index 000000000..51dddf0fd --- /dev/null +++ b/desktop-tauri/mobile/src/lib/servers.ts @@ -0,0 +1,286 @@ +/** + * 服务器(网关)列表与当前选择。 + * + * ── 两种运行形态 ────────────────────────────────────────────── + * · **PWA / 浏览器**:页面就是网关出的,同源,地址恒为当前 origin。 + * 这时不该让用户填地址 —— 填了也只会把请求打到别处去。 + * · **原生 App**:界面打进包里,与网关不同源。用户要自己填地址,还可能有多台。 + * + * 判据是 `isPackaged()`:Capacitor 在原生壳里会往 window 挂 `Capacitor`, + * 浏览器里没有。不用 UA 判断 —— 桌面浏览器开 `/m` 与手机浏览器开是同一份代码。 + * + * ── 为什么每台服务器各存一份登录态 ──────────────────────────── + * 会话令牌是按服务器签发的,混着存会让「切到 B 却带着 A 的令牌」—— + * 表现是切换后立刻 401、再切回 A 又好了,很难查。所以令牌按服务器 id 分键。 + */ + +export type ServerEntry = { + /** 稳定 id(创建时生成,切换 / 删除都靠它,不随地址改而变) */ + id: string + /** 用户给这台起的名字,空则显示地址 */ + name: string + /** 归一化后的基地址,形如 `http://192.168.1.23:3065`(不带结尾斜杠) */ + base: string +} + +const SERVERS_KEY = 'agent2api.servers' +const ACTIVE_KEY = 'agent2api.activeServer' + +/** 是否跑在打包的原生壳里(Capacitor 注入的全局标记) */ +export function isPackaged(): boolean { + if (typeof window === 'undefined') return false + const cap = (window as Window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor + return Boolean(cap?.isNativePlatform?.()) +} + +/** + * 当前原生平台:`android` / `ios`,浏览器里是 `web`。 + * + * ── 为什么要区分到这一步 ────────────────────────────────────── + * 两种壳的「应用内更新」根本不同: + * · Android 能自己下载 APK 并调起安装器(ApkUpdaterPlugin); + * · iOS 不允许 App 自行安装可执行代码 —— 只能跳到 App Store / TestFlight, + * 所以那边不该出现「立即更新」这种下载按钮。 + * 只用 `isPackaged()` 判断会把 iOS 也当成 APK 形态,点下去只会报 + * 「当前不是 APK 形态」。 + */ +export type NativePlatform = 'android' | 'ios' | 'web' + +export function nativePlatform(): NativePlatform { + if (typeof window === 'undefined') return 'web' + const cap = (window as Window & { Capacitor?: { getPlatform?: () => string } }).Capacitor + const platform = cap?.getPlatform?.() + if (platform === 'android') return 'android' + if (platform === 'ios') return 'ios' + return 'web' +} + +/** + * 归一化用户输入的地址。 + * + * 用户会输 `192.168.1.23`、`192.168.1.23:3065`、`http://192.168.1.23:3065/`、 + * `192.168.1.23:3065/m`、`https://gw.example.com` 等各种形态。统一成 + * `scheme://host[:port]`: + * · 协议只认 http / https,写了别的(ftp:// 之类)视为非法; + * · 没写协议就补 `http://`(局域网网关基本都是 http); + * · 去掉结尾斜杠与路径(用户可能直接粘了 `/m` 或 `/v1`); + * · http 没写端口时补网关默认端口 3065;https 没写端口时不补,交给 + * 浏览器按标准 443 处理 —— 显式写 `:443` 反而会让某些反代/CDN 多一层歧义。 + * 非法输入返回空串,由调用方提示。 + */ +export function normalizeBase(raw: string): string { + let text = raw.trim() + if (!text) return '' + // 协议是「用户想走哪条链路」的唯一表达,必须保留;只接受 http / https + const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(text) + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : '' + if (scheme && scheme !== 'http' && scheme !== 'https') return '' + // 用户从浏览器地址栏复制时会带 /m、/v1、#hash 之类,一律砍掉 + text = text.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '') + text = text.split(/[/?#]/)[0] + if (!text) return '' + // host 可以是 IPv4 / 主机名 / 方括号 IPv6;方括号里的冒号要整体匹配 + const match = /^(\[[0-9a-fA-F:]+\]|[^:]+)(?::(\d+))?$/.exec(text) + if (!match) return '' + const host = match[1].toLowerCase() + const port = match[2] + // 只接受 IPv4 / 主机名 / 方括号 IPv6;localhost 也走这条 + if (!/^[\w.-]+$/.test(host) && !/^\[[0-9a-fA-F:]+\]$/.test(host)) return '' + if (port && (Number(port) < 1 || Number(port) > 65535)) return '' + if (port) return `${scheme || 'http'}://${host}:${port}` + // 没写端口:http 补网关默认端口,https 走标准 443(不显式写出来) + return scheme === 'https' ? `https://${host}` : `http://${host}:3065` +} + +/** + * 从基地址里取出给用户看的 host:port。 + * + * 只省略 `http://`:明文是局域网网关的默认形态,省掉更省地方;`https://` + * 必须留着 —— 用户要能一眼看出这条地址走的是不是加密链路。 + */ +export function baseLabel(base: string): string { + return base.replace(/^http:\/\//, '') +} + +function readServers(): ServerEntry[] { + try { + const raw = localStorage.getItem(SERVERS_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (item): item is ServerEntry => + Boolean(item) && typeof item === 'object' && typeof (item as ServerEntry).base === 'string', + ) + } catch { + return [] + } +} + +function writeServers(list: ServerEntry[]): void { + try { + localStorage.setItem(SERVERS_KEY, JSON.stringify(list)) + } catch { + /* 隐私模式:本次会话有效,下次要重填 */ + } +} + +export function listServers(): ServerEntry[] { + return readServers() +} + +/** 当前服务器;未配置过返回 null(APK 首次启动就是这个状态) */ +export function activeServer(): ServerEntry | null { + if (!isPackaged()) { + // 浏览器 / PWA:永远是当前 origin,不进列表 + return { id: 'origin', name: '', base: location.origin } + } + const list = readServers() + let id = '' + try { + id = localStorage.getItem(ACTIVE_KEY) || '' + } catch { + /* 忽略 */ + } + return list.find(item => item.id === id) || list[0] || null +} + +/** 请求用的基地址;没有可用服务器时返回空串(调用方应先进配置页) */ +export function apiBase(): string { + return activeServer()?.base || '' +} + +/** 当前服务器 id(登录态按它分键) */ +export function activeServerId(): string { + return activeServer()?.id || '' +} + +/** 在途请求与页面缓存同时绑定服务器 id 和地址,编辑地址也会换代。 */ +export function serverIdentity(): string { + const server = activeServer() + return server ? `${server.id}:${server.base}` : '' +} + +/** + * 新增或更新一台服务器。返回它本身(已存在同地址则复用那条记录, + * 不产生重复项)。 + */ +export function saveServer(input: { id?: string; name?: string; base: string }): ServerEntry { + const base = normalizeBase(input.base) + if (!base) throw new Error('地址不合法') + const list = readServers() + const existing = input.id + ? list.find(item => item.id === input.id) + : list.find(item => item.base === base) + if (existing) { + if (existing.base !== base) { + clearServerAuth(existing.id) + storeServerKey(existing.id, '') + } + existing.base = base + existing.name = (input.name || '').trim() + writeServers(list) + return existing + } + const entry: ServerEntry = { + id: `srv_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, + name: (input.name || '').trim(), + base, + } + writeServers([...list, entry]) + return entry +} + +export function removeServer(id: string): void { + const current = activeServerId() + const list = readServers().filter(item => item.id !== id) + writeServers(list) + clearServerAuth(id) + storeServerKey(id, '') + if (current === id) { + try { + localStorage.setItem(ACTIVE_KEY, list[0]?.id || '') + } catch { + /* 忽略 */ + } + } +} + +export function setActiveServer(id: string): void { + try { + localStorage.setItem(ACTIVE_KEY, id) + } catch { + /* 忽略 */ + } +} + +/* ── 按服务器隔离的凭证 ───────────────────────────────────── */ + +const KEY_STORAGE = (id: string) => `agent2api.webKey.${id}` +const PANEL_AUTH = (id: string) => `agent2api.panelAuth.${id}` + +export type PanelAuth = { + access?: string + refresh?: string + /** + * access 的到期时刻(毫秒;0 / 缺省 = 不知道)。 + * + * **不是凭据**:它只回答「该不该在下个请求前先续期」(见 client.ts 的 + * `ensureFreshSession`)。服务端仍然按会话表判有效期,这里算错只会退回 + * 「401 → 续期」的老路径。与令牌存在同一条记录里:清会话时一处删干净。 + */ + expiresAt?: number +} + +export function readServerKey(id: string): string { + try { + return localStorage.getItem(KEY_STORAGE(id)) || '' + } catch { + return '' + } +} + +export function storeServerKey(id: string, value: string): void { + try { + if (value) localStorage.setItem(KEY_STORAGE(id), value) + else localStorage.removeItem(KEY_STORAGE(id)) + } catch { + /* 隐私模式:只留在内存 */ + } +} + +export function readServerAuth(id: string): PanelAuth | null { + try { + const raw = localStorage.getItem(PANEL_AUTH(id)) + return raw ? (JSON.parse(raw) as PanelAuth) : null + } catch { + return null + } +} + +/** + * 落盘一对令牌。`expiresIn` 是服务端下发的剩余秒数(见 `issue_response`), + * 转成绝对时刻存下来 —— 相对秒数在页面挂久了就失去意义。 + */ +export function storeServerAuth( + id: string, + access: string, + refresh: string, + expiresIn?: number, +): void { + const seconds = Number(expiresIn) + const expiresAt = Number.isFinite(seconds) && seconds > 0 ? Date.now() + seconds * 1000 : 0 + try { + localStorage.setItem(PANEL_AUTH(id), JSON.stringify({ access, refresh, expiresAt })) + } catch { + /* 同上 */ + } +} + +export function clearServerAuth(id: string): void { + try { + localStorage.removeItem(PANEL_AUTH(id)) + } catch { + /* 无害 */ + } +} diff --git a/desktop-tauri/mobile/src/lib/usage.ts b/desktop-tauri/mobile/src/lib/usage.ts new file mode 100644 index 000000000..aa8d5ec9f --- /dev/null +++ b/desktop-tauri/mobile/src/lib/usage.ts @@ -0,0 +1,211 @@ +/** + * 余额读数解析。 + * + * 后端的 `usage` 对象按家给不同形状(workbuddy 的 `totalLeft` 既有契约、 + * 归一化的 `available` + `wallets`、以及只给展示串的 `availableView`)。 + * 这里**按字段形状探测**而不是按 provider 分派:provider 只决定谁去查, + * 不决定查回来长什么样。桌面端 `accounts-panels.tsx` 的 `usageSummary` + * 是同一套口径,手机端只保留「一行读数 + 一句完整说明」两档。 + * + * 解析失败一律降级成中性读数(`muted`),不抛错 —— 余额查不到是常态, + * 不该把账号页拖红。 + */ + +import type { UsageRow, UsageSnapshot } from '../api/gateway' +import { t, locale } from '../i18n' +import { creditStatisticsWalletsOf, recordReadingsOf, snapshotReadingsOf, usageAggregateOf, usageNumberOrNull, usageSupplementReadingsOf, usageUnitLabel, usageWarningsOf, usageWindowLabel, usageWindowView } from '../../../ui-shared/usage-readings' +import { formatStamp } from './format' +import { mergeUsageQueryRow, usageQueryFailureStatus } from '../../../ui-shared/usage-query-state' +import { creditExpirySummaryOf, creditExpiryText, creditExpiryUnitOf, expiringCreditsOf } from '../../../ui-shared/credit-expiry' + +export type UsageView = { + kind: 'ok' | 'warn' | 'bad' | 'muted' + /** 行内短读数,例如「可用 12.3 万」「未查询」「查询失败」 */ + text: string + /** 完整说明(给详情页/长按提示用) */ + detail: string + failureStatus?: string + failureDetail?: string + expiry?: ReturnType +} + +/** + * 紧凑数字:整数走千分位,超过一万用「万」、超过一亿用「亿」。 + * + * 刻意不 import `lib/format` 的同名能力:那个模块依赖页面层,反过来被 + * 数据层引用会绕出循环;这里只服务余额一种口径,自己写一份更稳。 + */ +function compact(value: unknown): string { + // null / 空串不能被 Number() 悄悄翻成 0:那会把「读不到」显示成「可用 0」 + const num = usageNumberOrNull(value) + if (num === null) return '—' + const abs = Math.abs(num) + // 「万 / 亿」只保留 1~2 位小数:行内读数要短,精度对余额没有行动价值 + const chinese = locale().startsWith('zh') + if (abs >= 1e8 && chinese) return `${(num / 1e8).toFixed(2)} ${locale() === 'zh-Hant' ? '億' : '亿'}` + if (abs >= 1e4 && chinese) return `${(num / 1e4).toFixed(1)} ${locale() === 'zh-Hant' ? '萬' : '万'}` + if (!chinese && abs >= 1e6) return `${(num / 1e6).toFixed(1)}M` + if (!chinese && abs >= 1e4) return `${(num / 1000).toFixed(1)}k` + const language = locale() === 'zh-Hans' ? 'zh-CN' : locale() + if (Number.isInteger(num)) return num.toLocaleString(language) + return num.toLocaleString(language, { maximumFractionDigits: 2 }) +} + +/** usage 里可读的「单位」:缺失时按积分兜底 */ +function unitOf(usage: Record): string { + const unit = usage.unit + return typeof unit === 'string' && unit.trim() ? usageUnitLabel(unit.trim(), t) : t('积分') +} + +/** 补充查询中的部分失败说明。 */ +function partialErrors(usage: Record): string[] { + const parts = usageWarningsOf(usage).map(line => t(line)) + if (usage.statisticsError) { + parts.push(t('订阅统计未读到:{error}', { error: String(usage.statisticsError) })) + } + return parts +} + +/** 单行余额读数:把快照里的 `usage` / `error` 翻成一行文本 + 完整说明 */ +export function usageText(row?: UsageRow, now = Date.now()): UsageView { + // ① 还没查过(快照里没有这一行) + if (row === undefined) { + return { kind: 'muted', text: t('未查询'), detail: t('尚未查询该账号的余额') } + } + + // ② 这一行明确失败:「未配置查询凭证」是中性提示,其余才是红色失败 + if (row.error) { + if (row.code === 'usage_not_configured') { + return { kind: 'muted', text: t('未配置'), detail: t(row.error) } + } + const status = usageQueryFailureStatus(row, t, formatStamp) + const previous = row.lastUsage ? usageText({ usage: row.lastUsage }, now) : null + return { + kind: 'bad', + text: previous?.text || t('查询失败'), + detail: [status?.detail || t(row.error), previous?.detail].filter(Boolean).join(' · '), + failureStatus: previous ? status?.text : undefined, + failureDetail: previous ? status?.detail : undefined, + expiry: previous?.expiry, + } + } + + const usage = row.usage + if (!usage || typeof usage !== 'object' || Array.isArray(usage)) { + return { kind: 'muted', text: t('无数据'), detail: t('未返回可识别的余额数据') } + } + + const data = usage as Record + if (data.error) return { kind: 'bad', text: t('查询失败'), detail: t(String(data.error)) } + const errors = partialErrors(data) + const aggregate = usageAggregateOf(data, t) + const expiry = creditExpiryText(creditExpirySummaryOf(expiringCreditsOf(data, now), now), t, compact, creditExpiryUnitOf(data)) + const readings = [ + ...(aggregate ? [`${aggregate.label} ${aggregate.unlimited ? t('不限') : compact(aggregate.remaining)}${aggregate.unit && !aggregate.unlimited ? ` ${aggregate.unit}` : ''}`] : []), + ...usageSupplementReadingsOf(data, t, compact), + ...snapshotReadingsOf(data, t, compact).map(reading => `${reading.name} ${reading.view}`), + ...recordReadingsOf(data, t, compact, formatStamp), + ] + const baseKind: UsageView['kind'] = errors.length ? 'warn' : 'ok' + const withErrors = (view: UsageView): UsageView => ({ + kind: errors.length ? 'warn' : view.kind, + text: view.text, + detail: [...new Set([view.detail, ...readings, expiry.detail, ...errors].filter(Boolean))].join(' · '), + expiry: expiry.badge ? expiry : undefined, + }) + + if (Array.isArray(data.windows) && data.windows.length) { + const windows = data.windows.filter((item): item is Record => Boolean(item) && typeof item === 'object') + const detail = windows.map(window => { + const label = usageWindowLabel(window.label, t) + const view = usageWindowView(window, t, compact) + const reset = typeof window.resetsAt === 'number' && window.resetsAt > 0 + ? t('重置 {date}', { date: formatStamp(window.resetsAt) }) : '' + return [label, view, reset].filter(Boolean).join(' ') + }).join(' · ') + return withErrors({ kind: baseKind, text: t('分窗口额度'), detail }) + } + + // ④ workbuddy 既有形状:totalLeft / planLeft / bonusLeft + if (Object.prototype.hasOwnProperty.call(data, 'totalLeft')) { + const total = data.unlimited === true ? '∞' : compact(data.totalLeft) + const parts = [t('总剩余 {total}', { total })] + if (data.planLeft !== undefined && data.planLeft !== null) { + parts.push(t('套餐 {plan}', { plan: compact(data.planLeft) })) + } + if (data.bonusLeft !== undefined && data.bonusLeft !== null) { + parts.push(t('奖励 {bonus}', { bonus: compact(data.bonusLeft) })) + } + return withErrors({ kind: baseKind, text: t('可用 {total}', { total }), detail: parts.join(' · ') }) + } + + // ⑤ 后端已经给好的紧凑展示串(ZCode 的「1亿 token」之类)优先 + if (typeof data.availableView === 'string' && data.availableView.trim()) { + const text = data.availableView.trim() + const parts = [text] + if (data.available !== undefined && data.available !== null) { + parts.push(t('可用 {value} {unit}', { value: compact(data.available), unit: unitOf(data) })) + } + return withErrors({ kind: baseKind, text, detail: parts.join(' · ') }) + } + + // ⑥ 归一化形状:available 是数字 + if (typeof data.available === 'number' && Number.isFinite(data.available)) { + return withErrors({ + kind: baseKind, + text: t('可用 {value} {unit}', { value: compact(data.available), unit: unitOf(data) }), + detail: t('可用 {value} {unit}', { value: compact(data.available), unit: unitOf(data) }), + }) + } + + // ⑦ 只有钱包明细:取第一个带 balance 的钱包做读数,全部钱包进 detail + if (Array.isArray(data.wallets)) { + const wallets = creditStatisticsWalletsOf(data) ?? data.wallets.filter( + (item): item is Record => Boolean(item) && typeof item === 'object', + ) + const lead = wallets.find(item => usageNumberOrNull(item.balance) !== null) + if (lead) { + const walletUnit = typeof lead.unit === 'string' && lead.unit.trim() ? usageUnitLabel(lead.unit.trim(), t) : unitOf(data) + const detail = wallets + .map(item => { + const name = + (typeof item.displayName === 'string' && item.displayName) || + (typeof item.type === 'string' && item.type) || + t('明细') + const balance = + item.balance !== undefined && item.balance !== null + ? compact(item.balance) + : typeof item.balanceView === 'string' && item.balanceView + ? item.balanceView + : '—' + return `${name} ${balance}` + }) + .join(' · ') + return withErrors({ + kind: baseKind, + text: t('可用 {value} {unit}', { value: compact(lead.balance), unit: walletUnit }), + detail, + }) + } + } + + const supplements = usageSupplementReadingsOf(data, t, compact) + if (supplements.length) return withErrors({ kind: 'muted', text: supplements[0], detail: supplements.join(' · ') }) + // ⑧ 形状认不出来 + return withErrors({ kind: 'muted', text: t('无数据'), detail: t('未返回可识别的余额数据') }) +} + +/** 把快照行按账号 id 索引(没有 id 的行跳过) */ +export function usageIndex(snapshot: UsageSnapshot | null): Map { + const map = new Map() + const rows = snapshot?.results + if (!Array.isArray(rows)) return map + rows.forEach(row => { + if (row && typeof row.id === 'string' && row.id) map.set(row.id, row) + }) + return map +} + +export function mergeUsageRow(row: UsageRow, previous?: UsageRow): UsageRow { + return mergeUsageQueryRow({ ...row, at: row.at || Date.now() }, previous) +} diff --git a/desktop-tauri/mobile/src/lib/usePolling.ts b/desktop-tauri/mobile/src/lib/usePolling.ts new file mode 100644 index 000000000..5fe6662ad --- /dev/null +++ b/desktop-tauri/mobile/src/lib/usePolling.ts @@ -0,0 +1,75 @@ +import * as React from 'react' + +/** + * 按页面可见性刷新的轮询 hook。 + * + * 手机端的轮询必须满足三件事,缺一个就会变成「后台偷偷烧流量」: + * ① 页面不可见(切到别的 App / 锁屏)时停表; + * ② 上一次请求还没回来时不叠加下一次; + * ③ 卸载即停,不留悬挂定时器。 + * + * `interval` 传 0 表示不轮询(只跑首次)。 + */ +export function usePolling( + run: () => Promise | void, + interval: number, + deps: React.DependencyList = [], +): { refreshing: boolean; refresh: () => void } { + const runRef = React.useRef(run) + runRef.current = run + const [refreshing, setRefreshing] = React.useState(false) + const busyRef = React.useRef(false) + const timerRef = React.useRef(null) + + const tick = React.useCallback(async () => { + if (busyRef.current) return + busyRef.current = true + setRefreshing(true) + try { + await runRef.current() + } finally { + busyRef.current = false + setRefreshing(false) + } + }, []) + + React.useEffect(() => { + let disposed = false + const clear = () => { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current) + timerRef.current = null + } + } + const start = () => { + clear() + if (interval <= 0 || disposed) return + timerRef.current = window.setInterval(() => { + if (document.visibilityState === 'visible') void tick() + }, interval) + } + + void tick() + start() + + const onVisibility = () => { + if (document.visibilityState === 'visible') { + // 回到前台立刻补一次,用户不会盯着旧数据 + void tick() + start() + } else { + clear() + } + } + document.addEventListener('visibilitychange', onVisibility) + + return () => { + disposed = true + clear() + document.removeEventListener('visibilitychange', onVisibility) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [interval, tick, ...deps]) + + return { refreshing, refresh: () => void tick() } +} diff --git a/desktop-tauri/mobile/src/lib/usePwa.ts b/desktop-tauri/mobile/src/lib/usePwa.ts new file mode 100644 index 000000000..bcbcaba98 --- /dev/null +++ b/desktop-tauri/mobile/src/lib/usePwa.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from 'react' +import { getPwaSnapshot, subscribePwa, type PwaState } from './pwa' + +/** + * 订阅 PWA 状态。 + * + * `getPwaSnapshot` 返回的是同一份缓存快照(状态没变时引用不变), + * 满足 useSyncExternalStore 对「同状态必须同引用」的要求 —— 否则每次 + * 渲染都会被判定为变了,进而无限重渲染。 + */ +export function usePwa(): PwaState { + return useSyncExternalStore(subscribePwa, getPwaSnapshot, getPwaSnapshot) +} diff --git a/desktop-tauri/mobile/src/main.tsx b/desktop-tauri/mobile/src/main.tsx new file mode 100644 index 000000000..6bfa72b36 --- /dev/null +++ b/desktop-tauri/mobile/src/main.tsx @@ -0,0 +1,37 @@ +import { createRoot } from 'react-dom/client' +import { App } from './App' +import { setAuthLostHandler } from './api/client' +import { store } from './state/store' +import { initPwa } from './lib/pwa' +import './styles/mobile.css' + +/** + * 手机端入口。 + * + * 与 ui-islands 的区别:那个是「一堆岛挂到既有页面上」,这个是**独占整个 + * 页面**的应用 —— 所以用 createRoot 挂到 #root,不需要 `display: contents` + * 那套宿主技巧。 + * + * 401 接管:客户端自己不做跳转(它不知道当前界面在做什么),由这里注册 + * 回调,交给 store 弹「重新登录 / 补 Key」层。 + */ +setAuthLostHandler(mode => { + store.setAuthLost(mode) +}) + +/** + * PWA 运行时必须在 render 之前初始化,不能放进组件的 useEffect: + * `beforeinstallprompt` 在页面加载后不久就会触发,而事件对象只能捕获一次 —— + * 等 React 挂载完再挂监听,有可能已经错过(首屏快的时候)。 + * 这里的返回值是清理函数,单页应用整个生命周期不卸载,忽略即可。 + */ +initPwa() + +const host = document.getElementById('root') +if (host) { + createRoot(host).render() +} else { + // 挂载点缺失时给出可读错误,而不是静默白屏 + document.body.innerHTML = + '
手机端挂载失败:页面缺少 #root 容器。
' +} diff --git a/desktop-tauri/mobile/src/pages/AccountSheet.tsx b/desktop-tauri/mobile/src/pages/AccountSheet.tsx new file mode 100644 index 000000000..fe08b53ab --- /dev/null +++ b/desktop-tauri/mobile/src/pages/AccountSheet.tsx @@ -0,0 +1,519 @@ +import { useEffect, useState } from 'react' +import { Sheet, SheetContent, SheetHeader, SheetBody, SheetFooter, SheetTitle , Input, Button } from '@ui' +import { Icon } from '../components/Icon' +import { ProviderLogo } from '../components/ProviderLogo' +import { Badge } from '../components/ui' +import { store } from '../state/store' +import { + clearAccountRateLimits, + deleteAccount, + fetchAccountUsage, + fetchUsageSnapshot, + promoteAccount, + refreshAccount, + renameAccount, + setAccountEnabled, + type Account, + type UsageRow, +} from '../api/gateway' +import { accountName, activeLimits, formatExpiry, formatRelative, formatStamp, isRateLimited } from '../lib/format' +import { expiryOf, hasExpiry, isLongLivedCredential, supportsUsage } from '../lib/providerFacts' +import { mergeUsageRow, usageIndex, usageText } from '../lib/usage' +import { useDisplayClock } from '../../../ui-shared/use-display-clock' +import { credentialExpiryRowsOf, credentialExpiryText } from '../../../ui-shared/credential-expiry' +import { upstreamCooldownOf, upstreamSuspendedReasonOf } from '../../../ui-shared/account-upstream-state' +import { providerLabel } from '../lib/providers' +import { serverIdentity } from '../lib/servers' +import { t } from '../i18n' +import { confirmDialog } from '@ui' +import { MetricGrid, MetricCard } from '@ui' + +/** + * 账号详情抽屉。 + * + * 手机上不做「编辑表单」:账号的凭证、代理、限流规则都是多字段表单,窄屏填 + * 起来痛苦且容易出错。这一层只给**查看 + 几个高频动作**(启停、刷新凭证、 + * 查询余额、设为首选、清除限流、删除),编辑仍回桌面端。 + */ +export function AccountSheet({ + account, + onClose, + onChanged, +}: { + account: Account | null + onClose: () => void + onChanged: () => void +}) { + const now = useDisplayClock() + const [busy, setBusy] = useState('') + /** 快照里这一行的余额结论;undefined = 还没查过 */ + const [usageRow, setUsageRow] = useState(undefined) + const [editingName, setEditingName] = useState(false) + const [nameDraft, setNameDraft] = useState('') + const open = Boolean(account) + const accountId = account?.id || '' + const identity = serverIdentity() + + // 换账号(或关掉抽屉)时重置:编辑态不能带到下一个账号上。 + // 余额走快照而不是逐个查询 —— 快照只读记录表,不触发上游,打开抽屉不会变慢。 + useEffect(() => { + setEditingName(false) + setNameDraft('') + setUsageRow(undefined) + if (!accountId || !account || !supportsUsage(account)) return + let cancelled = false + fetchUsageSnapshot() + .then(snapshot => { + if (!cancelled) setUsageRow(usageIndex(snapshot).get(accountId)) + }) + .catch(() => { + /* 余额快照读不到是常态:保持「未查询」,不打扰用户 */ + }) + return () => { + cancelled = true + } + }, [accountId]) + + const run = async (kind: string, task: () => Promise, success: string): Promise => { + if (serverIdentity() !== identity) return false + setBusy(kind) + try { + await task() + if (serverIdentity() !== identity) return false + store.toast(success) + onChanged() + return true + } catch (error) { + if (serverIdentity() === identity) store.toast(error instanceof Error ? error.message : String(error), 'err') + return false + } finally { + if (serverIdentity() === identity) setBusy('') + } + } + + const remove = async () => { + if (!account) return + const name = accountName(account) + // 删除不可恢复:手机上用两次确认(第一次进二次确认态) + if (!await confirmDialog(t('确定删除账号「{name}」?这个操作不可恢复。', { name }))) return + if (await run('delete', () => deleteAccount(account.id), t('已删除账号'))) onClose() + } + + /** 手动查这一个账号:会真的打上游,可能慢,所以单独走 busy='usage' */ + const queryUsage = async () => { + if (!account || !supportsUsage(account) || serverIdentity() !== identity) return + setBusy('usage') + try { + const payload = await fetchAccountUsage(account.id) + if (serverIdentity() !== identity) return + const row = payload.results?.find(item => item.id === account.id) + if (row?.code === 'refresh_token_expired') onChanged() + setUsageRow(previous => mergeUsageRow(row || { id: account.id, error: t('未返回余额数据') }, previous)) + const view = row ? usageText(row) : null + store.toast(!view ? t('未返回余额数据') : view.kind === 'bad' ? view.detail : t('余额已更新'), + !view || view.kind === 'bad' ? 'err' : view.kind === 'warn' ? 'warn' : 'ok') + } catch (error) { + if (serverIdentity() === identity) { + const message = error instanceof Error ? error.message : String(error) + setUsageRow(previous => mergeUsageRow({ id: account.id, error: message }, previous)) + store.toast(message, 'err') + } + } finally { + if (serverIdentity() === identity) setBusy('') + } + } + + const saveName = async () => { + if (!account || serverIdentity() !== identity) return + const next = nameDraft.trim() + if (!next) { + store.toast(t('备注名不能为空'), 'err') + return + } + setBusy('name') + try { + await renameAccount(account.id, next) + if (serverIdentity() !== identity) return + store.toast(t('备注名已更新')) + setEditingName(false) + onChanged() + } catch (error) { + if (serverIdentity() === identity) store.toast(error instanceof Error ? error.message : String(error), 'err') + } finally { + if (serverIdentity() === identity) setBusy('') + } + } + + /** 不传 model 时同时清除账号冷却和全部模型限流。 */ + const clearLimit = async (model?: string) => { + if (!account) return + const question = model + ? t('确定清除模型「{model}」的限流标记?', { model }) + : t('确定清除该账号的冷却与全部模型限流标记?') + if (!await confirmDialog(question)) return + await run('clear', () => clearAccountRateLimits(account.id, model), t('限流已清除')) + } + + const promote = async () => { + if (!account) return + await run('promote', () => promoteAccount(account.id), t('已设为首选')) + } + + const limits = account ? activeLimits(account) : [] + const cooldown = upstreamCooldownOf(account) + const suspendedReason = upstreamSuspendedReasonOf(account) + const expiresAt = account ? expiryOf(account) : 0 + const credentialRows = account ? credentialExpiryRowsOf(account, expiresAt) : [] + const usageView = usageText(usageRow, now) + const usageColor = + usageView.kind === 'bad' + ? 'var(--m-danger)' + : usageView.kind === 'warn' + ? 'var(--m-warn)' + : 'var(--m-text)' + const isPrimary = (store.get().session?.routedAccountId || store.get().session?.accounts?.currentAccountId) === account?.id + const sortActive = (store.get().session?.accounts?.sorting?.rules[String(account?.provider || '')] || []).some(rule => rule.on) + const canQueryUsage = Boolean(account && supportsUsage(account)) + const canRefresh = Boolean(account && account.desktop !== true && (account.canRefresh === true || account.hasRefreshToken === true)) + + return ( + { if (!next) onClose() }}> + + + {t('账号详情')} + + + {account ? ( + <> +
+ +
+
+ {accountName(account)} +
+
+ {providerLabel(account.provider, account.editionLabel)} + {account.priority !== undefined ? ` · #${account.priority}` : ''} +
+
+ + {statusText(account)} + +
+ + {/* 备注名就地编辑:手机上没有表单页,改一个字段不该跳走 */} +
+ {editingName ? ( + <> + setNameDraft(event.target.value)} + placeholder={t('备注名')} + style={{ + flex: 1, + minWidth: 0, + border: 0, + outline: 'none', + background: 'transparent', + color: 'var(--m-text)', + // 16px 以下 iOS Safari 聚焦时会自动放大页面 + fontSize: 16, + }} + /> + + + + ) : ( + + )} +
+ + + {credentialRows.length || hasExpiry(account.provider) ? ( + +
{t('有效期')}
+ {credentialRows.length ?
+ {credentialRows.map(row =>
+ {row.label} + + {credentialExpiryText(row, t)} + +
)} +
:
+ {isLongLivedCredential(account.provider) ? t('长期有效') : formatExpiry(expiresAt)} +
} +
+ ) : null} + +
{t('余额')}
+
+ {canQueryUsage ? usageView.text : t('未接入查询')} +
+ {canQueryUsage && usageView.expiry ? ( +
+
{usageView.expiry.summary}
+ {usageView.expiry.today ?
{usageView.expiry.today}
: null} +
+ ) : null} + {canQueryUsage && usageView.failureStatus ? ( +
+ {usageView.failureStatus} +
+ ) : null} +
+ +
{t('凭证')}
+
{account.hasCredentials === false ? t('缺失') : t('完整')}
+
+ +
{t('限流模型')}
+
{limits.length}
+
+ +
{t('最近更新')}
+
+ {formatStamp(account.updatedAt as number)} +
+
+
+ + {/* 首选是队列位次,不是账号属性:给它一个整行按钮,别混进删除那一排 */} + + +
+
+ {t('账号 ID')} + + {account.id} + +
+ {account.email ? ( +
+ {t('邮箱')} + {account.email} +
+ ) : null} +
+ {t('启用')} + {account.enabled ? t('是') : t('否')} +
+ {/* 读数之外还有话要说时才占一行(部分失败、钱包明细) */} + {canQueryUsage && usageView.detail && usageView.detail !== usageView.text ? ( +
+ {t('余额详情')} + + {usageView.detail} + +
+ ) : null} + {suspendedReason ? ( +
+ {t('上游限制 · 已禁用')} + + {suspendedReason} + +
+ ) : null} + {account.needsRelogin ? ( +
+ {t('需要重新登录')} + + {account.needsReloginReason || t('登录授权已失效,请重新登录;自动续期无法恢复已过期或已撤销的刷新令牌。')} + +
+ ) : null} +
+ + {limits.length || cooldown ? ( + <> +
+ {t('限流明细')} + +
+
+ {cooldown ? ( +
+ {t('账号冷却')} +
+
{t('{time} 恢复', { time: formatRelative(cooldown.resetAt) })}
+
{t('账号全部模型暂时停止转发 · 到恢复时间自动解除')}
+ {cooldown.message ?
{cooldown.message}
: null} +
+
+ ) : null} + {limits.map(({ model, resetAt }) => ( +
+ + {model} + + + {t('{time} 恢复', { time: formatRelative(resetAt) })} + + +
+ ))} +
+ + ) : null} + + ) : null} +
+ + {canQueryUsage ? : null} + {canRefresh ? : account ?

{t('未声明可手动续期;本机引用请在原客户端处理登录态。')}

: null} +
+ + +
+
+
+
+ ) +} + +function statusTone(account: Account): 'ok' | 'warn' | 'bad' | 'mute' { + if (account.needsRelogin || upstreamSuspendedReasonOf(account)) return 'bad' + if (!account.enabled) return 'mute' + if (isRateLimited(account)) return 'warn' + const expiresAt = expiryOf(account) + if (expiresAt > 0 && expiresAt <= Date.now()) return 'bad' + return 'ok' +} + +function statusText(account: Account): string { + if (account.needsRelogin) return t('需重登') + if (upstreamSuspendedReasonOf(account)) return t('上游限制 · 已禁用') + if (!account.enabled) return t('已禁用') + if (upstreamCooldownOf(account)) return t('账号冷却') + if (isRateLimited(account)) return t('限流中') + const expiresAt = expiryOf(account) + if (expiresAt > 0 && expiresAt <= Date.now()) return t('已过期') + return t('正常') +} diff --git a/desktop-tauri/mobile/src/pages/AccountsPage.tsx b/desktop-tauri/mobile/src/pages/AccountsPage.tsx new file mode 100644 index 000000000..824a9c90e --- /dev/null +++ b/desktop-tauri/mobile/src/pages/AccountsPage.tsx @@ -0,0 +1,575 @@ +import { InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton } from '@ui' +import { useMemo, useState } from 'react' +import { PageHead, IconButton, Badge, Empty, ErrorNote, SkeletonList } from '../components/ui' +import { ProviderLogo } from '../components/ProviderLogo' +import { HiddenAccountsNotice } from '../components/HiddenAccountsNotice' +import { Icon } from '../components/Icon' +import { AccountSheet } from './AccountSheet' +import { DynamicProviderSheet } from './add-account-dynamic-sheet' +import { + AccountsFilterSheet, + DEFAULT_FILTERS, + activeFilterCount, + type AccountFilters, +} from '../components/AccountsFilterSheet' +import { useStore, store } from '../state/store' +import { + fetchUsageSnapshot, + setAccountEnabled, + type Account, + type UsageRow, + type UsageSnapshot, +} from '../api/gateway' +import { accountName, formatExpiry, formatRelative, isRateLimited } from '../lib/format' +import { expiryOf, hasExpiry, isLongLivedCredential, supportsUsage } from '../lib/providerFacts' +import { usageIndex, usageText } from '../lib/usage' +import { useDisplayClock } from '../../../ui-shared/use-display-clock' +import { credentialExpiryRowsOf, credentialExpiryText } from '../../../ui-shared/credential-expiry' +import { upstreamCooldownOf, upstreamSuspendedReasonOf } from '../../../ui-shared/account-upstream-state' +import { providerLabel } from '../lib/providers' +import { usePolling } from '../lib/usePolling' +import { usePullToRefresh, PullIndicator } from '../components/PullToRefresh' +import { t } from '../i18n' +import { Button, Input, Card } from '@ui' +import { Switch as UiSwitch } from '@ui' +import { Toggle as UiToggle } from '@ui' + +/** + * 账号。 + * + * ── 为什么是分组卡片而不是表格 ──────────────────────────────── + * 桌面端账号表有 10 列(顺序 / 账号 / 代理 / 连接数 / 状态 / 限流 / 有效期 / + * 余额 / 操作…),压不进 390px。手机上只留五个关键读数:邮箱、状态、有效期、 + * 余额、代理;提供商信息上移到分组头,行里不再重复。 + * + * ── 分组口径 ────────────────────────────────────────────────── + * 组内使用网关的全局消耗顺序,组顺位仍由账号库的手动优先级决定。 + */ + +type Filter = 'all' | 'enabled' | 'limited' | 'disabled' + +export function AccountsPage() { + const state = useStore() + const [filters, setFilters] = useState(DEFAULT_FILTERS) + const [filterOpen, setFilterOpen] = useState(false) + const [query, setQuery] = useState('') + const [openId, setOpenId] = useState(null) + const [adding, setAdding] = useState(false) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [usage, setUsage] = useState(null) + const accounts = state.session?.accounts?.accounts || [] + const sessionSorting = state.session?.accounts?.sorting + const sorting = (usage?.sorting?.updatedAt || 0) > (sessionSorting?.updatedAt || 0) ? usage?.sorting : sessionSorting + + const load = async () => { + // 余额快照与账号刷新并行:快照失败不影响账号列表(余额查不到是常态, + // 静默降级成「未查询」,不弹 toast 也不进页内错误条) + void fetchUsageSnapshot() + .then(setUsage) + .catch(() => {}) + try { + await store.refresh(true) + setError('') + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)) + } + } + + usePolling(load, 30_000) + + const pull = usePullToRefresh({ onRefresh: load }) + + // 快照行按 id 索引:每次 render 重建 Map 太浪费,且行组件会因此白白重渲 + const usageById = useMemo(() => usageIndex(usage), [usage]) + + /** 这家有没有「有效期」概念、是不是长期有效、到期时刻 —— 筛选与行渲染共用 */ + const expiryInfo = (account: Account) => { + const at = expiryOf(account) + return { + at, + has: hasExpiry(account.provider), + longLived: isLongLivedCredential(account.provider), + expired: at > 0 && at <= Date.now(), + } + } + + /** 余额读数归成三档(未查询 / 失败 / 有数)—— 与 usageText 的 kind 对应 */ + const usageState = (account: Account): 'has' | 'unqueried' | 'failed' => { + if (!supportsUsage(account)) return 'unqueried' + const row = usageById.get(account.id) + if (!row) return 'unqueried' + if (row.error) return 'failed' + return 'has' + } + + /** 各维度选项的计数:基于**搜索词过滤后**的集合,数字与点进去看到的一致 */ + const counts = useMemo(() => { + const keyword = query.trim().toLowerCase() + const base = accounts.filter(item => { + if (!keyword) return true + const haystack = [item.name, item.nickname, item.email, item.id, item.provider] + .filter(Boolean) + .join(' ') + .toLowerCase() + return haystack.includes(keyword) + }) + const expiryOfAccount = (account: Account) => { + const at = expiryOf(account) + return { + at, + longLived: isLongLivedCredential(account.provider), + expired: at > 0 && at <= Date.now(), + soon: at > Date.now() && at - Date.now() <= 7 * 86_400_000, + } + } + const usageOfAccount = (account: Account): 'has' | 'unqueried' | 'failed' => { + if (!supportsUsage(account)) return 'unqueried' + const row = usageById.get(account.id) + if (!row) return 'unqueried' + if (row.error) return 'failed' + return 'has' + } + return { + status: { + all: base.length, + enabled: base.filter(item => item.enabled).length, + disabled: base.filter(item => !item.enabled).length, + limited: base.filter(item => item.enabled && isRateLimited(item)).length, + relogin: base.filter(item => item.needsRelogin).length, + expired: base.filter(item => expiryOfAccount(item).expired).length, + }, + expiry: { + all: base.length, + soon: base.filter(item => expiryOfAccount(item).soon).length, + expired: base.filter(item => expiryOfAccount(item).expired).length, + longlived: base.filter(item => expiryOfAccount(item).longLived).length, + }, + usage: { + all: base.length, + has: base.filter(item => usageOfAccount(item) === 'has').length, + unqueried: base.filter(item => usageOfAccount(item) === 'unqueried').length, + failed: base.filter(item => usageOfAccount(item) === 'failed').length, + }, + } + // usageById 变化(余额刷新)要重算余额那组计数 + }, [accounts, query, usageById]) + + /** 可选的提供商(按当前账号里出现过的去重,保持后端给的先后) */ + const providerOptions = useMemo(() => { + const seen = new Set() + const list: Array<{ id: string; label: string }> = [] + accounts.forEach(item => { + const id = String(item.provider || 'unknown') + if (seen.has(id)) return + seen.add(id) + list.push({ id, label: providerLabel(id, state.session?.accounts?.providers?.find(provider => provider.id === id)?.label) }) + }) + return list + }, [accounts, state.session?.accounts?.providers]) + + const filtered = useMemo(() => { + const keyword = query.trim().toLowerCase() + return accounts.filter(item => { + // ① 状态(单选) + if (filters.status === 'enabled' && !item.enabled) return false + if (filters.status === 'disabled' && item.enabled) return false + if (filters.status === 'limited' && !(item.enabled && isRateLimited(item))) return false + if (filters.status === 'relogin' && !item.needsRelogin) return false + if (filters.status === 'expired' && !expiryInfo(item).expired) return false + // ② 提供商(多选,空 = 不限) + if (filters.providers.length && !filters.providers.includes(String(item.provider || 'unknown'))) { + return false + } + // ③ 有效期 + if (filters.expiry !== 'all') { + const info = expiryInfo(item) + if (filters.expiry === 'longlived' && !info.longLived) return false + if (filters.expiry === 'expired' && !info.expired) return false + if (filters.expiry === 'soon') { + if (!(info.at > Date.now() && info.at - Date.now() <= 7 * 86_400_000)) return false + } + } + // ④ 余额 + if (filters.usage !== 'all' && usageState(item) !== filters.usage) return false + // ⑤ 搜索词 + if (!keyword) return true + const haystack = [item.name, item.nickname, item.email, item.id, item.provider] + .filter(Boolean) + .join(' ') + .toLowerCase() + return haystack.includes(keyword) + }) + // expiryInfo / usageState 是纯函数(只读 Date.now 与 props),不必进依赖 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accounts, filters, query, usageById]) + + const groups = useMemo(() => { + const map = new Map() + filtered.forEach(item => { + const provider = String(item.provider || 'unknown') + const list = map.get(provider) || [] + list.push(item) + map.set(provider, list) + }) + return [...map.entries()] + .map(([provider, list]) => { + const positions = new Map((sorting?.accountOrder[provider] || []).map((id, index) => [id, index])) + return { + provider, + list: [...list].sort((a, b) => positions.size + ? (positions.get(a.id) ?? Infinity) - (positions.get(b.id) ?? Infinity) + : (a.priority ?? 999) - (b.priority ?? 999)), + } + }) + .sort((a, b) => sorting + ? (sorting.providerOrder.indexOf(a.provider) < 0 ? Infinity : sorting.providerOrder.indexOf(a.provider)) + - (sorting.providerOrder.indexOf(b.provider) < 0 ? Infinity : sorting.providerOrder.indexOf(b.provider)) + : Math.min(...a.list.map(account => account.priority ?? 999)) - Math.min(...b.list.map(account => account.priority ?? 999))) + }, [filtered, sorting]) + + const enabledCount = accounts.filter(item => item.enabled).length + const limitedCount = accounts.filter(item => item.enabled && isRateLimited(item)).length + + const toggle = async (account: Account, next: boolean) => { + setBusy(true) + try { + await setAccountEnabled(account.id, next) + store.toast(next ? t('已启用 {name}', { name: accountName(account) }) : t('已禁用 {name}', { name: accountName(account) })) + await store.refresh(true) + } catch (failure) { + store.toast(failure instanceof Error ? failure.message : String(failure), 'err') + } finally { + setBusy(false) + } + } + + const openAccount = openId ? accounts.find(item => item.id === openId) || null : null + + return ( +
+ +
+ + + + void load()} + /> + + } + /> + +
+ + setQuery(event.target.value)} placeholder={t('搜索账号 / 邮箱 / 备注')} /> + + {query ? setQuery('')} aria-label={t('清除')}> : null} + +
+ + {/* 生效中的条件用一条摘要显示:横排 chip 只留「全部」与当前选中的, + 避免四颗常驻 chip 与面板里的选项各说一套 */} +
+ setFilters(DEFAULT_FILTERS)} + > + {t('全部')} + {accounts.length} + + {activeFilterCount(filters) > 0 ? ( + setFilterOpen(true)}> + + {t('已筛选 {n} 项', { n: filtered.length })} + + ) : null} +
+
+ + {/* 只有这一块跟着手指滚:筛选条常驻,换筛选不用先滑回顶部 */} +
+ {error || state.sessionError ? : null} +
+ + {adding ? setAdding(false)} onAdded={() => { + setAdding(false) + void load() + }} /> : null} + setOpenId(null)} + onChanged={() => void store.refresh(true)} + /> + setFilterOpen(false)} + filters={filters} + onChange={setFilters} + providers={providerOptions} + counts={counts} + /> +
+ ) +} + +function AccountRow({ + account, + rank, + usageRow, + onOpen, + onToggle, + busy, +}: { + account: Account + /** 组内序号(1 起):后端 priority 是全局唯一队列里的绝对位次,手机上按组内序号读 */ + rank: number + /** 该账号在余额快照里的那一行;快照没有这个账号时是 undefined(= 未查询) */ + usageRow?: UsageRow + onOpen: () => void + onToggle: (next: boolean) => void + busy: boolean +}) { + const now = useDisplayClock() + const limited = isRateLimited(account) + const cooldown = upstreamCooldownOf(account) + const suspendedReason = upstreamSuspendedReasonOf(account) + const expiresAt = expiryOf(account) + const expired = expiresAt > 0 && expiresAt <= Date.now() + const tone = account.needsRelogin || suspendedReason + ? 'bad' + : !account.enabled + ? 'mute' + : limited + ? 'warn' + : expired + ? 'bad' + : 'ok' + const badgeText = account.needsRelogin + ? t('需重登') + : suspendedReason + ? t('上游限制 · 已禁用') + : !account.enabled + ? t('已禁用') + : cooldown + ? t('账号冷却') + : limited + ? t('限流') + : expired + ? t('已过期') + : t('正常') + // 有效期:这家没有这个概念时不显示,而不是显示「—」(少一行噪音); + // 长期有效的家写「长期有效」—— 与「—」(读不到到期时间)是两件事 + const credentialRows = credentialExpiryRowsOf(account, expiresAt) + const expiryText = credentialRows.length ? '' : isLongLivedCredential(account.provider) + ? t('长期有效') + : hasExpiry(account.provider) ? formatExpiry(expiresAt) : '' + // 余额:只有这家有查询能力时才占一行;快照里没有这一行 = 还没查过 + const usageView = usageText(usageRow, now) + const showUsage = supportsUsage(account) + const usageColor = + usageView.kind === 'bad' + ? 'var(--m-danger)' + : usageView.kind === 'warn' + ? 'var(--m-warn)' + : 'var(--m-text-3)' + + return ( +
+ +
+ + + {account.editionLabel || formatRelative(account.updatedAt as number)} + +
+
+ ) +} + +/** 小号开关:账号行里用它,尺寸比设置页的档位小一档 */ +function SwitchSmall({ + checked, + onChange, + disabled, + label, +}: { + checked: boolean + onChange: (next: boolean) => void + disabled?: boolean + label: string +}) { + return ( + + ) +} diff --git a/desktop-tauri/mobile/src/pages/AgentPage.tsx b/desktop-tauri/mobile/src/pages/AgentPage.tsx new file mode 100644 index 000000000..5a276dd79 --- /dev/null +++ b/desktop-tauri/mobile/src/pages/AgentPage.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react' +import { PageIntro, IconButton, Badge, Empty, ErrorNote, SkeletonList } from '../components/ui' +import { GlyphLogo } from '../components/ProviderLogo' +import { Icon } from '../components/Icon' +import { PullIndicator, usePullToRefresh } from '../components/PullToRefresh' +import { store } from '../state/store' +import { fetchAgents, previewAgentRestore, restoreAgentClient, type AgentClient } from '../api/gateway' +import { usePolling } from '../lib/usePolling' +import { t } from '../i18n' +import { Card, Button, SegmentedControl } from '@ui' +import { AgentResourcesPanel } from '../../../ui-shared/agent-resources-panel' +import { apiCall } from '../api/client' +import type { ResourceKind, ResourceRequest } from '../../../ui-shared/agent-resources' +const resourceRequest: ResourceRequest = (method, path, body) => apiCall(path, { method, body }) +import { confirmDialog } from '@ui' + +/** + * Agent 接管。 + * + * ── 手机上只读,这是有意的 ──────────────────────────────────── + * 这一页的作用是改写**本机 AI 客户端的配置文件**(`~/.claude/settings.json` + * 之类)。手机读不到那台机器的文件系统 —— 它连的是网关,而网关跑在另一台 + * 机器上。所以手机上: + * · 看得到有哪些客户端、各自的配置路径、是否已接管; + * · 可以「还原」(走网关的 `/api/agents/restore`,那是网关在替我们写); + * · **不能**改配置内容(那需要浏览本机文件、选 profile)。 + * + * 页内明说这一点,免得用户以为功能坏了。 + */ +export function AgentPage() { + const [clients, setClients] = useState(null) + const [error, setError] = useState('') + const [busy, setBusy] = useState('') + const [tabs, setTabs] = useState>({}) + + const load = async () => { + try { + const data = await fetchAgents() + setClients(data.clients || []) + setError('') + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)) + } + } + + usePolling(load, 120_000) + + const pull = usePullToRefresh({ onRefresh: load }) + + const restore = async (client: AgentClient) => { + if (!client.id) return + setBusy(client.id) + try { + const preview = await previewAgentRestore(client.id) + const confirmed = await confirmDialog( +
+

{t('恢复作用在网关所在机器;密钥已隐藏,不会覆盖应用后的外部修改。')}

+ {preview.warnings.map((warning, index) =>

{warning}

)} + {preview.files.map(file =>
{file.path} +
{file.diff.filter(line => line.kind !== 'same').map(line => `${line.kind === 'added' ? '+' : '−'} ${line.text}`).join('\n')}
+
)} +
, + { title: t('撤销上次应用'), confirmText: t('确认恢复') }, + ) + if (!confirmed) return + await restoreAgentClient(client.id, preview.token) + store.toast(t('配置已恢复并校验')) + await load() + } catch (failure) { + store.toast(failure instanceof Error ? failure.message : String(failure), 'err') + } finally { + setBusy('') + } + } + + return ( +
+ + void load()} />} + /> + +
+ +
+ {t('手机端可查看模型配置、撤销上次应用,并管理提示词、Skill 与 MCP。')} + {t('所有操作作用在网关所在机器。')} +
+
+
+ + {error ? : null} + {!clients ? : null} + + {clients && !clients.length ? ( +
+ + + +
+ ) : null} + + {(clients || []).map(client => ( +
+ +
+ +
+
{client.name || client.id}
+
+ {client.resolvedPath || client.configPath || '—'} +
+
+ {client.viaGateway ? t('接入本网关') : t('未接入本网关')} +
+ setTabs(previous => ({ ...previous, [client.id || '']: value }))} + aria-label={t('客户端管理分类')} options={[{ value: 'config', label: t('模型配置') }, { value: 'prompt', label: t('提示词') }, { value: 'skills', label: 'Skill' }, { value: 'mcp', label: 'MCP' }]} /> + {tabs[client.id || ''] && tabs[client.id || ''] !== 'config' ? store.toast(text, bad ? 'err' : 'ok')} /> : null} +
+ {client.detectionState === 'missing' ? t('未检测到') : client.detectionState === 'uninitialized' ? t('尚未初始化') : client.detectionState === 'read_error' ? t('读取失败') : client.detectionState === 'unsupported' ? t('仅可读取') : t('客户端原生配置')} + {client.readError ? {client.readError} : null} + {client.applied?.canUndo ? ( + + ) : null} +
+
+
+ ))} + +
+ + {t('接入状态来自客户端配置文件。恢复前显示差异;文件被其他程序修改时拒绝覆盖。')} + +
+
+ ) +} diff --git a/desktop-tauri/mobile/src/pages/AssistantPage.tsx b/desktop-tauri/mobile/src/pages/AssistantPage.tsx new file mode 100644 index 000000000..e59762f6f --- /dev/null +++ b/desktop-tauri/mobile/src/pages/AssistantPage.tsx @@ -0,0 +1,824 @@ +import { useEffect, useRef, useState } from 'react' +import { Icon } from '../components/Icon' +import { MarkdownLite } from '../components/MarkdownLite' +import { Button, Textarea, Sheet, SheetContent, SheetHeader, SheetTitle, SheetBody, confirmDialog } from '@ui' +import { store } from '../state/store' +import { AssistantModelPicker } from '../components/AssistantModelPicker' +import { useAssistantModels, unavailableModelMessage } from '../lib/assistant-models' +import { selectedModel } from '../../../ui-shared/assistant-models' +import { + fetchAssistantConfig, + saveAssistantConfig, + streamAssistantChat, + saveAssistantSession, listAssistantSessions, getAssistantSession, forkAssistantSession, summarizeAssistantSession, saveAssistantTitleConfig, deleteAssistantSession, + type AssistantConfig, type SessionSummary, type SessionDetail, + type ChatMessage, + type ToolCall, +} from '../api/assistant' +import { toolsPayload, toolById } from '../lib/assistantTools' +import { ellipsisMiddle } from '../lib/format' +import { t } from '../i18n' +import { imageContext, type ImageAsset } from '../../../ui-shared/assistant-image-tools' +import { assistantFileContext, formatFileSize, type AssistantTextFile } from '../../../ui-shared/assistant-files' +import { AssistantImageResults } from '../../../ui-shared/assistant-image-results' +import { loadAssistantImage } from '../api/assistant-images' +import { saveMobileAssistantImage } from '../lib/assistant-image-save' +import { readMobileAttachments } from '../lib/assistant-attachments' +import { serverIdentity } from '../lib/servers' +import { AssistantMessageActions } from '../../../ui-shared/assistant-message-actions' +import { AssistantTitleSettings } from '../../../ui-shared/assistant-title-settings' +import { parseAssistantMessages, removeAssistantMessage } from '../../../ui-shared/assistant-history' +import { replayBubbles, type Bubble } from './assistant/history' +import { AssistantAvatar as Avatar, assistantBubbleStyle as bubbleStyle, parseAssistantArgs as parseArgs } from './assistant/display' +import { AssistantHistory } from './assistant/AssistantHistory' +import { apiCall } from '../api/client' +import { useModelApiKeys } from '../../../ui-shared/use-model-api-keys' +import { ModelApiKeyField } from '../../../ui-shared/model-api-key-field' +import { modelApiKeyAllows, modelApiKeyLabel, type ModelApiKey } from '../../../ui-shared/model-api-key' + +/** + * AI 助手(手机端)。 + * + * ── 请求走网关,不在手机上直连模型 ──────────────────────────── + * 对话发到 `POST /api/assistant/chat`,由**网关**转发到上游:模型路由、账号 + * 选取、429 换号、协议翻译、用量记账全在服务器那一侧(见 `api::assistant` + * 的模块头)。手机端只做两件事:把消息发出去、把流回来的 delta 画出来。 + * 手机不持有任何上游凭证,也没有直连任何模型服务的代码。 + * + * ── 为什么不再有「快捷查询」那一套 ──────────────────────────── + * 上一版用四个本地写死的查询冒充对话(直接打管理 API,不经过模型),输入框 + * 是个点了只弹提示的假按钮。那套东西的问题是:用户以为在跟模型说话,实际上 + * 只能问那四件事。现在四个快捷入口保留为**提问模板** —— 点一下把话填进输入框 + * 并发出去,走的是真正的对话链路。 + * + * ── 模型从哪来 ──────────────────────────────────────────────── + * 默认用桌面端「助手设置」里选的那个(与桌面端共用一份配置)。手机端可以在 + * 顶部临时换一个已启用的模型,只影响本次会话,不写回配置 —— 手机上改全局 + * 设置容易误触,而换模型是个高频的临时动作。 + */ + +type QuickAsk = { + label: string + icon: Parameters[0]['name'] +} + +const QUICK_ASKS: QuickAsk[] = [ + { label: t('各账号余额还剩多少?'), icon: 'wallet' }, + { label: t('哪些模型在多家重复了?'), icon: 'model' }, + { label: t('今天用了多少 Token?'), icon: 'chart' }, + { label: t('现在有哪些账号被限流?'), icon: 'alert' }, +] + +export function AssistantPage() { + const [bubbles, setBubbles] = useState([]) + const [draft, setDraft] = useState('') + const [attachments, setAttachments] = useState([]) + const [textFiles, setTextFiles] = useState([]) + const [readingAttachments, setReadingAttachments] = useState(false) + const [busy, setBusy] = useState(false) + const [historyBusy, setHistoryBusy] = useState(false) + const [historyOpen, setHistoryOpen] = useState(false) + const [titleSettingsOpen, setTitleSettingsOpen] = useState(false) + const [config, setConfig] = useState(null) + const keyState = useModelApiKeys(() => apiCall('/api/model-access/keys')) + const selectedKey = keyState.keys.find(key => key.id === config?.apiKeyId) + const [keyPickerOpen, setKeyPickerOpen] = useState(false) + const [savingKey, setSavingKey] = useState(false) + const [sessions, setSessions] = useState([]) + const [title, setTitle] = useState('') + const titleRef = useRef('') + /** 默认模型(来自网关配置);null = 还没读到 */ + const [defaultModel, setDefaultModel] = useState(undefined) + /** 本次会话临时选的模型;空串 = 跟随配置 */ + const [model, setModel] = useState('') + const { models, ready: modelsReady, loading: modelsLoading, error: modelsError, refresh: refreshModels } = useAssistantModels(selectedKey?.key) + const [pickerOpen, setPickerOpen] = useState(false) + const checkingModelRef = useRef(false) + const [checkingModel, setCheckingModel] = useState(false) + /** 网关里勾选过的工具表(空 = 纯聊天,后端也不会注入系统提示词) */ + const [tools, setTools] = useState>>([]) + const enabledToolsRef = useRef>({}) + const modeRef = useRef<'confirm' | 'full'>('confirm') + const sessionRef = useRef(crypto.randomUUID()) + const mountedRef = useRef(true) + const saveQueueRef = useRef>(Promise.resolve()) + const historyRef = useRef([]) + const fileRef = useRef(null) + const abortRef = useRef(null) + const scrollRef = useRef(null) + const inputRef = useRef(null) + + const persist = (session: string, messages: ChatMessage[], selected: string, identity: string): Promise => { + const snapshot = parseAssistantMessages(messages) + const snapshotTitle = titleRef.current + const pending = saveQueueRef.current.catch(() => {}).then(async () => { + if (serverIdentity() !== identity) return + const saved = await saveAssistantSession(session, snapshot, selected, snapshotTitle || undefined) + if (mountedRef.current && serverIdentity() === identity) { + if (sessionRef.current === session) { titleRef.current = saved.title; setTitle(saved.title) } + setSessions(previous => [saved, ...previous.filter(item => item.id !== saved.id)]) + } + }) + saveQueueRef.current = pending.catch(failure => { + if (mountedRef.current && serverIdentity() === identity) store.toast(t('这条会话没能存进历史:{message}', { message: failure instanceof Error ? failure.message : String(failure) }), 'err') + }) + return pending + } + + useEffect(() => { + mountedRef.current = true + return () => { mountedRef.current = false; abortRef.current?.abort() } + }, []) + + // 读默认模型与工具勾选表:工具表决定后端注不注入系统提示词,必须一起读 + useEffect(() => { + let cancelled = false + fetchAssistantConfig() + .then(config => { + if (cancelled) return + setDefaultModel(config.model || null) + setConfig(config) + const enabled = config.tools || {} + enabledToolsRef.current = enabled + modeRef.current = config.mode === 'full' ? 'full' : 'confirm' + setTools(toolsPayload(enabled)) + }) + .catch(() => { + if (!cancelled) setDefaultModel(null) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + let cancelled = false + void listAssistantSessions().then(items => { if (!cancelled) setSessions(items) }).catch(() => {}) + return () => { cancelled = true } + }, []) + + useEffect(() => { + if (pickerOpen) void refreshModels().catch(() => {}) + }, [pickerOpen, refreshModels]) + + // 新内容到达时贴底(用户往上翻看历史时不打扰:只有本来就在底部才跟随) + useEffect(() => { + const node = scrollRef.current + if (!node) return + const nearBottom = node.scrollHeight - node.scrollTop - node.clientHeight < 120 + if (nearBottom) node.scrollTop = node.scrollHeight + }, [bubbles]) + + const effectiveModel = model || defaultModel || '' + const currentOption = selectedModel(models, effectiveModel) + const modelWarning = modelsError + ? t('读取模型清单失败:{message}', { message: modelsError }) + : !modelsReady ? t('正在读取可用模型…') + : effectiveModel && !currentOption ? unavailableModelMessage(effectiveModel) : '' + + /** 发一条消息(`text` 为空时取输入框) */ + const send = async (text?: string) => { + const content = (text ?? draft).trim() + if ((!content && !attachments.length && !textFiles.length) || busy || readingAttachments || checkingModelRef.current) return + if (!effectiveModel) { + setPickerOpen(true) + return + } + const session = sessionRef.current + const identity = serverIdentity() + checkingModelRef.current = true + setCheckingModel(true) + let requestModel: string + let requestKey: ModelApiKey + try { + const [options, keys] = await Promise.all([refreshModels(), keyState.refresh()]) + if (!mountedRef.current || sessionRef.current !== session || serverIdentity() !== identity) return + const key = keys.find(key => key.id === config?.apiKeyId) + if (!key) { + setKeyPickerOpen(true) + store.toast(t('请先选择一把 API Key'), 'err') + return + } + if (!modelApiKeyAllows(key, effectiveModel)) { + store.toast(t('所选 API Key 无权调用此模型或提供商'), 'err') + return + } + requestKey = key + const option = selectedModel(options, effectiveModel) + if (!option) { + store.toast(unavailableModelMessage(effectiveModel), 'err') + setPickerOpen(true) + return + } + requestModel = option.name + } catch (failure) { + if (!(failure instanceof DOMException && failure.name === 'AbortError') && sessionRef.current === session && serverIdentity() === identity) { + store.toast(t('读取模型清单失败:{message}', { message: failure instanceof Error ? failure.message : String(failure) }), 'err') + } + return + } finally { + checkingModelRef.current = false + if (serverIdentity() === identity) setCheckingModel(false) + } + const attached = [...attachments] + const attachedFiles = [...textFiles] + const userId = crypto.randomUUID() + const replyId = crypto.randomUUID() + const history: ChatMessage[] = [...historyRef.current, { + id: userId, role: 'user', content, + ...(attached.length ? { images: attached } : {}), + ...(attachedFiles.length ? { files: attachedFiles } : {}), + }] + setBubbles(prev => [ + ...prev, + { id: userId, messageIds: [userId], role: 'user', text: content, attachments: attached, files: attachedFiles }, + { id: replyId, messageIds: [replyId], model: requestModel, role: 'assistant', text: '', streaming: true, tools: [] }, + ]) + setDraft('') + setAttachments([]) + setTextFiles([]) + setBusy(true) + + const controller = new AbortController() + abortRef.current = controller + const patch = (next: Partial) => + setBubbles(prev => prev.map(item => (item.id === replyId ? { ...item, ...next } : item))) + + let messages = history + let unfinishedText = '' + let unfinishedId = replyId + try { + await persist(session, messages, requestModel, identity).catch(() => {}) + /** 本轮执行过的工具(显示在气泡下方,让用户知道模型查了什么) */ + const usedTools: string[] = [] + const generatedImages: ImageAsset[] = [] + for (let round = 0; round < 6; round++) { + if (controller.signal.aborted || !mountedRef.current || serverIdentity() !== identity) break + const roundId = round === 0 ? replyId : crypto.randomUUID() + unfinishedId = roundId + setBubbles(previous => previous.map(item => item.id === replyId ? { ...item, messageIds: [...new Set([...(item.messageIds || []), roundId])] } : item)) + let pendingCalls: ToolCall[] = [] + let roundText = '' + unfinishedText = '' + + await streamAssistantChat({ + apiKey: requestKey.key, + messages, + tools, + model: requestModel, + signal: controller.signal, + onEvent: event => { + if (!mountedRef.current || sessionRef.current !== session || serverIdentity() !== identity) return + if (event.type === 'delta') { + roundText += event.text + unfinishedText = roundText + setBubbles(prev => + prev.map(item => + item.id === replyId ? { ...item, text: item.text + event.text } : item, + ), + ) + } else if (event.type === 'toolCalls') { + pendingCalls = event.calls + } + }, + }) + if (!mountedRef.current || sessionRef.current !== session || serverIdentity() !== identity) return + unfinishedText = '' + + if (!pendingCalls.length) { + messages = [...messages, { id: roundId, role: 'assistant', model: requestModel, content: roundText }] + break + } + + // 把这一轮的 assistant 消息(含工具调用)与工具结果一起回灌 + messages = [ + ...messages, + { id: roundId, role: 'assistant', model: requestModel, content: roundText, toolCalls: pendingCalls }, + ] + for (const call of pendingCalls) { + if (!mountedRef.current || sessionRef.current !== session || serverIdentity() !== identity) break + if (controller.signal.aborted) { + messages = [...messages, { role: 'tool', toolCallId: call.id, content: '用户已停止,未执行这次操作。' }] + continue + } + const tool = toolById(call.name) + let result: string + let resultImages: ImageAsset[] | undefined + try { + if (!tool) throw new Error(t('未知的工具:{name}', { name: call.name })) + if (enabledToolsRef.current[tool.id] !== true) throw new Error(t('工具「{name}」未获授权,未执行。', { name: tool.label })) + const args = parseArgs(call.arguments) + const refreshingBalance = tool.id === 'list_account_balance' && args.refresh === true + if ((tool.kind !== 'read' || refreshingBalance) && modeRef.current !== 'full') { + const plan = tool.plan?.(args) + const description = [plan?.lead || t('将执行 {label}。', { label: tool.label }), + plan?.table ? plan.table.rows.map(row => row.join(':')).join('\n') : JSON.stringify(args, null, 2), + plan?.note, refreshingBalance ? t('实时余额查询会请求上游,并可能触发已配置的自动禁用规则。') + : tool.kind === 'run' ? t('这会真实发起上游请求并消耗额度') : t('确认后立即生效'), + ].filter(Boolean).join('\n') + if (!await confirmDialog(description, { title: tool.label, confirmText: t('执行') })) { + messages = [...messages, { role: 'tool', toolCallId: call.id, content: '用户取消了这次操作,未执行。' }] + continue + } + } + if (controller.signal.aborted || !mountedRef.current || serverIdentity() !== identity) throw new DOMException('Aborted', 'AbortError') + if (tool.id === 'generate_images' || tool.id === 'edit_images') await persist(session, messages, requestModel, identity) + const outcome = await tool.run(args, { session, images: imageContext(messages), files: assistantFileContext(messages), confirmed: true, signal: controller.signal, apiKey: requestKey.key, apiKeyName: requestKey.name }) + if (serverIdentity() !== identity || sessionRef.current !== session) break + result = outcome.text + resultImages = outcome.images + generatedImages.push(...(outcome.images || [])) + usedTools.push(tool.label) + patch({ tools: [...usedTools], images: [...generatedImages] }) + } catch (failure) { + // 工具失败不中断对话:把错误如实回给模型,让它自己决定怎么解释 + result = JSON.stringify({ + error: failure instanceof Error ? failure.message : String(failure), + }) + } + messages = [ + ...messages, + { role: 'tool', toolCallId: call.id, content: result, images: resultImages }, + ] + if (sessionRef.current === session && serverIdentity() === identity) historyRef.current = messages + if (resultImages?.length) await persist(session, messages, requestModel, identity).catch(() => {}) + } + await persist(session, messages, requestModel, identity).catch(() => {}) + if (controller.signal.aborted || !mountedRef.current) break + if (round === 5) patch({ error: t('工具调用已达本轮上限,已停止继续执行。') }) + } + + if (sessionRef.current !== session || serverIdentity() !== identity) return + historyRef.current = messages + await persist(session, messages, requestModel, identity).catch(() => {}) + if (!controller.signal.aborted && !sessions.find(item => item.id === session)?.titleGenerated) { + void summarizeAssistantSession(session, requestKey.key).then(saved => { + if (!mountedRef.current || serverIdentity() !== identity) return + if (sessionRef.current === session) { titleRef.current = saved.title; setTitle(saved.title) } + setSessions(previous => previous.map(item => item.id === saved.id ? saved : item)) + }).catch(failure => { if (mountedRef.current && serverIdentity() === identity) store.toast(t('标题总结失败:{message}', { message: failure instanceof Error ? failure.message : String(failure) }), 'warn') }) + } + // 流正常结束但一个字都没有:多半是上游只回了 tool_calls 或空响应 + setBubbles(prev => + prev.map(item => { + if (item.id !== replyId) return item + if (item.text.trim() || item.images?.length) return { ...item, streaming: false } + return { ...item, streaming: false, error: t('模型没有返回内容') } + }), + ) + } catch (failure) { + if (sessionRef.current !== session || serverIdentity() !== identity) return + if (unfinishedText) messages = [...messages, { id: unfinishedId, role: 'assistant', model: requestModel, content: unfinishedText }] + historyRef.current = messages + void persist(session, messages, requestModel, identity).catch(() => {}) + const aborted = controller.signal.aborted + patch({ + streaming: false, + error: aborted + ? undefined + : failure instanceof Error + ? failure.message + : String(failure), + }) + if (aborted) store.toast(t('已停止生成')) + } finally { + if (abortRef.current === controller) abortRef.current = null + if (mountedRef.current) { + setBusy(false) + inputRef.current?.focus() + } + } + } + + const addAttachments = async (files: FileList | File[]) => { + if (readingAttachments || busy || checkingModelRef.current) return + const session = sessionRef.current + setReadingAttachments(true) + try { + const result = await readMobileAttachments(files, { images: attachments.length, files: textFiles.length }) + if (sessionRef.current === session) { + setAttachments(current => [...current, ...result.images]) + setTextFiles(current => [...current, ...result.files]) + } + } catch (failure) { + store.toast(failure instanceof Error ? failure.message : String(failure), 'err') + } finally { setReadingAttachments(false) } + } + + const stop = () => { + abortRef.current?.abort() + } + + const clear = () => { + if (busy || checkingModelRef.current) return + setBubbles([]) + setDraft('') + setAttachments([]) + setTextFiles([]) + historyRef.current = [] + titleRef.current = '' + setTitle('') + sessionRef.current = crypto.randomUUID() + setHistoryOpen(false) + } + + const showSession = (detail: SessionDetail) => { + historyRef.current = detail.messages + sessionRef.current = detail.id + titleRef.current = detail.title + setTitle(detail.title) + setModel(detail.model || '') + setBubbles(replayBubbles(detail.messages, detail.model)) + setAttachments([]) + setTextFiles([]) + setDraft('') + setHistoryOpen(false) + } + + const openSession = async (id: string) => { + if (busy || historyBusy || checkingModelRef.current) return + setHistoryBusy(true) + try { await saveQueueRef.current; showSession(await getAssistantSession(id)) } + catch (failure) { store.toast(failure instanceof Error ? failure.message : String(failure), 'err') } + finally { setHistoryBusy(false) } + } + + const operateMessage = async (bubble: Bubble, action: 'edit' | 'delete' | 'fork', text = '') => { + if (busy || historyBusy || checkingModelRef.current) return + setHistoryBusy(true) + const identity = serverIdentity() + try { + await saveQueueRef.current + let messages = parseAssistantMessages(historyRef.current) + if (action === 'fork') { + await persist(sessionRef.current, messages, effectiveModel, identity) + const detail = await forkAssistantSession(sessionRef.current, bubble.messageIds?.at(-1) || bubble.id) + setSessions(previous => [detail, ...previous]) + showSession(detail) + return + } + const ids = new Set(bubble.messageIds || [bubble.id]) + if (action === 'delete') { for (const id of ids) messages = removeAssistantMessage(messages, id) } + else { + let first = true + messages = messages.map(message => { + if (!ids.has(message.id!)) return message + const content = first ? text : '' + first = false + return { ...message, content } + }) + } + historyRef.current = messages + setBubbles(replayBubbles(messages, effectiveModel)) + await persist(sessionRef.current, messages, effectiveModel, identity) + } catch (failure) { store.toast(failure instanceof Error ? failure.message : String(failure), 'err') } + finally { setHistoryBusy(false) } + } + + return ( +
+ {/* + 页头必须自己吃下安全区:其他一级页用 `.m-head`,那里带了 + `calc(var(--m-safe-t) + …)`;这一页是手写的紧凑头(对话页要省纵向空间), + 漏了 safe-t 就会被刘海 / 状态栏压住。离线时顶部那条通知自己占安全区, + 这里再让一次会多出一段空白 —— 与 `.m-app[data-offline] .m-head` 同口径。 + */} +
+ +
+ {title || t('新对话')} + + +
+ +
+ +
+ {bubbles.length === 0 ? ( +
+ +
+ {t('有什么可以帮你?可以问配置、查余额、让我改模型设置。')} +
+
+ ) : null} + + {bubbles.map(bubble => { + if (bubble.role === 'user') { + return ( +
+
+
+ {bubble.attachments?.map((url, index) => {t('图片)} + {bubble.files?.length ?
+ {bubble.files.map((file, index) => + {file.name} + {formatFileSize(file.size)} + )} +
: null} + {bubble.text} +
+ operateMessage(bubble, 'edit', text)} onDelete={() => operateMessage(bubble, 'delete')} /> +
+
+ ) + } + return ( +
+ +
+
{bubble.model || effectiveModel}
+
+ {bubble.text ? : bubble.streaming ? t('正在思考…') : null} + {bubble.streaming ? ( +
+ {!bubble.streaming ? operateMessage(bubble, 'edit', text)} onDelete={() => operateMessage(bubble, 'delete')} onFork={() => operateMessage(bubble, 'fork')} /> : null} + {bubble.images?.length ? : null} + {bubble.error ? ( +
+ {bubble.error} +
+ ) : null} + {bubble.tools?.length ? ( +
+ {t('已调用')} + {bubble.tools.map((label, index) => ( + + {label} + + ))} +
+ ) : null} +
+
+ ) + })} + + {bubbles.length === 0 ? ( +
+ {QUICK_ASKS.map(item => ( + + ))} +
+ ) : null} +
+ +
+ {attachments.length ?
+ {attachments.map((url, index) => )} +
: null} + {textFiles.length ?
+ {textFiles.map((file, index) => )} +
: null} + {textFiles.length ?
{t('JSON 文件会以文本发送给当前对话模型,其中的凭证也会保存在这条会话里。')}
: null} + { + const files = event.currentTarget.files + if (files) void addAttachments(files) + event.currentTarget.value = '' + }} /> +
+ +