@huicai/cli 2026.709.53670 → 2026.710.97949
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/skills/api-design/SKILL.md +25 -0
- package/skills/code-review/SKILL.md +29 -0
- package/skills/commit-and-pr/SKILL.md +36 -0
- package/skills/debug-root-cause/SKILL.md +26 -0
- package/skills/dependency-upgrade/SKILL.md +24 -0
- package/skills/explain-codebase/SKILL.md +27 -0
- package/skills/perf-optimize/SKILL.md +25 -0
- package/skills/refactor-safely/SKILL.md +26 -0
- package/skills/release-checklist/SKILL.md +34 -0
- package/skills/security-audit/SKILL.md +28 -0
- package/skills/sql-tuning/SKILL.md +27 -0
- package/skills/write-tests/SKILL.md +26 -0
- package/src/agent.js +19 -2
- package/src/banner.js +83 -0
- package/src/commands.js +16 -0
- package/src/config.js +2 -0
- package/src/markdown.js +0 -0
- package/src/render.js +79 -0
- package/src/repl.js +11 -14
- package/src/skills.js +17 -6
- package/src/tui/app.js +104 -14
- package/src/usage.js +43 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huicai/cli",
|
|
3
|
-
"version": "2026.
|
|
4
|
-
"versionDisplay": "2026.07.
|
|
3
|
+
"version": "2026.710.97949",
|
|
4
|
+
"versionDisplay": "2026.07.10.97949",
|
|
5
5
|
"description": "在终端运行的智能体 CLI:以 HuiCai_AI 平台的 OpenAI 兼容 /v1 为模型后端,在用户本机执行文件/命令工具。",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"bin",
|
|
15
15
|
"src",
|
|
16
|
+
"skills",
|
|
16
17
|
"README.md",
|
|
17
18
|
"LICENSE"
|
|
18
19
|
],
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: api-design
|
|
3
|
+
description: 设计或评审 HTTP/REST 接口。当用户要"设计接口/加个 API/评审接口/定义端点/改接口契约"时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 接口设计
|
|
8
|
+
|
|
9
|
+
## 原则
|
|
10
|
+
- **面向资源**:URL 用名词复数(`/orders/{id}/items`),动作用 HTTP 方法表达,别把动词塞进路径。
|
|
11
|
+
- **方法语义**:GET 读且幂等无副作用;POST 创建/动作;PUT 全量替换(幂等);PATCH 局部更新;DELETE 删除(幂等)。
|
|
12
|
+
- **状态码**:2xx 成功(201 创建 + Location;204 无体);4xx 客户端错(400 校验/401 未认证/403 越权/404 不存在/409 冲突/422 语义错/429 限流);5xx 服务端错。
|
|
13
|
+
- **一致的错误体**:`{ code, message, details? }`,机器可读 code + 人可读 message。
|
|
14
|
+
- **入参校验**:类型/必填/范围/枚举;对非法输入返回明确原因。
|
|
15
|
+
|
|
16
|
+
## 要点清单
|
|
17
|
+
- **分页/排序/过滤**:约定统一参数(如 `page/size` 或游标 `cursor`);大集合默认分页。
|
|
18
|
+
- **幂等性**:写接口考虑幂等键(`Idempotency-Key`),防重复提交。
|
|
19
|
+
- **版本化与兼容**:加字段兼容、删/改字段谨慎;破坏性变更走新版本。
|
|
20
|
+
- **鉴权**:每个端点声明所需权限,校验资源归属。
|
|
21
|
+
- **安全**:限流、载荷大小上限、避免在 URL 放敏感信息。
|
|
22
|
+
- **文档**:请求/响应示例、错误清单、字段含义。
|
|
23
|
+
|
|
24
|
+
## 评审时
|
|
25
|
+
- 对照上面清单逐条核;指出不一致与风险;给出改后契约(路径/方法/状态码/请求响应示例)。
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: code-review
|
|
3
|
+
description: 系统化审查代码改动(正确性/边界/安全/可读性/测试)。当用户要求"审查/review/看看这段代码有没有问题/把关质量",或提交前自查时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 代码审查
|
|
8
|
+
|
|
9
|
+
对一处改动或一段代码做结构化审查。按下述顺序逐项过,**每条结论都指向具体文件:行**,给出可执行的修改建议而非泛泛而谈。
|
|
10
|
+
|
|
11
|
+
## 流程
|
|
12
|
+
1. **先定位范围**:用 `git diff`(有 git 时经 run_bash)或 read_file 通读改动;先理解意图,再挑毛病。
|
|
13
|
+
2. **逐维度检查**(按严重度排序输出):
|
|
14
|
+
- **正确性**:逻辑是否符合意图?边界值(空/0/负数/超长/并发)?off-by-one?异常路径与错误处理?
|
|
15
|
+
- **数据与状态**:可变共享状态、竞态、事务边界、缓存一致性、资源泄漏(文件/连接/定时器未释放)。
|
|
16
|
+
- **安全**:注入(SQL/命令/路径穿越)、鉴权与越权、敏感信息硬编码/日志泄漏、反序列化、SSRF。
|
|
17
|
+
- **接口契约**:入参校验、返回值/错误码约定、向后兼容、幂等性。
|
|
18
|
+
- **可读性**:命名、重复代码、过长函数、隐藏副作用、魔法数字、与周边代码风格是否一致。
|
|
19
|
+
- **测试**:关键路径与边界是否有测试覆盖?改动是否破坏既有测试?
|
|
20
|
+
3. **验证**:能跑测试/编译就跑(run_bash),用事实说话,别只靠推理。
|
|
21
|
+
|
|
22
|
+
## 输出格式
|
|
23
|
+
- 按 `严重(必须改) / 建议 / 可选` 三档分组;
|
|
24
|
+
- 每条:`文件:行 — 问题一句话 → 具体改法`;
|
|
25
|
+
- 末尾一句总体结论(可合入 / 改完再合 / 有阻塞问题)。
|
|
26
|
+
|
|
27
|
+
## 注意
|
|
28
|
+
- 只审改动相关的问题,别扩散重写;尊重既有约定。
|
|
29
|
+
- 不确定的地方标注"需确认",不要臆断。
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: commit-and-pr
|
|
3
|
+
description: 写规范的提交信息与 PR 描述(Conventional Commits)。当用户要"提交/commit/写提交信息/开 PR/写 PR 描述/总结本次改动"时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 提交与 PR
|
|
8
|
+
|
|
9
|
+
## 提交前
|
|
10
|
+
1. run_bash 跑 `git status` + `git diff`(含已暂存)通读**全部**改动,确认无调试代码/无关文件/密钥。
|
|
11
|
+
2. 一次提交只做一件事;跨主题的改动拆成多个提交。
|
|
12
|
+
|
|
13
|
+
## 提交信息(Conventional Commits)
|
|
14
|
+
格式:`type(scope): 简明主题`,主题用祈使句、≤50 字、不加句号。
|
|
15
|
+
- **type**:feat / fix / refactor / perf / docs / test / build / chore / style / ci。
|
|
16
|
+
- 正文(可选,空一行后):解释**为什么**这么改、影响面、权衡;关联 issue(`Refs #123`)。
|
|
17
|
+
- 破坏性变更:正文加 `BREAKING CHANGE: ...`。
|
|
18
|
+
|
|
19
|
+
示例:
|
|
20
|
+
```
|
|
21
|
+
fix(auth): 修复令牌过期后未刷新导致的 401
|
|
22
|
+
|
|
23
|
+
原逻辑只在请求前检查过期,长响应期间过期不会重试。
|
|
24
|
+
改为遇 401 自动刷新一次再重放。Refs #482
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## PR 描述
|
|
28
|
+
- **What**:一句话说清这个 PR 做了什么;
|
|
29
|
+
- **Why**:动机/背景/要解决的问题;
|
|
30
|
+
- **How**:关键实现思路与取舍(只讲非显而易见的);
|
|
31
|
+
- **测试**:怎么验证的(命令/步骤/截图);
|
|
32
|
+
- **风险与回滚**:影响面、兼容性、如何回退。
|
|
33
|
+
|
|
34
|
+
## 注意
|
|
35
|
+
- 未经用户明确要求,不要自行 `git push` 或建远程 PR;先展示信息草稿。
|
|
36
|
+
- 提交信息如实描述,别夸大"已全部测试通过"除非真的跑过。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: debug-root-cause
|
|
3
|
+
description: 系统化定位 bug 的根因(而非改表象)。当用户报"报错/崩溃/结果不对/偶发/线上故障/为什么会这样"并需要排查时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 根因定位
|
|
8
|
+
|
|
9
|
+
目标是找到**根因**并用证据证明,而不是打补丁盖住症状。
|
|
10
|
+
|
|
11
|
+
## 流程
|
|
12
|
+
1. **复现与收敛**:先明确「预期 vs 实际」。找到最小复现步骤/输入。偶发问题记录触发条件与频率。
|
|
13
|
+
2. **读现场**:完整读错误信息与**栈顶到栈底**;用 grep 定位报错字符串来源;read_file 看相关代码与其调用方。
|
|
14
|
+
3. **建立假设**:列 2-3 个可能原因,按可能性与验证成本排序。别急着改。
|
|
15
|
+
4. **二分定位**:用 run_bash 加日志/打印、缩小输入、`git bisect`(可行时)、注释法逐步夹逼到最小可疑范围。
|
|
16
|
+
5. **验证根因**:能稳定「制造」和「消除」该 bug,才算找到根因。说明因果链:`A 导致 B 导致现象`。
|
|
17
|
+
6. **修复**:改根因;评估同类隐患(同一模式是否在别处也存在);补一个能覆盖此 bug 的回归测试。
|
|
18
|
+
7. **回归**:跑测试/复现步骤确认修好且没引入新问题。
|
|
19
|
+
|
|
20
|
+
## 常见根因清单
|
|
21
|
+
- 空/未初始化/类型不符;边界与 off-by-one;异步竞态与未 await;缓存/状态不一致;
|
|
22
|
+
- 环境差异(版本/配置/时区/编码/路径);错误被吞掉;第三方契约变化;并发与锁。
|
|
23
|
+
|
|
24
|
+
## 注意
|
|
25
|
+
- 先证据后结论;每一步说清"这一步排除/证实了什么"。
|
|
26
|
+
- 不能复现就别声称已修复——给出继续排查的手段。
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dependency-upgrade
|
|
3
|
+
description: 安全地升级第三方依赖/框架版本。当用户说"升级依赖/更新版本/升级框架/修依赖漏洞/迁移到新版本"时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 依赖升级
|
|
8
|
+
|
|
9
|
+
## 流程
|
|
10
|
+
1. **摸清现状**:列出当前版本与目标版本;确认是补丁/次要/大版本(语义化版本:大版本可能有破坏性变更)。
|
|
11
|
+
2. **读变更**:查 CHANGELOG / 迁移指南 / 破坏性变更(breaking changes)与废弃项。心里有数再动。
|
|
12
|
+
3. **小步升级**:优先逐个/逐级升,而非一次跳多个大版本;每步后跑测试与构建(run_bash)。
|
|
13
|
+
4. **适配改动**:按迁移指南改调用点;处理废弃 API、改名、默认值变化、配置迁移。
|
|
14
|
+
5. **验证**:全量测试 + 构建 + 关键路径冒烟;留意运行时(非编译期)才暴露的行为变化。
|
|
15
|
+
6. **锁定**:更新锁文件;确认传递依赖无冲突/无新的高危漏洞。
|
|
16
|
+
|
|
17
|
+
## 优先级判断
|
|
18
|
+
- **安全漏洞**修复:优先、尽快;
|
|
19
|
+
- 只为"用新版":评估收益 vs 风险与工作量,别为升级而升级。
|
|
20
|
+
|
|
21
|
+
## 注意
|
|
22
|
+
- 升级和功能改动分开提交,便于二分与回滚。
|
|
23
|
+
- 大版本升级先在分支验证;有回滚预案。
|
|
24
|
+
- 如实报告哪些通过、哪些需人工适配,别声称"无痛升级"除非确已验证。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: explain-codebase
|
|
3
|
+
description: 快速摸清并讲清一个陌生代码库/模块的架构与关键路径。当用户问"这个项目怎么跑起来的/架构是什么/从哪看起/帮我理解这块代码"时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 讲清一个代码库
|
|
8
|
+
|
|
9
|
+
目标:用最少的阅读,给出一张"够用的地图",让人能上手。
|
|
10
|
+
|
|
11
|
+
## 流程
|
|
12
|
+
1. **鸟瞰**:读 README / package.json / 构建配置 / 目录树(list_files)。识别语言、框架、构建与运行方式、入口文件。
|
|
13
|
+
2. **找入口与主干**:定位 main/启动脚本/路由表/主循环;顺着一条**典型请求或典型操作**从入口追到出口("打通一条线"胜过泛读)。
|
|
14
|
+
3. **抓关键抽象**:核心数据模型、主要模块边界与依赖方向、跨模块通信方式(调用/事件/队列/HTTP)。
|
|
15
|
+
4. **定位关键机制**:鉴权、配置、错误处理、持久化、并发、外部集成分别在哪。
|
|
16
|
+
5. **验证理解**:用 grep 核对假设;不确定处标注。
|
|
17
|
+
|
|
18
|
+
## 输出
|
|
19
|
+
- **一段话总览**:这是什么、怎么跑、核心思路;
|
|
20
|
+
- **架构图(文字版)**:主要模块 + 依赖/数据流方向;
|
|
21
|
+
- **关键路径**:一条端到端流程的文件:函数 链路;
|
|
22
|
+
- **上手指引**:常用命令(构建/测试/运行)、改动某功能应从哪个文件入手;
|
|
23
|
+
- **注意事项/坑**:非显而易见的约定。
|
|
24
|
+
|
|
25
|
+
## 注意
|
|
26
|
+
- 优先深追一条主线,别试图读完所有文件。
|
|
27
|
+
- 结论都能指到具体文件:行;拿不准就说拿不准。
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: perf-optimize
|
|
3
|
+
description: 定位并优化性能瓶颈(慢/CPU 高/内存涨/延迟大)。当用户说"太慢了/性能差/优化速度/内存泄漏/接口延迟高"时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 性能优化
|
|
8
|
+
|
|
9
|
+
铁律:**先测量,后优化**。不要凭直觉改;不要过早优化。
|
|
10
|
+
|
|
11
|
+
## 流程
|
|
12
|
+
1. **定目标**:明确指标与场景(P95 延迟?吞吐?内存峰值?)与当前基线数值。
|
|
13
|
+
2. **测量定位**:用 profiler / 计时日志 / benchmark(run_bash)找出**真正**的热点。80% 时间往往在少数点。别优化不重要的地方。
|
|
14
|
+
3. **分析根因**,对号入座:
|
|
15
|
+
- **算法/复杂度**:不必要的 O(n²)、重复计算、能用哈希/索引替代的线性扫描;
|
|
16
|
+
- **I/O**:N+1 查询、缺索引、无批处理、无缓存、同步阻塞、无连接池;
|
|
17
|
+
- **内存**:大对象常驻、未释放、复制过多、泄漏(只增不减);
|
|
18
|
+
- **并发**:锁竞争、串行化本可并行的工作;
|
|
19
|
+
- **网络**:往返次数多、载荷大、无压缩/无分页。
|
|
20
|
+
4. **改一处、测一处**:每次只动一个变量,用同一基准对比确认真的变快,并确认**功能未变**(跑测试)。
|
|
21
|
+
5. **收尾**:记录优化前后数值与做法;保留可读性,别为微小收益牺牲正确性/可维护性。
|
|
22
|
+
|
|
23
|
+
## 注意
|
|
24
|
+
- 缓存要考虑失效与一致性;并发优化要防竞态。
|
|
25
|
+
- 拿数据说话,避免"感觉快了"。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: refactor-safely
|
|
3
|
+
description: 在不改变外部行为的前提下安全重构(拆函数/去重/改名/解耦)。当用户要"重构/优化结构/消除重复/降复杂度/清理代码"时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 安全重构
|
|
8
|
+
|
|
9
|
+
重构的铁律:**行为不变,只改结构**。功能变更与重构不要混在一次改动里。
|
|
10
|
+
|
|
11
|
+
## 流程
|
|
12
|
+
1. **建立安全网**:先确认有测试能覆盖要动的代码;没有就**先补特征测试**(characterization test)锁住当前行为,再动手。
|
|
13
|
+
2. **小步前进**:一次只做一种变换(提取函数 / 内联 / 改名 / 移动 / 去重 / 拆类)。每步后立即跑测试保持绿灯。
|
|
14
|
+
3. **常见手法**:
|
|
15
|
+
- 提取重复逻辑为函数/常量;
|
|
16
|
+
- 拆过长函数为职责单一的小函数;
|
|
17
|
+
- 用早返回消除深层嵌套;
|
|
18
|
+
- 命名表达意图;消除魔法数字;
|
|
19
|
+
- 收窄可变作用域、减少副作用与隐藏依赖。
|
|
20
|
+
4. **保持接口稳定**:对外签名/返回/错误约定尽量不变;必须变时评估调用方并同步更新。
|
|
21
|
+
5. **验证**:全量测试 + 必要的手动核对;对照重构前后行为一致。
|
|
22
|
+
|
|
23
|
+
## 注意
|
|
24
|
+
- 不顺手改功能、不"顺便"修 bug(发现 bug 单独记录/单独改)。
|
|
25
|
+
- 改动可回退、可解释;提交信息写清「重构:做了什么变换、为什么」。
|
|
26
|
+
- 尊重既有风格与架构,别为个人偏好大改。
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: release-checklist
|
|
3
|
+
description: 发布/上线前的系统化检查。当用户说"要发版/上线前检查/发布准备/打包发布/release"时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 发布前检查
|
|
8
|
+
|
|
9
|
+
上线前逐项确认,宁可慢一步也别带病发布。
|
|
10
|
+
|
|
11
|
+
## 代码与质量
|
|
12
|
+
- [ ] 全量测试通过;关键路径手动冒烟;无遗留调试代码/临时开关。
|
|
13
|
+
- [ ] 改动已 review;变更范围清楚、可回退。
|
|
14
|
+
- [ ] 版本号已更新,遵循项目版本规范(如语义化或日期制)。
|
|
15
|
+
|
|
16
|
+
## 配置与依赖
|
|
17
|
+
- [ ] 新增配置项在各环境齐备且有默认;密钥/凭证不在代码里。
|
|
18
|
+
- [ ] 依赖锁定;无已知高危漏洞;数据库迁移脚本可重放、可回滚。
|
|
19
|
+
|
|
20
|
+
## 兼容与数据
|
|
21
|
+
- [ ] 接口/协议向后兼容;破坏性变更有迁移方案与公告。
|
|
22
|
+
- [ ] 数据迁移在预演环境验证过;大表变更评估锁与耗时。
|
|
23
|
+
|
|
24
|
+
## 可观测与回滚
|
|
25
|
+
- [ ] 日志/监控/告警覆盖新功能;有关键指标看板。
|
|
26
|
+
- [ ] 明确回滚方案与触发条件;灰度/分批发布计划。
|
|
27
|
+
|
|
28
|
+
## 发布
|
|
29
|
+
- [ ] 变更记录(changelog)与发布说明就绪。
|
|
30
|
+
- [ ] 低峰期发布;发布后盯核心指标一段时间。
|
|
31
|
+
|
|
32
|
+
## 注意
|
|
33
|
+
- 逐项用事实核对(跑命令/看配置),别只勾选。
|
|
34
|
+
- 未经明确授权不执行不可逆的发布动作,先汇总待办与风险。
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security-audit
|
|
3
|
+
description: 对代码做安全自查(OWASP 常见风险)。当用户要"安全审计/查漏洞/这段代码安全吗/上线安全检查",或涉及鉴权/输入处理/密钥时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 安全自查
|
|
8
|
+
|
|
9
|
+
按 OWASP 常见风险逐项排查,聚焦本次改动与其数据流。**只报可利用的真实风险**,给出复现思路与修法。
|
|
10
|
+
|
|
11
|
+
## 检查清单
|
|
12
|
+
- **注入**:SQL/NoSQL/命令/LDAP 注入;是否用参数化查询/白名单,而非字符串拼接。
|
|
13
|
+
- **路径穿越 / SSRF**:文件路径、URL 是否校验并限制在允许范围;用户可控地址是否可打内网。
|
|
14
|
+
- **鉴权与越权**:每个敏感操作是否校验身份与**资源归属**(水平/垂直越权);默认拒绝。
|
|
15
|
+
- **敏感信息**:密钥/口令是否硬编码、是否进日志/报错/前端;传输是否加密。
|
|
16
|
+
- **输入校验**:类型/长度/范围/编码;反序列化不可信数据;文件上传类型与大小。
|
|
17
|
+
- **XSS / CSRF**(Web):输出转义、CSP、CSRF token、SameSite Cookie。
|
|
18
|
+
- **依赖**:已知漏洞组件、过时版本。
|
|
19
|
+
- **加密**:别自造算法;密码用强哈希(bcrypt/argon2)加盐;随机数用 CSPRNG。
|
|
20
|
+
- **错误处理**:不向用户暴露堆栈/内部细节;失败要安全(fail closed)。
|
|
21
|
+
- **限流与滥用**:关键接口是否有速率限制与配额。
|
|
22
|
+
|
|
23
|
+
## 输出
|
|
24
|
+
- 按 `高/中/低` 风险分级;每条:`位置 — 风险与可被如何利用 → 修复建议`。
|
|
25
|
+
- 无法确认的列为"需人工复核"。
|
|
26
|
+
|
|
27
|
+
## 注意
|
|
28
|
+
- 只做防御性分析与加固,不产出可直接攻击他人系统的利用代码。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sql-tuning
|
|
3
|
+
description: 排查并优化慢 SQL / 数据库查询。当用户说"SQL 慢/查询超时/加索引/慢查询/数据库性能"时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# SQL 优化
|
|
8
|
+
|
|
9
|
+
## 流程
|
|
10
|
+
1. **拿到执行计划**:`EXPLAIN`(或 `EXPLAIN ANALYZE`)看真实扫描方式与行数估计。别靠猜。
|
|
11
|
+
2. **看几个关键点**:
|
|
12
|
+
- 是否**全表扫描**本可走索引?
|
|
13
|
+
- 索引是否命中(最左前缀、无隐式类型转换、无对列做函数运算导致失效)?
|
|
14
|
+
- 估算行数与实际是否差很多(统计信息过期)?
|
|
15
|
+
- 是否有大排序/临时表/文件排序(filesort)?
|
|
16
|
+
3. **常见改法**:
|
|
17
|
+
- 为高选择性的过滤/连接/排序列建**合适的(组合)索引**;覆盖索引避免回表;
|
|
18
|
+
- 消除 **N+1**:批量查询 / JOIN / IN 替代循环单查;
|
|
19
|
+
- 只 SELECT 需要的列;分页用键集分页(keyset)替代大 OFFSET;
|
|
20
|
+
- 避免 `%xxx` 前导通配、`OR` 拆成 UNION、把计算移出 WHERE 的列;
|
|
21
|
+
- 大批量写用批处理/分批提交。
|
|
22
|
+
4. **验证**:改后再 EXPLAIN + 实测耗时对比;确认结果集不变。
|
|
23
|
+
|
|
24
|
+
## 注意
|
|
25
|
+
- 索引不是越多越好(拖慢写入、占空间);按查询模式加。
|
|
26
|
+
- 分库分表/分片场景注意分片键、跨片聚合与去重的坑。
|
|
27
|
+
- 改索引/大表 DDL 评估锁与线上影响,先在从库/低峰验证。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: write-tests
|
|
3
|
+
description: 为一段代码补高质量测试(单元/集成)。当用户要"加测试/补测试/提高覆盖率/TDD",或改完关键逻辑需要测试兜底时使用。
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 写测试
|
|
8
|
+
|
|
9
|
+
## 流程
|
|
10
|
+
1. **先摸清工程约定**:用什么测试框架与断言库?测试文件放哪、怎么命名、怎么运行?读 2-3 个既有测试**照着写**,别引入新风格/新依赖。
|
|
11
|
+
2. **理清被测契约**:函数/模块的输入输出、副作用、错误路径分别是什么。
|
|
12
|
+
3. **设计用例**(覆盖优先级从高到低):
|
|
13
|
+
- 正常主路径(happy path)1-2 个;
|
|
14
|
+
- **边界**:空/0/负/超大/超长/首尾元素/单元素;
|
|
15
|
+
- **错误路径**:非法入参、异常、超时、依赖失败;
|
|
16
|
+
- 不变量与幂等;必要的并发/顺序敏感场景。
|
|
17
|
+
4. **写**:每个用例只测一件事,命名说清「输入→期望」;用最少的 mock(只挡外部 IO/网络/时间随机)。
|
|
18
|
+
5. **跑**:run_bash 执行测试,全绿为止;顺手确认新测试**确实会因 bug 失败**(反向验证有效性)。
|
|
19
|
+
|
|
20
|
+
## 好测试的标准
|
|
21
|
+
- 确定性(不 flaky)、快、隔离(不依赖执行顺序/外部状态)、失败信息可读。
|
|
22
|
+
- 测行为与契约,不测实现细节(避免一重构就红)。
|
|
23
|
+
|
|
24
|
+
## 注意
|
|
25
|
+
- 覆盖率是手段不是目的;优先覆盖高风险与易错边界。
|
|
26
|
+
- 别为了让测试过而弱化断言或改产品代码逻辑。
|
package/src/agent.js
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
// - 文本化工具调用防泄漏(与平台侧同款):模型把工具调用写成纯文本标记时(DeepSeek 系退化)
|
|
18
18
|
// 过滤不显示、不进会话,并注入纠正指令重试(一次要求改走 tool_calls 通道,二次禁工具强制作答)。
|
|
19
19
|
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
20
22
|
import { streamChat } from './api.js';
|
|
21
23
|
import { MarkupFilter, stripMarkup } from './markup-filter.js';
|
|
22
24
|
import { TOOL_SPECS, READONLY_TOOLS, runTool } from './tools.js';
|
|
23
|
-
import { toolLabel, indent } from './render.js';
|
|
25
|
+
import { toolLabel, indent, renderFileDiff } from './render.js';
|
|
24
26
|
import { compactIfNeeded, buildModelMessages } from './compaction.js';
|
|
25
27
|
import { makeTodoTool, formatTodos, TODO_TOOL_NAME } from './todo.js';
|
|
26
28
|
import { makeMemoryTool } from './memory.js';
|
|
@@ -67,7 +69,7 @@ export function capToolResult(text) {
|
|
|
67
69
|
* @param {{spec:object,run:Function,readOnly:boolean}[]} [p.extraTools] 动态外部工具(MCP / 子智能体)
|
|
68
70
|
* @param {number} [p.depth] 子智能体递归深度(0=顶层;todo/memory 仅顶层提供)
|
|
69
71
|
* @param {{write:(s:string)=>void}} [p.io] 输出通道(子智能体传静默通道)
|
|
70
|
-
* @param {object} [p.events] 结构化事件:onDelta/onReasoning/onAssistantText/onToolStart/onToolResult/onSkip/onInfo/onTodo/onMemory/onTurnEnd
|
|
72
|
+
* @param {object} [p.events] 结构化事件:onDelta/onReasoning/onAssistantText/onToolStart/onToolResult/onFileDiff/onSkip/onInfo/onTodo/onMemory/onTurnEnd
|
|
71
73
|
* @param {{list:any[]}} [p.todoState] 任务清单状态(调用方持有可跨轮保留)
|
|
72
74
|
* @param {{upTo?:number, summary?:string}} [p.compaction] 压缩状态(随会话持久化;原消息不被修改)
|
|
73
75
|
* @param {() => (string|null)} [p.pullQueued] 取一条用户排队消息(无则 null)
|
|
@@ -287,6 +289,17 @@ export async function runTurn({
|
|
|
287
289
|
else out(c.gray(` ⊘ ${toolLabel(item.name, item.args)} — 已跳过\n`));
|
|
288
290
|
messages.push({ role: 'tool', tool_call_id: item.tc.id, content: reason || '未授权,未执行。' });
|
|
289
291
|
};
|
|
292
|
+
// 写文件类工具(edit_file/write_file)成功后向 UI 展示「更改的行」(display-only,不进模型上下文)。
|
|
293
|
+
// 以「执行前后文件内容对比」判定:未改动/失败/被拒 → before==after → 空 diff,天然不显示。
|
|
294
|
+
const isFileWrite = (name) => name === 'edit_file' || name === 'write_file';
|
|
295
|
+
const readSafe = (abs) => { try { return fs.readFileSync(abs, 'utf8'); } catch { return ''; } };
|
|
296
|
+
const emitFileDiff = (item, before) => {
|
|
297
|
+
const abs = path.resolve(cwd, String(item.args.path));
|
|
298
|
+
const diff = renderFileDiff(before, readSafe(abs), { path: String(item.args.path) });
|
|
299
|
+
if (!diff) return;
|
|
300
|
+
if (ev) ev.onFileDiff?.(diff);
|
|
301
|
+
else out(diff + '\n');
|
|
302
|
+
};
|
|
290
303
|
|
|
291
304
|
let i = 0;
|
|
292
305
|
while (i < parsed.length) {
|
|
@@ -332,8 +345,12 @@ export async function runTurn({
|
|
|
332
345
|
continue;
|
|
333
346
|
}
|
|
334
347
|
emitStart(cur);
|
|
348
|
+
const before = isFileWrite(cur.name) && cur.args?.path
|
|
349
|
+
? readSafe(path.resolve(cwd, String(cur.args.path)))
|
|
350
|
+
: null;
|
|
335
351
|
const result = await execOne(cur);
|
|
336
352
|
emitResult(cur, result);
|
|
353
|
+
if (before != null) emitFileDiff(cur, before);
|
|
337
354
|
i++;
|
|
338
355
|
}
|
|
339
356
|
|
package/src/banner.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// 启动欢迎横幅:渐变 ASCII 字标 + 上下文信息 + 快捷提示(对标 Claude CLI 进入时的欢迎屏)。
|
|
2
|
+
// 零依赖;真彩(COLORTERM=truecolor)时走 24-bit 青→紫渐变,仅 16 色时退化为青色,NO_COLOR/非 TTY 输出朴素字标。
|
|
3
|
+
|
|
4
|
+
import { c, colorEnabled } from './colors.js';
|
|
5
|
+
|
|
6
|
+
// 5 行块字(每字形 5 列宽,字间 1 空格)。
|
|
7
|
+
const FONT = {
|
|
8
|
+
H: ['█ █', '█ █', '█████', '█ █', '█ █'],
|
|
9
|
+
U: ['█ █', '█ █', '█ █', '█ █', '█████'],
|
|
10
|
+
I: ['█████', ' █ ', ' █ ', ' █ ', '█████'],
|
|
11
|
+
C: ['█████', '█ ', '█ ', '█ ', '█████'],
|
|
12
|
+
A: ['█████', '█ █', '█████', '█ █', '█ █'],
|
|
13
|
+
' ': [' ', ' ', ' ', ' ', ' '],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function wordmark(word) {
|
|
17
|
+
const rows = ['', '', '', '', ''];
|
|
18
|
+
[...word].forEach((ch, idx) => {
|
|
19
|
+
const g = FONT[ch] || FONT[' '];
|
|
20
|
+
for (let r = 0; r < 5; r++) rows[r] += (idx ? ' ' : '') + g[r];
|
|
21
|
+
});
|
|
22
|
+
return rows;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const truecolor = colorEnabled && /truecolor|24bit/i.test(process.env.COLORTERM || '');
|
|
26
|
+
const lerp = (a, b, t) => Math.round(a + (b - a) * t);
|
|
27
|
+
|
|
28
|
+
// 按列做水平渐变;无真彩退化为青色;无色原样返回。
|
|
29
|
+
function gradientRow(row, from, to) {
|
|
30
|
+
if (!colorEnabled) return row;
|
|
31
|
+
const chars = [...row];
|
|
32
|
+
const n = chars.length;
|
|
33
|
+
let out = '';
|
|
34
|
+
for (let i = 0; i < n; i++) {
|
|
35
|
+
const ch = chars[i];
|
|
36
|
+
if (ch === ' ') { out += ' '; continue; }
|
|
37
|
+
if (!truecolor) { out += `\x1b[36m${ch}`; continue; }
|
|
38
|
+
const t = n <= 1 ? 0 : i / (n - 1);
|
|
39
|
+
out += `\x1b[38;2;${lerp(from[0], to[0], t)};${lerp(from[1], to[1], t)};${lerp(from[2], to[2], t)}m${ch}`;
|
|
40
|
+
}
|
|
41
|
+
return out + '\x1b[0m';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PAD = ' ';
|
|
45
|
+
const FROM = [0, 229, 255]; // 青
|
|
46
|
+
const TO = [168, 85, 247]; // 紫
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 生成欢迎横幅字符串(不含结尾换行)。
|
|
50
|
+
* @param {object} p
|
|
51
|
+
* @param {string} p.model 当前模型 / 平台目标
|
|
52
|
+
* @param {string} p.cwd 工作目录
|
|
53
|
+
* @param {string} [p.version] 版本展示串
|
|
54
|
+
* @param {boolean} [p.platform] 是否平台伪模型(agent-/workflow-/datahuman-)
|
|
55
|
+
* @param {boolean} [p.yolo] 是否已开自动放行
|
|
56
|
+
* @param {string} [p.consult] 混合模式咨询目标展示名
|
|
57
|
+
* @param {boolean} [p.ink] 是否 ink 富终端(附注一行)
|
|
58
|
+
*/
|
|
59
|
+
export function renderWelcome({ model, cwd, version, platform, yolo, consult, ink } = {}) {
|
|
60
|
+
const cols = process.stdout.columns || 60;
|
|
61
|
+
const sep = c.gray('─'.repeat(Math.min(Math.max(cols - 2, 24), 50)));
|
|
62
|
+
const L = [''];
|
|
63
|
+
|
|
64
|
+
if (colorEnabled) {
|
|
65
|
+
for (const row of wordmark('HUICAI')) L.push(PAD + gradientRow(row, FROM, TO));
|
|
66
|
+
} else {
|
|
67
|
+
L.push(PAD + 'H U I C A I');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
L.push('');
|
|
71
|
+
L.push(PAD + c.bold('HuiCai AI') + c.gray(' · 终端智能体') + (version ? c.gray(' v' + version) : '')
|
|
72
|
+
+ (ink ? c.gray(' · ink') : ''));
|
|
73
|
+
L.push(PAD + sep);
|
|
74
|
+
L.push(PAD + c.gray(platform ? '目标 ' : '模型 ') + c.cyan(model || c.gray('(未设置,/model 设置)'))
|
|
75
|
+
+ (platform ? c.gray(' · 平台编排(无本机工具)') : ''));
|
|
76
|
+
L.push(PAD + c.gray('目录 ') + cwd);
|
|
77
|
+
if (consult) L.push(PAD + c.gray('咨询 ') + c.magenta(consult) + c.gray(' · 混合模式(本机工具 + 平台咨询)'));
|
|
78
|
+
if (yolo) L.push(PAD + c.yellow('⚠ 自动放行(yolo) 已开:写文件/执行命令不再逐次确认'));
|
|
79
|
+
L.push('');
|
|
80
|
+
L.push(PAD + c.magenta('✦ ') + c.gray('/help 命令 · @文件 引用 · !命令 直通 shell · Ctrl+C 中断'));
|
|
81
|
+
|
|
82
|
+
return L.join('\n');
|
|
83
|
+
}
|
package/src/commands.js
CHANGED
|
@@ -217,6 +217,15 @@ const BUILTINS = [
|
|
|
217
217
|
ctx.print(ctx.cwd);
|
|
218
218
|
},
|
|
219
219
|
},
|
|
220
|
+
{
|
|
221
|
+
name: 'markdown', desc: '切换助手回复的 Markdown 渲染(TUI 富文本;关闭则显示原始文本,便于复制)',
|
|
222
|
+
run(ctx) {
|
|
223
|
+
const next = ctx.config.markdown === false; // 当前关→开,否则→关
|
|
224
|
+
ctx.config.markdown = next;
|
|
225
|
+
saveConfig({ markdown: next });
|
|
226
|
+
ctx.print('Markdown 渲染:' + (next ? '开(下一条回复起生效)' : '关(显示原始 Markdown 文本)'));
|
|
227
|
+
},
|
|
228
|
+
},
|
|
220
229
|
{
|
|
221
230
|
name: 'init', desc: '让智能体调研项目并生成/完善 HUICAI.md',
|
|
222
231
|
run() {
|
|
@@ -259,6 +268,13 @@ const BUILTINS = [
|
|
|
259
268
|
},
|
|
260
269
|
];
|
|
261
270
|
|
|
271
|
+
/** 全部可用命令(内置 + 自定义),供斜杠自动补全弹层用。返回 [{name,args,desc}]。 */
|
|
272
|
+
export function listCommands(cwd) {
|
|
273
|
+
const builtins = BUILTINS.map((b) => ({ name: b.name, args: b.args || '', desc: b.desc }));
|
|
274
|
+
const custom = loadCustomCommands(cwd).map((c) => ({ name: c.name, args: '', desc: c.desc }));
|
|
275
|
+
return [...builtins, ...custom];
|
|
276
|
+
}
|
|
277
|
+
|
|
262
278
|
/** 读取自定义命令:项目 .huicai/commands/*.md 优先于全局同名。 */
|
|
263
279
|
export function loadCustomCommands(cwd) {
|
|
264
280
|
const dirs = [
|
package/src/config.js
CHANGED
|
@@ -43,6 +43,8 @@ export function loadConfig() {
|
|
|
43
43
|
memoryEnabled: process.env.HUICAI_MEMORY === '0' ? false : file.memory !== false,
|
|
44
44
|
// 思考过程流式展示:默认开;配置 showReasoning:false 或 HUICAI_REASONING=0 关闭
|
|
45
45
|
showReasoning: process.env.HUICAI_REASONING === '0' ? false : file.showReasoning !== false,
|
|
46
|
+
// 助手回复 Markdown 渲染(TUI 富文本):默认开;配置 markdown:false 或 HUICAI_MARKDOWN=0 关闭(显示原始文本)
|
|
47
|
+
markdown: process.env.HUICAI_MARKDOWN === '0' ? false : file.markdown !== false,
|
|
46
48
|
// 客户端来源标识(对话落库审计用):默认 cli;VSCode 扩展进程内置 HUICAI_CLIENT=vscode
|
|
47
49
|
client: (process.env.HUICAI_CLIENT || 'cli').trim().toLowerCase(),
|
|
48
50
|
};
|
package/src/markdown.js
ADDED
|
Binary file
|
package/src/render.js
CHANGED
|
@@ -40,6 +40,85 @@ export function renderEditDiff(oldStr, newStr) {
|
|
|
40
40
|
return out.join('\n');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* 逐行 LCS diff:返回 [{type:' '|'-'|'+', text}]。超大文本退化为「全删+全增」以防 O(n·m) 爆内存。
|
|
45
|
+
*/
|
|
46
|
+
export function diffLines(oldText, newText) {
|
|
47
|
+
// 空文本视为「零行」(新建文件时旧内容为空,不应显示一条幽灵空行删除)
|
|
48
|
+
const split = (t) => (t == null || t === '' ? [] : String(t).split('\n'));
|
|
49
|
+
const a = split(oldText);
|
|
50
|
+
const b = split(newText);
|
|
51
|
+
const n = a.length, m = b.length;
|
|
52
|
+
if (n * m > 4_000_000) {
|
|
53
|
+
return [...a.map((t) => ({ type: '-', text: t })), ...b.map((t) => ({ type: '+', text: t }))];
|
|
54
|
+
}
|
|
55
|
+
// dp[i][j] = a[i..] 与 b[j..] 的最长公共子序列长度
|
|
56
|
+
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
57
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
58
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
59
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const ops = [];
|
|
63
|
+
let i = 0, j = 0;
|
|
64
|
+
while (i < n && j < m) {
|
|
65
|
+
if (a[i] === b[j]) { ops.push({ type: ' ', text: a[i] }); i++; j++; }
|
|
66
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: '-', text: a[i] }); i++; }
|
|
67
|
+
else { ops.push({ type: '+', text: b[j] }); j++; }
|
|
68
|
+
}
|
|
69
|
+
while (i < n) ops.push({ type: '-', text: a[i++] });
|
|
70
|
+
while (j < m) ops.push({ type: '+', text: b[j++] });
|
|
71
|
+
return ops;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 把一次文件更改渲染为「带行号的统一 diff」(对标 Claude Code 编辑后展示更改的行):
|
|
76
|
+
* 增行绿色 `+`、删行红色 `-`、上下文灰色;仅显示变更行及其上下 context 行,中间大段用 ⋯ 省略。
|
|
77
|
+
* 无变化返回空串。返回的是带 ANSI 的字符串(可直接 write,也可放进 ink <Text>)。
|
|
78
|
+
* @param {string} oldText
|
|
79
|
+
* @param {string} newText
|
|
80
|
+
* @param {{path?:string, context?:number, maxLines?:number}} [opts]
|
|
81
|
+
*/
|
|
82
|
+
export function renderFileDiff(oldText, newText, opts = {}) {
|
|
83
|
+
const { path = '', context = 3, maxLines = 80 } = opts;
|
|
84
|
+
const ops = diffLines(oldText, newText);
|
|
85
|
+
let adds = 0, dels = 0;
|
|
86
|
+
for (const o of ops) { if (o.type === '+') adds++; else if (o.type === '-') dels++; }
|
|
87
|
+
if (adds === 0 && dels === 0) return '';
|
|
88
|
+
|
|
89
|
+
// 标注每行的旧/新行号
|
|
90
|
+
const rows = [];
|
|
91
|
+
let oldNo = 0, newNo = 0;
|
|
92
|
+
for (const o of ops) {
|
|
93
|
+
if (o.type === ' ') { oldNo++; newNo++; rows.push({ type: ' ', text: o.text, no: oldNo }); }
|
|
94
|
+
else if (o.type === '-') { oldNo++; rows.push({ type: '-', text: o.text, no: oldNo }); }
|
|
95
|
+
else { newNo++; rows.push({ type: '+', text: o.text, no: newNo }); }
|
|
96
|
+
}
|
|
97
|
+
// 需展示的行 = 变更行及其上下 context 行
|
|
98
|
+
const show = new Array(rows.length).fill(false);
|
|
99
|
+
rows.forEach((r, idx) => {
|
|
100
|
+
if (r.type === ' ') return;
|
|
101
|
+
for (let k = Math.max(0, idx - context); k <= Math.min(rows.length - 1, idx + context); k++) show[k] = true;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const width = String(Math.max(oldNo, newNo)).length;
|
|
105
|
+
const pad = (no) => String(no == null ? '' : no).padStart(width);
|
|
106
|
+
const out = [(path ? c.bold(path) + ' ' : '') + c.green('+' + adds) + ' ' + c.red('−' + dels)];
|
|
107
|
+
let elided = false, shown = 0, truncated = false;
|
|
108
|
+
for (let idx = 0; idx < rows.length; idx++) {
|
|
109
|
+
if (!show[idx]) { if (!elided) { out.push(c.gray(' ' + ' '.repeat(width) + ' ⋯')); elided = true; } continue; }
|
|
110
|
+
elided = false;
|
|
111
|
+
if (shown >= maxLines) { truncated = true; break; }
|
|
112
|
+
const r = rows[idx];
|
|
113
|
+
if (r.type === '+') out.push(c.green(' ' + pad(r.no) + ' + ') + c.green(r.text));
|
|
114
|
+
else if (r.type === '-') out.push(c.red(' ' + pad(r.no) + ' - ') + c.red(r.text));
|
|
115
|
+
else out.push(c.gray(' ' + pad(r.no) + ' ' + r.text));
|
|
116
|
+
shown++;
|
|
117
|
+
}
|
|
118
|
+
if (truncated) out.push(c.gray(` …(差异较大,仅显示前 ${maxLines} 行;共 +${adds} −${dels})`));
|
|
119
|
+
return out.join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
43
122
|
export function indent(s, pad = ' ') {
|
|
44
123
|
return String(s)
|
|
45
124
|
.split('\n')
|
package/src/repl.js
CHANGED
|
@@ -12,6 +12,7 @@ import { makeConsultTool } from './consult.js';
|
|
|
12
12
|
import { runSlash } from './commands.js';
|
|
13
13
|
import { expandAtMentions, buildUserContent, runBangCommand, loadHistory, appendHistory } from './input.js';
|
|
14
14
|
import { makeSkillTools } from './skills.js';
|
|
15
|
+
import { renderWelcome } from './banner.js';
|
|
15
16
|
|
|
16
17
|
/** 汇集动态工具:本地技能(load_skill)+ 本地 MCP 工具 + dispatch_agent 子智能体。返回 {extraTools, clients}。 */
|
|
17
18
|
export async function buildExtraTools(config, cwd) {
|
|
@@ -31,19 +32,15 @@ export async function buildExtraTools(config, cwd) {
|
|
|
31
32
|
return { extraTools, clients: mcp.clients };
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
function banner(config, cwd, permissions) {
|
|
35
|
-
process.stdout.write(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
` ${permissions.isYolo() ? '⚠ 自动放行(yolo) 已开 · ' : ''}` +
|
|
44
|
-
'/help 命令 · @文件 引用 · !命令 直通 shell · Ctrl+C 中断本回合',
|
|
45
|
-
) + '\n',
|
|
46
|
-
);
|
|
35
|
+
function banner(config, cwd, permissions, version) {
|
|
36
|
+
process.stdout.write(renderWelcome({
|
|
37
|
+
model: config.model,
|
|
38
|
+
cwd,
|
|
39
|
+
version,
|
|
40
|
+
platform: /^(agent|workflow|datahuman)-/.test(String(config.model || '')),
|
|
41
|
+
yolo: permissions.isYolo(),
|
|
42
|
+
consult: config.consult ? (config.consultLabel || config.consult) : null,
|
|
43
|
+
}) + '\n');
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
export async function startRepl({ config, cwd, yolo, initialPrompt, session, version }) {
|
|
@@ -66,7 +63,7 @@ export async function startRepl({ config, cwd, yolo, initialPrompt, session, ver
|
|
|
66
63
|
else process.stdout.write('\n' + c.gray('输入 /exit 退出。') + '\n');
|
|
67
64
|
});
|
|
68
65
|
|
|
69
|
-
banner(config, cwd, permissions);
|
|
66
|
+
banner(config, cwd, permissions, version);
|
|
70
67
|
echoResumed(session);
|
|
71
68
|
const { extraTools, clients } = await buildExtraTools(config, cwd); // 连接 MCP(若已配置)
|
|
72
69
|
|
package/src/skills.js
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import os from 'node:os';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
// 随 CLI 一起分发的内置技能目录(<包根>/skills)。作为「只读、最低优先级」的技能来源:
|
|
13
|
+
// 用户在项目 .huicai/skills 或全局 ~/.huicai/skills 建同名技能即可覆盖。仅进入「发现/载入」读路径,
|
|
14
|
+
// 不进入 skillDirs()(pull 写入目标仍是用户目录,勿写包内)。
|
|
15
|
+
export const BUNDLED_SKILLS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
10
16
|
|
|
11
17
|
/** 附件允许的文本扩展名(与平台 SkillFile.TEXT_EXTS 对齐;二进制不收) */
|
|
12
18
|
const TEXT_EXTS = new Set([
|
|
@@ -67,13 +73,18 @@ function collectFiles(dir, base = '', depth = 0, out = []) {
|
|
|
67
73
|
*/
|
|
68
74
|
export function loadLocalSkills(cwd) {
|
|
69
75
|
const byName = new Map();
|
|
70
|
-
|
|
71
|
-
dirs
|
|
76
|
+
// 优先级:项目 > 全局 > 内置(同名先扫者生效,故用户目录可覆盖内置技能)
|
|
77
|
+
const dirs = [
|
|
78
|
+
...(cwd ? [{ dir: path.join(cwd, '.huicai', 'skills'), scope: 'project' }] : []),
|
|
79
|
+
{ dir: path.join(os.homedir(), '.huicai', 'skills'), scope: 'global' },
|
|
80
|
+
{ dir: BUNDLED_SKILLS_DIR, scope: 'builtin' },
|
|
81
|
+
];
|
|
82
|
+
for (const { dir: base, scope } of dirs) {
|
|
72
83
|
let entries;
|
|
73
84
|
try {
|
|
74
85
|
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
75
86
|
} catch {
|
|
76
|
-
|
|
87
|
+
continue;
|
|
77
88
|
}
|
|
78
89
|
for (const e of entries) {
|
|
79
90
|
if (!e.isDirectory()) continue;
|
|
@@ -86,17 +97,17 @@ export function loadLocalSkills(cwd) {
|
|
|
86
97
|
}
|
|
87
98
|
const fm = parseFrontmatter(raw);
|
|
88
99
|
const name = (fm.name || e.name).trim();
|
|
89
|
-
if (!name || byName.has(name)) continue; //
|
|
100
|
+
if (!name || byName.has(name)) continue; // 同名:先扫者(更高优先级)生效
|
|
90
101
|
byName.set(name, {
|
|
91
102
|
name,
|
|
92
103
|
description: fm.description || '',
|
|
93
104
|
version: fm.version || '',
|
|
94
105
|
dir,
|
|
95
|
-
scope
|
|
106
|
+
scope,
|
|
96
107
|
files: collectFiles(dir),
|
|
97
108
|
});
|
|
98
109
|
}
|
|
99
|
-
}
|
|
110
|
+
}
|
|
100
111
|
return [...byName.values()];
|
|
101
112
|
}
|
|
102
113
|
|
package/src/tui/app.js
CHANGED
|
@@ -13,11 +13,15 @@
|
|
|
13
13
|
import React, { useState, useRef, useReducer, useEffect, useMemo } from 'react';
|
|
14
14
|
import { render, Box, Text, Static, useInput, useApp } from 'ink';
|
|
15
15
|
import { runTurn } from '../agent.js';
|
|
16
|
+
import { fetchUsage } from '../api.js';
|
|
16
17
|
import { toolLabel } from '../render.js';
|
|
18
|
+
import { renderMarkdown, looksLikeMarkdown } from '../markdown.js';
|
|
19
|
+
import { renderWelcome } from '../banner.js';
|
|
17
20
|
import { createSession, saveSession } from '../session.js';
|
|
18
21
|
import { buildExtraTools } from '../repl.js';
|
|
19
22
|
import { closeMcp } from '../mcp.js';
|
|
20
|
-
import { runSlash } from '../commands.js';
|
|
23
|
+
import { runSlash, listCommands } from '../commands.js';
|
|
24
|
+
import { usageStatus, fmtReset } from '../usage.js';
|
|
21
25
|
import { expandAtMentions, buildUserContent, runBangCommand, loadHistory, appendHistory } from '../input.js';
|
|
22
26
|
import { loadAllowRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from '../settings.js';
|
|
23
27
|
|
|
@@ -35,12 +39,13 @@ function clipLines(s, maxLines = 12) {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
function fmtTok(n) {
|
|
42
|
+
n = Number(n) || 0;
|
|
38
43
|
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
42
47
|
|
|
43
|
-
function App({ config, cwd, session: initialSession, extraTools, yolo, initialPrompt, version, onExit }) {
|
|
48
|
+
export function App({ config, cwd, session: initialSession, extraTools, yolo, initialPrompt, version, onExit }) {
|
|
44
49
|
const app = useApp();
|
|
45
50
|
const logRef = useRef([]); // 已定稿记录:{id,kind,text}
|
|
46
51
|
const idRef = useRef(0);
|
|
@@ -72,6 +77,11 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
72
77
|
const [todos, setTodos] = useState([]);
|
|
73
78
|
const [queueLen, setQueueLen] = useState(0);
|
|
74
79
|
const [spin, setSpin] = useState(0);
|
|
80
|
+
const [win, setWin] = useState(null); // Token 用量窗口(5小时/7天)最新快照
|
|
81
|
+
const winRef = useRef(null); // 同上,供异步路径同步读取(发起回合前的用尽拦截)
|
|
82
|
+
const [sugIdx, setSugIdx] = useState(0); // 斜杠命令补全:选中项
|
|
83
|
+
const [hideSug, setHideSug] = useState(false); // 斜杠命令补全:Esc 临时收起
|
|
84
|
+
const allCommands = useMemo(() => listCommands(cwd), [cwd]); // 命令清单(含自定义),一次性
|
|
75
85
|
|
|
76
86
|
const addLog = (kind, text) => {
|
|
77
87
|
logRef.current.push({ id: idRef.current++, kind, text });
|
|
@@ -79,7 +89,11 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
79
89
|
};
|
|
80
90
|
const flushLive = () => {
|
|
81
91
|
if (liveRef.current) {
|
|
82
|
-
|
|
92
|
+
// 定稿时把助手正文渲染为 Markdown(ink <Text> 原样保留内嵌 ANSI);流式期间仍显示原文,完成即定稿为富文本。
|
|
93
|
+
// 纯散文(无 Markdown 结构)直接留原文,避免无谓处理与边角改动。
|
|
94
|
+
const raw = liveRef.current;
|
|
95
|
+
const md = config.markdown !== false && looksLikeMarkdown(raw);
|
|
96
|
+
addLog('assistant', md ? renderMarkdown(raw) : raw);
|
|
83
97
|
liveRef.current = '';
|
|
84
98
|
}
|
|
85
99
|
};
|
|
@@ -95,6 +109,22 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
95
109
|
return () => clearInterval(t);
|
|
96
110
|
}, [status]);
|
|
97
111
|
|
|
112
|
+
// Token 用量窗口:进入时拉一次,之后每回合结束 + 定时刷新(best-effort,离线/平台不支持则静默)
|
|
113
|
+
const refreshUsage = async () => {
|
|
114
|
+
if (!config.apiKey) return;
|
|
115
|
+
try {
|
|
116
|
+
const u = await fetchUsage(config.baseUrl, config.apiKey);
|
|
117
|
+
winRef.current = u;
|
|
118
|
+
setWin(u);
|
|
119
|
+
} catch { /* 忽略 */ }
|
|
120
|
+
};
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
void refreshUsage();
|
|
123
|
+
const t = setInterval(() => { void refreshUsage(); }, 180000);
|
|
124
|
+
return () => clearInterval(t);
|
|
125
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
98
128
|
const thinkRef = useRef(''); // 思考过程(reasoning)流式缓冲,仅动态区展示不入定稿记录
|
|
99
129
|
const events = {
|
|
100
130
|
onDelta: (t) => {
|
|
@@ -112,6 +142,7 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
112
142
|
addLog('tool', toolLabel(name, args));
|
|
113
143
|
},
|
|
114
144
|
onToolResult: (name, res) => addLog('toolresult', shortLine(res)),
|
|
145
|
+
onFileDiff: (diff) => addLog('diff', diff), // 文件更改的行(已带 ANSI,Static 原样呈现)
|
|
115
146
|
onSkip: (name, args) => addLog('skip', toolLabel(name, args) + ' — 已跳过'),
|
|
116
147
|
onInfo: (t) => {
|
|
117
148
|
flushLive();
|
|
@@ -150,6 +181,12 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
150
181
|
};
|
|
151
182
|
|
|
152
183
|
const runUserTurn = async (text) => {
|
|
184
|
+
// 用量窗口已用尽 → 终止:不发起新回合(进行中的回合由平台 429 终态自然结束)
|
|
185
|
+
const gate = usageStatus(winRef.current);
|
|
186
|
+
if (gate && gate.level === 'exhausted') {
|
|
187
|
+
addLog('info', `✗ Token 用量窗口(${gate.label})已用尽,暂无法继续。${fmtReset(gate.resetAtEpochMs)}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
153
190
|
const { text: expanded, images } = await expandAtMentions(text, cwd, config);
|
|
154
191
|
sessionRef.current.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
|
|
155
192
|
setBusy(true);
|
|
@@ -181,6 +218,7 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
181
218
|
addLog('info', '已清空排队消息');
|
|
182
219
|
}
|
|
183
220
|
setBusy(false);
|
|
221
|
+
void refreshUsage(); // 回合结束刷新用量窗口(含被 429 用尽的情形 → 更新右下角提示 + 拦截下一回合)
|
|
184
222
|
};
|
|
185
223
|
|
|
186
224
|
const doExit = () => {
|
|
@@ -289,6 +327,16 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
289
327
|
}
|
|
290
328
|
};
|
|
291
329
|
|
|
330
|
+
// 斜杠命令自动补全:仅在「以 / 开头且尚未输入空格(还在打命令名)」时给候选
|
|
331
|
+
const slashMatch = /^\/(\S*)$/.exec(inp.text);
|
|
332
|
+
const slashQuery = slashMatch ? slashMatch[1] : null;
|
|
333
|
+
const suggestions = slashQuery == null
|
|
334
|
+
? []
|
|
335
|
+
: allCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(slashQuery.toLowerCase())).slice(0, 8);
|
|
336
|
+
const popupOpen = suggestions.length > 0 && !hideSug;
|
|
337
|
+
// 候选变化时复位选中项与「Esc 收起」态(继续打字即重新弹出)
|
|
338
|
+
useEffect(() => { setSugIdx(0); setHideSug(false); }, [slashQuery]);
|
|
339
|
+
|
|
292
340
|
useInput((ch, key) => {
|
|
293
341
|
// 授权提示优先(y 一次 / a 本会话 / A 永久规则 / n·Esc·回车 拒绝)
|
|
294
342
|
if (pendingRef.current) {
|
|
@@ -318,6 +366,18 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
318
366
|
if (ch === 'n' || key.escape || key.return) return done({ allow: false, reason: '用户拒绝了该操作。' });
|
|
319
367
|
return;
|
|
320
368
|
}
|
|
369
|
+
// 斜杠命令补全弹层打开时,接管 上下选择 / Tab 补全 / 回车执行 / Esc 收起;其它键落到下方输入以继续过滤
|
|
370
|
+
if (popupOpen) {
|
|
371
|
+
const pick = () => suggestions[Math.min(sugIdx, suggestions.length - 1)];
|
|
372
|
+
if (key.upArrow) return setSugIdx((x) => (x - 1 + suggestions.length) % suggestions.length);
|
|
373
|
+
if (key.downArrow) return setSugIdx((x) => (x + 1) % suggestions.length);
|
|
374
|
+
if (key.tab) {
|
|
375
|
+
const name = pick().name;
|
|
376
|
+
return setInp({ text: '/' + name + ' ', cursor: name.length + 2 });
|
|
377
|
+
}
|
|
378
|
+
if (key.return) return void submit('/' + pick().name);
|
|
379
|
+
if (key.escape) return setHideSug(true);
|
|
380
|
+
}
|
|
321
381
|
if (key.escape) {
|
|
322
382
|
if (statusRef.current === 'busy' && abortRef.current) {
|
|
323
383
|
abortRef.current.abort();
|
|
@@ -373,6 +433,11 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
373
433
|
const before = inp.text.slice(0, inp.cursor);
|
|
374
434
|
const at = inp.text[inp.cursor] ?? ' ';
|
|
375
435
|
const after = inp.text.slice(inp.cursor + 1);
|
|
436
|
+
const termWidth = Math.max(8, process.stdout.columns || 80); // 输入栏上下蓝线宽度(随终端自适应)
|
|
437
|
+
const uStat = usageStatus(win); // 用量告警(null=正常,不显示)
|
|
438
|
+
const uWarn = !uStat ? '' : uStat.level === 'exhausted'
|
|
439
|
+
? `⛔ ${uStat.label}用量已用尽${fmtReset(uStat.resetAtEpochMs) ? ' · ' + fmtReset(uStat.resetAtEpochMs) : ''}`
|
|
440
|
+
: `⚠ ${uStat.label}用量 ${Math.round(uStat.pct * 100)}%(${fmtTok(uStat.used)}/${fmtTok(uStat.limit)})`;
|
|
376
441
|
|
|
377
442
|
return h(
|
|
378
443
|
Box,
|
|
@@ -420,28 +485,44 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
|
|
|
420
485
|
h(Text, null, '[y] 允许一次 · [a] 本会话 · [A] 永久(写入项目设置) · [n/Esc] 拒绝'),
|
|
421
486
|
)
|
|
422
487
|
: null,
|
|
488
|
+
// 斜杠命令补全弹层(输入 / 即弹;↑↓ 选择 · Tab 补全 · ⏎ 执行 · Esc 关闭)
|
|
489
|
+
popupOpen
|
|
490
|
+
? h(
|
|
491
|
+
Box,
|
|
492
|
+
{ flexDirection: 'column', marginTop: 1 },
|
|
493
|
+
h(Text, { color: 'gray' }, ' 斜杠命令 · ↑↓ 选择 · Tab 补全 · ⏎ 执行 · Esc 关闭'),
|
|
494
|
+
...suggestions.map((s, i) =>
|
|
495
|
+
h(
|
|
496
|
+
Box,
|
|
497
|
+
{ key: s.name },
|
|
498
|
+
h(Text, { color: 'cyan', bold: i === sugIdx },
|
|
499
|
+
(i === sugIdx ? ' ❯ ' : ' ') + '/' + s.name + (s.args ? ' ' + s.args : '')),
|
|
500
|
+
h(Text, { color: 'gray' }, ' ' + String(s.desc || '').replace(/\s+/g, ' ').slice(0, 46)),
|
|
501
|
+
),
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
: null,
|
|
505
|
+
// 输入栏:上下各一条蓝色横线包住输入行(弹层打开时其自带间距,蓝线贴紧)
|
|
506
|
+
h(Box, { marginTop: popupOpen ? 0 : 1 }, h(Text, { color: 'blue' }, '─'.repeat(termWidth))),
|
|
423
507
|
// 输入行(带光标;忙时输入会排队)
|
|
424
508
|
h(
|
|
425
509
|
Box,
|
|
426
|
-
|
|
510
|
+
null,
|
|
427
511
|
h(Text, { color: status === 'busy' ? 'yellow' : 'cyan' }, status === 'busy' ? '⧗ ' : '› '),
|
|
428
512
|
h(Text, null, before),
|
|
429
513
|
h(Text, { inverse: true }, at),
|
|
430
514
|
h(Text, null, after),
|
|
431
515
|
),
|
|
432
|
-
|
|
516
|
+
h(Text, { color: 'blue' }, '─'.repeat(termWidth)),
|
|
517
|
+
// 底栏:左=目录/模型/队列;右下角=用量告警(仅 ≥80% 或用尽时实时出现)
|
|
433
518
|
h(
|
|
434
519
|
Box,
|
|
435
|
-
|
|
436
|
-
h(
|
|
437
|
-
Text,
|
|
438
|
-
{ color: 'gray' },
|
|
520
|
+
{ justifyContent: 'space-between' },
|
|
521
|
+
h(Text, { color: 'gray' },
|
|
439
522
|
`${cwdName} · ${config.model}${yoloRef.current ? ' · ⚠ yolo' : ''}`
|
|
440
|
-
+ ` · tok ${usage.prompt}/${usage.completion}`
|
|
441
|
-
+ (ctxTok ? ` · ctx ~${fmtTok(ctxTok)}` : '')
|
|
442
523
|
+ (queueLen ? ` · 排队 ${queueLen}` : '')
|
|
443
|
-
+ ' · /help',
|
|
444
|
-
),
|
|
524
|
+
+ ' · /help'),
|
|
525
|
+
uWarn ? h(Text, { color: uStat.level === 'exhausted' ? 'red' : 'yellow', bold: true }, uWarn) : h(Text, null, ''),
|
|
445
526
|
),
|
|
446
527
|
);
|
|
447
528
|
}
|
|
@@ -451,6 +532,7 @@ function LogLine({ item }) {
|
|
|
451
532
|
const { kind, text } = item;
|
|
452
533
|
if (kind === 'user') return h(Box, { marginTop: 1 }, h(Text, { color: 'cyan', bold: true }, '› '), h(Text, { bold: true }, text));
|
|
453
534
|
if (kind === 'assistant') return h(Box, null, h(Text, null, text));
|
|
535
|
+
if (kind === 'diff') return h(Box, { marginLeft: 2 }, h(Text, null, text)); // 更改的行(已带 ANSI)
|
|
454
536
|
if (kind === 'tool') return h(Text, { color: 'cyan' }, ' ⏺ ' + text);
|
|
455
537
|
if (kind === 'toolresult') return h(Text, { color: 'gray' }, ' ' + text);
|
|
456
538
|
if (kind === 'skip') return h(Text, { color: 'gray' }, ' ⊘ ' + text);
|
|
@@ -466,7 +548,15 @@ function LogLine({ item }) {
|
|
|
466
548
|
* 启动 ink TUI。先(在 ink 接管屏幕前)连接 MCP 并打印连接信息,再进入全屏界面;退出后关闭 MCP。
|
|
467
549
|
*/
|
|
468
550
|
export async function runInk({ config, cwd, session, yolo, initialPrompt, version }) {
|
|
469
|
-
process.stdout.write(
|
|
551
|
+
process.stdout.write(renderWelcome({
|
|
552
|
+
model: config.model,
|
|
553
|
+
cwd,
|
|
554
|
+
version,
|
|
555
|
+
platform: /^(agent|workflow|datahuman)-/.test(String(config.model || '')),
|
|
556
|
+
yolo,
|
|
557
|
+
consult: config.consult ? (config.consultLabel || config.consult) : null,
|
|
558
|
+
ink: true,
|
|
559
|
+
}) + '\n');
|
|
470
560
|
const { extraTools, clients } = await buildExtraTools(config, cwd);
|
|
471
561
|
const { waitUntilExit } = render(
|
|
472
562
|
h(App, { config, cwd, session, extraTools, yolo, initialPrompt, version, onExit: () => closeMcp(clients) }),
|
package/src/usage.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Token 用量窗口告警判定(纯函数,零依赖)。从 /v1/usage 的快照推导「是否该提示 / 是否用尽」。
|
|
2
|
+
// limit 约定:>0 上限;-1 无限;0 该档未开放。两窗(5小时/7天)取占比最高者。
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {{allowed?:boolean, used5h?:number, limit5h?:number, used7d?:number, limit7d?:number, resetAtEpochMs?:number|null}|null} win
|
|
6
|
+
* @returns {{level:'warn'|'exhausted', label:string, used:number, limit:number, pct:number, resetAtEpochMs?:number|null}|null}
|
|
7
|
+
* null = 正常(<80% 或无可判定窗口)。warn ≥80%;exhausted ≥100% 或平台判定 allowed=false。
|
|
8
|
+
*/
|
|
9
|
+
export function usageStatus(win) {
|
|
10
|
+
if (!win) return null;
|
|
11
|
+
let worst = null;
|
|
12
|
+
for (const w of [
|
|
13
|
+
{ label: '5小时', used: win.used5h, limit: win.limit5h },
|
|
14
|
+
{ label: '7天', used: win.used7d, limit: win.limit7d },
|
|
15
|
+
]) {
|
|
16
|
+
if (typeof w.limit !== 'number' || w.limit <= 0 || typeof w.used !== 'number') continue; // -1 无限 / 0 未开放
|
|
17
|
+
const pct = w.used / w.limit;
|
|
18
|
+
if (!worst || pct > worst.pct) worst = { ...w, pct };
|
|
19
|
+
}
|
|
20
|
+
if (win.allowed === false) {
|
|
21
|
+
return {
|
|
22
|
+
level: 'exhausted',
|
|
23
|
+
label: worst?.label || '窗口',
|
|
24
|
+
used: worst?.used,
|
|
25
|
+
limit: worst?.limit,
|
|
26
|
+
pct: worst ? worst.pct : 1,
|
|
27
|
+
resetAtEpochMs: win.resetAtEpochMs,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (!worst) return null;
|
|
31
|
+
if (worst.pct >= 1) return { level: 'exhausted', ...worst, resetAtEpochMs: win.resetAtEpochMs };
|
|
32
|
+
if (worst.pct >= 0.8) return { level: 'warn', ...worst, resetAtEpochMs: win.resetAtEpochMs };
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 剩余时长(到 resetAtEpochMs)转「N小时M分后重置」;已过或缺省返回空串。 */
|
|
37
|
+
export function fmtReset(ms) {
|
|
38
|
+
const d = (ms || 0) - Date.now();
|
|
39
|
+
if (d <= 0) return '';
|
|
40
|
+
const h = Math.floor(d / 3600000);
|
|
41
|
+
const m = Math.round((d % 3600000) / 60000);
|
|
42
|
+
return h > 0 ? `${h}小时${m}分后重置` : `${m}分后重置`;
|
|
43
|
+
}
|