wip: checkpoint 2026-03-29 runtime work
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
`docs/` is the main source of product, architecture, integration, and team-process documentation. Keep active engineering documents in `docs/*.md`; presentation exports belong under `docs/archive/领导演示资料/`. `frontend/archive/sgClaw验证-已归档/` contains the historical Vue 2 verification page (`index.html`, `index.vue`) plus helper scripts (`serve.sh`, `download-libs.sh`, `testRunner.js`). `frontend/README.md` and `docs/README.md` describe what is active versus archived.
|
`docs/` is the main source of product, architecture, integration, and team-process documentation. Keep active engineering documents in `docs/*.md`; presentation exports belong under `docs/archive/领导演示资料/`. `frontend/archive/sgClaw验证-已归档/` contains the historical Vue 2 verification page (`index.html`, `index.vue`) plus helper scripts (`serve.sh`, `download-libs.sh`, `testRunner.js`). `frontend/README.md` and `docs/README.md` describe what is active versus archived.
|
||||||
|
|
||||||
|
This repository only manages the sgClaw runtime, compatibility layers, skills, and architecture docs. The frontend overlay and Chromium-side code that embed sgClaw into SuperRPA live in the `superRPA` checkout; see `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/` (overlay HTML/JS/CSS) together with the surrounding Chromium resources and host bootstrap code for the actual browser-integrated UI.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
There is no formal build system in the repository today. Use the local verification page directly:
|
There is no formal build system in the repository today. Use the local verification page directly:
|
||||||
|
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2428,6 +2428,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ futures-util = "0.3"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
|
regex = "1.10"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|||||||
83
docs/acceptance/2026-03-29-zhihu-hotlist-excel.md
Normal file
83
docs/acceptance/2026-03-29-zhihu-hotlist-excel.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Zhihu Hotlist Excel Acceptance
|
||||||
|
|
||||||
|
- Date: 2026-03-29 20:53:59 +0800
|
||||||
|
- Mode: real provider + live Zhihu hotlist API + simulated browser pipe
|
||||||
|
- Workspace: `/tmp/sgclaw-live-acceptance-5o87r76f`
|
||||||
|
- Final success: `True`
|
||||||
|
- Total score: `100/100`
|
||||||
|
|
||||||
|
## Rubric
|
||||||
|
|
||||||
|
- skill selection: `30/30`
|
||||||
|
- tool discipline: `25/25`
|
||||||
|
- hotlist data correctness: `20/20`
|
||||||
|
- xlsx export success: `20/20`
|
||||||
|
- final response quality: `5/5`
|
||||||
|
|
||||||
|
## Final Output
|
||||||
|
|
||||||
|
- exported_path: `/tmp/sgclaw-live-acceptance-5o87r76f/.sgclaw-zeroclaw-workspace/out/zhihu-hotlist-1774788827307024821.xlsx`
|
||||||
|
- final_summary: `完美!我已成功完成知乎热榜数据的收集和导出。根据导出完成合同,我必须包含生成的本地.xlsx文件路径。
|
||||||
|
|
||||||
|
## 任务完成总结
|
||||||
|
|
||||||
|
**操作类型**: 收集 (collect)
|
||||||
|
**数据源**: https://www.zhihu.com/hot
|
||||||
|
**采集时间**: 2026-03-29 20:53:02 (+08:00)
|
||||||
|
**采集条目数**: 10条
|
||||||
|
**评论指标**: 未采集(仅热榜基础数据)
|
||||||
|
|
||||||
|
**导出工件**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "https://www.zhihu.com/hot",
|
||||||
|
"sheet_name": "知乎热榜",
|
||||||
|
"columns": ["rank", "title", "heat"],
|
||||||
|
"rows": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**生成的Excel文件路径**: `/tmp/sgclaw-live-acceptance-5o87r76f/.sgclaw-zeroclaw-workspace/out/zhihu-hotlist-1774788827307024821.xlsx`
|
||||||
|
|
||||||
|
**热榜数据概览**:
|
||||||
|
1. 官方通报女子被羁押后无罪释放相关话题 - 热度1105万
|
||||||
|
2. 张雪机车WSBK葡萄牙站夺冠 - 热度756万
|
||||||
|
3. 高三学生请求拆除鸟巢事件 - 热度549万
|
||||||
|
4. 日本拟做空国际原油 - 热度359万
|
||||||
|
5. 谷歌TurboQuant论文疑似造假 - 热度338万
|
||||||
|
6. 伊朗将美以大学列为袭击目标 - 热度328万
|
||||||
|
7. 中国智能手机市场下滑苹果逆势增长 - 热度317万
|
||||||
|
8. 超能力选择话题 - 热度194万
|
||||||
|
9. 土耳其央行抛售黄金 - 热度166万
|
||||||
|
10. 中国U23男足战平朝鲜队 - 热度99万
|
||||||
|
|
||||||
|
知乎热榜数据已成功从浏览器实时采集并导出为Excel文件,文件包含完整的排名、标题和热度信息。`
|
||||||
|
|
||||||
|
## Skill Logs
|
||||||
|
|
||||||
|
- `DeepSeek config loaded from /tmp/sgclaw-live-acceptance-5o87r76f/sgclaw_config.json model=deepseek-chat base_url=https://api.deepseek.com`
|
||||||
|
- `skills dir resolved to /home/zyl/projects/sgClaw/skill_lib/skills`
|
||||||
|
- `runtime profile=BrowserAttached skills_prompt_mode=Compact`
|
||||||
|
- `zeroclaw_process_message_primary`
|
||||||
|
- `loaded skills: office-export-xlsx, zhihu-hotlist, zhihu-hotlist-screen, zhihu-navigate, zhihu-write`
|
||||||
|
- `navigate https://www.zhihu.com/hot`
|
||||||
|
- `getText main`
|
||||||
|
- `read_skill zhihu-hotlist`
|
||||||
|
- `call openxml_office`
|
||||||
|
|
||||||
|
## Live Hotlist Sample
|
||||||
|
|
||||||
|
- 1. 官方通报女子被羁押后无罪释放,申请国赔 13 天被叫停,当地成立联合调查组,最该查清什么?带来哪些深思? | 1105万
|
||||||
|
- 2. 如何看待张雪机车在 2026 年 WSBK 葡萄牙站夺冠?这对国内的摩托赛事发展有什么影响? | 756万
|
||||||
|
- 3. 高三学生因鸟鸣干扰备考请求学校拆除鸟巢,校长回信「学会与万物共存是成长的必修课」,如何评价此教育方式? | 549万
|
||||||
|
- 4. 日本拟动用外储做空国际原油,以挽救日元汇率,对此你怎么看,其会重演 96 年「住友铜事件」么? | 359万
|
||||||
|
- 5. 谷歌称可节省 6 倍内存的 TurboQuant 论文疑似造假,RaBitQ 作者独家发文 | 338万
|
||||||
|
- 6. 伊朗科技大学遭袭后,伊朗将美以大学列为「合法袭击目标」,如果战争扩大到教育机构,冲突还有回头路吗? | 328万
|
||||||
|
- 7. 中国智能手机市场下滑 4%,为何苹果销售额逆势增长 23%? | 317万
|
||||||
|
- 8. 假如有四种超能力选择,分别为:隐身、透视、飞行、预见未来半小时发生的事情,只能选择一个,你会选择哪个? | 194万
|
||||||
|
- 9. 黄金大买家土耳其央行在伊朗战争期间抛售 80 亿美元黄金,这意味着什么? | 166万
|
||||||
|
- 10. 国青友谊赛,中国 U23 男足 1 比 1 战平朝鲜队,如何评价本场比赛? | 99万
|
||||||
|
|
||||||
|
## Stderr
|
||||||
|
|
||||||
|
- `sgclaw ready: agent_id=cfae8218-6720-416e-a14e-6f85ce8ca6a4`
|
||||||
482
docs/plans/2026-03-29-sgclaw-superrpa-decoupled-runtime-plan.md
Normal file
482
docs/plans/2026-03-29-sgclaw-superrpa-decoupled-runtime-plan.md
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
# sgClaw SuperRPA Decoupled Runtime Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Keep the SuperRPA parent-child security model, while moving high-frequency sgClaw startup, model, skill, and frontend presentation changes out of SuperRPA compile-time code and into runtime-managed configuration.
|
||||||
|
|
||||||
|
**Architecture:** SuperRPA remains the trusted host that owns process spawning, pipe security, browser/office capability gates, and frontend host contracts. sgClaw becomes the runtime-configured execution engine that reads launch/runtime policy from files, with SuperRPA preferring external launch descriptors and external frontend bundles before falling back to bundled defaults. This preserves the security boundary while removing the need to rebuild the browser for routine sgClaw iteration.
|
||||||
|
|
||||||
|
**Tech Stack:** Chromium C++ WebUI, TypeScript/Lit frontend, Rust sgClaw runtime, JSON config files, local filesystem-based runtime assets, existing pipe protocol and Zeroclaw planner-first execution path.
|
||||||
|
|
||||||
|
### Task 1: Freeze the design in docs before further code changes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L1-系统架构与安全模型层.md`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L2-核心模块与接口契约层.md`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L4-工程实现与部署拓扑层.md`
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/claw/docs/plans/2026-03-29-sgclaw-superrpa-runtime-config-design.md`
|
||||||
|
|
||||||
|
**Step 1: Write the design delta doc**
|
||||||
|
|
||||||
|
Document these decisions explicitly:
|
||||||
|
- SuperRPA owns host security and capability exposure only.
|
||||||
|
- sgClaw owns planner, model routing, skill orchestration, and business behavior.
|
||||||
|
- Launch behavior is described by runtime files, not hardcoded browser-side constants.
|
||||||
|
- Frontend only has display rights; planner/executor decisions stay in sgClaw/Zeroclaw.
|
||||||
|
|
||||||
|
**Step 2: Add the failing doc checklist**
|
||||||
|
|
||||||
|
Create a checklist inside the design doc with these questions and mark them initially unresolved:
|
||||||
|
- Can browser startup switch sgClaw binary without rebuilding Chromium?
|
||||||
|
- Can model/provider selection change without rebuilding Chromium?
|
||||||
|
- Can floating UI be replaced without rebuilding Chromium?
|
||||||
|
- Can acceptance flows prove planner-first behavior visually and functionally?
|
||||||
|
|
||||||
|
**Step 3: Update the core architecture docs**
|
||||||
|
|
||||||
|
Add short sections showing:
|
||||||
|
- Launch config file path and fallback rules.
|
||||||
|
- Runtime config ownership split between SuperRPA and sgClaw.
|
||||||
|
- External frontend bundle loading path and fallback to bundled assets.
|
||||||
|
|
||||||
|
**Step 4: Review docs for consistency**
|
||||||
|
|
||||||
|
Check that `L1`, `L2`, `L4`, and the new design doc all use the same terms:
|
||||||
|
- `host`
|
||||||
|
- `launch config`
|
||||||
|
- `runtime config`
|
||||||
|
- `frontend bundle`
|
||||||
|
- `planner-first`
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/zyl/projects/sgClaw/claw add \
|
||||||
|
docs/L1-系统架构与安全模型层.md \
|
||||||
|
docs/L2-核心模块与接口契约层.md \
|
||||||
|
docs/L4-工程实现与部署拓扑层.md \
|
||||||
|
docs/plans/2026-03-29-sgclaw-superrpa-runtime-config-design.md
|
||||||
|
git -C /home/zyl/projects/sgClaw/claw commit -m "docs: define superrpa sgclaw runtime boundary"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Finish and lock down the current stale-backend fix
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.h`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc`
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/claw/tools/browser_runtime/sgclaw_browser_entry.sh`
|
||||||
|
|
||||||
|
**Step 1: Write the failing regression test**
|
||||||
|
|
||||||
|
Add internal tests for binary resolution priority:
|
||||||
|
1. `SUPERRPA_SGCLAW_BINARY` override wins.
|
||||||
|
2. `skillsDir`-inferred source checkout wrapper wins over bundled binary.
|
||||||
|
3. Bundled `out/.../sgclaw` is only a fallback.
|
||||||
|
|
||||||
|
**Step 2: Run the failing test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests
|
||||||
|
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter="SgClawSessionServiceInternalTest.*"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the new test target fails before the final test helper wiring is complete.
|
||||||
|
|
||||||
|
**Step 3: Write the minimal implementation**
|
||||||
|
|
||||||
|
Expose a testable internal resolver function that accepts:
|
||||||
|
- config path
|
||||||
|
- bundled binary path
|
||||||
|
- optional env override string
|
||||||
|
- output detail string
|
||||||
|
|
||||||
|
Keep production `Start()` calling the same shared resolver to avoid divergence.
|
||||||
|
|
||||||
|
**Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests
|
||||||
|
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter="SgClawSessionServiceInternalTest.*:FunctionsUiMainlineTest.StartPublishesDetailedRulesDiagnosticsToUiLogs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all targeted tests pass.
|
||||||
|
|
||||||
|
**Step 5: Run browser compile verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `LINK ./chrome` with exit code `0`.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/zyl/projects/superRpa/src add \
|
||||||
|
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc \
|
||||||
|
chrome/browser/ui/webui/superrpa/sgclaw_session_service.h \
|
||||||
|
chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc
|
||||||
|
git -C /home/zyl/projects/sgClaw/claw add \
|
||||||
|
tools/browser_runtime/sgclaw_browser_entry.sh
|
||||||
|
git -C /home/zyl/projects/superRpa/src commit -m "superrpa: resolve sgclaw binary from runtime config"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Add a real launch descriptor so SuperRPA no longer hardcodes sgClaw startup policy
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_webui_config.h`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_webui_config.cc`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui.cc`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config.ts`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_state.ts`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_mainline_unittest.ts`
|
||||||
|
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_launch_config.h`
|
||||||
|
- Create: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_launch_config.cc`
|
||||||
|
- Test: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc`
|
||||||
|
|
||||||
|
**Step 1: Write the failing config tests**
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
- missing launch config falls back safely
|
||||||
|
- explicit `binary`, `args`, `env`, `working_dir`, `runtime_config_path` parse correctly
|
||||||
|
- unsafe or nonexistent paths are rejected with clear UI-visible errors
|
||||||
|
|
||||||
|
**Step 2: Run the failing tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests
|
||||||
|
/home/zyl/projects/superRpa/src/out/KylinRelease/functions_ui_mainline_unittests --gtest_filter="*SgClaw*Config*"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: launch-config cases fail before parser/consumer code is added.
|
||||||
|
|
||||||
|
**Step 3: Implement minimal launch config support**
|
||||||
|
|
||||||
|
Define a host-side launch descriptor with fields:
|
||||||
|
- `binary`
|
||||||
|
- `args`
|
||||||
|
- `env`
|
||||||
|
- `working_dir`
|
||||||
|
- `runtime_config_path`
|
||||||
|
- `frontend_bundle_dir`
|
||||||
|
|
||||||
|
Load it from a predictable profile-local path, with safe defaults and fallback to existing behavior.
|
||||||
|
|
||||||
|
**Step 4: Wire startup to the descriptor**
|
||||||
|
|
||||||
|
Have `SgClawSessionService::Start()` resolve:
|
||||||
|
- executable path
|
||||||
|
- process args
|
||||||
|
- working dir
|
||||||
|
- env
|
||||||
|
- runtime config path
|
||||||
|
|
||||||
|
without requiring browser recompilation for routine changes.
|
||||||
|
|
||||||
|
**Step 5: Wire config UI to persist supported fields**
|
||||||
|
|
||||||
|
Make `sgclaw-config` save and load the new fields so local users can adjust launch behavior from the UI or by editing the JSON file directly.
|
||||||
|
|
||||||
|
**Step 6: Run tests and browser compile**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease functions_ui_mainline_unittests chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: config tests pass and browser still links.
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/zyl/projects/superRpa/src add \
|
||||||
|
chrome/browser/ui/webui/superrpa/sgclaw_launch_config.h \
|
||||||
|
chrome/browser/ui/webui/superrpa/sgclaw_launch_config.cc \
|
||||||
|
chrome/browser/ui/webui/superrpa/sgclaw_webui_config.h \
|
||||||
|
chrome/browser/ui/webui/superrpa/sgclaw_webui_config.cc \
|
||||||
|
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc \
|
||||||
|
chrome/browser/ui/webui/superrpa/functions_ui.cc \
|
||||||
|
chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config.ts \
|
||||||
|
chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_state.ts \
|
||||||
|
chrome/browser/resources/superrpa/devtools/functions/sgclaw-config/sgclaw-config_mainline_unittest.ts
|
||||||
|
git -C /home/zyl/projects/superRpa/src commit -m "superrpa: add runtime launch config for sgclaw"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Expand sgClaw runtime config so model/provider/skill policy live in sgClaw, not SuperRPA
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/config/settings.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/config/mod.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/config_adapter.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/runtime.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/planner.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_config_test.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/runtime_profile_test.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/planner_test.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/docs/L3-数据流与Skill体系层.md`
|
||||||
|
|
||||||
|
**Step 1: Write failing Rust tests**
|
||||||
|
|
||||||
|
Cover config-driven behavior for:
|
||||||
|
- planner-first mode
|
||||||
|
- provider list / active provider
|
||||||
|
- browser backend selection
|
||||||
|
- office backend selection
|
||||||
|
- skills prompt mode
|
||||||
|
- runtime profile
|
||||||
|
|
||||||
|
**Step 2: Run the failing tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test compat_config_test runtime_profile_test planner_test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: new config fields are missing or ignored.
|
||||||
|
|
||||||
|
**Step 3: Implement minimal config schema changes**
|
||||||
|
|
||||||
|
Add fields that let sgClaw choose behavior without host rebuild:
|
||||||
|
- `planner_mode`
|
||||||
|
- `providers`
|
||||||
|
- `active_provider`
|
||||||
|
- `browser_backend`
|
||||||
|
- `office_backend`
|
||||||
|
- `skills_prompt_mode`
|
||||||
|
- `runtime_profile`
|
||||||
|
|
||||||
|
**Step 4: Keep Zeroclaw-first execution**
|
||||||
|
|
||||||
|
Ensure the planner reads config before execution and produces a visible plan event for the frontend, but the frontend still only renders what sgClaw emits.
|
||||||
|
|
||||||
|
**Step 5: Re-run Rust tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml compat_config_test runtime_profile_test planner_test runtime_task_flow_test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: planner/config tests pass.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/zyl/projects/sgClaw/claw add \
|
||||||
|
src/config/settings.rs \
|
||||||
|
src/config/mod.rs \
|
||||||
|
src/compat/config_adapter.rs \
|
||||||
|
src/agent/runtime.rs \
|
||||||
|
src/agent/planner.rs \
|
||||||
|
tests/compat_config_test.rs \
|
||||||
|
tests/runtime_profile_test.rs \
|
||||||
|
tests/planner_test.rs \
|
||||||
|
docs/L3-数据流与Skill体系层.md
|
||||||
|
git -C /home/zyl/projects/sgClaw/claw commit -m "sgclaw: move runtime policy into config"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Decouple the floating UI so visual iteration stops depending on Chromium rebuilds
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui.cc`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/claw/frontend/runtime-host/README.md`
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/claw/frontend/runtime-host/manifest.example.json`
|
||||||
|
|
||||||
|
**Step 1: Write failing UI host tests**
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
- external frontend bundle dir is preferred when declared in launch config
|
||||||
|
- bundled frontend assets still load when external assets are absent
|
||||||
|
- planner events are rendered as plan cards/log lines before execution
|
||||||
|
|
||||||
|
**Step 2: Run the failing frontend/browser tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome/test/data/webui_test_resources
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the relevant TypeScript tests already wired for the sgClaw chat surface.
|
||||||
|
|
||||||
|
**Step 3: Implement the minimal external bundle loader**
|
||||||
|
|
||||||
|
SuperRPA should:
|
||||||
|
- keep the host shell and JS bridge fixed
|
||||||
|
- optionally load external `sgclaw-chat` assets from runtime-configured directory
|
||||||
|
- fall back to bundled assets when missing
|
||||||
|
|
||||||
|
**Step 4: Surface planner output early**
|
||||||
|
|
||||||
|
Use existing runtime event flow so the frontend shows:
|
||||||
|
- plan summary
|
||||||
|
- current step
|
||||||
|
- execution logs
|
||||||
|
|
||||||
|
without moving control logic into the frontend.
|
||||||
|
|
||||||
|
**Step 5: Re-run tests**
|
||||||
|
|
||||||
|
Run the existing sgClaw chat WebUI tests and a browser smoke.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/zyl/projects/superRpa/src add \
|
||||||
|
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
|
||||||
|
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_state.ts \
|
||||||
|
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_mainline_unittest.ts \
|
||||||
|
chrome/browser/ui/webui/superrpa/functions_ui.cc \
|
||||||
|
chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc
|
||||||
|
git -C /home/zyl/projects/sgClaw/claw add \
|
||||||
|
frontend/runtime-host/README.md \
|
||||||
|
frontend/runtime-host/manifest.example.json
|
||||||
|
git -C /home/zyl/projects/superRpa/src commit -m "superrpa: support external sgclaw frontend bundle"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Close the current remaining behavioral gaps before new feature work
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/live_acceptance_score_test.py`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts`
|
||||||
|
|
||||||
|
**Step 1: Write failing acceptance assertions**
|
||||||
|
|
||||||
|
Add explicit checks for:
|
||||||
|
- no repeated assistant paragraphs
|
||||||
|
- no fake fallback data when browser path exists
|
||||||
|
- planner-first output appears before tool execution
|
||||||
|
- Zhihu hotlist extraction returns structured rows
|
||||||
|
- office export returns a real output path
|
||||||
|
|
||||||
|
**Step 2: Run the failing acceptance flow**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /home/zyl/projects/sgClaw/claw/tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: current score exposes the remaining regressions if they still exist.
|
||||||
|
|
||||||
|
**Step 3: Fix the smallest issue set first**
|
||||||
|
|
||||||
|
Order:
|
||||||
|
1. repeated message rendering / repeated summary emission
|
||||||
|
2. planner event visibility
|
||||||
|
3. structured hotlist extraction handoff
|
||||||
|
4. office export path propagation
|
||||||
|
|
||||||
|
**Step 4: Re-run acceptance**
|
||||||
|
|
||||||
|
Run the same command until:
|
||||||
|
- `hotlist_data_correctness > 0`
|
||||||
|
- `xlsx_export_success > 0`
|
||||||
|
- repeated text is absent
|
||||||
|
|
||||||
|
**Step 5: Record fresh evidence**
|
||||||
|
|
||||||
|
Update the acceptance markdown with:
|
||||||
|
- timestamp
|
||||||
|
- score
|
||||||
|
- exact exported path
|
||||||
|
- screenshot/log snippets
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/zyl/projects/sgClaw/claw add \
|
||||||
|
tests/live_acceptance_score_test.py \
|
||||||
|
tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py \
|
||||||
|
docs/acceptance/2026-03-29-zhihu-hotlist-excel.md
|
||||||
|
git -C /home/zyl/projects/superRpa/src add \
|
||||||
|
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts \
|
||||||
|
chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat_messages.ts
|
||||||
|
git -C /home/zyl/projects/sgClaw/claw commit -m "acceptance: stabilize zhihu hotlist excel flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Final integrated verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify only: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||||
|
- Verify only: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||||
|
- Verify only: `/home/zyl/projects/sgClaw/claw/tools/browser_runtime/sgclaw_browser_entry.sh`
|
||||||
|
|
||||||
|
**Step 1: Build all affected binaries**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
autoninja -C /home/zyl/projects/superRpa/src/out/KylinRelease chrome functions_ui_mainline_unittests
|
||||||
|
cargo test --manifest-path /home/zyl/projects/sgClaw/claw/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both complete successfully.
|
||||||
|
|
||||||
|
**Step 2: Do the live browser smoke**
|
||||||
|
|
||||||
|
Run browser with the local profile and verify the logs include one of:
|
||||||
|
- `using SUPERRPA_SGCLAW_BINARY override: ...`
|
||||||
|
- `using source checkout sgclaw inferred from skillsDir: ...`
|
||||||
|
- `using bundled sgclaw from browser output dir: ...`
|
||||||
|
|
||||||
|
The expected dev mode result is the source checkout path, not the stale bundled fallback.
|
||||||
|
|
||||||
|
**Step 3: Run the final business acceptance**
|
||||||
|
|
||||||
|
Ask sgClaw to:
|
||||||
|
1. read Zhihu hotlist
|
||||||
|
2. export Excel
|
||||||
|
3. open the screen presentation in a new tab
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
- planner appears first
|
||||||
|
- skills are actually used
|
||||||
|
- exported file path is returned
|
||||||
|
- new-tab presentation opens
|
||||||
|
|
||||||
|
**Step 4: Record the result**
|
||||||
|
|
||||||
|
Append the final evidence to:
|
||||||
|
- `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/zyl/projects/sgClaw/claw commit -m "chore: record final sgclaw superrpa runtime verification"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remaining Items Explicitly Carried Into This Plan
|
||||||
|
|
||||||
|
- The current stale-backend risk is not considered closed until the resolver has automated regression coverage.
|
||||||
|
- The current local edit in `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/functions_ui_mainline_unittest.cc` must be either completed under Task 2 or replaced with the final tested version.
|
||||||
|
- The current wrapper script `/home/zyl/projects/sgClaw/claw/tools/browser_runtime/sgclaw_browser_entry.sh` is still untracked and must be committed as part of Task 2.
|
||||||
|
- The Zhihu hotlist to Excel acceptance still has unresolved correctness and export-path gaps and remains part of the critical path.
|
||||||
|
- The repeated assistant text regression remains part of the critical path because it corrupts operator trust during demos.
|
||||||
|
|
||||||
|
Plan complete and saved to `docs/plans/2026-03-29-sgclaw-superrpa-decoupled-runtime-plan.md`. Two execution options:
|
||||||
|
|
||||||
|
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
|
||||||
|
|
||||||
|
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
|
||||||
|
|
||||||
|
**Which approach?**
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
# SGClaw ZeroClaw Planner-First Realignment Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Realign the browser submit path so `sgclaw` uses `zeroclaw` as the primary planner/executor, with `sgclaw` acting only as the secure SuperRPA host plus custom tool bridge.
|
||||||
|
|
||||||
|
**Architecture:** Stop treating `zeroclaw` as a thin LLM wrapper. The browser message path should enter a `zeroclaw`-native orchestration entry point first, let `zeroclaw` perform planning/tool-loop control, and expose SuperRPA-specific browser/office/screen capabilities as regular tools inside that runtime. Any deterministic fast paths for Zhihu/Office must be implemented as `zeroclaw`-aligned execution components, not as frontend-owned control flow. The frontend may display the generated plan and current stage for UX, but it must not own planning or execution decisions.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust, `sgclaw` compat bridge, `third_party/zeroclaw` agent loop, SuperRPA browser pipe, local skill library, OpenXML office export, HTML screen export, cargo tests, Python live acceptance.
|
||||||
|
|
||||||
|
### Task 1: Freeze The Current Architecture Gap With Characterization Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||||
|
- Reference only: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
|
||||||
|
- Reference only: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||||
|
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/agent/loop_.rs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add a test that submits `读取知乎热榜前10,并导出 excel 文件` through `handle_browser_message_with_context(...)` and asserts the browser submit path does **not** terminate inside the current thin `Agent::turn_streamed(...)` compat bridge.
|
||||||
|
|
||||||
|
The test should check for one of these observable signals:
|
||||||
|
- a new orchestration mode log such as `zeroclaw_process_message_primary`
|
||||||
|
- absence of the old `compat_llm_primary` mode log
|
||||||
|
- absence of selector-thrashing logs like repeated `getText .HotList-item`, `[data-hot-item]`, `ol li`
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test browser_submit_path_prefers_zeroclaw_process_message_orchestrator -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the current implementation still enters `src/compat/runtime.rs` and drives `agent.turn_streamed(...)` directly.
|
||||||
|
|
||||||
|
**Step 3: Write the smallest additional characterization test**
|
||||||
|
|
||||||
|
Add a second failing test that proves SuperRPA-specific tools remain available after the orchestration switch:
|
||||||
|
- browser host tool
|
||||||
|
- `openxml_office`
|
||||||
|
- `screen_html_export`
|
||||||
|
|
||||||
|
This test should not require real network calls.
|
||||||
|
|
||||||
|
**Step 4: Run both failing tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: at least the new characterization tests fail for the expected reason.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/compat_runtime_test.rs
|
||||||
|
git commit -m "test: characterize browser path bypass of zeroclaw orchestrator"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Introduce A ZeroClaw-Native Browser Orchestration Entry Point
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/claw/src/compat/orchestration.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/mod.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||||
|
- Reference only: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/agent/loop_.rs:4752`
|
||||||
|
|
||||||
|
**Step 1: Write the failing unit test for the new entry point**
|
||||||
|
|
||||||
|
Add a test for a new helper in `src/compat/orchestration.rs` that:
|
||||||
|
- receives browser task context
|
||||||
|
- builds a `zeroclaw` config
|
||||||
|
- returns a browser-safe orchestration handle or result
|
||||||
|
|
||||||
|
The test should prove the new helper is chosen by `handle_browser_message_with_context(...)`.
|
||||||
|
|
||||||
|
**Step 2: Run the new test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test browser_submit_path_prefers_zeroclaw_process_message_orchestrator -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the helper does not exist yet.
|
||||||
|
|
||||||
|
**Step 3: Implement the minimal entry point**
|
||||||
|
|
||||||
|
Create `src/compat/orchestration.rs` with one responsibility:
|
||||||
|
- bridge browser submit tasks into a `zeroclaw`-native orchestration path
|
||||||
|
|
||||||
|
Do not implement Zhihu-specific logic here. This layer must only:
|
||||||
|
- map config
|
||||||
|
- map task context/history
|
||||||
|
- inject SuperRPA tools
|
||||||
|
- call the chosen `zeroclaw` orchestration function
|
||||||
|
|
||||||
|
**Step 4: Switch `handle_browser_message_with_context(...)` to the new entry point**
|
||||||
|
|
||||||
|
Modify:
|
||||||
|
- `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
|
||||||
|
|
||||||
|
Replace the direct `compat::runtime::execute_task_with_sgclaw_settings(...)` primary path with the new orchestration bridge.
|
||||||
|
|
||||||
|
**Step 5: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test browser_submit_path_prefers_zeroclaw_process_message_orchestrator -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/orchestration.rs src/compat/mod.rs src/agent/mod.rs src/compat/runtime.rs tests/compat_runtime_test.rs
|
||||||
|
git commit -m "refactor: route browser submit flow through zeroclaw orchestration bridge"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Register SuperRPA Browser/Office/Screen Capabilities As Native ZeroClaw Tools
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/browser_tool_adapter.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/openxml_office_tool.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/screen_html_export_tool.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/orchestration.rs`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_openxml_office_tool_test.rs`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_screen_html_export_tool_test.rs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing tool-registration test**
|
||||||
|
|
||||||
|
Add a test that asserts the `zeroclaw` orchestration path exposes:
|
||||||
|
- the preferred SuperRPA browser tool
|
||||||
|
- `openxml_office` when Excel export is requested
|
||||||
|
- `screen_html_export` when screen export is requested
|
||||||
|
|
||||||
|
The test must verify this through the new orchestration path, not the old compat path.
|
||||||
|
|
||||||
|
**Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test browser_orchestration_registers_superrpa_tools_natively -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL until tool wiring is complete.
|
||||||
|
|
||||||
|
**Step 3: Implement minimal native tool registration**
|
||||||
|
|
||||||
|
Ensure the new orchestration bridge injects `sgclaw` tools into the `zeroclaw` runtime without changing frontend code. Keep tool naming stable:
|
||||||
|
- `superrpa_browser`
|
||||||
|
- `openxml_office`
|
||||||
|
- `screen_html_export`
|
||||||
|
|
||||||
|
**Step 4: Verify tool-level tests still pass**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_openxml_office_tool_test -- --nocapture
|
||||||
|
cargo test --test compat_screen_html_export_tool_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Run the new orchestration registration test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test browser_orchestration_registers_superrpa_tools_natively -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/browser_tool_adapter.rs src/compat/openxml_office_tool.rs src/compat/screen_html_export_tool.rs src/runtime/engine.rs src/compat/orchestration.rs tests/compat_runtime_test.rs tests/compat_openxml_office_tool_test.rs tests/compat_screen_html_export_tool_test.rs
|
||||||
|
git commit -m "feat: expose superrpa browser and export tools through zeroclaw orchestration"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Remove Frontend-Owned Or Custom Compat Mainline Control Flow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/agent/mod.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/skill_runner.rs`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||||
|
- Reference only: `/home/zyl/projects/sgClaw/claw/docs/plans/2026-03-29-sgclaw-zeroclaw-planner-first-execution-plan.md`
|
||||||
|
|
||||||
|
**Step 1: Write the failing regression test**
|
||||||
|
|
||||||
|
Add a test that proves Zhihu hotlist export no longer depends on a frontend-owned mainline such as:
|
||||||
|
- `compat_skill_runner_primary`
|
||||||
|
- direct `sgclaw`-local branching before `zeroclaw`
|
||||||
|
|
||||||
|
The expected primary mode should be a `zeroclaw`-owned orchestration mode.
|
||||||
|
|
||||||
|
**Step 2: Run the regression test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test zhihu_export_does_not_use_frontend_owned_mainline -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL while `src/compat/skill_runner.rs` still owns primary control flow.
|
||||||
|
|
||||||
|
**Step 3: Remove or demote the custom mainline**
|
||||||
|
|
||||||
|
Change the code so:
|
||||||
|
- `src/compat/skill_runner.rs` becomes either a helper invoked inside the `zeroclaw` tool/runtime ecosystem, or is removed if redundant
|
||||||
|
- `src/agent/mod.rs` no longer branches to a custom primary executor before `zeroclaw`
|
||||||
|
|
||||||
|
Do not leave two competing primary modes.
|
||||||
|
|
||||||
|
**Step 4: Run the regression test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test zhihu_export_does_not_use_frontend_owned_mainline -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Run the broader compat suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/runtime.rs src/agent/mod.rs src/compat/skill_runner.rs tests/compat_runtime_test.rs
|
||||||
|
git commit -m "refactor: remove frontend-owned primary control flow from browser submit path"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Align Skills With ZeroClaw Execution Semantics Instead Of Prompt-Only Semantics
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/third_party/zeroclaw/src/tools/read_skill.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/read_skill_tool_test.rs`
|
||||||
|
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
|
||||||
|
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/SKILL.md`
|
||||||
|
- Reference only: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist-screen/SKILL.md`
|
||||||
|
|
||||||
|
**Step 1: Write the failing skill-execution regression test**
|
||||||
|
|
||||||
|
Add a test that proves skill usage in the browser submit path is not just:
|
||||||
|
- prompt injection
|
||||||
|
- `read_skill` text stuffing
|
||||||
|
- model-led selector wandering
|
||||||
|
|
||||||
|
Instead, the test should verify the task produces:
|
||||||
|
- a plan-driven collection/execution flow
|
||||||
|
- a real `.xlsx` or `.html` artifact path
|
||||||
|
- no selector-thrashing loop
|
||||||
|
|
||||||
|
**Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test browser_skill_usage_is_execution_not_prompt_only -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL until skill semantics are aligned with execution.
|
||||||
|
|
||||||
|
**Step 3: Implement the minimal alignment**
|
||||||
|
|
||||||
|
Change the orchestration so `read_skill` is a fallback for missing context, not the primary means of making high-frequency browser workflows executable.
|
||||||
|
|
||||||
|
Keep:
|
||||||
|
- skill discovery
|
||||||
|
- skill references
|
||||||
|
- artifact contract wording
|
||||||
|
|
||||||
|
Reduce:
|
||||||
|
- over-reliance on prompt stuffing
|
||||||
|
- over-reliance on model-led selector discovery for known workflows
|
||||||
|
|
||||||
|
**Step 4: Re-run the skill regression tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test browser_skill_usage_is_execution_not_prompt_only -- --nocapture
|
||||||
|
cargo test --test read_skill_tool_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/runtime/engine.rs src/compat/runtime.rs third_party/zeroclaw/src/tools/read_skill.rs tests/compat_runtime_test.rs tests/read_skill_tool_test.rs
|
||||||
|
git commit -m "refactor: align browser skill execution with zeroclaw-native workflow semantics"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Verify The Planner-First Path End-To-End
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/runtime_profile_test.rs`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_config_test.rs`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/live_acceptance_score_test.py`
|
||||||
|
- Reference only: `/home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw`
|
||||||
|
|
||||||
|
**Step 1: Run the Rust regression suites**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test runtime_profile_test -- --nocapture
|
||||||
|
cargo test --test compat_config_test -- --nocapture
|
||||||
|
cargo test --test compat_runtime_test -- --nocapture
|
||||||
|
cargo test --test read_skill_tool_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 2: Run the Python scoring test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python3 -m unittest tests/live_acceptance_score_test.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 3: Run the live Zhihu hotlist Excel acceptance**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python3 tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- total score returns to `100`
|
||||||
|
- logs show planner-first `zeroclaw` orchestration instead of selector-thrashing
|
||||||
|
- no `shell`, `web_fetch`, `web_search_tool`
|
||||||
|
- final summary includes a real `.xlsx` path
|
||||||
|
|
||||||
|
**Step 4: Update the acceptance note**
|
||||||
|
|
||||||
|
Record:
|
||||||
|
- new orchestration mode
|
||||||
|
- tool sequence
|
||||||
|
- timing notes
|
||||||
|
- any remaining selector or latency risk
|
||||||
|
|
||||||
|
**Step 5: Rebuild and sync the runtime binary used by SuperRPA**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo build
|
||||||
|
cp /home/zyl/projects/sgClaw/claw/target/debug/sgclaw /home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw
|
||||||
|
sha256sum /home/zyl/projects/sgClaw/claw/target/debug/sgclaw /home/zyl/projects/superRpa/src/out/KylinRelease/sgclaw
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the two hashes match exactly.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/acceptance/2026-03-29-zhihu-hotlist-excel.md tests/runtime_profile_test.rs tests/compat_config_test.rs tests/compat_runtime_test.rs tests/live_acceptance_score_test.py
|
||||||
|
git commit -m "test: verify planner-first zeroclaw browser orchestration end to end"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Surface The Generated Plan In The Chat UI Without Giving Frontend Control
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/event_bridge.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/pipe/protocol.rs`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/ui/webui/superrpa/sgclaw_session_service.cc`
|
||||||
|
- Modify: `/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/` (the active sgClaw chat UI files that render task progress)
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/pipe_protocol_test.rs`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing protocol/UI test**
|
||||||
|
|
||||||
|
Add a test that proves the backend can emit a structured planning event before tool execution starts. The event must carry:
|
||||||
|
- a short plan title
|
||||||
|
- a flat ordered step list
|
||||||
|
- current phase such as `planning`, `executing`, `completed`
|
||||||
|
|
||||||
|
The frontend test or fixture should verify the chat can render the plan summary without waiting for final completion.
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test pipe_protocol_test -- --nocapture
|
||||||
|
cargo test --test compat_runtime_test plan_events_are_emitted_before_browser_execution -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the protocol does not yet expose a dedicated plan-progress event.
|
||||||
|
|
||||||
|
**Step 3: Add the minimal backend event shape**
|
||||||
|
|
||||||
|
Extend the `sgclaw` pipe/event bridge so the orchestration layer can emit:
|
||||||
|
- planner summary
|
||||||
|
- execution stage transitions
|
||||||
|
|
||||||
|
Keep the event read-only from the frontend’s perspective. The UI may display it, but cannot edit or branch execution.
|
||||||
|
|
||||||
|
**Step 4: Render the plan in the active chat UI**
|
||||||
|
|
||||||
|
Update the SuperRPA sgClaw chat UI so it:
|
||||||
|
- prints the generated plan immediately after planning completes
|
||||||
|
- keeps the plan compact and collapsible
|
||||||
|
- highlights the current phase while waiting
|
||||||
|
|
||||||
|
Do not add frontend-owned retry logic, decision logic, or browser action generation.
|
||||||
|
|
||||||
|
**Step 5: Run verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cargo test --test pipe_protocol_test -- --nocapture
|
||||||
|
cargo test --test compat_runtime_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 6: Manual browser validation**
|
||||||
|
|
||||||
|
Submit:
|
||||||
|
```text
|
||||||
|
读取知乎热榜前10,并导出 excel 文件
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- the chat first shows a short generated plan
|
||||||
|
- the user sees stage transitions instead of a blank wait
|
||||||
|
- execution still follows the backend-owned `zeroclaw` path
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/event_bridge.rs src/pipe/protocol.rs tests/pipe_protocol_test.rs tests/compat_runtime_test.rs
|
||||||
|
git commit -m "feat: surface backend-generated execution plans in sgclaw chat ui"
|
||||||
|
```
|
||||||
444
docs/plans/2026-03-29-zhihu-hotlist-office-export-plan.md
Normal file
444
docs/plans/2026-03-29-zhihu-hotlist-office-export-plan.md
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
# Zhihu Hotlist To Excel Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make sgClaw reliably read Zhihu hotlist data through a Zhihu browser skill and export the collected structured result into a local `.xlsx` file through an independent Office skill.
|
||||||
|
|
||||||
|
**Architecture:** Keep zeroclaw as the core planner, but stop it from wandering across unrelated tools once a browser-attached skill is selected. The hotlist skill must produce a strict structured artifact, and the Office skill must consume that artifact through a dedicated `openxml_office` tool that wraps the sibling `openxml_cli` project. For the first delivery, reuse `openxml_cli template render` with a bundled `.xlsx` template instead of inventing a new workbook-construction API.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust, vendored zeroclaw, sgClaw browser pipe, skill packages under `/home/zyl/projects/sgClaw/skill_lib`, sibling `openxml_cli`, JSON payload handoff, `.xlsx` template render, Python/Rust regression tests, real-provider smoke verification.
|
||||||
|
|
||||||
|
## Scope Guard
|
||||||
|
|
||||||
|
- In scope:
|
||||||
|
- browser-attached skill execution discipline
|
||||||
|
- `zhihu-hotlist` structured export artifact
|
||||||
|
- new `office-export-xlsx` skill
|
||||||
|
- new `openxml_office` runtime tool
|
||||||
|
- end-to-end acceptance for "读取知乎热榜数据,并导出 excel 文件"
|
||||||
|
- Out of scope:
|
||||||
|
- generic Office authoring platform
|
||||||
|
- arbitrary shell-based export flows
|
||||||
|
- browser-side file generation as the main export path
|
||||||
|
- broad multi-site data export before Zhihu hotlist is stable
|
||||||
|
|
||||||
|
## Current Findings To Preserve
|
||||||
|
|
||||||
|
- Real-provider validation already proved that `zhihu-hotlist`, `zhihu-navigate`, and `zhihu-write` can be selected through `read_skill`.
|
||||||
|
- The current failure mode is not "skill missing" but "tool discipline collapse":
|
||||||
|
- `file_read`, `glob_search`, and `shell` are attempted after `read_skill`
|
||||||
|
- `zhihu-write` can fill title/body but still exceeds max tool iterations
|
||||||
|
- `zhihu-navigate` succeeds for some intents but still detours through non-browser tools
|
||||||
|
- The sibling Office project already exists at `/home/zyl/projects/sgClaw/openxml_cli`.
|
||||||
|
- `openxml_cli` currently exposes `capabilities`, `template inspect`, `template validate`, and `template render`; it does not yet expose a direct "create workbook from scratch" command.
|
||||||
|
|
||||||
|
## Final Acceptance Contract
|
||||||
|
|
||||||
|
Input:
|
||||||
|
|
||||||
|
```text
|
||||||
|
读取知乎热榜数据,并导出 excel 文件
|
||||||
|
```
|
||||||
|
|
||||||
|
Required behavior:
|
||||||
|
|
||||||
|
1. sgClaw selects `zhihu-hotlist`.
|
||||||
|
2. sgClaw gathers hotlist rows through the SuperRPA browser interface only.
|
||||||
|
3. sgClaw converts the result into a structured JSON export payload.
|
||||||
|
4. sgClaw selects `office-export-xlsx`.
|
||||||
|
5. sgClaw calls `openxml_office`.
|
||||||
|
6. A local `.xlsx` file is produced and its path is returned.
|
||||||
|
|
||||||
|
Required logs:
|
||||||
|
|
||||||
|
- `read_skill zhihu-hotlist`
|
||||||
|
- browser actions only: `navigate`, `getText`, optionally `click`
|
||||||
|
- `read_skill office-export-xlsx`
|
||||||
|
- `call openxml_office`
|
||||||
|
|
||||||
|
Forbidden logs during the mainline path:
|
||||||
|
|
||||||
|
- `call shell`
|
||||||
|
- `call glob_search`
|
||||||
|
- `call file_read` on skill references or skill roots
|
||||||
|
- `docker run`
|
||||||
|
|
||||||
|
Required Excel content:
|
||||||
|
|
||||||
|
- one sheet named `知乎热榜`
|
||||||
|
- columns: `rank`, `title`, `heat`
|
||||||
|
- at least 10 hotlist rows
|
||||||
|
- exported values match the collected rows
|
||||||
|
|
||||||
|
## Task 1: Lock Browser-Attached Skill Runs To The Right Tools
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/tool_policy.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||||
|
|
||||||
|
**Intent:**
|
||||||
|
- Once the task is clearly in a browser-attached Zhihu skill flow, the runtime must stop offering unrelated tools such as `shell`, `glob_search`, and arbitrary `file_read`.
|
||||||
|
|
||||||
|
**Step 1: Write the failing regression tests**
|
||||||
|
|
||||||
|
Add focused tests in `tests/compat_runtime_test.rs` for:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn zhihu_hotlist_skill_flow_does_not_expose_shell_or_glob_tools() {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_attached_export_flow_exposes_browser_and_office_tools_only() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Assertions to include:
|
||||||
|
|
||||||
|
- request tool list contains `superrpa_browser`
|
||||||
|
- request tool list contains `read_skill`
|
||||||
|
- request tool list does not contain `shell`
|
||||||
|
- request tool list does not contain `glob_search`
|
||||||
|
- request tool list does not contain generic `file_read` during the constrained browser skill phase
|
||||||
|
|
||||||
|
**Step 2: Run the focused tests to verify failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test zhihu_hotlist_skill_flow_does_not_expose_shell_or_glob_tools -- --nocapture
|
||||||
|
cargo test --test compat_runtime_test browser_attached_export_flow_exposes_browser_and_office_tools_only -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- fail because current runtime still exposes too many tools in browser-attached mode
|
||||||
|
|
||||||
|
**Step 3: Implement minimal constrained-tool policy**
|
||||||
|
|
||||||
|
Implement a browser-skill execution mode that:
|
||||||
|
|
||||||
|
- keeps `superrpa_browser`
|
||||||
|
- keeps compatibility alias `browser_action`
|
||||||
|
- keeps `read_skill`
|
||||||
|
- optionally keeps the new `openxml_office` tool only for export tasks
|
||||||
|
- removes `shell`, `glob_search`, and free-form `file_read` from the allowed tool list for these phases
|
||||||
|
|
||||||
|
Do this in `src/runtime/engine.rs` by deriving a narrower `allowed_tools` set from:
|
||||||
|
|
||||||
|
- runtime profile
|
||||||
|
- browser surface present flag
|
||||||
|
- instruction intent
|
||||||
|
- whether export mode is active
|
||||||
|
|
||||||
|
**Step 4: Re-run the focused tests**
|
||||||
|
|
||||||
|
Run the same commands.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- both pass
|
||||||
|
|
||||||
|
## Task 2: Convert Zhihu Hotlist Skill To Structured Output First
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/skill_lib/skills/zhihu-hotlist/SKILL.md`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/skill_lib_validation_test.py`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||||
|
|
||||||
|
**Intent:**
|
||||||
|
- The hotlist skill should stop ending with prose-only summaries. Its primary output must be a stable export artifact the Office skill can consume.
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add tests that enforce:
|
||||||
|
|
||||||
|
- `zhihu-hotlist` prompt body contains an explicit `Export Artifact` section
|
||||||
|
- the artifact schema includes `sheet_name`, `columns`, and `rows`
|
||||||
|
- runtime regression checks can find those fields in the skill content when `read_skill` is used
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest tests.skill_lib_validation_test
|
||||||
|
cargo test --test compat_runtime_test handle_browser_message_executes_real_zhihu_hotlist_skill_flow -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- validation fails because the artifact contract is not yet required
|
||||||
|
|
||||||
|
**Step 3: Update `zhihu-hotlist`**
|
||||||
|
|
||||||
|
Add an `Export Artifact` section that requires this shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "https://www.zhihu.com/hot",
|
||||||
|
"sheet_name": "知乎热榜",
|
||||||
|
"columns": ["rank", "title", "heat"],
|
||||||
|
"rows": [[1, "标题", "344万"]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add hard rules:
|
||||||
|
|
||||||
|
- no extra exploratory tools after the browser data is collected
|
||||||
|
- prose summary is secondary, structured artifact is primary
|
||||||
|
|
||||||
|
**Step 4: Re-run tests**
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- validation passes
|
||||||
|
|
||||||
|
## Task 3: Create The Office Export Skill Package
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/SKILL.md`
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/references/export-flow.md`
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/skill_lib/skills/office-export-xlsx/assets/zhihu_hotlist_template.xlsx`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/skill_lib_validation_test.py`
|
||||||
|
|
||||||
|
**Intent:**
|
||||||
|
- Add a fully separate Office skill that knows nothing about browser scraping and only turns structured table data into a local Excel file.
|
||||||
|
|
||||||
|
**Step 1: Write the failing validation test**
|
||||||
|
|
||||||
|
Extend `tests/skill_lib_validation_test.py` so discovery expects:
|
||||||
|
|
||||||
|
```python
|
||||||
|
EXPECTED_SKILL_NAMES = [
|
||||||
|
"office-export-xlsx",
|
||||||
|
"zhihu-hotlist",
|
||||||
|
"zhihu-navigate",
|
||||||
|
"zhihu-write",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also require the new skill to mention:
|
||||||
|
|
||||||
|
- `openxml_office`
|
||||||
|
- `.xlsx`
|
||||||
|
- `sheet_name`
|
||||||
|
- `columns`
|
||||||
|
- `rows`
|
||||||
|
|
||||||
|
**Step 2: Run the validation test to verify failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest tests.skill_lib_validation_test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- fail because the new skill package does not exist yet
|
||||||
|
|
||||||
|
**Step 3: Create the skill package**
|
||||||
|
|
||||||
|
`SKILL.md` must define:
|
||||||
|
|
||||||
|
- when to use: local Office export from structured rows
|
||||||
|
- required input schema
|
||||||
|
- output: exported file path
|
||||||
|
- tool rule: only call `openxml_office`, do not use browser tools
|
||||||
|
|
||||||
|
`export-flow.md` must define:
|
||||||
|
|
||||||
|
- validate payload shape
|
||||||
|
- choose output path
|
||||||
|
- invoke `openxml_office`
|
||||||
|
- return file path and row count
|
||||||
|
|
||||||
|
The first workbook template should be a fixed `zhihu_hotlist_template.xlsx` with:
|
||||||
|
|
||||||
|
- sheet `知乎热榜`
|
||||||
|
- row 1 headers already present
|
||||||
|
- table fill anchored to a stable name or placeholder expected by `openxml_cli`
|
||||||
|
|
||||||
|
**Step 4: Re-run validation**
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- new skill passes audit
|
||||||
|
|
||||||
|
## Task 4: Add The `openxml_office` Runtime Tool
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/claw/src/compat/openxml_office_tool.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/mod.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/compat/runtime.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/tool_policy.rs`
|
||||||
|
- Test: `/home/zyl/projects/sgClaw/claw/tests/compat_openxml_office_tool_test.rs`
|
||||||
|
|
||||||
|
**Intent:**
|
||||||
|
- Wrap sibling `openxml_cli` as a first-class local tool instead of leaking Office export through shell prompting.
|
||||||
|
|
||||||
|
**Step 1: Write the failing tool test**
|
||||||
|
|
||||||
|
Create `tests/compat_openxml_office_tool_test.rs` with cases for:
|
||||||
|
|
||||||
|
- capability probe
|
||||||
|
- render request assembly for xlsx export
|
||||||
|
- rejection when rows/columns are missing
|
||||||
|
- stable JSON output containing `output_path`
|
||||||
|
|
||||||
|
**Step 2: Run the test to verify failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_openxml_office_tool_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- fail because the tool does not exist
|
||||||
|
|
||||||
|
**Step 3: Implement minimal tool**
|
||||||
|
|
||||||
|
Tool contract:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "export_hotlist_xlsx",
|
||||||
|
"template_path": ".../zhihu_hotlist_template.xlsx",
|
||||||
|
"output_path": "/tmp/zhihu_hotlist.xlsx",
|
||||||
|
"sheet_name": "知乎热榜",
|
||||||
|
"columns": ["rank", "title", "heat"],
|
||||||
|
"rows": [[1, "标题", "344万"]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation rules:
|
||||||
|
|
||||||
|
- write the payload JSON to a temp file
|
||||||
|
- invoke sibling `openxml_cli template render --request <file> --json`
|
||||||
|
- return parsed JSON result and normalized `output_path`
|
||||||
|
- no free-form shell composition from model text
|
||||||
|
|
||||||
|
**Step 4: Re-run the focused tests**
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- pass
|
||||||
|
|
||||||
|
## Task 5: Wire Export Tasks To Use Two Skills In Sequence
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/src/runtime/engine.rs`
|
||||||
|
- Modify: `/home/zyl/projects/sgClaw/claw/tests/compat_runtime_test.rs`
|
||||||
|
|
||||||
|
**Intent:**
|
||||||
|
- The single user instruction must naturally flow from hotlist capture into Office export, not end after the first skill.
|
||||||
|
|
||||||
|
**Step 1: Write the failing runtime test**
|
||||||
|
|
||||||
|
Add a focused regression test for:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn zhihu_hotlist_export_task_reads_hotlist_skill_then_office_skill() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Assertions:
|
||||||
|
|
||||||
|
- request stream includes `read_skill zhihu-hotlist`
|
||||||
|
- later includes `read_skill office-export-xlsx`
|
||||||
|
- office phase exposes `openxml_office`
|
||||||
|
- no `shell` is exposed in the constrained task path
|
||||||
|
|
||||||
|
**Step 2: Run the test to verify failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_runtime_test zhihu_hotlist_export_task_reads_hotlist_skill_then_office_skill -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- fail because the task currently has no structured handoff to Office export
|
||||||
|
|
||||||
|
**Step 3: Implement minimal chaining support**
|
||||||
|
|
||||||
|
Do not add a hard-coded workflow engine.
|
||||||
|
|
||||||
|
Minimal implementation:
|
||||||
|
|
||||||
|
- strengthen prompt contract so export tasks require structured hotlist artifact
|
||||||
|
- include `openxml_office` in allowed tools for export intent
|
||||||
|
- keep browser-only tools for the collection phase and Office-only tool for the export phase
|
||||||
|
|
||||||
|
**Step 4: Re-run the test**
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- pass
|
||||||
|
|
||||||
|
## Task 6: Add Real Acceptance Harness And Scoring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/claw/tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py`
|
||||||
|
- Create: `/home/zyl/projects/sgClaw/claw/docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||||
|
|
||||||
|
**Intent:**
|
||||||
|
- Make the final acceptance repeatable with the real user config and a transparent score.
|
||||||
|
|
||||||
|
**Step 1: Write the script**
|
||||||
|
|
||||||
|
The script must:
|
||||||
|
|
||||||
|
- use `/home/zyl/.config/superrpa/Default/superrpa/sgclaw_config.json`
|
||||||
|
- boot local `target/debug/sgclaw`
|
||||||
|
- send one browser `submit_task`
|
||||||
|
- respond to browser commands with controlled fixture responses
|
||||||
|
- capture:
|
||||||
|
- loaded skills
|
||||||
|
- selected skills
|
||||||
|
- forbidden tool calls
|
||||||
|
- final summary
|
||||||
|
- exported file path
|
||||||
|
|
||||||
|
**Step 2: Define score rubric**
|
||||||
|
|
||||||
|
Rubric:
|
||||||
|
|
||||||
|
- `skill selection`: 30
|
||||||
|
- `tool discipline`: 25
|
||||||
|
- `hotlist data correctness`: 20
|
||||||
|
- `xlsx export success`: 20
|
||||||
|
- `final response quality`: 5
|
||||||
|
|
||||||
|
Automatic deductions:
|
||||||
|
|
||||||
|
- `shell` called: `-15`
|
||||||
|
- `glob_search` called: `-10`
|
||||||
|
- `file_read` on skill references: `-10`
|
||||||
|
- wrong skill selected first: `-15`
|
||||||
|
- export missing output path: `-20`
|
||||||
|
|
||||||
|
**Step 3: Run acceptance**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- prints total score and per-dimension breakdown
|
||||||
|
- stores final evidence in `docs/acceptance/2026-03-29-zhihu-hotlist-excel.md`
|
||||||
|
|
||||||
|
## Delivery Sequence
|
||||||
|
|
||||||
|
Execute in this order:
|
||||||
|
|
||||||
|
1. Task 1: constrain tools
|
||||||
|
2. Task 2: structure hotlist output
|
||||||
|
3. Task 3: add office skill package
|
||||||
|
4. Task 4: add `openxml_office`
|
||||||
|
5. Task 5: chain the two skills
|
||||||
|
6. Task 6: run acceptance and score
|
||||||
|
|
||||||
|
## Definition Of Done
|
||||||
|
|
||||||
|
- browser-attached hotlist tasks no longer wander into `shell`, `glob_search`, or ad-hoc `file_read`
|
||||||
|
- `office-export-xlsx` exists as an independent skill
|
||||||
|
- `openxml_office` exists as an explicit tool
|
||||||
|
- a single user task can collect hotlist data and export `.xlsx`
|
||||||
|
- acceptance score is at least `85/100`
|
||||||
126
src/agent/mod.rs
126
src/agent/mod.rs
@@ -4,10 +4,11 @@ pub mod runtime;
|
|||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||||
use crate::compat::runtime::CompatTaskContext;
|
use crate::compat::runtime::CompatTaskContext;
|
||||||
use crate::config::DeepSeekSettings;
|
use crate::config::SgClawSettings;
|
||||||
use crate::pipe::{
|
use crate::pipe::{
|
||||||
AgentMessage, BrowserMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport,
|
AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -58,12 +59,12 @@ impl AgentRuntimeContext {
|
|||||||
Ok(Self::new(config_path, workspace_root))
|
Ok(Self::new(config_path, workspace_root))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_deepseek_settings(&self) -> Result<Option<DeepSeekSettings>, PipeError> {
|
fn load_sgclaw_settings(&self) -> Result<Option<SgClawSettings>, PipeError> {
|
||||||
DeepSeekSettings::load(self.config_path.as_deref())
|
SgClawSettings::load(self.config_path.as_deref())
|
||||||
.map_err(|err| PipeError::Protocol(err.to_string()))
|
.map_err(|err| PipeError::Protocol(err.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deepseek_source_label(&self) -> String {
|
fn settings_source_label(&self) -> String {
|
||||||
match &self.config_path {
|
match &self.config_path {
|
||||||
Some(path) if path.exists() => path.display().to_string(),
|
Some(path) if path.exists() => path.display().to_string(),
|
||||||
_ => "environment".to_string(),
|
_ => "environment".to_string(),
|
||||||
@@ -88,39 +89,9 @@ fn send_mode_log<T: Transport>(transport: &T, mode: &str) -> Result<(), PipeErro
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn explicit_non_task_response(history: &[ConversationMessage], instruction: &str) -> Option<String> {
|
fn missing_llm_configuration_summary() -> String {
|
||||||
if !history.is_empty() {
|
"未配置大语言模型。请先在 sgclaw_config.json 或环境变量中配置 apiKey、baseUrl 与 model。"
|
||||||
return None;
|
.to_string()
|
||||||
}
|
|
||||||
|
|
||||||
let trimmed = instruction.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Some("sgClaw 目前只处理浏览器任务,请直接描述要打开、搜索、点击或提取的网页操作。".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
const TASK_HINTS: &[&str] = &[
|
|
||||||
"打开", "搜索", "点击", "输入", "导航", "跳转", "访问", "提取", "获取", "网页", "页面",
|
|
||||||
"标签页", "百度", "知乎", "google", "open", "search", "click", "type", "navigate",
|
|
||||||
];
|
|
||||||
if TASK_HINTS.iter().any(|hint| trimmed.contains(hint)) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHITCHAT_INPUTS: &[&str] = &[
|
|
||||||
"hi", "hello", "hey", "你好", "您好", "嗨", "在吗", "你是谁", "介绍一下你自己",
|
|
||||||
];
|
|
||||||
if CHITCHAT_INPUTS
|
|
||||||
.iter()
|
|
||||||
.any(|candidate| trimmed.eq_ignore_ascii_case(candidate) || trimmed == *candidate)
|
|
||||||
{
|
|
||||||
return Some("sgClaw 现在是浏览器任务入口,不做通用闲聊。请直接说你想在网页上执行什么操作,例如“打开百度搜索天气”。".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed.chars().count() <= 8 {
|
|
||||||
return Some("sgClaw 现在只处理浏览器任务。请直接描述网页操作目标,例如“打开知乎搜索天气”或“提取当前页面标题”。".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_plan<T: Transport>(
|
fn execute_plan<T: Transport>(
|
||||||
@@ -187,10 +158,11 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
|||||||
page_url,
|
page_url,
|
||||||
page_title,
|
page_title,
|
||||||
} => {
|
} => {
|
||||||
if let Some(summary) = explicit_non_task_response(&messages, &instruction) {
|
let instruction = instruction.trim().to_string();
|
||||||
|
if instruction.is_empty() {
|
||||||
return transport.send(&AgentMessage::TaskComplete {
|
return transport.send(&AgentMessage::TaskComplete {
|
||||||
success: false,
|
success: false,
|
||||||
summary,
|
summary: "请输入任务内容。".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,19 +182,64 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let completion = match context.load_deepseek_settings() {
|
let completion = match context.load_sgclaw_settings() {
|
||||||
Ok(Some(settings)) => {
|
Ok(Some(settings)) => {
|
||||||
|
let resolved_skills_dir =
|
||||||
|
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||||
let _ = transport.send(&AgentMessage::LogEntry {
|
let _ = transport.send(&AgentMessage::LogEntry {
|
||||||
level: "info".to_string(),
|
level: "info".to_string(),
|
||||||
message: format!(
|
message: format!(
|
||||||
"DeepSeek config loaded from {} model={} base_url={}",
|
"DeepSeek config loaded from {} model={} base_url={}",
|
||||||
context.deepseek_source_label(),
|
context.settings_source_label(),
|
||||||
settings.model,
|
settings.provider_model,
|
||||||
settings.base_url
|
settings.provider_base_url
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
let _ = transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"skills dir resolved to {}",
|
||||||
|
resolved_skills_dir.display()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
let _ = transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"runtime profile={:?} skills_prompt_mode={:?}",
|
||||||
|
settings.runtime_profile,
|
||||||
|
settings.skills_prompt_mode
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if crate::compat::orchestration::should_use_primary_orchestration(
|
||||||
|
&instruction,
|
||||||
|
task_context.page_url.as_deref(),
|
||||||
|
task_context.page_title.as_deref(),
|
||||||
|
) {
|
||||||
|
let _ = send_mode_log(transport, "zeroclaw_process_message_primary");
|
||||||
|
match crate::compat::orchestration::execute_task_with_sgclaw_settings(
|
||||||
|
transport,
|
||||||
|
browser_tool.clone(),
|
||||||
|
&instruction,
|
||||||
|
&task_context,
|
||||||
|
&context.workspace_root,
|
||||||
|
&settings,
|
||||||
|
) {
|
||||||
|
Ok(summary) => {
|
||||||
|
return transport.send(&AgentMessage::TaskComplete {
|
||||||
|
success: true,
|
||||||
|
summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return transport.send(&AgentMessage::TaskComplete {
|
||||||
|
success: false,
|
||||||
|
summary: err.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = send_mode_log(transport, "compat_llm_primary");
|
let _ = send_mode_log(transport, "compat_llm_primary");
|
||||||
match crate::compat::runtime::execute_task(
|
match crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||||
transport,
|
transport,
|
||||||
browser_tool.clone(),
|
browser_tool.clone(),
|
||||||
&instruction,
|
&instruction,
|
||||||
@@ -240,24 +257,9 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => match planner::plan_instruction(&instruction) {
|
Ok(None) => AgentMessage::TaskComplete {
|
||||||
Ok(plan) => {
|
|
||||||
let _ = send_mode_log(transport, "deterministic_planner");
|
|
||||||
match execute_plan(transport, browser_tool, &plan) {
|
|
||||||
Ok(summary) => AgentMessage::TaskComplete {
|
|
||||||
success: true,
|
|
||||||
summary,
|
|
||||||
},
|
|
||||||
Err(err) => AgentMessage::TaskComplete {
|
|
||||||
success: false,
|
success: false,
|
||||||
summary: err.to_string(),
|
summary: missing_llm_configuration_summary(),
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => AgentMessage::TaskComplete {
|
|
||||||
success: false,
|
|
||||||
summary: PipeError::Protocol(err.to_string()).to_string(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = transport.send(&AgentMessage::LogEntry {
|
let _ = transport.send(&AgentMessage::LogEntry {
|
||||||
|
|||||||
@@ -1,29 +1,70 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use reqwest::Url;
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use zeroclaw::tools::{Tool, ToolResult};
|
use zeroclaw::tools::{Tool, ToolResult};
|
||||||
|
|
||||||
use crate::pipe::{Action, BrowserPipeTool, Transport};
|
use crate::pipe::{Action, BrowserPipeTool, ExecutionSurfaceMetadata, Transport};
|
||||||
|
|
||||||
pub const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
pub const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||||
|
pub const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
|
||||||
|
const BROWSER_ACTION_TOOL_DESCRIPTION: &str =
|
||||||
|
"Execute browser actions in SuperRPA through the existing sgClaw pipe protocol.";
|
||||||
|
const SUPERRPA_BROWSER_TOOL_DESCRIPTION: &str =
|
||||||
|
"Use SuperRPA's dedicated privileged browser interface for page navigation, DOM reading, clicking, and typing inside the protected browser host.";
|
||||||
|
const MAX_DATA_STRING_CHARS: usize = 2048;
|
||||||
|
const MAX_AOM_STRING_CHARS: usize = 128;
|
||||||
|
const MAX_DATA_ARRAY_ITEMS: usize = 12;
|
||||||
|
const MAX_DATA_OBJECT_FIELDS: usize = 24;
|
||||||
|
const MAX_DATA_RECURSION_DEPTH: usize = 4;
|
||||||
|
|
||||||
pub struct ZeroClawBrowserTool<T: Transport> {
|
pub struct ZeroClawBrowserTool<T: Transport> {
|
||||||
browser_tool: BrowserPipeTool<T>,
|
browser_tool: BrowserPipeTool<T>,
|
||||||
|
tool_name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Transport> ZeroClawBrowserTool<T> {
|
impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||||
pub fn new(browser_tool: BrowserPipeTool<T>) -> Self {
|
pub fn new(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||||
Self { browser_tool }
|
Self::named(
|
||||||
|
browser_tool,
|
||||||
|
BROWSER_ACTION_TOOL_NAME,
|
||||||
|
BROWSER_ACTION_TOOL_DESCRIPTION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_superrpa(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||||
|
Self::named(
|
||||||
|
browser_tool,
|
||||||
|
SUPERRPA_BROWSER_TOOL_NAME,
|
||||||
|
SUPERRPA_BROWSER_TOOL_DESCRIPTION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn named(
|
||||||
|
browser_tool: BrowserPipeTool<T>,
|
||||||
|
tool_name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
browser_tool,
|
||||||
|
tool_name,
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||||
|
self.browser_tool.surface_metadata()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
BROWSER_ACTION_TOOL_NAME
|
self.tool_name
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Execute browser actions in SuperRPA through the existing sgClaw pipe protocol."
|
self.description
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> Value {
|
fn parameters_schema(&self) -> Value {
|
||||||
@@ -72,8 +113,9 @@ impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
|||||||
let output = serde_json::to_string(&json!({
|
let output = serde_json::to_string(&json!({
|
||||||
"seq": result.seq,
|
"seq": result.seq,
|
||||||
"success": result.success,
|
"success": result.success,
|
||||||
"data": result.data,
|
"data": compact_json_value(&result.data, 0),
|
||||||
"aom_snapshot": result.aom_snapshot,
|
"aom_snapshot": compact_aom_snapshot(&result.aom_snapshot),
|
||||||
|
"aom_snapshot_count": result.aom_snapshot.len(),
|
||||||
"timing": result.timing
|
"timing": result.timing
|
||||||
}))?;
|
}))?;
|
||||||
|
|
||||||
@@ -103,9 +145,10 @@ fn parse_browser_action_request(args: Value) -> Result<BrowserActionRequest, Bro
|
|||||||
};
|
};
|
||||||
|
|
||||||
let action_name = take_required_string(&mut args, "action")?;
|
let action_name = take_required_string(&mut args, "action")?;
|
||||||
let expected_domain = take_required_string(&mut args, "expected_domain")?;
|
let raw_expected_domain = take_required_string(&mut args, "expected_domain")?;
|
||||||
let action = parse_action(&action_name)?;
|
let action = parse_action(&action_name)?;
|
||||||
validate_action_params(&action_name, &args)?;
|
validate_action_params(&action_name, &args)?;
|
||||||
|
let expected_domain = normalize_expected_domain(&action, &raw_expected_domain, &args)?;
|
||||||
|
|
||||||
Ok(BrowserActionRequest {
|
Ok(BrowserActionRequest {
|
||||||
action,
|
action,
|
||||||
@@ -178,6 +221,59 @@ fn require_non_empty_string(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_expected_domain(
|
||||||
|
action: &Action,
|
||||||
|
raw_expected_domain: &str,
|
||||||
|
args: &Map<String, Value>,
|
||||||
|
) -> Result<String, BrowserActionAdapterError> {
|
||||||
|
if matches!(action, Action::Navigate) {
|
||||||
|
if let Some(url) = args.get("url").and_then(Value::as_str) {
|
||||||
|
if let Some(host) = host_from_url(url) {
|
||||||
|
return Ok(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_domain_like(raw_expected_domain).ok_or_else(|| {
|
||||||
|
BrowserActionAdapterError::InvalidArguments(format!(
|
||||||
|
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_from_url(raw: &str) -> Option<String> {
|
||||||
|
Url::parse(raw)
|
||||||
|
.ok()?
|
||||||
|
.host_str()
|
||||||
|
.map(|host| host.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_domain_like(raw: &str) -> Option<String> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(host) = host_from_url(trimmed) {
|
||||||
|
return Some(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
let without_scheme = trimmed
|
||||||
|
.trim_start_matches("https://")
|
||||||
|
.trim_start_matches("http://");
|
||||||
|
let host = without_scheme
|
||||||
|
.split(['/', '?', '#'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
(!host.is_empty()).then_some(host)
|
||||||
|
}
|
||||||
|
|
||||||
fn format_browser_action_error(data: &Value) -> String {
|
fn format_browser_action_error(data: &Value) -> String {
|
||||||
if let Some(error) = data.get("error") {
|
if let Some(error) = data.get("error") {
|
||||||
if let Some(message) = error.get("message").and_then(Value::as_str) {
|
if let Some(message) = error.get("message").and_then(Value::as_str) {
|
||||||
@@ -193,6 +289,111 @@ fn format_browser_action_error(data: &Value) -> String {
|
|||||||
format!("browser action failed: {data}")
|
format!("browser action failed: {data}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compact_json_value(value: &Value, depth: usize) -> Value {
|
||||||
|
compact_json_value_with_string_limit(value, depth, MAX_DATA_STRING_CHARS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_aom_snapshot(snapshot: &[Value]) -> Value {
|
||||||
|
Value::Array(
|
||||||
|
snapshot
|
||||||
|
.iter()
|
||||||
|
.take(MAX_DATA_ARRAY_ITEMS)
|
||||||
|
.map(|item| compact_aom_value(item, 0))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_aom_value(value: &Value, depth: usize) -> Value {
|
||||||
|
if depth >= MAX_DATA_RECURSION_DEPTH {
|
||||||
|
return Value::String("[truncated nested value]".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
let mut compacted = Map::new();
|
||||||
|
for (key, item) in map.iter().take(MAX_DATA_OBJECT_FIELDS) {
|
||||||
|
if matches!(key.as_str(), "text" | "value" | "html") {
|
||||||
|
let summary = item
|
||||||
|
.as_str()
|
||||||
|
.map(|text| format!("[{} chars omitted]", text.chars().count()))
|
||||||
|
.unwrap_or_else(|| "[omitted]".to_string());
|
||||||
|
compacted.insert(key.clone(), Value::String(summary));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
compacted.insert(key.clone(), compact_aom_value(item, depth + 1));
|
||||||
|
}
|
||||||
|
Value::Object(compacted)
|
||||||
|
}
|
||||||
|
Value::Array(items) => Value::Array(
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.take(MAX_DATA_ARRAY_ITEMS)
|
||||||
|
.map(|item| compact_aom_value(item, depth + 1))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
_ => compact_json_value_with_string_limit(value, depth, MAX_AOM_STRING_CHARS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_json_value_with_string_limit(
|
||||||
|
value: &Value,
|
||||||
|
depth: usize,
|
||||||
|
max_string_chars: usize,
|
||||||
|
) -> Value {
|
||||||
|
if depth >= MAX_DATA_RECURSION_DEPTH {
|
||||||
|
return Value::String("[truncated nested value]".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
match value {
|
||||||
|
Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
|
||||||
|
Value::String(text) => Value::String(truncate_string(text, max_string_chars)),
|
||||||
|
Value::Array(items) => {
|
||||||
|
let mut compacted: Vec<Value> = items
|
||||||
|
.iter()
|
||||||
|
.take(MAX_DATA_ARRAY_ITEMS)
|
||||||
|
.map(|item| compact_json_value_with_string_limit(item, depth + 1, max_string_chars))
|
||||||
|
.collect();
|
||||||
|
if items.len() > MAX_DATA_ARRAY_ITEMS {
|
||||||
|
compacted.push(Value::String(format!(
|
||||||
|
"[{} more items omitted]",
|
||||||
|
items.len() - MAX_DATA_ARRAY_ITEMS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Value::Array(compacted)
|
||||||
|
}
|
||||||
|
Value::Object(map) => {
|
||||||
|
let mut compacted = Map::new();
|
||||||
|
for (key, item) in map.iter().take(MAX_DATA_OBJECT_FIELDS) {
|
||||||
|
compacted.insert(
|
||||||
|
key.clone(),
|
||||||
|
compact_json_value_with_string_limit(item, depth + 1, max_string_chars),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if map.len() > MAX_DATA_OBJECT_FIELDS {
|
||||||
|
compacted.insert(
|
||||||
|
"_truncated_fields".to_string(),
|
||||||
|
Value::String(format!(
|
||||||
|
"{} additional fields omitted",
|
||||||
|
map.len() - MAX_DATA_OBJECT_FIELDS
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Value::Object(compacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_string(text: &str, max_chars: usize) -> String {
|
||||||
|
let total_chars = text.chars().count();
|
||||||
|
if total_chars <= max_chars {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix: String = text.chars().take(max_chars).collect();
|
||||||
|
format!("{prefix}...[truncated {} chars]", total_chars - max_chars)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
enum BrowserActionAdapterError {
|
enum BrowserActionAdapterError {
|
||||||
#[error("unsupported action: {0}")]
|
#[error("unsupported action: {0}")]
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ pub fn log_entry_for_turn_event(event: &TurnEvent) -> Option<AgentMessage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn format_tool_call(name: &str, args: &Value) -> String {
|
fn format_tool_call(name: &str, args: &Value) -> String {
|
||||||
if name != "browser_action" {
|
if name == "read_skill" {
|
||||||
|
let skill_name = args
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("<missing-skill>");
|
||||||
|
return format!("read_skill {skill_name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_browser_tool_call(name) {
|
||||||
return format!("call {name}");
|
return format!("call {name}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +62,14 @@ fn format_tool_call(name: &str, args: &Value) -> String {
|
|||||||
.unwrap_or("<missing-selector>");
|
.unwrap_or("<missing-selector>");
|
||||||
format!("getText {selector}")
|
format!("getText {selector}")
|
||||||
}
|
}
|
||||||
other => format!("browser_action {other}"),
|
other => format!("{name} {other}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_browser_tool_call(name: &str) -> bool {
|
||||||
|
name == "browser_action" || name == "superrpa_browser"
|
||||||
|
}
|
||||||
|
|
||||||
fn is_tool_error(output: &str) -> bool {
|
fn is_tool_error(output: &str) -> bool {
|
||||||
output.starts_with("Error:")
|
output.starts_with("Error:")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,8 @@ pub mod config_adapter;
|
|||||||
pub mod cron_adapter;
|
pub mod cron_adapter;
|
||||||
pub mod event_bridge;
|
pub mod event_bridge;
|
||||||
pub mod memory_adapter;
|
pub mod memory_adapter;
|
||||||
|
pub mod openxml_office_tool;
|
||||||
|
pub mod orchestration;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
pub mod screen_html_export_tool;
|
||||||
|
pub mod workflow_executor;
|
||||||
|
|||||||
392
src/compat/openxml_office_tool.rs
Normal file
392
src/compat/openxml_office_tool.rs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use zeroclaw::tools::{Tool, ToolResult};
|
||||||
|
|
||||||
|
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
|
||||||
|
const DEFAULT_SHEET_NAME: &str = "知乎热榜";
|
||||||
|
const MAX_COLUMNS: [&str; 3] = ["rank", "title", "heat"];
|
||||||
|
|
||||||
|
pub struct OpenXmlOfficeTool {
|
||||||
|
workspace_root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenXmlOfficeTool {
|
||||||
|
pub fn new(workspace_root: PathBuf) -> Self {
|
||||||
|
Self { workspace_root }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenXmlOfficeArgs {
|
||||||
|
sheet_name: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
rows: Vec<Vec<Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
output_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for OpenXmlOfficeTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
OPENXML_OFFICE_TOOL_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Export structured Zhihu hotlist rows into a local .xlsx file through the OpenXML office pipeline."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"required": ["sheet_name", "columns", "rows"],
|
||||||
|
"properties": {
|
||||||
|
"sheet_name": { "type": "string" },
|
||||||
|
"columns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output_path": { "type": "string" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||||
|
let parsed = match serde_json::from_value::<OpenXmlOfficeArgs>(args) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => return Ok(failed_tool_result(format!("invalid tool arguments: {err}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if parsed.sheet_name.trim() != DEFAULT_SHEET_NAME {
|
||||||
|
return Ok(failed_tool_result(format!(
|
||||||
|
"unsupported sheet_name: expected {DEFAULT_SHEET_NAME}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_columns = MAX_COLUMNS
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if parsed.columns != expected_columns {
|
||||||
|
return Ok(failed_tool_result(
|
||||||
|
"unsupported columns: expected [rank, title, heat]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.rows.is_empty() {
|
||||||
|
return Ok(failed_tool_result("rows must not be empty".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.rows.iter().any(|row| row.len() != 3) {
|
||||||
|
return Ok(failed_tool_result(
|
||||||
|
"each row must contain exactly 3 values".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let job_root = create_job_root(&self.workspace_root)?;
|
||||||
|
let template_path = job_root.join("zhihu_hotlist_template.xlsx");
|
||||||
|
let payload_path = job_root.join("payload.json");
|
||||||
|
let request_path = job_root.join("request.json");
|
||||||
|
let output_path = parsed
|
||||||
|
.output_path
|
||||||
|
.as_deref()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| default_output_path(&self.workspace_root));
|
||||||
|
|
||||||
|
write_hotlist_template(&template_path, parsed.rows.len())?;
|
||||||
|
write_payload_json(&payload_path, &parsed.rows)?;
|
||||||
|
write_request_json(&request_path, &template_path, &payload_path, &output_path)?;
|
||||||
|
|
||||||
|
let rendered = run_openxml_cli(&request_path)?;
|
||||||
|
let artifact_path = rendered["data"]["artifact"]["path"]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| output_path.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: json!({
|
||||||
|
"sheet_name": DEFAULT_SHEET_NAME,
|
||||||
|
"output_path": artifact_path,
|
||||||
|
"row_count": parsed.rows.len(),
|
||||||
|
"renderer": OPENXML_OFFICE_TOOL_NAME
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed_tool_result(error: String) -> ToolResult {
|
||||||
|
ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_job_root(workspace_root: &Path) -> anyhow::Result<PathBuf> {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)?
|
||||||
|
.as_nanos();
|
||||||
|
let path = workspace_root.join(".sgclaw-openxml").join(format!("{nanos}"));
|
||||||
|
fs::create_dir_all(&path)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_output_path(workspace_root: &Path) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|value| value.as_nanos())
|
||||||
|
.unwrap_or_default();
|
||||||
|
workspace_root
|
||||||
|
.join("out")
|
||||||
|
.join(format!("zhihu-hotlist-{nanos}.xlsx"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_payload_json(path: &Path, rows: &[Vec<Value>]) -> anyhow::Result<()> {
|
||||||
|
let mut variables = BTreeMap::new();
|
||||||
|
for (idx, row) in rows.iter().enumerate() {
|
||||||
|
let row_index = idx + 1;
|
||||||
|
variables.insert(format!("RANK_{row_index}"), value_to_string(&row[0]));
|
||||||
|
variables.insert(format!("TITLE_{row_index}"), value_to_string(&row[1]));
|
||||||
|
variables.insert(format!("HEAT_{row_index}"), value_to_string(&row[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"variables": variables,
|
||||||
|
"tables": {},
|
||||||
|
"images": {}
|
||||||
|
});
|
||||||
|
fs::write(path, serde_json::to_vec_pretty(&payload)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_request_json(
|
||||||
|
path: &Path,
|
||||||
|
template_path: &Path,
|
||||||
|
payload_path: &Path,
|
||||||
|
output_path: &Path,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if let Some(parent) = output_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = json!({
|
||||||
|
"api_version": "2026-03-26",
|
||||||
|
"job": "zhihu_hotlist_export",
|
||||||
|
"template": {
|
||||||
|
"kind": "xlsx",
|
||||||
|
"path": template_path
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"path": output_path
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"json_path": payload_path
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"strict": true,
|
||||||
|
"allow_unresolved": false,
|
||||||
|
"dry_run": false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fs::write(path, serde_json::to_vec_pretty(&request)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
|
||||||
|
let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.map(|path| path.join("openxml_cli").join("Cargo.toml"))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli manifest path"))?;
|
||||||
|
let binary_path = manifest_path
|
||||||
|
.parent()
|
||||||
|
.map(|path| path.join("target").join("debug").join("openxml-cli"))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli binary path"))?;
|
||||||
|
|
||||||
|
let output = if binary_path.exists() {
|
||||||
|
Command::new(&binary_path)
|
||||||
|
.args([
|
||||||
|
"template",
|
||||||
|
"render",
|
||||||
|
"--request",
|
||||||
|
request_path.to_string_lossy().as_ref(),
|
||||||
|
"--json",
|
||||||
|
])
|
||||||
|
.output()?
|
||||||
|
} else {
|
||||||
|
Command::new("cargo")
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"--quiet",
|
||||||
|
"--manifest-path",
|
||||||
|
manifest_path.to_string_lossy().as_ref(),
|
||||||
|
"--",
|
||||||
|
"template",
|
||||||
|
"render",
|
||||||
|
"--request",
|
||||||
|
request_path.to_string_lossy().as_ref(),
|
||||||
|
"--json",
|
||||||
|
])
|
||||||
|
.output()?
|
||||||
|
};
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
return Err(anyhow::anyhow!(if stderr.is_empty() {
|
||||||
|
"openxml_cli render failed".to_string()
|
||||||
|
} else {
|
||||||
|
stderr
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8(output.stdout)?;
|
||||||
|
Ok(serde_json::from_str(&stdout)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_string(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::String(text) => text.clone(),
|
||||||
|
Value::Number(number) => number.to_string(),
|
||||||
|
Value::Bool(flag) => flag.to_string(),
|
||||||
|
Value::Null => String::new(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_hotlist_template(path: &Path, row_count: usize) -> anyhow::Result<()> {
|
||||||
|
let build_root = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("template path has no parent"))?
|
||||||
|
.join("template-build");
|
||||||
|
fs::create_dir_all(build_root.join("_rels"))?;
|
||||||
|
fs::create_dir_all(build_root.join("docProps"))?;
|
||||||
|
fs::create_dir_all(build_root.join("xl/_rels"))?;
|
||||||
|
fs::create_dir_all(build_root.join("xl/worksheets"))?;
|
||||||
|
|
||||||
|
fs::write(build_root.join("[Content_Types].xml"), content_types_xml())?;
|
||||||
|
fs::write(build_root.join("_rels/.rels"), root_rels_xml())?;
|
||||||
|
fs::write(build_root.join("docProps/app.xml"), app_xml())?;
|
||||||
|
fs::write(build_root.join("docProps/core.xml"), core_xml())?;
|
||||||
|
fs::write(build_root.join("xl/workbook.xml"), workbook_xml())?;
|
||||||
|
fs::write(
|
||||||
|
build_root.join("xl/_rels/workbook.xml.rels"),
|
||||||
|
workbook_rels_xml(),
|
||||||
|
)?;
|
||||||
|
fs::write(
|
||||||
|
build_root.join("xl/worksheets/sheet1.xml"),
|
||||||
|
worksheet_xml(row_count),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let zip = Command::new("zip")
|
||||||
|
.current_dir(&build_root)
|
||||||
|
.args(["-q", "-r", path.to_string_lossy().as_ref(), "."])
|
||||||
|
.output()?;
|
||||||
|
if !zip.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&zip.stderr);
|
||||||
|
return Err(anyhow::anyhow!(format!(
|
||||||
|
"failed to create xlsx template: {}",
|
||||||
|
stderr.trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&build_root);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worksheet_xml(row_count: usize) -> String {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
rows.push(
|
||||||
|
"<row r=\"1\"><c r=\"A1\" t=\"inlineStr\"><is><t>rank</t></is></c><c r=\"B1\" t=\"inlineStr\"><is><t>title</t></is></c><c r=\"C1\" t=\"inlineStr\"><is><t>heat</t></is></c></row>"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
for idx in 1..=row_count {
|
||||||
|
let excel_row = idx + 1;
|
||||||
|
rows.push(format!(
|
||||||
|
"<row r=\"{excel_row}\"><c r=\"A{excel_row}\" t=\"inlineStr\"><is><t>{{{{RANK_{idx}}}}}</t></is></c><c r=\"B{excel_row}\" t=\"inlineStr\"><is><t>{{{{TITLE_{idx}}}}}</t></is></c><c r=\"C{excel_row}\" t=\"inlineStr\"><is><t>{{{{HEAT_{idx}}}}}</t></is></c></row>"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
|
||||||
|
<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
|
||||||
|
<sheetData>{}</sheetData>\
|
||||||
|
</worksheet>",
|
||||||
|
rows.join("")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_types_xml() -> &'static str {
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||||
|
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||||
|
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||||||
|
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
||||||
|
</Types>"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_rels_xml() -> &'static str {
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||||
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||||
|
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
||||||
|
</Relationships>"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_xml() -> &'static str {
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
|
||||||
|
xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
||||||
|
<Application>sgClaw</Application>
|
||||||
|
</Properties>"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn core_xml() -> &'static str {
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:dcterms="http://purl.org/dc/terms/"
|
||||||
|
xmlns:dcmitype="http://purl.org/dc/dcmitype/"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||||
|
<dc:title>Zhihu Hotlist Export</dc:title>
|
||||||
|
</cp:coreProperties>"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workbook_xml() -> &'static str {
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||||
|
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<sheets>
|
||||||
|
<sheet name="知乎热榜" sheetId="1" r:id="rId1"/>
|
||||||
|
</sheets>
|
||||||
|
</workbook>"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workbook_rels_xml() -> &'static str {
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||||
|
</Relationships>"#
|
||||||
|
}
|
||||||
67
src/compat/orchestration.rs
Normal file
67
src/compat/orchestration.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::compat::runtime::CompatTaskContext;
|
||||||
|
use crate::config::SgClawSettings;
|
||||||
|
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
|
||||||
|
|
||||||
|
pub fn should_use_primary_orchestration(
|
||||||
|
instruction: &str,
|
||||||
|
page_url: Option<&str>,
|
||||||
|
page_title: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
let normalized = instruction.to_ascii_lowercase();
|
||||||
|
let needs_export = normalized.contains("excel")
|
||||||
|
|| normalized.contains("xlsx")
|
||||||
|
|| instruction.contains("导出")
|
||||||
|
|| instruction.contains("大屏")
|
||||||
|
|| instruction.contains("新标签页")
|
||||||
|
|| normalized.contains("dashboard");
|
||||||
|
|
||||||
|
crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) && needs_export
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||||
|
transport: &T,
|
||||||
|
browser_tool: BrowserPipeTool<T>,
|
||||||
|
instruction: &str,
|
||||||
|
task_context: &CompatTaskContext,
|
||||||
|
workspace_root: &Path,
|
||||||
|
settings: &SgClawSettings,
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
let route = crate::compat::workflow_executor::detect_route(
|
||||||
|
instruction,
|
||||||
|
task_context.page_url.as_deref(),
|
||||||
|
task_context.page_title.as_deref(),
|
||||||
|
);
|
||||||
|
let primary_result = crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||||
|
transport,
|
||||||
|
browser_tool.clone(),
|
||||||
|
instruction,
|
||||||
|
task_context,
|
||||||
|
workspace_root,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
match (route, primary_result) {
|
||||||
|
(Some(route), Ok(summary))
|
||||||
|
if crate::compat::workflow_executor::should_fallback_after_summary(&summary, &route) =>
|
||||||
|
{
|
||||||
|
crate::compat::workflow_executor::execute_route(
|
||||||
|
transport,
|
||||||
|
&browser_tool,
|
||||||
|
workspace_root,
|
||||||
|
instruction,
|
||||||
|
route,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(_, Ok(summary)) => Ok(summary),
|
||||||
|
(Some(route), Err(_)) => crate::compat::workflow_executor::execute_route(
|
||||||
|
transport,
|
||||||
|
&browser_tool,
|
||||||
|
workspace_root,
|
||||||
|
instruction,
|
||||||
|
route,
|
||||||
|
),
|
||||||
|
(None, Err(err)) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
382
src/compat/screen_html_export_tool.rs
Normal file
382
src/compat/screen_html_export_tool.rs
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use reqwest::Url;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use zeroclaw::tools::{Tool, ToolResult};
|
||||||
|
|
||||||
|
const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
|
||||||
|
const DEFAULT_SCREEN_TITLE: &str = "知乎热榜主题分类分析大屏";
|
||||||
|
const TEMPLATE: &str = include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../skill_lib/skills/zhihu-hotlist-screen/assets/zhihu-hotlist-echarts.html"
|
||||||
|
));
|
||||||
|
const PAYLOAD_START_MARKER: &str = " const defaultPayload = ";
|
||||||
|
const PAYLOAD_END_MARKER: &str = "\n\n const themeMeta = {";
|
||||||
|
|
||||||
|
pub struct ScreenHtmlExportTool {
|
||||||
|
workspace_root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScreenHtmlExportTool {
|
||||||
|
pub fn new(workspace_root: PathBuf) -> Self {
|
||||||
|
Self { workspace_root }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ScreenHtmlExportArgs {
|
||||||
|
#[serde(default)]
|
||||||
|
snapshot_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
generated_at_ms: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
rows: Option<Vec<Vec<Value>>>,
|
||||||
|
#[serde(default)]
|
||||||
|
table: Option<Vec<ScreenTableRow>>,
|
||||||
|
#[serde(default)]
|
||||||
|
categories: Option<Vec<ScreenCategory>>,
|
||||||
|
#[serde(default)]
|
||||||
|
output_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
struct ScreenCategory {
|
||||||
|
category_code: String,
|
||||||
|
category_label: String,
|
||||||
|
item_count: u64,
|
||||||
|
total_heat: u64,
|
||||||
|
avg_heat: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
struct ScreenTableRow {
|
||||||
|
rank: u64,
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
category_code: String,
|
||||||
|
category_label: String,
|
||||||
|
heat_text: String,
|
||||||
|
heat_value: u64,
|
||||||
|
reply_count: u64,
|
||||||
|
upvote_count: u64,
|
||||||
|
favorite_count: u64,
|
||||||
|
heart_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ScreenPayload {
|
||||||
|
snapshot_id: String,
|
||||||
|
generated_at_ms: u64,
|
||||||
|
categories: Vec<ScreenCategory>,
|
||||||
|
table: Vec<ScreenTableRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ScreenHtmlExportTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
SCREEN_HTML_EXPORT_TOOL_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Render a local Zhihu hotlist ECharts dashboard HTML for leadership demos and new-tab presentation."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"snapshot_id": { "type": "string" },
|
||||||
|
"generated_at_ms": { "type": "integer" },
|
||||||
|
"rows": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "object" }
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "object" }
|
||||||
|
},
|
||||||
|
"output_path": { "type": "string" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||||
|
let parsed = match serde_json::from_value::<ScreenHtmlExportArgs>(args) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => return Ok(failed_tool_result(format!("invalid tool arguments: {err}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let table = match parsed.table {
|
||||||
|
Some(table) if !table.is_empty() => table,
|
||||||
|
Some(_) => return Ok(failed_tool_result("table must not be empty".to_string())),
|
||||||
|
None => match parsed.rows {
|
||||||
|
Some(rows) => build_table_from_rows(&rows)?,
|
||||||
|
None => {
|
||||||
|
return Ok(failed_tool_result(
|
||||||
|
"rows or table is required for screen_html_export".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if table.is_empty() {
|
||||||
|
return Ok(failed_tool_result("table must not be empty".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let categories = parsed
|
||||||
|
.categories
|
||||||
|
.filter(|items| !items.is_empty())
|
||||||
|
.unwrap_or_else(|| derive_categories(&table));
|
||||||
|
let payload = ScreenPayload {
|
||||||
|
snapshot_id: parsed
|
||||||
|
.snapshot_id
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(default_snapshot_id),
|
||||||
|
generated_at_ms: parsed.generated_at_ms.unwrap_or_else(now_ms),
|
||||||
|
categories,
|
||||||
|
table,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = render_template(&payload)?;
|
||||||
|
let output_path = parsed
|
||||||
|
.output_path
|
||||||
|
.as_deref()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| default_output_path(&self.workspace_root));
|
||||||
|
write_output_html(&output_path, &rendered)?;
|
||||||
|
|
||||||
|
let presentation_url = file_url_for_path(&output_path);
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: json!({
|
||||||
|
"title": DEFAULT_SCREEN_TITLE,
|
||||||
|
"output_path": output_path,
|
||||||
|
"renderer": SCREEN_HTML_EXPORT_TOOL_NAME,
|
||||||
|
"row_count": payload.table.len(),
|
||||||
|
"snapshot_id": payload.snapshot_id,
|
||||||
|
"presentation": {
|
||||||
|
"mode": "new_tab",
|
||||||
|
"title": DEFAULT_SCREEN_TITLE,
|
||||||
|
"url": presentation_url,
|
||||||
|
"open_in_new_tab": true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed_tool_result(error: String) -> ToolResult {
|
||||||
|
ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_table_from_rows(rows: &[Vec<Value>]) -> anyhow::Result<Vec<ScreenTableRow>> {
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("rows must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, row)| {
|
||||||
|
if row.len() != 3 {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"each row must contain exactly 3 values: rank, title, heat"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rank = value_to_rank(&row[0]).unwrap_or((index + 1) as u64);
|
||||||
|
let title = value_to_string(&row[1]);
|
||||||
|
if title.trim().is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("title must not be empty"));
|
||||||
|
}
|
||||||
|
let heat_text = value_to_string(&row[2]);
|
||||||
|
let heat_value = parse_heat_value(&heat_text);
|
||||||
|
let (category_code, category_label) = classify_title(&title);
|
||||||
|
|
||||||
|
Ok(ScreenTableRow {
|
||||||
|
rank,
|
||||||
|
title,
|
||||||
|
url: format!("https://www.zhihu.com/question/hotlist-{rank}"),
|
||||||
|
category_code: category_code.to_string(),
|
||||||
|
category_label: category_label.to_string(),
|
||||||
|
heat_text,
|
||||||
|
heat_value,
|
||||||
|
reply_count: 0,
|
||||||
|
upvote_count: 0,
|
||||||
|
favorite_count: 0,
|
||||||
|
heart_count: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_categories(table: &[ScreenTableRow]) -> Vec<ScreenCategory> {
|
||||||
|
let mut grouped: BTreeMap<(String, String), (u64, u64)> = BTreeMap::new();
|
||||||
|
for row in table {
|
||||||
|
let key = (row.category_code.clone(), row.category_label.clone());
|
||||||
|
let entry = grouped.entry(key).or_insert((0, 0));
|
||||||
|
entry.0 += 1;
|
||||||
|
entry.1 += row.heat_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped
|
||||||
|
.into_iter()
|
||||||
|
.map(|((category_code, category_label), (item_count, total_heat))| ScreenCategory {
|
||||||
|
category_code,
|
||||||
|
category_label,
|
||||||
|
item_count,
|
||||||
|
total_heat,
|
||||||
|
avg_heat: if item_count == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
total_heat / item_count
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_title(title: &str) -> (&'static str, &'static str) {
|
||||||
|
let normalized = title.to_ascii_lowercase();
|
||||||
|
if contains_any(&normalized, &["ai", "芯片", "科技", "算法", "机器人", "无人机"]) {
|
||||||
|
return ("technology", "科技");
|
||||||
|
}
|
||||||
|
if contains_any(&normalized, &["电影", "综艺", "明星", "周杰伦", "短剧", "娱乐"]) {
|
||||||
|
return ("entertainment", "娱乐");
|
||||||
|
}
|
||||||
|
if contains_any(&normalized, &["足球", "比赛", "联赛", "国足", "体育", "冠军"]) {
|
||||||
|
return ("sports", "体育");
|
||||||
|
}
|
||||||
|
if contains_any(&normalized, &["航母", "作战", "军", "军事", "演训"]) {
|
||||||
|
return ("military", "军事");
|
||||||
|
}
|
||||||
|
if contains_any(&normalized, &["出口", "经济", "市场", "财经", "消费", "股"]) {
|
||||||
|
return ("finance", "财经");
|
||||||
|
}
|
||||||
|
("society", "社会")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
|
||||||
|
needles.iter().any(|needle| haystack.contains(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_heat_value(heat_text: &str) -> u64 {
|
||||||
|
let compact = heat_text.trim().replace(',', "");
|
||||||
|
if compact.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let number_part = compact
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| ch.is_ascii_digit() || *ch == '.')
|
||||||
|
.collect::<String>();
|
||||||
|
let base = number_part.parse::<f64>().unwrap_or(0.0);
|
||||||
|
|
||||||
|
let multiplier = if compact.contains('亿') {
|
||||||
|
100_000_000.0
|
||||||
|
} else if compact.contains('万') {
|
||||||
|
10_000.0
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
(base * multiplier).round() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_string(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::String(text) => text.clone(),
|
||||||
|
Value::Number(number) => number.to_string(),
|
||||||
|
Value::Bool(flag) => flag.to_string(),
|
||||||
|
Value::Null => String::new(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_rank(value: &Value) -> Option<u64> {
|
||||||
|
match value {
|
||||||
|
Value::Number(number) => number.as_u64(),
|
||||||
|
Value::String(text) => text.trim().parse::<u64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_template(payload: &ScreenPayload) -> anyhow::Result<String> {
|
||||||
|
let payload_json = serde_json::to_string_pretty(payload)?;
|
||||||
|
let payload_start = TEMPLATE
|
||||||
|
.find(PAYLOAD_START_MARKER)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("default payload start marker missing"))?;
|
||||||
|
let payload_end = TEMPLATE
|
||||||
|
.find(PAYLOAD_END_MARKER)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("default payload end marker missing"))?;
|
||||||
|
let replacement = format!(
|
||||||
|
"{PAYLOAD_START_MARKER}{}\n",
|
||||||
|
indent_block(&payload_json, " ")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"{}{}{}",
|
||||||
|
&TEMPLATE[..payload_start],
|
||||||
|
replacement,
|
||||||
|
&TEMPLATE[payload_end..],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn indent_block(value: &str, indent: &str) -> String {
|
||||||
|
value
|
||||||
|
.lines()
|
||||||
|
.map(|line| format!("{indent}{line}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_output_html(path: &Path, rendered: &str) -> anyhow::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(path, rendered)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_output_path(workspace_root: &Path) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|value| value.as_nanos())
|
||||||
|
.unwrap_or_default();
|
||||||
|
workspace_root
|
||||||
|
.join("out")
|
||||||
|
.join(format!("zhihu-hotlist-screen-{nanos}.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_snapshot_id() -> String {
|
||||||
|
format!("zhihu-hotlist-screen-{}", now_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|value| value.as_millis() as u64)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_url_for_path(path: &Path) -> String {
|
||||||
|
Url::from_file_path(path)
|
||||||
|
.map(|url| url.to_string())
|
||||||
|
.unwrap_or_else(|_| format!("file://{}", path.display()))
|
||||||
|
}
|
||||||
346
src/compat/workflow_executor.rs
Normal file
346
src/compat/workflow_executor.rs
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use zeroclaw::tools::Tool;
|
||||||
|
|
||||||
|
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||||
|
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||||
|
use crate::pipe::{Action, AgentMessage, BrowserPipeTool, PipeError, Transport};
|
||||||
|
|
||||||
|
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||||
|
const ZHIHU_HOT_URL: &str = "https://www.zhihu.com/hot";
|
||||||
|
const HOTLIST_ROOT_SELECTORS: [&str; 3] = ["main", "body", "html"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum WorkflowRoute {
|
||||||
|
ZhihuHotlistExportXlsx,
|
||||||
|
ZhihuHotlistScreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct HotlistItem {
|
||||||
|
rank: u64,
|
||||||
|
title: String,
|
||||||
|
heat: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_route(
|
||||||
|
instruction: &str,
|
||||||
|
page_url: Option<&str>,
|
||||||
|
page_title: Option<&str>,
|
||||||
|
) -> Option<WorkflowRoute> {
|
||||||
|
if !crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = instruction.to_ascii_lowercase();
|
||||||
|
if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") {
|
||||||
|
return Some(WorkflowRoute::ZhihuHotlistScreen);
|
||||||
|
}
|
||||||
|
if normalized.contains("excel") || normalized.contains("xlsx") || instruction.contains("导出") {
|
||||||
|
return Some(WorkflowRoute::ZhihuHotlistExportXlsx);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bool {
|
||||||
|
let normalized = summary.to_ascii_lowercase();
|
||||||
|
if normalized.contains(".xlsx") || normalized.contains(".html") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let looks_like_denial = summary.contains("拒绝") ||
|
||||||
|
normalized.contains("denied") ||
|
||||||
|
normalized.contains("failed") ||
|
||||||
|
summary.contains("失败") ||
|
||||||
|
summary.contains("无法");
|
||||||
|
|
||||||
|
looks_like_denial || matches!(route, WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_route<T: Transport + 'static>(
|
||||||
|
transport: &T,
|
||||||
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
|
workspace_root: &Path,
|
||||||
|
instruction: &str,
|
||||||
|
route: WorkflowRoute,
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
let top_n = extract_top_n(instruction);
|
||||||
|
let items = collect_hotlist_items(transport, browser_tool, top_n)?;
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(PipeError::Protocol(
|
||||||
|
"知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
match route {
|
||||||
|
WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items),
|
||||||
|
WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_hotlist_items<T: Transport + 'static>(
|
||||||
|
transport: &T,
|
||||||
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
|
top_n: usize,
|
||||||
|
) -> Result<Vec<HotlistItem>, PipeError> {
|
||||||
|
navigate_hotlist_with_retry(transport, browser_tool)?;
|
||||||
|
|
||||||
|
for selector in HOTLIST_ROOT_SELECTORS {
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: format!("getText {selector}"),
|
||||||
|
})?;
|
||||||
|
let response = browser_tool.invoke(
|
||||||
|
Action::GetText,
|
||||||
|
json!({ "selector": selector }),
|
||||||
|
ZHIHU_DOMAIN,
|
||||||
|
)?;
|
||||||
|
if !response.success {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text = response.data["text"].as_str().unwrap_or_default();
|
||||||
|
let items = parse_hotlist_items(text, top_n);
|
||||||
|
if !items.is_empty() {
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigate_hotlist_with_retry<T: Transport + 'static>(
|
||||||
|
transport: &T,
|
||||||
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
|
) -> Result<(), PipeError> {
|
||||||
|
let mut last_error = None;
|
||||||
|
for _ in 0..2 {
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: format!("navigate {ZHIHU_HOT_URL}"),
|
||||||
|
})?;
|
||||||
|
match browser_tool.invoke(
|
||||||
|
Action::Navigate,
|
||||||
|
json!({ "url": ZHIHU_HOT_URL }),
|
||||||
|
ZHIHU_DOMAIN,
|
||||||
|
) {
|
||||||
|
Ok(response) if response.success => return Ok(()),
|
||||||
|
Ok(response) => {
|
||||||
|
last_error = Some(PipeError::Protocol(format!(
|
||||||
|
"navigate failed: {}",
|
||||||
|
response.data
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(err) => last_error = Some(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| {
|
||||||
|
PipeError::Protocol("navigate failed without detailed error".to_string())
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_xlsx<T: Transport>(
|
||||||
|
transport: &T,
|
||||||
|
workspace_root: &Path,
|
||||||
|
items: &[HotlistItem],
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "call openxml_office".to_string(),
|
||||||
|
})?;
|
||||||
|
let tool = OpenXmlOfficeTool::new(workspace_root.to_path_buf());
|
||||||
|
let rows = items
|
||||||
|
.iter()
|
||||||
|
.map(|item| json!([item.rank, item.title, item.heat]))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let runtime = tokio::runtime::Runtime::new()
|
||||||
|
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||||
|
let result = runtime
|
||||||
|
.block_on(tool.execute(json!({
|
||||||
|
"sheet_name": "知乎热榜",
|
||||||
|
"columns": ["rank", "title", "heat"],
|
||||||
|
"rows": rows,
|
||||||
|
})))
|
||||||
|
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||||
|
if !result.success {
|
||||||
|
return Err(PipeError::Protocol(
|
||||||
|
result.error.unwrap_or_else(|| "openxml_office failed".to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Value = serde_json::from_str(&result.output)
|
||||||
|
.map_err(|err| PipeError::Protocol(format!("invalid openxml_office output: {err}")))?;
|
||||||
|
let output_path = payload["output_path"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| PipeError::Protocol("openxml_office did not return output_path".to_string()))?;
|
||||||
|
Ok(format!("已导出知乎热榜 Excel {output_path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_screen<T: Transport>(
|
||||||
|
transport: &T,
|
||||||
|
workspace_root: &Path,
|
||||||
|
items: &[HotlistItem],
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "call screen_html_export".to_string(),
|
||||||
|
})?;
|
||||||
|
let tool = ScreenHtmlExportTool::new(workspace_root.to_path_buf());
|
||||||
|
let rows = items
|
||||||
|
.iter()
|
||||||
|
.map(|item| json!([item.rank, item.title, item.heat]))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let runtime = tokio::runtime::Runtime::new()
|
||||||
|
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||||
|
let result = runtime
|
||||||
|
.block_on(tool.execute(json!({ "rows": rows })))
|
||||||
|
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||||
|
if !result.success {
|
||||||
|
return Err(PipeError::Protocol(
|
||||||
|
result.error.unwrap_or_else(|| "screen_html_export failed".to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Value = serde_json::from_str(&result.output)
|
||||||
|
.map_err(|err| PipeError::Protocol(format!("invalid screen_html_export output: {err}")))?;
|
||||||
|
let output_path = payload["output_path"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| PipeError::Protocol("screen_html_export did not return output_path".to_string()))?;
|
||||||
|
Ok(format!("已生成知乎热榜大屏 {output_path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hotlist_items(text: &str, top_n: usize) -> Vec<HotlistItem> {
|
||||||
|
let mut items = parse_single_line_items(text, top_n);
|
||||||
|
if !items.is_empty() {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = normalize_lines(text);
|
||||||
|
let mut seen_ranks = BTreeSet::new();
|
||||||
|
let mut idx = 0usize;
|
||||||
|
|
||||||
|
while idx < lines.len() && items.len() < top_n {
|
||||||
|
let Some(rank) = parse_rank(&lines[idx]) else {
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !seen_ranks.insert(rank) {
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut title = None;
|
||||||
|
let mut heat = None;
|
||||||
|
for candidate in lines.iter().skip(idx + 1).take(6) {
|
||||||
|
if parse_rank(candidate).is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if heat.is_none() && looks_like_heat(candidate) {
|
||||||
|
heat = Some(normalize_heat(candidate));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if title.is_none() && !is_noise_line(candidate) {
|
||||||
|
title = Some(candidate.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(title), Some(heat)) = (title, heat) {
|
||||||
|
items.push(HotlistItem { rank, title, heat });
|
||||||
|
}
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort_by_key(|item| item.rank);
|
||||||
|
items.truncate(top_n);
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_single_line_items(text: &str, top_n: usize) -> Vec<HotlistItem> {
|
||||||
|
let re = Regex::new(
|
||||||
|
r"(?m)^\s*(\d{1,2})[\.、\s]+(.+?)\s+(\d+(?:\.\d+)?\s*[万亿kKmM]?)\s*(?:热度)?\s*$",
|
||||||
|
)
|
||||||
|
.expect("valid hotlist single-line regex");
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut seen_ranks = BTreeSet::new();
|
||||||
|
|
||||||
|
for capture in re.captures_iter(text) {
|
||||||
|
let rank = capture
|
||||||
|
.get(1)
|
||||||
|
.and_then(|value| value.as_str().parse::<u64>().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if rank == 0 || !seen_ranks.insert(rank) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title = capture.get(2).map(|value| value.as_str().trim()).unwrap_or("");
|
||||||
|
let heat = capture.get(3).map(|value| value.as_str().trim()).unwrap_or("");
|
||||||
|
if title.is_empty() || heat.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push(HotlistItem {
|
||||||
|
rank,
|
||||||
|
title: title.to_string(),
|
||||||
|
heat: normalize_heat(heat),
|
||||||
|
});
|
||||||
|
if items.len() >= top_n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_lines(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_rank(line: &str) -> Option<u64> {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if trimmed.chars().all(|ch| ch.is_ascii_digit()) {
|
||||||
|
return trimmed.parse::<u64>().ok().filter(|value| *value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rank_re = Regex::new(r"^(\d{1,2})[\.、\s]").expect("valid rank regex");
|
||||||
|
rank_re
|
||||||
|
.captures(trimmed)
|
||||||
|
.and_then(|capture| capture.get(1))
|
||||||
|
.and_then(|value| value.as_str().parse::<u64>().ok())
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_heat(line: &str) -> bool {
|
||||||
|
let compact = line.replace(' ', "");
|
||||||
|
let heat_re = Regex::new(r"^\d+(?:\.\d+)?(?:万|亿|k|K|m|M)?(?:热度)?$").expect("valid heat regex");
|
||||||
|
heat_re.is_match(compact.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_heat(line: &str) -> String {
|
||||||
|
line.replace(' ', "")
|
||||||
|
.trim_end_matches("热度")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_noise_line(line: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
line,
|
||||||
|
"知乎" | "知乎热榜" | "热榜" | "首页" | "发现" | "等你来答" | "更多内容"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_top_n(instruction: &str) -> usize {
|
||||||
|
let re = Regex::new(r"(?:前|top\s*)(\d{1,2})").expect("valid top-n regex");
|
||||||
|
re.captures(&instruction.to_ascii_lowercase())
|
||||||
|
.and_then(|capture| capture.get(1))
|
||||||
|
.and_then(|value| value.as_str().parse::<usize>().ok())
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
.unwrap_or(10)
|
||||||
|
}
|
||||||
316
src/runtime/engine.rs
Normal file
316
src/runtime/engine.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use zeroclaw::agent::dispatcher::NativeToolDispatcher;
|
||||||
|
use zeroclaw::agent::Agent;
|
||||||
|
use zeroclaw::config::{Config as ZeroClawConfig, SkillsPromptInjectionMode};
|
||||||
|
use zeroclaw::memory::Memory;
|
||||||
|
use zeroclaw::observability::{NoopObserver, Observer};
|
||||||
|
use zeroclaw::providers::Provider;
|
||||||
|
use zeroclaw::runtime::NativeRuntime;
|
||||||
|
use zeroclaw::tools::{self, ReadSkillTool};
|
||||||
|
use zeroclaw::SecurityPolicy;
|
||||||
|
|
||||||
|
use crate::compat::memory_adapter::build_memory;
|
||||||
|
use crate::pipe::PipeError;
|
||||||
|
use crate::runtime::{RuntimeProfile, ToolPolicy};
|
||||||
|
|
||||||
|
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||||
|
const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
|
||||||
|
const READ_SKILL_TOOL_NAME: &str = "read_skill";
|
||||||
|
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
|
||||||
|
const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
|
||||||
|
const BROWSER_TOOL_CONTRACT_PROMPT: &str = "SuperRPA browser interface contract:\n- Use superrpa_browser as the preferred dedicated SuperRPA interface inside this browser host.\n- browser_action is a legacy alias with the same contract; prefer superrpa_browser when choosing between them.\n- Browser actions allowed by policy are already approved by the user inside this BrowserAttached host.\n- Do not claim a browser action was denied, blocked, or rejected unless an actual tool call returns an error.\n- expected_domain must be the bare hostname only, for example www.zhihu.com.\n- Never include scheme, path, query, fragment, or port in expected_domain.\n- selector values are executed with document.querySelector(...), so they must be valid CSS selectors only.\n- Never use XPath selectors or jQuery-style :contains().\n- Prefer direct navigation to canonical URLs when they are known, instead of clicking text links to reach common pages.\n- If you need broad page content, use getText with a valid CSS selector such as body or a stable container.\n- If a task matches an installed skill, load that skill first and then execute it through the SuperRPA interface.";
|
||||||
|
const ZHIHU_HOTLIST_EXECUTION_PROMPT: &str = "Zhihu hotlist execution contract:\n- Treat Zhihu hotlist export/presentation requests as a real browser workflow, not as a text-only summarization task.\n- You must attempt the browser workflow before concluding failure; a prose-only answer is invalid for this workflow.\n- If the current page is not already `https://www.zhihu.com/hot`, navigate there first.\n- Collect the live list with superrpa_browser using `getText` on `main` first; only fall back to `body` or `html` if `main` is unavailable.\n- Extract ordered rows containing `rank`, `title`, and `heat` from the live page text.\n- Do not use shell, web_fetch, web_search_tool, or fabricated sample data for this workflow.\n- Do not repeat the same sentence or section in your final answer.";
|
||||||
|
const OFFICE_EXPORT_COMPLETION_PROMPT: &str = "Export completion contract:\n- This task requires a real Excel export.\n- After the Zhihu rows are available, you must call openxml_office before finishing.\n- Never fabricate, simulate, or invent substitute hotlist data when a live collection/export task fails.\n- If live collection fails, report the failure concisely instead of producing fake rows.\n- Do not stop after describing how you will parse or export the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the generated local .xlsx path.";
|
||||||
|
const SCREEN_EXPORT_COMPLETION_PROMPT: &str = "Presentation completion contract:\n- This task requires a real dashboard artifact.\n- After the Zhihu rows are available, you must call screen_html_export before finishing.\n- Do not stop after describing how you will render or present the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the local .html path and the presentation object.";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeEngine {
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
tool_policy: ToolPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeEngine {
|
||||||
|
pub fn new(profile: RuntimeProfile) -> Self {
|
||||||
|
Self {
|
||||||
|
profile,
|
||||||
|
tool_policy: ToolPolicy::for_profile(profile),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn profile(&self) -> RuntimeProfile {
|
||||||
|
self.profile
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_policy(&self) -> &ToolPolicy {
|
||||||
|
&self.tool_policy
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn browser_surface_enabled(&self) -> bool {
|
||||||
|
self.tool_policy
|
||||||
|
.allowed_tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| {
|
||||||
|
tool == BROWSER_ACTION_TOOL_NAME || tool == SUPERRPA_BROWSER_TOOL_NAME
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_agent(
|
||||||
|
&self,
|
||||||
|
provider: Box<dyn Provider>,
|
||||||
|
config: &ZeroClawConfig,
|
||||||
|
skills_dir: &Path,
|
||||||
|
mut tools: Vec<Box<dyn zeroclaw::tools::Tool>>,
|
||||||
|
browser_surface_present: bool,
|
||||||
|
instruction: &str,
|
||||||
|
) -> Result<Agent, PipeError> {
|
||||||
|
let memory: Arc<dyn Memory> =
|
||||||
|
Arc::from(build_memory(config).map_err(map_anyhow_to_pipe_error)?);
|
||||||
|
let security = Arc::new(SecurityPolicy::from_config(
|
||||||
|
&config.autonomy,
|
||||||
|
&config.workspace_dir,
|
||||||
|
));
|
||||||
|
let observer: Arc<dyn Observer> = Arc::new(NoopObserver);
|
||||||
|
let skills = load_runtime_skills(config, skills_dir);
|
||||||
|
let (mut runtime_tools, _, _, _, _, _) = tools::all_tools_with_runtime(
|
||||||
|
Arc::new(config.clone()),
|
||||||
|
&security,
|
||||||
|
Arc::new(NativeRuntime::new()),
|
||||||
|
memory.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&config.browser,
|
||||||
|
&config.http_request,
|
||||||
|
&config.web_fetch,
|
||||||
|
&config.workspace_dir,
|
||||||
|
&config.agents,
|
||||||
|
config.api_key.as_deref(),
|
||||||
|
config,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
runtime_tools.append(&mut tools);
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
config.skills.prompt_injection_mode,
|
||||||
|
SkillsPromptInjectionMode::Compact
|
||||||
|
) && skills_dir != config.workspace_dir.join("skills")
|
||||||
|
{
|
||||||
|
runtime_tools.retain(|tool| tool.name() != READ_SKILL_TOOL_NAME);
|
||||||
|
runtime_tools.push(Box::new(ReadSkillTool::with_runtime_skills_dir(
|
||||||
|
config.workspace_dir.clone(),
|
||||||
|
Some(skills_dir.to_path_buf()),
|
||||||
|
config.skills.allow_scripts,
|
||||||
|
config.skills.open_skills_enabled,
|
||||||
|
config.skills.open_skills_dir.clone(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Agent::builder()
|
||||||
|
.provider(provider)
|
||||||
|
.tools(runtime_tools)
|
||||||
|
.memory(memory)
|
||||||
|
.observer(observer)
|
||||||
|
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||||
|
.config(config.agent.clone())
|
||||||
|
.model_name(
|
||||||
|
config
|
||||||
|
.default_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "deepseek-chat".to_string()),
|
||||||
|
)
|
||||||
|
.temperature(config.default_temperature)
|
||||||
|
.workspace_dir(config.workspace_dir.clone())
|
||||||
|
.skills(skills)
|
||||||
|
.skills_prompt_mode(config.skills.prompt_injection_mode)
|
||||||
|
.allowed_tools(self.allowed_tools_for_config(
|
||||||
|
config,
|
||||||
|
browser_surface_present,
|
||||||
|
instruction,
|
||||||
|
))
|
||||||
|
.build()
|
||||||
|
.map_err(map_anyhow_to_pipe_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_instruction(
|
||||||
|
&self,
|
||||||
|
instruction: &str,
|
||||||
|
page_url: Option<&str>,
|
||||||
|
page_title: Option<&str>,
|
||||||
|
browser_surface_present: bool,
|
||||||
|
) -> String {
|
||||||
|
let trimmed_instruction = instruction.trim();
|
||||||
|
if !browser_surface_present || !self.browser_surface_enabled() {
|
||||||
|
return trimmed_instruction.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sections = vec![BROWSER_TOOL_CONTRACT_PROMPT.to_string()];
|
||||||
|
if is_zhihu_hotlist_task(trimmed_instruction, page_url, page_title) {
|
||||||
|
sections.push(ZHIHU_HOTLIST_EXECUTION_PROMPT.to_string());
|
||||||
|
}
|
||||||
|
if task_needs_office_export(trimmed_instruction) {
|
||||||
|
sections.push(OFFICE_EXPORT_COMPLETION_PROMPT.to_string());
|
||||||
|
}
|
||||||
|
if task_needs_screen_export(trimmed_instruction) {
|
||||||
|
sections.push(SCREEN_EXPORT_COMPLETION_PROMPT.to_string());
|
||||||
|
}
|
||||||
|
if let Some(page_context) = build_page_context_message(page_url, page_title) {
|
||||||
|
sections.push(page_context);
|
||||||
|
}
|
||||||
|
sections.push(format!("User task: {trimmed_instruction}"));
|
||||||
|
sections.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn loaded_skill_names(
|
||||||
|
&self,
|
||||||
|
config: &ZeroClawConfig,
|
||||||
|
skills_dir: &Path,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut names = load_runtime_skills(config, skills_dir)
|
||||||
|
.into_iter()
|
||||||
|
.map(|skill| skill.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_attach_openxml_office_tool(&self, instruction: &str) -> bool {
|
||||||
|
task_needs_office_export(instruction)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_attach_screen_html_export_tool(&self, instruction: &str) -> bool {
|
||||||
|
task_needs_screen_export(instruction)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_tools_for_config(
|
||||||
|
&self,
|
||||||
|
config: &ZeroClawConfig,
|
||||||
|
browser_surface_present: bool,
|
||||||
|
instruction: &str,
|
||||||
|
) -> Option<Vec<String>> {
|
||||||
|
let mut allowed_tools = self.tool_policy.allowed_tools.clone();
|
||||||
|
if !browser_surface_present {
|
||||||
|
allowed_tools.retain(|tool| {
|
||||||
|
tool != BROWSER_ACTION_TOOL_NAME && tool != SUPERRPA_BROWSER_TOOL_NAME
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
config.skills.prompt_injection_mode,
|
||||||
|
SkillsPromptInjectionMode::Compact
|
||||||
|
) {
|
||||||
|
allowed_tools.push(READ_SKILL_TOOL_NAME.to_string());
|
||||||
|
}
|
||||||
|
if task_needs_office_export(instruction) {
|
||||||
|
allowed_tools.push(OPENXML_OFFICE_TOOL_NAME.to_string());
|
||||||
|
}
|
||||||
|
if task_needs_screen_export(instruction) {
|
||||||
|
allowed_tools.push(SCREEN_HTML_EXPORT_TOOL_NAME.to_string());
|
||||||
|
}
|
||||||
|
if task_needs_local_file_read(instruction) {
|
||||||
|
allowed_tools.push("file_read".to_string());
|
||||||
|
}
|
||||||
|
allowed_tools.dedup();
|
||||||
|
|
||||||
|
if matches!(self.profile, RuntimeProfile::GeneralAssistant) &&
|
||||||
|
self.tool_policy.may_use_non_browser_tools
|
||||||
|
{
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(allowed_tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn task_needs_local_file_read(instruction: &str) -> bool {
|
||||||
|
let normalized = instruction.trim();
|
||||||
|
normalized.contains("/home/") ||
|
||||||
|
normalized.contains("./") ||
|
||||||
|
normalized.contains("../")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_zhihu_hotlist_task(
|
||||||
|
instruction: &str,
|
||||||
|
page_url: Option<&str>,
|
||||||
|
page_title: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
let normalized_instruction = instruction.to_ascii_lowercase();
|
||||||
|
let normalized_url = page_url.unwrap_or_default().to_ascii_lowercase();
|
||||||
|
let normalized_title = page_title.unwrap_or_default().to_ascii_lowercase();
|
||||||
|
|
||||||
|
let is_zhihu = normalized_instruction.contains("zhihu") ||
|
||||||
|
instruction.contains("知乎") ||
|
||||||
|
normalized_url.contains("zhihu.com") ||
|
||||||
|
normalized_title.contains("zhihu") ||
|
||||||
|
page_title.unwrap_or_default().contains("知乎");
|
||||||
|
let is_hotlist = normalized_instruction.contains("hotlist") ||
|
||||||
|
instruction.contains("热榜") ||
|
||||||
|
normalized_url.contains("/hot") ||
|
||||||
|
normalized_title.contains("hotlist") ||
|
||||||
|
page_title.unwrap_or_default().contains("热榜");
|
||||||
|
|
||||||
|
is_zhihu && is_hotlist
|
||||||
|
}
|
||||||
|
|
||||||
|
fn task_needs_office_export(instruction: &str) -> bool {
|
||||||
|
let normalized = instruction.to_ascii_lowercase();
|
||||||
|
normalized.contains("excel")
|
||||||
|
|| normalized.contains(".xlsx")
|
||||||
|
|| normalized.contains("导出")
|
||||||
|
|| normalized.contains("xlsx")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn task_needs_screen_export(instruction: &str) -> bool {
|
||||||
|
let normalized = instruction.to_ascii_lowercase();
|
||||||
|
normalized.contains("大屏")
|
||||||
|
|| normalized.contains("看板")
|
||||||
|
|| normalized.contains("dashboard")
|
||||||
|
|| normalized.contains("screen")
|
||||||
|
|| normalized.contains("echarts")
|
||||||
|
|| normalized.contains("演示")
|
||||||
|
|| normalized.contains("汇报")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_runtime_skills(config: &ZeroClawConfig, skills_dir: &Path) -> Vec<zeroclaw::skills::Skill> {
|
||||||
|
let default_skills_dir = config.workspace_dir.join("skills");
|
||||||
|
if skills_dir == default_skills_dir {
|
||||||
|
return zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut skills = zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
|
||||||
|
skills.retain(|skill| {
|
||||||
|
skill
|
||||||
|
.location
|
||||||
|
.as_ref()
|
||||||
|
.map(|location| !location.starts_with(&default_skills_dir))
|
||||||
|
.unwrap_or(true)
|
||||||
|
});
|
||||||
|
skills.extend(zeroclaw::skills::load_skills_from_directory(
|
||||||
|
skills_dir,
|
||||||
|
config.skills.allow_scripts,
|
||||||
|
));
|
||||||
|
skills
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_page_context_message(page_url: Option<&str>, page_title: Option<&str>) -> Option<String> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if let Some(page_url) = page_url.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
parts.push(format!("Current page URL: {page_url}"));
|
||||||
|
}
|
||||||
|
if let Some(page_title) = page_title.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
parts.push(format!("Current page title: {page_title}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"Current browser context:\n{}",
|
||||||
|
parts.join("\n")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_anyhow_to_pipe_error(err: anyhow::Error) -> PipeError {
|
||||||
|
PipeError::Protocol(err.to_string())
|
||||||
|
}
|
||||||
7
src/runtime/mod.rs
Normal file
7
src/runtime/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod engine;
|
||||||
|
mod profile;
|
||||||
|
mod tool_policy;
|
||||||
|
|
||||||
|
pub use engine::{is_zhihu_hotlist_task, RuntimeEngine};
|
||||||
|
pub use profile::RuntimeProfile;
|
||||||
|
pub use tool_policy::ToolPolicy;
|
||||||
36
src/runtime/tool_policy.rs
Normal file
36
src/runtime/tool_policy.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use crate::runtime::RuntimeProfile;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ToolPolicy {
|
||||||
|
pub requires_browser_surface: bool,
|
||||||
|
pub may_use_non_browser_tools: bool,
|
||||||
|
pub allowed_tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolPolicy {
|
||||||
|
pub fn for_profile(profile: RuntimeProfile) -> Self {
|
||||||
|
match profile {
|
||||||
|
RuntimeProfile::BrowserAttached => Self {
|
||||||
|
requires_browser_surface: false,
|
||||||
|
may_use_non_browser_tools: true,
|
||||||
|
allowed_tools: vec![
|
||||||
|
"superrpa_browser".to_string(),
|
||||||
|
"browser_action".to_string(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
RuntimeProfile::BrowserHeavy => Self {
|
||||||
|
requires_browser_surface: true,
|
||||||
|
may_use_non_browser_tools: true,
|
||||||
|
allowed_tools: vec![
|
||||||
|
"superrpa_browser".to_string(),
|
||||||
|
"browser_action".to_string(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
RuntimeProfile::GeneralAssistant => Self {
|
||||||
|
requires_browser_surface: false,
|
||||||
|
may_use_non_browser_tools: true,
|
||||||
|
allowed_tools: Vec::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
tests/compat_openxml_office_tool_test.rs
Normal file
53
tests/compat_openxml_office_tool_test.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command as ProcessCommand;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use sgclaw::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use zeroclaw::tools::Tool;
|
||||||
|
|
||||||
|
fn temp_workspace_root() -> PathBuf {
|
||||||
|
let root = std::env::temp_dir().join(format!("sgclaw-openxml-office-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openxml_office_tool_renders_hotlist_xlsx_from_rows() {
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let output_path = workspace_root.join("out/zhihu-hotlist.xlsx");
|
||||||
|
let tool = OpenXmlOfficeTool::new(workspace_root.clone());
|
||||||
|
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"sheet_name": "知乎热榜",
|
||||||
|
"columns": ["rank", "title", "heat"],
|
||||||
|
"rows": [
|
||||||
|
[1, "问题一", "344万"],
|
||||||
|
[2, "问题二", "266万"]
|
||||||
|
],
|
||||||
|
"output_path": output_path
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.success, "{result:?}");
|
||||||
|
assert!(output_path.exists());
|
||||||
|
assert!(result.output.contains(output_path.to_str().unwrap()));
|
||||||
|
|
||||||
|
let unzip = ProcessCommand::new("unzip")
|
||||||
|
.args([
|
||||||
|
"-p",
|
||||||
|
output_path.to_str().unwrap(),
|
||||||
|
"xl/worksheets/sheet1.xml",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(unzip.status.success());
|
||||||
|
|
||||||
|
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||||
|
assert!(xml.contains("问题一"));
|
||||||
|
assert!(xml.contains("344万"));
|
||||||
|
assert!(xml.contains("问题二"));
|
||||||
|
assert!(!xml.contains("{{TITLE_1}}"));
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
50
tests/compat_screen_html_export_tool_test.rs
Normal file
50
tests/compat_screen_html_export_tool_test.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sgclaw::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use zeroclaw::tools::Tool;
|
||||||
|
|
||||||
|
fn temp_workspace_root() -> PathBuf {
|
||||||
|
let root = std::env::temp_dir().join(format!("sgclaw-screen-html-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn screen_html_export_tool_renders_dashboard_html_with_presentation_contract() {
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let output_path = workspace_root.join("out/zhihu-hotlist-screen.html");
|
||||||
|
let tool = ScreenHtmlExportTool::new(workspace_root.clone());
|
||||||
|
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"snapshot_id": "snapshot-20260329",
|
||||||
|
"generated_at_ms": 1774713600000u64,
|
||||||
|
"rows": [
|
||||||
|
[1, "问题一", "344万"],
|
||||||
|
[2, "问题二", "266万"]
|
||||||
|
],
|
||||||
|
"output_path": output_path
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.success, "{result:?}");
|
||||||
|
assert!(output_path.exists());
|
||||||
|
|
||||||
|
let payload: Value = serde_json::from_str(&result.output).unwrap();
|
||||||
|
let html = std::fs::read_to_string(&output_path).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(payload["output_path"], json!(output_path));
|
||||||
|
assert_eq!(payload["presentation"]["mode"], json!("new_tab"));
|
||||||
|
assert_eq!(payload["renderer"], json!("screen_html_export"));
|
||||||
|
assert!(payload["presentation"]["url"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("file://"));
|
||||||
|
assert!(html.contains("snapshot-20260329"));
|
||||||
|
assert!(html.contains("问题一"));
|
||||||
|
assert!(html.contains("344万"));
|
||||||
|
assert!(html.contains("const defaultPayload ="));
|
||||||
|
}
|
||||||
31
tests/live_acceptance_score_test.py
Normal file
31
tests/live_acceptance_score_test.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from tools.live_acceptance.run_zhihu_hotlist_excel_acceptance import HotItem, score_acceptance
|
||||||
|
|
||||||
|
|
||||||
|
class LiveAcceptanceScoreTest(unittest.TestCase):
|
||||||
|
def test_score_acceptance_handles_preloaded_office_skill_without_read_skill_log(self):
|
||||||
|
result = {
|
||||||
|
"logs": [
|
||||||
|
{"message": "navigate https://www.zhihu.com/hot"},
|
||||||
|
{"message": "navigate https://www.zhihu.com/hot"},
|
||||||
|
{"message": "getText body"},
|
||||||
|
{"message": "call openxml_office"},
|
||||||
|
],
|
||||||
|
"final_task": {
|
||||||
|
"success": True,
|
||||||
|
"summary": "已导出 Excel",
|
||||||
|
},
|
||||||
|
"stderr": [],
|
||||||
|
"exports": [],
|
||||||
|
}
|
||||||
|
items = [HotItem(rank=1, title="标题", heat="123万")]
|
||||||
|
|
||||||
|
score = score_acceptance(result, items)
|
||||||
|
|
||||||
|
self.assertEqual(score["skill_selection"], 30)
|
||||||
|
self.assertEqual(score["final_response_quality"], 5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
80
tests/read_skill_tool_test.rs
Normal file
80
tests/read_skill_tool_test.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use zeroclaw::tools::{ReadSkillTool, Tool};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_skill_inlines_referenced_markdown_files() {
|
||||||
|
let workspace_dir = temp_workspace_dir();
|
||||||
|
let skill_dir = workspace_dir.join("skills/zhihu-hotlist");
|
||||||
|
let refs_dir = skill_dir.join("references");
|
||||||
|
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
skill_dir.join("SKILL.md"),
|
||||||
|
concat!(
|
||||||
|
"# Zhihu Hotlist\n\n",
|
||||||
|
"Follow [collection-flow.md](references/collection-flow.md).\n",
|
||||||
|
"Apply [data-quality.md](references/data-quality.md).\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
refs_dir.join("collection-flow.md"),
|
||||||
|
"# Collection Flow\n\nCollect rows from the hotlist first.\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
refs_dir.join("data-quality.md"),
|
||||||
|
"# Data Quality\n\nMark partial metrics explicitly.\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tool = ReadSkillTool::new(workspace_dir, false, None);
|
||||||
|
let result = tool.execute(json!({ "name": "zhihu-hotlist" })).await.unwrap();
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
assert!(result.output.contains("# Zhihu Hotlist"));
|
||||||
|
assert!(result.output.contains("## Referenced File: references/collection-flow.md"));
|
||||||
|
assert!(result.output.contains("Collect rows from the hotlist first."));
|
||||||
|
assert!(result.output.contains("## Referenced File: references/data-quality.md"));
|
||||||
|
assert!(result.output.contains("Mark partial metrics explicitly."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_skill_recursively_inlines_relative_asset_references() {
|
||||||
|
let workspace_dir = temp_workspace_dir();
|
||||||
|
let skill_dir = workspace_dir.join("skills/zhihu-hotlist");
|
||||||
|
let refs_dir = skill_dir.join("references");
|
||||||
|
let assets_dir = skill_dir.join("assets");
|
||||||
|
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||||
|
std::fs::create_dir_all(&assets_dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
skill_dir.join("SKILL.md"),
|
||||||
|
"# Zhihu Hotlist\n\nFollow [collection-flow.md](references/collection-flow.md).\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
refs_dir.join("collection-flow.md"),
|
||||||
|
"Use `assets/zhihu_hotlist_flow.source.json` for exact selectors.\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
assets_dir.join("zhihu_hotlist_flow.source.json"),
|
||||||
|
"{\n \"selectors\": [\".HotList-list\", \".HotItem\"]\n}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tool = ReadSkillTool::new(workspace_dir, false, None);
|
||||||
|
let result = tool.execute(json!({ "name": "zhihu-hotlist" })).await.unwrap();
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
assert!(result.output.contains("## Referenced File: references/collection-flow.md"));
|
||||||
|
assert!(result.output.contains("## Referenced File: assets/zhihu_hotlist_flow.source.json"));
|
||||||
|
assert!(result.output.contains("\"selectors\": [\".HotList-list\", \".HotItem\"]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_workspace_dir() -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("sgclaw-read-skill-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
115
tests/skill_lib_validation_test.py
Normal file
115
tests/skill_lib_validation_test.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import importlib.util
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SKILL_LIB_ROOT = REPO_ROOT.parent / "skill_lib"
|
||||||
|
SKILLS_DIR = SKILL_LIB_ROOT / "skills"
|
||||||
|
VALIDATOR_PATH = REPO_ROOT / "scripts" / "validate_skill_lib.py"
|
||||||
|
EXPECTED_SKILL_NAMES = [
|
||||||
|
"office-export-xlsx",
|
||||||
|
"zhihu-hotlist",
|
||||||
|
"zhihu-hotlist-screen",
|
||||||
|
"zhihu-navigate",
|
||||||
|
"zhihu-write",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_validator_module():
|
||||||
|
spec = importlib.util.spec_from_file_location("validate_skill_lib", VALIDATOR_PATH)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.loader is not None
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class SkillLibValidationTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.validator = load_validator_module()
|
||||||
|
|
||||||
|
def test_discovers_expected_skill_packages(self):
|
||||||
|
skill_dirs = self.validator.discover_skill_dirs()
|
||||||
|
self.assertEqual([path.name for path in skill_dirs], EXPECTED_SKILL_NAMES)
|
||||||
|
|
||||||
|
def test_load_skill_matches_current_metadata(self):
|
||||||
|
loaded = {}
|
||||||
|
for skill_dir in self.validator.discover_skill_dirs():
|
||||||
|
record = self.validator.load_skill(skill_dir)
|
||||||
|
loaded[record.name] = record
|
||||||
|
|
||||||
|
self.assertEqual(sorted(loaded), EXPECTED_SKILL_NAMES)
|
||||||
|
|
||||||
|
for name, record in loaded.items():
|
||||||
|
self.assertEqual(record.name, name)
|
||||||
|
self.assertEqual(record.version, "0.1.0")
|
||||||
|
self.assertEqual(record.author, "sgclaw")
|
||||||
|
self.assertTrue(record.description.startswith("Use when"))
|
||||||
|
if name.startswith("zhihu-"):
|
||||||
|
self.assertIn("zhihu", record.tags)
|
||||||
|
self.assertIn("browser", record.tags)
|
||||||
|
if name == "office-export-xlsx":
|
||||||
|
self.assertIn("office", record.tags)
|
||||||
|
self.assertIn("xlsx", record.tags)
|
||||||
|
self.assertEqual(record.location, SKILLS_DIR / name / "SKILL.md")
|
||||||
|
self.assertTrue(record.prompt_body.lstrip().startswith("# "))
|
||||||
|
self.assertNotIn("\n---\n", record.prompt_body)
|
||||||
|
|
||||||
|
def test_each_skill_passes_audit_without_scripts(self):
|
||||||
|
for skill_dir in self.validator.discover_skill_dirs():
|
||||||
|
report = self.validator.audit_skill_directory(skill_dir, allow_scripts=False)
|
||||||
|
self.assertEqual(
|
||||||
|
report.findings,
|
||||||
|
[],
|
||||||
|
f"{skill_dir.name} findings: {report.findings}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_current_packages_keep_required_structure(self):
|
||||||
|
for name in EXPECTED_SKILL_NAMES:
|
||||||
|
skill_dir = SKILLS_DIR / name
|
||||||
|
self.assertTrue((skill_dir / "SKILL.md").is_file())
|
||||||
|
self.assertTrue((skill_dir / "references").is_dir())
|
||||||
|
self.assertTrue((skill_dir / "assets").is_dir())
|
||||||
|
|
||||||
|
def test_each_skill_declares_superrpa_browser_contract(self):
|
||||||
|
for name in [name for name in EXPECTED_SKILL_NAMES if name.startswith("zhihu-")]:
|
||||||
|
content = (SKILLS_DIR / name / "SKILL.md").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("superrpa_browser", content)
|
||||||
|
self.assertIn("expected_domain", content)
|
||||||
|
self.assertIn("CSS", content)
|
||||||
|
|
||||||
|
def test_zhihu_hotlist_declares_export_artifact_contract(self):
|
||||||
|
content = (SKILLS_DIR / "zhihu-hotlist" / "SKILL.md").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("Export Artifact", content)
|
||||||
|
self.assertIn('"sheet_name": "知乎热榜"', content)
|
||||||
|
self.assertIn('"columns": ["rank", "title", "heat"]', content)
|
||||||
|
self.assertIn('"rows": [[1, "标题", "344万"]]', content)
|
||||||
|
self.assertIn("structured artifact is primary", content)
|
||||||
|
|
||||||
|
def test_office_export_skill_declares_openxml_contract(self):
|
||||||
|
content = (SKILLS_DIR / "office-export-xlsx" / "SKILL.md").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("openxml_office", content)
|
||||||
|
self.assertIn(".xlsx", content)
|
||||||
|
self.assertIn("sheet_name", content)
|
||||||
|
self.assertIn("columns", content)
|
||||||
|
self.assertIn("rows", content)
|
||||||
|
|
||||||
|
def test_hotlist_screen_skill_declares_echarts_html_contract(self):
|
||||||
|
content = (SKILLS_DIR / "zhihu-hotlist-screen" / "SKILL.md").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("screen_html_export", content)
|
||||||
|
self.assertIn(".html", content)
|
||||||
|
self.assertIn("ECharts", content)
|
||||||
|
self.assertIn("大屏", content)
|
||||||
|
self.assertIn("新标签页", content)
|
||||||
|
self.assertIn("presentation", content)
|
||||||
|
|
||||||
|
def test_validate_all_skills_reports_pass(self):
|
||||||
|
results = self.validator.validate_all_skills(allow_scripts=False)
|
||||||
|
self.assertEqual([result.record.name for result in results], EXPECTED_SKILL_NAMES)
|
||||||
|
self.assertTrue(all(result.ok for result in results))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
145
third_party/zeroclaw/src/agent/agent.rs
vendored
145
third_party/zeroclaw/src/agent/agent.rs
vendored
@@ -801,6 +801,7 @@ impl Agent {
|
|||||||
} else {
|
} else {
|
||||||
text
|
text
|
||||||
};
|
};
|
||||||
|
let final_text = sanitize_final_text(&final_text);
|
||||||
|
|
||||||
// Store in response cache (text-only, no tool calls)
|
// Store in response cache (text-only, no tool calls)
|
||||||
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
|
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
|
||||||
@@ -1067,6 +1068,7 @@ impl Agent {
|
|||||||
} else {
|
} else {
|
||||||
text
|
text
|
||||||
};
|
};
|
||||||
|
let final_text = sanitize_final_text(&final_text);
|
||||||
|
|
||||||
// Store in response cache
|
// Store in response cache
|
||||||
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
|
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
|
||||||
@@ -1175,6 +1177,31 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_final_text(text: &str) -> String {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut last_normalized = String::new();
|
||||||
|
|
||||||
|
for block in trimmed.split("\n\n") {
|
||||||
|
let candidate = block.trim();
|
||||||
|
if candidate.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let normalized = candidate.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if !last_normalized.is_empty() && normalized == last_normalized {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(candidate.to_string());
|
||||||
|
last_normalized = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
config: Config,
|
config: Config,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
@@ -1333,6 +1360,67 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct StreamingDuplicateParagraphProvider;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for StreamingDuplicateParagraphProvider {
|
||||||
|
async fn chat_with_system(
|
||||||
|
&self,
|
||||||
|
_system_prompt: Option<&str>,
|
||||||
|
_message: &str,
|
||||||
|
_model: &str,
|
||||||
|
_temperature: f64,
|
||||||
|
) -> Result<String> {
|
||||||
|
Ok("ok".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat(
|
||||||
|
&self,
|
||||||
|
_request: ChatRequest<'_>,
|
||||||
|
_model: &str,
|
||||||
|
_temperature: f64,
|
||||||
|
) -> Result<crate::providers::ChatResponse> {
|
||||||
|
Ok(crate::providers::ChatResponse {
|
||||||
|
text: Some("fallback".into()),
|
||||||
|
tool_calls: vec![],
|
||||||
|
usage: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_streaming(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_chat(
|
||||||
|
&self,
|
||||||
|
_request: ChatRequest<'_>,
|
||||||
|
_model: &str,
|
||||||
|
_temperature: f64,
|
||||||
|
_options: crate::providers::traits::StreamOptions,
|
||||||
|
) -> futures_util::stream::BoxStream<
|
||||||
|
'static,
|
||||||
|
crate::providers::traits::StreamResult<crate::providers::traits::StreamEvent>,
|
||||||
|
> {
|
||||||
|
use crate::providers::traits::{StreamChunk, StreamEvent};
|
||||||
|
use futures_util::{stream, StreamExt};
|
||||||
|
|
||||||
|
stream::iter(vec![
|
||||||
|
Ok(StreamEvent::TextDelta(StreamChunk::delta(
|
||||||
|
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。",
|
||||||
|
))),
|
||||||
|
Ok(StreamEvent::TextDelta(StreamChunk::delta("\n\n"))),
|
||||||
|
Ok(StreamEvent::TextDelta(StreamChunk::delta(
|
||||||
|
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。",
|
||||||
|
))),
|
||||||
|
Ok(StreamEvent::TextDelta(StreamChunk::delta("\n\n"))),
|
||||||
|
Ok(StreamEvent::TextDelta(StreamChunk::delta("文件已生成。"))),
|
||||||
|
Ok(StreamEvent::Final),
|
||||||
|
])
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn turn_without_tools_returns_text() {
|
async fn turn_without_tools_returns_text() {
|
||||||
let provider = Box::new(MockProvider {
|
let provider = Box::new(MockProvider {
|
||||||
@@ -1419,6 +1507,42 @@ mod tests {
|
|||||||
.any(|msg| matches!(msg, ConversationMessage::ToolResults(_))));
|
.any(|msg| matches!(msg, ConversationMessage::ToolResults(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn turn_streamed_sanitizes_duplicate_final_paragraphs() {
|
||||||
|
let provider = Box::new(StreamingDuplicateParagraphProvider);
|
||||||
|
|
||||||
|
let memory_cfg = crate::config::MemoryConfig {
|
||||||
|
backend: "none".into(),
|
||||||
|
..crate::config::MemoryConfig::default()
|
||||||
|
};
|
||||||
|
let mem: Arc<dyn Memory> = Arc::from(
|
||||||
|
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
|
||||||
|
.expect("memory creation should succeed with valid config"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
|
||||||
|
let mut agent = Agent::builder()
|
||||||
|
.provider(provider)
|
||||||
|
.tools(vec![Box::new(MockTool)])
|
||||||
|
.memory(mem)
|
||||||
|
.observer(observer)
|
||||||
|
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||||
|
.workspace_dir(std::path::PathBuf::from("/tmp"))
|
||||||
|
.build()
|
||||||
|
.expect("agent builder should succeed with valid config");
|
||||||
|
|
||||||
|
let (event_tx, _event_rx) = tokio::sync::mpsc::channel(8);
|
||||||
|
let response = agent.turn_streamed("读取知乎热榜前10,并导出 excel 文件", event_tx).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response,
|
||||||
|
concat!(
|
||||||
|
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。\n\n",
|
||||||
|
"文件已生成。"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn turn_routes_with_hint_when_query_classification_matches() {
|
async fn turn_routes_with_hint_when_query_classification_matches() {
|
||||||
let seen_models = Arc::new(Mutex::new(Vec::new()));
|
let seen_models = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -1670,4 +1794,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(history.len(), 3);
|
assert_eq!(history.len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_final_text_collapses_consecutive_duplicate_paragraphs() {
|
||||||
|
let text = concat!(
|
||||||
|
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。\n\n",
|
||||||
|
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。\n\n",
|
||||||
|
"## 结果\n\n",
|
||||||
|
"文件已生成。"
|
||||||
|
);
|
||||||
|
|
||||||
|
let sanitized = sanitize_final_text(text);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sanitized,
|
||||||
|
concat!(
|
||||||
|
"由于浏览器和网络工具都遇到问题,我将采用一个替代方案:创建一个模拟的知乎热榜数据并导出Excel文件。\n\n",
|
||||||
|
"## 结果\n\n",
|
||||||
|
"文件已生成。"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
third_party/zeroclaw/src/agent/loop_.rs
vendored
78
third_party/zeroclaw/src/agent/loop_.rs
vendored
@@ -4753,6 +4753,15 @@ pub async fn process_message(
|
|||||||
config: Config,
|
config: Config,
|
||||||
message: &str,
|
message: &str,
|
||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
|
) -> Result<String> {
|
||||||
|
process_message_with_extra_tools(config, message, session_id, Vec::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process_message_with_extra_tools(
|
||||||
|
config: Config,
|
||||||
|
message: &str,
|
||||||
|
session_id: Option<&str>,
|
||||||
|
mut extra_tools: Vec<Box<dyn Tool>>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let observer: Arc<dyn Observer> =
|
let observer: Arc<dyn Observer> =
|
||||||
Arc::from(observability::create_observer(&config.observability));
|
Arc::from(observability::create_observer(&config.observability));
|
||||||
@@ -4805,6 +4814,7 @@ pub async fn process_message(
|
|||||||
let peripheral_tools: Vec<Box<dyn Tool>> =
|
let peripheral_tools: Vec<Box<dyn Tool>> =
|
||||||
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
|
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
|
||||||
tools_registry.extend(peripheral_tools);
|
tools_registry.extend(peripheral_tools);
|
||||||
|
tools_registry.append(&mut extra_tools);
|
||||||
|
|
||||||
// ── Wire MCP tools (non-fatal) — process_message path ────────
|
// ── Wire MCP tools (non-fatal) — process_message path ────────
|
||||||
// NOTE: Same ordering contract as the CLI path above — MCP tools must be
|
// NOTE: Same ordering contract as the CLI path above — MCP tools must be
|
||||||
@@ -4919,62 +4929,12 @@ pub async fn process_message(
|
|||||||
// Register skill-defined tools as callable tool specs (process_message path).
|
// Register skill-defined tools as callable tool specs (process_message path).
|
||||||
tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
|
tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
|
||||||
|
|
||||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
let mut tool_descs: Vec<(String, String)> = tools_registry
|
||||||
("shell", "Execute terminal commands."),
|
.iter()
|
||||||
("file_read", "Read file contents."),
|
.map(|tool| (tool.name().to_string(), tool.description().to_string()))
|
||||||
("file_write", "Write file contents."),
|
.collect();
|
||||||
("memory_store", "Save to memory."),
|
tool_descs.sort_by(|left, right| left.0.cmp(&right.0));
|
||||||
("memory_recall", "Search memory."),
|
tool_descs.dedup_by(|left, right| left.0 == right.0);
|
||||||
("memory_forget", "Delete a memory entry."),
|
|
||||||
(
|
|
||||||
"model_routing_config",
|
|
||||||
"Configure default model, scenario routing, and delegate agents.",
|
|
||||||
),
|
|
||||||
("screenshot", "Capture a screenshot."),
|
|
||||||
("image_info", "Read image metadata."),
|
|
||||||
];
|
|
||||||
if matches!(
|
|
||||||
config.skills.prompt_injection_mode,
|
|
||||||
crate::config::SkillsPromptInjectionMode::Compact
|
|
||||||
) {
|
|
||||||
tool_descs.push((
|
|
||||||
"read_skill",
|
|
||||||
"Load the full source for an available skill by name.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if config.browser.enabled {
|
|
||||||
tool_descs.push(("browser_open", "Open approved URLs in browser."));
|
|
||||||
}
|
|
||||||
if config.composio.enabled {
|
|
||||||
tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
|
|
||||||
}
|
|
||||||
if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
|
|
||||||
tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
|
|
||||||
tool_descs.push((
|
|
||||||
"gpio_write",
|
|
||||||
"Set GPIO pin high or low on connected hardware.",
|
|
||||||
));
|
|
||||||
tool_descs.push((
|
|
||||||
"arduino_upload",
|
|
||||||
"Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.",
|
|
||||||
));
|
|
||||||
tool_descs.push((
|
|
||||||
"hardware_memory_map",
|
|
||||||
"Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
|
|
||||||
));
|
|
||||||
tool_descs.push((
|
|
||||||
"hardware_board_info",
|
|
||||||
"Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
|
|
||||||
));
|
|
||||||
tool_descs.push((
|
|
||||||
"hardware_memory_read",
|
|
||||||
"Read actual memory/register values from Nucleo. Use when user asks to read registers, read memory, dump lower memory 0-126, or give address and value.",
|
|
||||||
));
|
|
||||||
tool_descs.push((
|
|
||||||
"hardware_capabilities",
|
|
||||||
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out tools excluded for non-CLI channels (gateway counts as non-CLI).
|
// Filter out tools excluded for non-CLI channels (gateway counts as non-CLI).
|
||||||
// Skip when autonomy is `Full` — full-autonomy agents keep all tools.
|
// Skip when autonomy is `Full` — full-autonomy agents keep all tools.
|
||||||
@@ -4984,6 +4944,10 @@ pub async fn process_message(
|
|||||||
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
|
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let tool_desc_refs: Vec<(&str, &str)> = tool_descs
|
||||||
|
.iter()
|
||||||
|
.map(|(name, description)| (name.as_str(), description.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let bootstrap_max_chars = if config.agent.compact_context {
|
let bootstrap_max_chars = if config.agent.compact_context {
|
||||||
Some(6000)
|
Some(6000)
|
||||||
@@ -4994,7 +4958,7 @@ pub async fn process_message(
|
|||||||
let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
|
let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
|
||||||
&config.workspace_dir,
|
&config.workspace_dir,
|
||||||
&model_name,
|
&model_name,
|
||||||
&tool_descs,
|
&tool_desc_refs,
|
||||||
&skills,
|
&skills,
|
||||||
Some(&config.identity),
|
Some(&config.identity),
|
||||||
bootstrap_max_chars,
|
bootstrap_max_chars,
|
||||||
|
|||||||
2
third_party/zeroclaw/src/agent/mod.rs
vendored
2
third_party/zeroclaw/src/agent/mod.rs
vendored
@@ -19,4 +19,4 @@ mod tests;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use agent::{Agent, AgentBuilder, TurnEvent};
|
pub use agent::{Agent, AgentBuilder, TurnEvent};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use loop_::{process_message, run};
|
pub use loop_::{process_message, process_message_with_extra_tools, run};
|
||||||
|
|||||||
26
third_party/zeroclaw/src/providers/compatible.rs
vendored
26
third_party/zeroclaw/src/providers/compatible.rs
vendored
@@ -229,6 +229,18 @@ impl OpenAiCompatibleProvider {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_choice_for_tools(&self, has_tools: bool) -> Option<String> {
|
||||||
|
if !has_tools {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::agent::loop_::TOOL_CHOICE_OVERRIDE
|
||||||
|
.try_with(Clone::clone)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.or_else(|| Some("auto".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Collect all `system` role messages, concatenate their content,
|
/// Collect all `system` role messages, concatenate their content,
|
||||||
/// and prepend to the first `user` message. Drop all system messages.
|
/// and prepend to the first `user` message. Drop all system messages.
|
||||||
/// Used for providers (e.g. MiniMax) that reject `role: system`.
|
/// Used for providers (e.g. MiniMax) that reject `role: system`.
|
||||||
@@ -1829,11 +1841,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||||||
} else {
|
} else {
|
||||||
Some(tools.to_vec())
|
Some(tools.to_vec())
|
||||||
},
|
},
|
||||||
tool_choice: if tools.is_empty() {
|
tool_choice: self.tool_choice_for_tools(!tools.is_empty()),
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some("auto".to_string())
|
|
||||||
},
|
|
||||||
max_tokens: self.max_tokens,
|
max_tokens: self.max_tokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1933,7 +1941,9 @@ impl Provider for OpenAiCompatibleProvider {
|
|||||||
reasoning_effort: self.reasoning_effort_for_model(model),
|
reasoning_effort: self.reasoning_effort_for_model(model),
|
||||||
tool_stream: self
|
tool_stream: self
|
||||||
.tool_stream_for_tools(tools.as_ref().is_some_and(|tools| !tools.is_empty())),
|
.tool_stream_for_tools(tools.as_ref().is_some_and(|tools| !tools.is_empty())),
|
||||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
tool_choice: self.tool_choice_for_tools(
|
||||||
|
tools.as_ref().is_some_and(|tools| !tools.is_empty()),
|
||||||
|
),
|
||||||
tools,
|
tools,
|
||||||
max_tokens: self.max_tokens,
|
max_tokens: self.max_tokens,
|
||||||
};
|
};
|
||||||
@@ -2087,7 +2097,9 @@ impl Provider for OpenAiCompatibleProvider {
|
|||||||
tool_stream: if options.enabled { Some(true) } else { None },
|
tool_stream: if options.enabled { Some(true) } else { None },
|
||||||
stream: Some(options.enabled),
|
stream: Some(options.enabled),
|
||||||
tools: tools.clone(),
|
tools: tools.clone(),
|
||||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
tool_choice: self.tool_choice_for_tools(
|
||||||
|
tools.as_ref().is_some_and(|tools| !tools.is_empty()),
|
||||||
|
),
|
||||||
max_tokens: self.max_tokens,
|
max_tokens: self.max_tokens,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
228
third_party/zeroclaw/src/tools/read_skill.rs
vendored
228
third_party/zeroclaw/src/tools/read_skill.rs
vendored
@@ -1,11 +1,14 @@
|
|||||||
use super::traits::{Tool, ToolResult};
|
use super::traits::{Tool, ToolResult};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::path::PathBuf;
|
use std::collections::{BTreeSet, VecDeque};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Compact-mode helper for loading a skill's source file on demand.
|
/// Compact-mode helper for loading a skill's source file on demand.
|
||||||
pub struct ReadSkillTool {
|
pub struct ReadSkillTool {
|
||||||
workspace_dir: PathBuf,
|
workspace_dir: PathBuf,
|
||||||
|
runtime_skills_dir: Option<PathBuf>,
|
||||||
|
allow_scripts: bool,
|
||||||
open_skills_enabled: bool,
|
open_skills_enabled: bool,
|
||||||
open_skills_dir: Option<String>,
|
open_skills_dir: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -18,6 +21,24 @@ impl ReadSkillTool {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
|
runtime_skills_dir: None,
|
||||||
|
allow_scripts: false,
|
||||||
|
open_skills_enabled,
|
||||||
|
open_skills_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_runtime_skills_dir(
|
||||||
|
workspace_dir: PathBuf,
|
||||||
|
runtime_skills_dir: Option<PathBuf>,
|
||||||
|
allow_scripts: bool,
|
||||||
|
open_skills_enabled: bool,
|
||||||
|
open_skills_dir: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace_dir,
|
||||||
|
runtime_skills_dir,
|
||||||
|
allow_scripts,
|
||||||
open_skills_enabled,
|
open_skills_enabled,
|
||||||
open_skills_dir,
|
open_skills_dir,
|
||||||
}
|
}
|
||||||
@@ -55,11 +76,27 @@ impl Tool for ReadSkillTool {
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?;
|
||||||
|
|
||||||
let skills = crate::skills::load_skills_with_open_skills_settings(
|
let mut skills = crate::skills::load_skills_with_open_skills_settings(
|
||||||
&self.workspace_dir,
|
&self.workspace_dir,
|
||||||
self.open_skills_enabled,
|
self.open_skills_enabled,
|
||||||
self.open_skills_dir.as_deref(),
|
self.open_skills_dir.as_deref(),
|
||||||
);
|
);
|
||||||
|
let default_skills_dir = self.workspace_dir.join("skills");
|
||||||
|
if let Some(runtime_skills_dir) = &self.runtime_skills_dir {
|
||||||
|
if runtime_skills_dir != &default_skills_dir {
|
||||||
|
skills.retain(|skill| {
|
||||||
|
skill
|
||||||
|
.location
|
||||||
|
.as_ref()
|
||||||
|
.map(|location| !location.starts_with(&default_skills_dir))
|
||||||
|
.unwrap_or(true)
|
||||||
|
});
|
||||||
|
skills.extend(crate::skills::load_skills_from_directory(
|
||||||
|
runtime_skills_dir,
|
||||||
|
self.allow_scripts,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let Some(skill) = skills
|
let Some(skill) = skills
|
||||||
.iter()
|
.iter()
|
||||||
@@ -93,7 +130,7 @@ impl Tool for ReadSkillTool {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
match tokio::fs::read_to_string(location).await {
|
match read_skill_bundle(location).await {
|
||||||
Ok(output) => Ok(ToolResult {
|
Ok(output) => Ok(ToolResult {
|
||||||
success: true,
|
success: true,
|
||||||
output,
|
output,
|
||||||
@@ -112,6 +149,152 @@ impl Tool for ReadSkillTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn read_skill_bundle(location: &Path) -> std::io::Result<String> {
|
||||||
|
let primary = tokio::fs::read_to_string(location).await?;
|
||||||
|
let Some(skill_root) = location.parent() else {
|
||||||
|
return Ok(primary);
|
||||||
|
};
|
||||||
|
let skill_root = skill_root.canonicalize().unwrap_or_else(|_| skill_root.to_path_buf());
|
||||||
|
let mut output = primary.clone();
|
||||||
|
let mut appended = BTreeSet::new();
|
||||||
|
let mut queued = BTreeSet::new();
|
||||||
|
let mut pending = VecDeque::new();
|
||||||
|
|
||||||
|
enqueue_reference_paths(
|
||||||
|
&primary,
|
||||||
|
location.parent().unwrap_or(skill_root.as_path()),
|
||||||
|
&skill_root,
|
||||||
|
&mut queued,
|
||||||
|
&mut pending,
|
||||||
|
);
|
||||||
|
|
||||||
|
while let Some(path) = pending.pop_front() {
|
||||||
|
let canonical = path.canonicalize().unwrap_or(path.clone());
|
||||||
|
if !canonical.starts_with(&skill_root) || !appended.insert(canonical.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(content) = tokio::fs::read_to_string(&canonical).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let relative = canonical
|
||||||
|
.strip_prefix(&skill_root)
|
||||||
|
.unwrap_or(canonical.as_path())
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
output.push_str("\n\n## Referenced File: ");
|
||||||
|
output.push_str(&relative);
|
||||||
|
output.push_str("\n\n");
|
||||||
|
output.push_str(&content);
|
||||||
|
|
||||||
|
enqueue_reference_paths(
|
||||||
|
&content,
|
||||||
|
canonical.parent().unwrap_or(skill_root.as_path()),
|
||||||
|
&skill_root,
|
||||||
|
&mut queued,
|
||||||
|
&mut pending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_reference_paths(
|
||||||
|
content: &str,
|
||||||
|
base_dir: &Path,
|
||||||
|
skill_root: &Path,
|
||||||
|
queued: &mut BTreeSet<PathBuf>,
|
||||||
|
pending: &mut VecDeque<PathBuf>,
|
||||||
|
) {
|
||||||
|
for candidate in extract_reference_paths(content) {
|
||||||
|
for resolved in resolve_reference_candidates(&candidate, base_dir, skill_root) {
|
||||||
|
let canonical = resolved.canonicalize().unwrap_or(resolved);
|
||||||
|
if !canonical.starts_with(skill_root) || !is_supported_reference_file(&canonical) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if queued.insert(canonical.clone()) {
|
||||||
|
pending.push_back(canonical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_reference_paths(content: &str) -> Vec<String> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
|
||||||
|
let mut cursor = content;
|
||||||
|
while let Some(start) = cursor.find("](") {
|
||||||
|
cursor = &cursor[start + 2..];
|
||||||
|
let Some(end) = cursor.find(')') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let raw = cursor[..end].trim();
|
||||||
|
if looks_like_relative_reference_path(raw) {
|
||||||
|
paths.push(raw.to_string());
|
||||||
|
}
|
||||||
|
cursor = &cursor[end + 1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut in_backticks = false;
|
||||||
|
let mut token = String::new();
|
||||||
|
for ch in content.chars() {
|
||||||
|
if ch == '`' {
|
||||||
|
if in_backticks {
|
||||||
|
let raw = token.trim();
|
||||||
|
if looks_like_relative_reference_path(raw) {
|
||||||
|
paths.push(raw.to_string());
|
||||||
|
}
|
||||||
|
token.clear();
|
||||||
|
}
|
||||||
|
in_backticks = !in_backticks;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_backticks {
|
||||||
|
token.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_relative_reference_path(raw: &str) -> bool {
|
||||||
|
if raw.is_empty() ||
|
||||||
|
raw.starts_with('/') ||
|
||||||
|
raw.starts_with("http://") ||
|
||||||
|
raw.starts_with("https://") ||
|
||||||
|
raw.starts_with('#')
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = raw.split('#').next().unwrap_or(raw).split('?').next().unwrap_or(raw);
|
||||||
|
let path = Path::new(candidate);
|
||||||
|
if path
|
||||||
|
.components()
|
||||||
|
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.contains('/') && is_supported_reference_file(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_reference_file(path: &Path) -> bool {
|
||||||
|
matches!(
|
||||||
|
path.extension().and_then(|value| value.to_str()),
|
||||||
|
Some("md" | "txt" | "json" | "html" | "toml" | "yaml" | "yml" | "csv")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_reference_candidates(raw: &str, base_dir: &Path, skill_root: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut candidates = vec![base_dir.join(raw)];
|
||||||
|
let skill_root_candidate = skill_root.join(raw);
|
||||||
|
if skill_root_candidate != candidates[0] {
|
||||||
|
candidates.push(skill_root_candidate);
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -184,4 +367,43 @@ description = "Ship safely"
|
|||||||
Some("Unknown skill 'calendar'. Available skills: weather")
|
Some("Unknown skill 'calendar'. Available skills: weather")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inlines_markdown_reference_files_for_skill_context() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let skill_dir = tmp.path().join("workspace/skills/zhihu-hotlist");
|
||||||
|
let refs_dir = skill_dir.join("references");
|
||||||
|
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
skill_dir.join("SKILL.md"),
|
||||||
|
concat!(
|
||||||
|
"# Zhihu Hotlist\n\n",
|
||||||
|
"Follow [collection-flow.md](references/collection-flow.md).\n",
|
||||||
|
"Apply [data-quality.md](references/data-quality.md).\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
refs_dir.join("collection-flow.md"),
|
||||||
|
"# Collection Flow\n\nCollect rows from the hotlist first.\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
refs_dir.join("data-quality.md"),
|
||||||
|
"# Data Quality\n\nMark partial metrics explicitly.\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = make_tool(&tmp)
|
||||||
|
.execute(json!({ "name": "zhihu-hotlist" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
assert!(result.output.contains("# Zhihu Hotlist"));
|
||||||
|
assert!(result.output.contains("## Referenced File: references/collection-flow.md"));
|
||||||
|
assert!(result.output.contains("Collect rows from the hotlist first."));
|
||||||
|
assert!(result.output.contains("## Referenced File: references/data-quality.md"));
|
||||||
|
assert!(result.output.contains("Mark partial metrics explicitly."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
402
tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
Normal file
402
tools/live_acceptance/run_zhihu_hotlist_excel_acceptance.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SGCLAW_BIN = REPO_ROOT / "target" / "debug" / "sgclaw"
|
||||||
|
REAL_CONFIG_PATH = Path("/home/zyl/.config/superrpa/Default/superrpa/sgclaw_config.json")
|
||||||
|
ACCEPTANCE_DOC = REPO_ROOT / "docs" / "acceptance" / "2026-03-29-zhihu-hotlist-excel.md"
|
||||||
|
ZH_HOTLIST_API = "https://www.zhihu.com/api/v3/feed/topstory/hot-list-web?limit=10&desktop=true"
|
||||||
|
HANDSHAKE_SEED = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HotItem:
|
||||||
|
rank: int
|
||||||
|
title: str
|
||||||
|
heat: str
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ensure_binary()
|
||||||
|
hot_items = fetch_live_hotlist()
|
||||||
|
result = run_live_acceptance(hot_items)
|
||||||
|
score = score_acceptance(result, hot_items)
|
||||||
|
write_acceptance_doc(result, hot_items, score)
|
||||||
|
print(json.dumps(score, ensure_ascii=False, indent=2))
|
||||||
|
print(f"evidence written to {ACCEPTANCE_DOC}")
|
||||||
|
return 0 if score["total_score"] >= 85 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_binary() -> None:
|
||||||
|
if SGCLAW_BIN.exists():
|
||||||
|
return
|
||||||
|
subprocess.run(["cargo", "build", "--bin", "sgclaw"], cwd=REPO_ROOT, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_live_hotlist() -> list[HotItem]:
|
||||||
|
response = requests.get(
|
||||||
|
ZH_HOTLIST_API,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0", "Referer": "https://www.zhihu.com/hot"},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()["data"]
|
||||||
|
items = []
|
||||||
|
for index, entry in enumerate(data[:10], start=1):
|
||||||
|
target = entry.get("target", {})
|
||||||
|
title = target.get("title_area", {}).get("text", "").strip()
|
||||||
|
raw_heat = target.get("metrics_area", {}).get("text", "").strip()
|
||||||
|
heat = normalize_heat_text(raw_heat)
|
||||||
|
if not title or not heat:
|
||||||
|
raise RuntimeError(f"missing title/heat in live hotlist entry {index}")
|
||||||
|
items.append(HotItem(rank=index, title=title, heat=heat))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_heat_text(text: str) -> str:
|
||||||
|
compact = re.sub(r"\s+", "", text)
|
||||||
|
compact = compact.removesuffix("热度")
|
||||||
|
return compact
|
||||||
|
|
||||||
|
|
||||||
|
def build_hotlist_text(items: list[HotItem]) -> str:
|
||||||
|
lines = []
|
||||||
|
for item in items:
|
||||||
|
lines.append(f"{item.rank}. {item.title}")
|
||||||
|
lines.append(f"热度 {item.heat}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def write_temp_config(workspace_root: Path) -> Path:
|
||||||
|
source = json.loads(REAL_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
config_path = workspace_root / "sgclaw_config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"apiKey": source["apiKey"],
|
||||||
|
"baseUrl": source["baseUrl"],
|
||||||
|
"model": source["model"],
|
||||||
|
"skillsDir": source["skillsDir"],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
|
def run_live_acceptance(items: list[HotItem]) -> dict:
|
||||||
|
workspace_root = Path(tempfile.mkdtemp(prefix="sgclaw-live-acceptance-"))
|
||||||
|
config_path = write_temp_config(workspace_root)
|
||||||
|
existing_exports = set(workspace_root.rglob("*.xlsx"))
|
||||||
|
hotlist_text = build_hotlist_text(items)
|
||||||
|
|
||||||
|
child = subprocess.Popen(
|
||||||
|
[str(SGCLAW_BIN), "--config-path", str(config_path)],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout_queue: queue.Queue[str] = queue.Queue()
|
||||||
|
stderr_lines: list[str] = []
|
||||||
|
start_reader(child.stdout, stdout_queue)
|
||||||
|
start_reader(child.stderr, None, stderr_lines)
|
||||||
|
|
||||||
|
send_line(
|
||||||
|
child,
|
||||||
|
{
|
||||||
|
"type": "init",
|
||||||
|
"version": "1.0",
|
||||||
|
"hmac_seed": HANDSHAKE_SEED,
|
||||||
|
"capabilities": ["browser_action"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
init_ack = read_json_line(stdout_queue, timeout=10)
|
||||||
|
if init_ack.get("type") != "init_ack":
|
||||||
|
raise RuntimeError(f"unexpected init response: {init_ack}")
|
||||||
|
|
||||||
|
send_line(
|
||||||
|
child,
|
||||||
|
{
|
||||||
|
"type": "submit_task",
|
||||||
|
"instruction": "读取知乎热榜数据,并导出 excel 文件",
|
||||||
|
"conversation_id": "",
|
||||||
|
"messages": [],
|
||||||
|
"page_url": "https://www.zhihu.com/",
|
||||||
|
"page_title": "知乎",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logs: list[dict] = []
|
||||||
|
final_task = None
|
||||||
|
current_page = "https://www.zhihu.com/"
|
||||||
|
deadline = time.time() + 180
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
message = read_json_line(stdout_queue, timeout=5)
|
||||||
|
except queue.Empty:
|
||||||
|
if child.poll() is not None:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
msg_type = message.get("type")
|
||||||
|
if msg_type == "log_entry":
|
||||||
|
logs.append(message)
|
||||||
|
continue
|
||||||
|
if msg_type == "command":
|
||||||
|
action = message["action"]
|
||||||
|
params = message.get("params", {})
|
||||||
|
seq = message["seq"]
|
||||||
|
if action == "navigate":
|
||||||
|
current_page = params.get("url", current_page)
|
||||||
|
respond_browser(child, seq, {"navigated": True, "url": current_page})
|
||||||
|
continue
|
||||||
|
if action == "click":
|
||||||
|
selector = params.get("selector", "")
|
||||||
|
if "hot" in selector:
|
||||||
|
current_page = "https://www.zhihu.com/hot"
|
||||||
|
respond_browser(child, seq, {"clicked": True, "selector": selector})
|
||||||
|
continue
|
||||||
|
if action == "getText":
|
||||||
|
text = hotlist_text if "zhihu.com" in current_page else ""
|
||||||
|
respond_browser(child, seq, {"text": text})
|
||||||
|
continue
|
||||||
|
if action == "type":
|
||||||
|
respond_browser(child, seq, {"typed": True})
|
||||||
|
continue
|
||||||
|
respond_browser(child, seq, {"unsupported_action": action}, success=False)
|
||||||
|
continue
|
||||||
|
if msg_type == "task_complete":
|
||||||
|
final_task = message
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
child.terminate()
|
||||||
|
child.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
child.kill()
|
||||||
|
child.wait(timeout=5)
|
||||||
|
|
||||||
|
exports = sorted(set(workspace_root.rglob("*.xlsx")) - existing_exports)
|
||||||
|
return {
|
||||||
|
"workspace_root": str(workspace_root),
|
||||||
|
"init_ack": init_ack,
|
||||||
|
"logs": logs,
|
||||||
|
"final_task": final_task,
|
||||||
|
"stderr": stderr_lines,
|
||||||
|
"exports": [str(path) for path in exports],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def start_reader(stream, output_queue: queue.Queue[str] | None, collector: list[str] | None = None) -> None:
|
||||||
|
def _reader() -> None:
|
||||||
|
try:
|
||||||
|
for line in stream:
|
||||||
|
if collector is not None:
|
||||||
|
collector.append(line.rstrip("\n"))
|
||||||
|
if output_queue is not None:
|
||||||
|
output_queue.put(line)
|
||||||
|
finally:
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_reader, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def send_line(child: subprocess.Popen, payload: dict) -> None:
|
||||||
|
assert child.stdin is not None
|
||||||
|
child.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||||
|
child.stdin.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def respond_browser(child: subprocess.Popen, seq: int, data: dict, success: bool = True) -> None:
|
||||||
|
send_line(
|
||||||
|
child,
|
||||||
|
{
|
||||||
|
"type": "response",
|
||||||
|
"seq": seq,
|
||||||
|
"success": success,
|
||||||
|
"data": data,
|
||||||
|
"aom_snapshot": [],
|
||||||
|
"timing": {"queue_ms": 1, "exec_ms": 10},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_line(output_queue: queue.Queue[str], timeout: int) -> dict:
|
||||||
|
raw = output_queue.get(timeout=timeout)
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def score_acceptance(result: dict, items: list[HotItem]) -> dict:
|
||||||
|
logs = [entry.get("message", "") for entry in result["logs"]]
|
||||||
|
final_task = result.get("final_task") or {}
|
||||||
|
exports = [Path(path) for path in result["exports"]]
|
||||||
|
exported_path = resolve_exported_path(exports, final_task.get("summary", ""))
|
||||||
|
|
||||||
|
skill_selection = 0
|
||||||
|
executed_hotlist_collection = (
|
||||||
|
"navigate https://www.zhihu.com/hot" in logs and
|
||||||
|
any(message.startswith("getText ") for message in logs)
|
||||||
|
)
|
||||||
|
read_hotlist_skill = "read_skill zhihu-hotlist" in logs
|
||||||
|
read_office_skill = "read_skill office-export-xlsx" in logs
|
||||||
|
completed_office_export = "call openxml_office" in logs
|
||||||
|
|
||||||
|
if read_hotlist_skill or executed_hotlist_collection:
|
||||||
|
skill_selection += 15
|
||||||
|
if read_office_skill or completed_office_export:
|
||||||
|
skill_selection += 15
|
||||||
|
if read_hotlist_skill and read_office_skill and \
|
||||||
|
logs.index("read_skill zhihu-hotlist") > logs.index("read_skill office-export-xlsx"):
|
||||||
|
skill_selection = max(0, skill_selection - 15)
|
||||||
|
|
||||||
|
tool_discipline = 25
|
||||||
|
if any(message == "call shell" for message in logs):
|
||||||
|
tool_discipline -= 15
|
||||||
|
if any(message == "call glob_search" for message in logs):
|
||||||
|
tool_discipline -= 10
|
||||||
|
if any(message == "call file_read" for message in logs):
|
||||||
|
tool_discipline -= 10
|
||||||
|
tool_discipline = max(0, tool_discipline)
|
||||||
|
|
||||||
|
hotlist_data_correctness = 0
|
||||||
|
xlsx_export_success = 0
|
||||||
|
workbook_ok = False
|
||||||
|
if exported_path and exported_path.exists():
|
||||||
|
with zipfile.ZipFile(exported_path) as archive:
|
||||||
|
sheet_xml = archive.read("xl/worksheets/sheet1.xml").decode("utf-8")
|
||||||
|
workbook_xml = archive.read("xl/workbook.xml").decode("utf-8")
|
||||||
|
title_matches = sum(1 for item in items if item.title in sheet_xml)
|
||||||
|
heat_matches = sum(1 for item in items if item.heat in sheet_xml)
|
||||||
|
if title_matches >= 10 and heat_matches >= 10:
|
||||||
|
hotlist_data_correctness = 20
|
||||||
|
elif title_matches >= 8 and heat_matches >= 8:
|
||||||
|
hotlist_data_correctness = 15
|
||||||
|
elif title_matches >= 5 and heat_matches >= 5:
|
||||||
|
hotlist_data_correctness = 10
|
||||||
|
workbook_ok = "知乎热榜" in workbook_xml and title_matches >= 10
|
||||||
|
if workbook_ok:
|
||||||
|
xlsx_export_success = 20
|
||||||
|
|
||||||
|
final_response_quality = 0
|
||||||
|
summary = final_task.get("summary", "")
|
||||||
|
if final_task.get("success") and summary.strip():
|
||||||
|
final_response_quality = 5
|
||||||
|
|
||||||
|
deductions = []
|
||||||
|
if not exported_path:
|
||||||
|
deductions.append("export missing output path")
|
||||||
|
|
||||||
|
total_score = (
|
||||||
|
skill_selection
|
||||||
|
+ tool_discipline
|
||||||
|
+ hotlist_data_correctness
|
||||||
|
+ xlsx_export_success
|
||||||
|
+ final_response_quality
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_score": total_score,
|
||||||
|
"skill_selection": skill_selection,
|
||||||
|
"tool_discipline": tool_discipline,
|
||||||
|
"hotlist_data_correctness": hotlist_data_correctness,
|
||||||
|
"xlsx_export_success": xlsx_export_success,
|
||||||
|
"final_response_quality": final_response_quality,
|
||||||
|
"final_success": bool(final_task.get("success")),
|
||||||
|
"final_summary": summary,
|
||||||
|
"exported_path": str(exported_path) if exported_path else "",
|
||||||
|
"deductions": deductions,
|
||||||
|
"logs": logs,
|
||||||
|
"stderr": result["stderr"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_exported_path(exports: list[Path], summary: str) -> Path | None:
|
||||||
|
match = re.search(r"(/[^\s`]+\.xlsx)", summary)
|
||||||
|
if match:
|
||||||
|
candidate = Path(match.group(1))
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
filtered = [
|
||||||
|
path
|
||||||
|
for path in exports
|
||||||
|
if path.name != "zhihu_hotlist_template.xlsx"
|
||||||
|
]
|
||||||
|
if filtered:
|
||||||
|
return sorted(filtered)[-1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_acceptance_doc(result: dict, items: list[HotItem], score: dict) -> None:
|
||||||
|
ACCEPTANCE_DOC.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lines = [
|
||||||
|
"# Zhihu Hotlist Excel Acceptance",
|
||||||
|
"",
|
||||||
|
f"- Date: {time.strftime('%Y-%m-%d %H:%M:%S %z')}",
|
||||||
|
"- Mode: real provider + live Zhihu hotlist API + simulated browser pipe",
|
||||||
|
f"- Workspace: `{result['workspace_root']}`",
|
||||||
|
f"- Final success: `{score['final_success']}`",
|
||||||
|
f"- Total score: `{score['total_score']}/100`",
|
||||||
|
"",
|
||||||
|
"## Rubric",
|
||||||
|
"",
|
||||||
|
f"- skill selection: `{score['skill_selection']}/30`",
|
||||||
|
f"- tool discipline: `{score['tool_discipline']}/25`",
|
||||||
|
f"- hotlist data correctness: `{score['hotlist_data_correctness']}/20`",
|
||||||
|
f"- xlsx export success: `{score['xlsx_export_success']}/20`",
|
||||||
|
f"- final response quality: `{score['final_response_quality']}/5`",
|
||||||
|
"",
|
||||||
|
"## Final Output",
|
||||||
|
"",
|
||||||
|
f"- exported_path: `{score['exported_path']}`",
|
||||||
|
f"- final_summary: `{score['final_summary']}`",
|
||||||
|
"",
|
||||||
|
"## Skill Logs",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for message in score["logs"]:
|
||||||
|
lines.append(f"- `{message}`")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Live Hotlist Sample",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for item in items:
|
||||||
|
lines.append(f"- {item.rank}. {item.title} | {item.heat}")
|
||||||
|
if score["stderr"]:
|
||||||
|
lines.extend(["", "## Stderr", ""])
|
||||||
|
for line in score["stderr"]:
|
||||||
|
lines.append(f"- `{line}`")
|
||||||
|
ACCEPTANCE_DOC.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
raise SystemExit(130)
|
||||||
Reference in New Issue
Block a user