Compare commits
88 Commits
main
...
6fee4e2083
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fee4e2083 | ||
|
|
46005d9718 | ||
|
|
d1711a3db3 | ||
|
|
b1647cd865 | ||
|
|
1c964c3e70 | ||
|
|
a6aa18c6d9 | ||
|
|
7420af8f31 | ||
|
|
42eb716b7e | ||
|
|
a325add167 | ||
|
|
d95b8aaf26 | ||
|
|
847f2c62c6 | ||
|
|
ea9147defb | ||
|
|
5ff6e05911 | ||
|
|
eead8f7802 | ||
|
|
d123ee0aca | ||
|
|
d26d96ef64 | ||
|
|
d996b511f6 | ||
|
|
b8d2eb9faa | ||
|
|
4167639231 | ||
|
|
78a36a73b4 | ||
|
|
74c42af717 | ||
|
|
bb15d14749 | ||
|
|
7289cc5779 | ||
|
|
689abf08ec | ||
|
|
2ffb42c181 | ||
|
|
614e9a3a45 | ||
|
|
517ac6bf39 | ||
|
|
dd7b3c582a | ||
|
|
f268668713 | ||
|
|
ce072c2ebe | ||
|
|
464f18c672 | ||
|
|
b5131c858a | ||
|
|
2e69fa7239 | ||
|
|
f84e11c631 | ||
|
|
73edf1e5cf | ||
|
|
87cee36173 | ||
|
|
67fe17302e | ||
|
|
45b54ab007 | ||
|
|
af8f261b79 | ||
|
|
f168f9f375 | ||
|
|
23845413c5 | ||
|
|
ea6be128e7 | ||
|
|
6c1865eb1c | ||
|
|
d00086a70b | ||
|
|
e7a4179513 | ||
|
|
15d4b0dcc1 | ||
|
|
294426ced9 | ||
|
|
ead9ea76fa | ||
|
|
e8d7d6b796 | ||
|
|
bd83d92480 | ||
|
|
044d38003d | ||
|
|
f07f7d63ef | ||
|
|
c60cd308ca | ||
|
|
6aa0c110bd | ||
|
|
390a431a4b | ||
|
|
0f70702914 | ||
|
|
8decd9554c | ||
|
|
adb64429ee | ||
|
|
32e2c59a40 | ||
|
|
fae2fd57d6 | ||
|
|
899c670e5c | ||
|
|
583bb117cb | ||
|
|
ad3778d4c5 | ||
|
|
4d1070dff0 | ||
|
|
0303111d5b | ||
|
|
7320fb7f79 | ||
|
|
dbbc5d030b | ||
|
|
ce6b3e6749 | ||
|
|
a957712590 | ||
|
|
0ebe060484 | ||
|
|
695a888840 | ||
|
|
733aee1e9a | ||
|
|
f8f822e1f3 | ||
|
|
3b156e4bd1 | ||
|
|
645dc60bae | ||
|
|
007959b903 | ||
|
|
447457b7d3 | ||
|
|
d230ff0389 | ||
|
|
883647dffc | ||
|
|
b454fa3f54 | ||
|
|
81de162756 | ||
|
|
630190e4d3 | ||
|
|
57b9be733d | ||
|
|
96c3bf1dee | ||
|
|
bdf8e12246 | ||
|
|
0dd655712c | ||
|
|
6068a8228b | ||
|
|
3e18350320 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,5 +6,8 @@ target/
|
||||
.qoder/
|
||||
.sgclaw_workspace/
|
||||
.sgclaw_workspace_dev1/
|
||||
.sgclaw-zeroclaw-workspace/
|
||||
sgclaw_config.json
|
||||
nul
|
||||
target-test/
|
||||
target-zhihu-nav/
|
||||
|
||||
67
Cargo.lock
generated
67
Cargo.lock
generated
@@ -2339,6 +2339,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
@@ -2377,6 +2386,8 @@ dependencies = [
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"tungstenite 0.29.0",
|
||||
"uuid",
|
||||
"zeroclawlabs",
|
||||
"zip 0.6.6",
|
||||
@@ -2739,6 +2750,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -2747,13 +2770,22 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -2763,6 +2795,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -2772,6 +2818,12 @@ dependencies = [
|
||||
"winnow 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -3443,6 +3495,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
@@ -3636,7 +3697,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
|
||||
@@ -17,6 +17,8 @@ serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] }
|
||||
toml = "0.8"
|
||||
tungstenite = "0.29"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
zip = { version = "0.6.6", default-features = false, features = ["deflate"] }
|
||||
zeroclaw = { package = "zeroclawlabs", path = "third_party/zeroclaw", default-features = false }
|
||||
|
||||
1572
docs/_tmp_sgbrowser_ws_api_doc.txt
Normal file
1572
docs/_tmp_sgbrowser_ws_api_doc.txt
Normal file
File diff suppressed because it is too large
Load Diff
145
docs/_tmp_sgbrowser_ws_probe_transcript.md
Normal file
145
docs/_tmp_sgbrowser_ws_probe_transcript.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# sgBrowser websocket probe transcript
|
||||
|
||||
Endpoint: `ws://127.0.0.1:12345`
|
||||
Timeout: `1500ms`
|
||||
Cargo target dir override: `D:/data/ideaSpace/rust/sgClaw/claw-new/target_task4`
|
||||
|
||||
## baseline-open
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "baseline-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'baseline-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 baseline-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
```
|
||||
|
||||
## open-agent
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "open-agent::[\"about:blank\",\"sgOpenAgent\"]" --step "post-open-agent-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.98s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'open-agent::["about:blank","sgOpenAgent"]' --step 'post-open-agent-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 open-agent
|
||||
SEND: ["about:blank","sgOpenAgent"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 post-open-agent-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## set-auth
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "set-auth::[\"about:blank\",\"sgSetAuthInfo\",\"probe-user\",\"probe-token\"]" --step "post-set-auth-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'set-auth::["about:blank","sgSetAuthInfo","probe-user","probe-token"]' --step 'post-set-auth-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 set-auth
|
||||
SEND: ["about:blank","sgSetAuthInfo","probe-user","probe-token"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 post-set-auth-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## browser-login
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step 'browser-login::["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]' --step 'post-browser-login-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'browser-login::["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]' --step 'post-browser-login-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 browser-login
|
||||
SEND: ["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 post-browser-login-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## active-tab
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "active-tab::[\"about:blank\",\"sgBrowerserActiveTab\",\"https://www.zhihu.com/hot\",\"probeCallback\"]" --step "post-active-tab-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'active-tab::["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]' --step 'post-active-tab-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 active-tab
|
||||
SEND: ["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 post-active-tab-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## combined-bootstrap
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "combined-open-agent::[\"about:blank\",\"sgOpenAgent\"]" --step "combined-active-tab::[\"about:blank\",\"sgBrowerserActiveTab\",\"https://www.zhihu.com/hot\",\"probeCallback\"]" --step "combined-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'combined-open-agent::["about:blank","sgOpenAgent"]' --step 'combined-active-tab::["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]' --step 'combined-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 combined-open-agent
|
||||
SEND: ["about:blank","sgOpenAgent"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 combined-active-tab
|
||||
SEND: ["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
|
||||
STEP 3 combined-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## requesturl-variants
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "target-as-requesturl::[\"https://www.zhihu.com/hot\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.94s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'target-as-requesturl::["https://www.zhihu.com/hot","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 target-as-requesturl
|
||||
SEND: ["https://www.zhihu.com/hot","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
```
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "baidu-requesturl::[\"https://www.baidu.com\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.94s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'baidu-requesturl::["https://www.baidu.com","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 baidu-requesturl
|
||||
SEND: ["https://www.baidu.com","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
```
|
||||
|
||||
| Sequence | Sent frames | First reply | Final outcome | Decision signal |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| baseline-open | `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | received only welcome banner; no numeric status or callback frame captured | does not satisfy Option A rule |
|
||||
| open-agent | `["about:blank","sgOpenAgent"]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | step 2 timed out with no reply | does not satisfy Option A rule |
|
||||
| set-auth | `["about:blank","sgSetAuthInfo","probe-user","probe-token"]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | step 2 timed out with no reply | does not satisfy Option A rule |
|
||||
| browser-login | `["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | step 2 timed out with no reply | does not satisfy Option A rule |
|
||||
| active-tab | `["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | step 2 timed out with no reply | does not satisfy Option A rule |
|
||||
| combined-bootstrap | `["about:blank","sgOpenAgent"]` then `["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | steps 2 and 3 timed out with no reply | does not satisfy Option A rule |
|
||||
| requesturl-variants | `["https://www.zhihu.com/hot","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` and `["https://www.baidu.com","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | each one-shot run received only the welcome banner; no numeric status or callback frame captured | does not satisfy Option A rule |
|
||||
|
||||
## Final decision
|
||||
|
||||
**Option B wins.**
|
||||
|
||||
Reason: the strict rule says Option A wins only if at least one sequence reproducibly yields real numeric status and/or callback frames for a real business action. Across the full required matrix, the reachable endpoint consistently returned only the websocket welcome banner on the first reply for each fresh connection, and every follow-on business-action step either timed out or produced no numeric status/callback frame. Therefore the evidence does not validate a raw-websocket bootstrap contract, so Option B is the required outcome.
|
||||
422
docs/collect_lineloss_troubleshooting_guide.md
Normal file
422
docs/collect_lineloss_troubleshooting_guide.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# collect_lineloss.js 从生成到可用的完整排查记录
|
||||
|
||||
本文档记录了 `tq-lineloss-report` skill 脚本从初始生成到最终可用的全部排查过程,包括遇到的每个错误、根因分析和修复方法。可作为后续类似 skill 开发的排查模板。
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
### 架构概览
|
||||
|
||||
```
|
||||
用户输入 "兰州公司 月累计 2026-03。。。"
|
||||
│
|
||||
▼
|
||||
sgClaw Rust 进程
|
||||
├── 解析指令 → DeterministicExecutionPlan
|
||||
├── 读取 collect_lineloss.js 脚本
|
||||
├── 包装为 IIFE:(function(){ const args = {...}; <脚本内容> })()
|
||||
├── 调用 sgBrowserExcuteJsCodeByDomain(domain, wrappedJs)
|
||||
│ 注入到浏览器中匹配 domain 的页面执行
|
||||
├── 等待回调:脚本通过 callBackJsToCpp 返回 JSON 结果
|
||||
├── 解析 artifact JSON → 提取 status/rows/reasons
|
||||
└── 生成 XLSX(Rust 侧)→ 返回 outcome
|
||||
```
|
||||
|
||||
### 关键差异:原始场景 vs Skill 模式
|
||||
|
||||
| 对比项 | 原始场景 (index.html) | Skill 模式 |
|
||||
|--------|----------------------|------------|
|
||||
| 脚本注入方式 | `sgBrowserExcuteJsCode(exactURL, js)` — 精确 URL | `sgBrowserExcuteJsCodeByDomain(domain, js)` — 仅域名匹配 |
|
||||
| 执行页面 | 业务子页面 `/tqLinelossStatis/tqQualifyRateMonitor` | 可能命中父框架页 `/gsllys` |
|
||||
| `window.mac` | 有(Vue 实例,`mounted()` 中 `window.mac = this`) | 无(没有 Vue 实例) |
|
||||
| 导出 Excel | JS 调 `localhost:13313`(本地场景页可访问) | JS 无法调 `localhost:13313`(CORS 阻断) |
|
||||
| 结果回传 | Rust 只需要 `.then()` 回调结果 | 同左,但脚本是 async 函数需 `.then()` 处理 |
|
||||
|
||||
---
|
||||
|
||||
## 排查时间线
|
||||
|
||||
### 第 1 阶段:基础管道问题
|
||||
|
||||
#### 问题 1: `missing_expected_domain`
|
||||
|
||||
**现象**: `status=blocked reasons=missing_expected_domain`
|
||||
|
||||
**根因**: Rust 侧 `deterministic_submit.rs` 构造 args 时没有传 `expected_domain` 字段。`derive_expected_domain()` 从 `page_url` 提取 host 时只取了域名不含端口,但传入 args 时 key 不匹配。
|
||||
|
||||
**修复**: 确保 `deterministic_submit_args()` 正确插入 `expected_domain` 到 args Map。
|
||||
|
||||
**涉及文件**: `src/compat/deterministic_submit.rs`
|
||||
|
||||
**是否需要重新编译**: 是
|
||||
|
||||
---
|
||||
|
||||
#### 问题 2: `target_url` 缺少端口号
|
||||
|
||||
**现象**: 脚本注入失败或注入到错误页面。
|
||||
|
||||
**根因**: `target_url` 被设为 `http://20.76.57.61`(无端口),但实际业务页面在 `http://20.76.57.61:18080/gsllys/...`。`sgBrowserExcuteJsCodeByDomain` 需要能匹配到正确的标签页。
|
||||
|
||||
**修复**: 在 `deterministic_submit.rs` 中设置完整 `target_url`:
|
||||
```rust
|
||||
const LINELLOSS_TARGET_URL: &str = "http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor";
|
||||
```
|
||||
|
||||
**涉及文件**: `src/compat/deterministic_submit.rs`
|
||||
|
||||
**是否需要重新编译**: 是
|
||||
|
||||
---
|
||||
|
||||
#### 问题 3: 脚本返回 `{}` 空对象
|
||||
|
||||
**现象**: Rust 侧收到的 artifact 是 `{}`,无任何数据。
|
||||
|
||||
**根因**: `collect_lineloss.js` 的入口 `buildBrowserEntrypointResult()` 是 `async` 函数,返回 Promise。Rust 侧 `build_eval_js` 包装器原来直接调用 `_s(v)` 发送结果,但 `v` 是一个 Promise 对象,JSON.stringify 后变成 `{}`。
|
||||
|
||||
**修复**: 在 `build_eval_js`(`callback_backend.rs`)中增加 Promise 检测:
|
||||
```rust
|
||||
// 旧代码
|
||||
"_s(v);"
|
||||
|
||||
// 新代码
|
||||
"if(v&&typeof v.then==='function'){v.then(_s).catch(function(){});}else{_s(v);}"
|
||||
```
|
||||
|
||||
如果返回值是 thenable(Promise),等它 resolve 后再发送回调。
|
||||
|
||||
**涉及文件**: `src/browser/callback_backend.rs` 中 `build_eval_js` 函数
|
||||
|
||||
**是否需要重新编译**: 是
|
||||
|
||||
**教训**: 所有 browser_script skill 如果入口函数是 async(返回 Promise),都需要这个 `.then()` 处理。这是管道层的通用修复。
|
||||
|
||||
---
|
||||
|
||||
### 第 2 阶段:页面上下文问题
|
||||
|
||||
#### 问题 4: `page_context_unavailable` (mac_missing)
|
||||
|
||||
**现象**:
|
||||
```
|
||||
tq-lineloss-report 国网兰州供电公司 2026-03 status=blocked rows=0 reasons=page_context_unavailable
|
||||
```
|
||||
|
||||
**排查过程**:
|
||||
|
||||
1. 在 `validatePageContext` 中添加诊断信息:
|
||||
```javascript
|
||||
// 临时诊断代码
|
||||
const diag = 'href=' + href + '|host=' + host + '|port=' + port + '|title=' + title + '|mac=' + hasMac;
|
||||
return { ok: false, reason: 'page_context_unavailable:mac_missing|' + diag };
|
||||
```
|
||||
|
||||
2. 页面返回的诊断结果:
|
||||
```
|
||||
href=http://20.76.57.61:18080/gsllys
|
||||
host=20.76.57.61
|
||||
port=18080
|
||||
title=台区线损大数据分析模块
|
||||
mac=false
|
||||
```
|
||||
|
||||
**根因**: `sgBrowserExcuteJsCodeByDomain("20.76.57.61")` 匹配到了父框架页 `/gsllys`,而不是业务子页面。`window.mac` 是业务子页面的 Vue 实例,在 `mounted()` 中通过 `window.mac = this` 设置,父框架页没有这个实例。
|
||||
|
||||
**关键认知**: 在 Skill 模式下没有 Vue 实例,`window.mac` 检查在架构上就不适用。脚本通过 AJAX 发绝对 URL 请求,不依赖页面本地状态。
|
||||
|
||||
**修复**: 删除 `globalThis.mac` 检查,只保留 host 匹配:
|
||||
```javascript
|
||||
// 修复前
|
||||
validatePageContext(args) {
|
||||
// ... 含 mac 检查 + 诊断代码
|
||||
if (!hasMac) {
|
||||
return { ok: false, reason: 'page_context_unavailable:mac_missing|' + diag };
|
||||
}
|
||||
}
|
||||
|
||||
// 修复后
|
||||
validatePageContext(args) {
|
||||
const host = normalizeText(globalThis.location?.hostname);
|
||||
const expected = normalizeText(args.expected_domain);
|
||||
if (!host) {
|
||||
return { ok: false, reason: 'page_context_unavailable' };
|
||||
}
|
||||
if (host !== expected) {
|
||||
return { ok: false, reason: 'page_context_mismatch' };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
```
|
||||
|
||||
**涉及文件**: `collect_lineloss.js` — `validatePageContext` 函数
|
||||
|
||||
**是否需要重新编译**: 否(JS 文件运行时读取)
|
||||
|
||||
**排查技巧**: 在 reasons 中拼接诊断信息(href/host/port/title/mac),不需要 F12 console,直接通过 Rust 侧的 summary 输出就能看到。
|
||||
|
||||
---
|
||||
|
||||
### 第 3 阶段:API 请求问题
|
||||
|
||||
#### 问题 5: `api_query_failed` — 返回 HTML 而非 JSON
|
||||
|
||||
**现象**:
|
||||
```
|
||||
status=error rows=0 reasons=api_query_failed:month_api_failed: SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
|
||||
```
|
||||
|
||||
**根因**: 后端服务检测到请求缺少 `X-Requested-With: XMLHttpRequest` 头,认为这不是 AJAX 请求,返回了 HTML 登录页面。jQuery 的 `$.ajax` 不会自动添加这个头。
|
||||
|
||||
**修复**: 在 `queryMonthData` 和 `queryWeekData` 的 `$.ajax` 调用中添加请求头:
|
||||
```javascript
|
||||
$.ajax({
|
||||
url,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
crossDomain: true,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }, // <-- 新增
|
||||
data: request,
|
||||
contentType: 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
success: resolve,
|
||||
error: (xhr, _status, err) => reject(new Error(
|
||||
`month_api_failed(${xhr.status}): ${String(err)}|body=${String(xhr.responseText || '').substring(0, 200)}`
|
||||
))
|
||||
});
|
||||
```
|
||||
|
||||
**涉及文件**: `collect_lineloss.js` — `queryMonthData` 和 `queryWeekData`
|
||||
|
||||
**是否需要重新编译**: 否
|
||||
|
||||
**排查技巧**: 在 error handler 中拼接 `xhr.responseText` 的前 200 字符到 reasons 中。如果看到 `<!DOCTYPE` 开头,说明后端返回了 HTML 而非 JSON。
|
||||
|
||||
**通用规则**: 内网 Java 后端通常依赖 `X-Requested-With: XMLHttpRequest` 来区分页面请求和 AJAX 请求。所有对内网 API 的 `$.ajax` 调用都应加上此头。
|
||||
|
||||
---
|
||||
|
||||
### 第 4 阶段:数据规范化问题
|
||||
|
||||
#### 问题 6: `row_normalization_failed` — 列名不匹配
|
||||
|
||||
**现象**:
|
||||
```
|
||||
status=error rows=0 reasons=row_normalization_failed:rawRows=12|keys=YGDL,ORG_NO,YXSL,TG_NUM...
|
||||
```
|
||||
|
||||
**根因**: 初始生成的 `MONTH_COLUMN_DEFS` 使用了猜测的列名:
|
||||
```javascript
|
||||
// 错误的列名
|
||||
['LINE_LOSS_RATE', '线损完成率(%)'],
|
||||
['PPQ', '累计供电量'],
|
||||
['UPQ', '累计售电量'],
|
||||
```
|
||||
|
||||
而 API 实际返回的列名是(参考原始场景 `index.html` 中的 `cols2`):
|
||||
```javascript
|
||||
// 正确的列名
|
||||
['ORG_NAME', '供电单位'],
|
||||
['YGDL', '累计供电量'],
|
||||
['YYDL', '累计售电量'],
|
||||
['YXSL', '线损完成率(%)'],
|
||||
['RAT_SCOPE', '线损率累计目标值'],
|
||||
['BLANK3', '目标完成率'],
|
||||
['BLANK2', '排行']
|
||||
```
|
||||
|
||||
**修复**: 按原始场景 `index.html` 中 `cols2` 的定义修正 `MONTH_COLUMN_DEFS`。
|
||||
|
||||
**排查技巧**: 在 `reasons` 中拼接 `rawRows.length` 和 `Object.keys(rawRows[0]).join(',')` 可以直接看到 API 返回了哪些字段。
|
||||
|
||||
**通用规则**: 生成 skill 脚本时,列定义必须从原始场景代码中精确复制,不能靠猜测。找 `cols1`/`cols2` 或表格渲染相关代码。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 7: `row_normalization_failed` — 数值类型不兼容
|
||||
|
||||
**现象**: 列名修正后仍报 `row_normalization_failed:rawRows=12`,12 行全部被过滤。
|
||||
|
||||
**根因**: `pickFirstNonEmpty()` 函数只识别字符串类型:
|
||||
```javascript
|
||||
function pickFirstNonEmpty(...values) {
|
||||
for (const value of values) {
|
||||
if (isNonEmptyString(value)) { // isNonEmptyString: typeof value === 'string'
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return ''; // API 返回数字 12345.67,typeof === 'number',被当作空值
|
||||
}
|
||||
```
|
||||
|
||||
API 返回的字段值是数字(如 `YGDL: 12345.67`),不是字符串。`pickFirstNonEmpty` 对数字返回 `''`,导致所有行的所有字段都为空,全部被过滤。
|
||||
|
||||
**修复**: `normalizeMonthRow` 不使用 `pickFirstNonEmpty`,改为直接处理任意类型值:
|
||||
```javascript
|
||||
// 修复前
|
||||
function normalizeMonthRow(rawRow) {
|
||||
const row = {};
|
||||
for (const key of MONTH_COLUMNS) {
|
||||
row[key] = pickFirstNonEmpty(rawRow?.[key]); // 数字类型 → ''
|
||||
}
|
||||
return MONTH_COLUMNS.every((key) => row[key] !== '') ? row : null;
|
||||
}
|
||||
|
||||
// 修复后
|
||||
function normalizeMonthRow(rawRow) {
|
||||
const row = {};
|
||||
for (const key of MONTH_COLUMNS) {
|
||||
const v = rawRow?.[key];
|
||||
row[key] = (v === null || v === undefined || v === '') ? '' : String(v).trim();
|
||||
}
|
||||
return MONTH_COLUMNS.every((key) => row[key] !== '') ? row : null;
|
||||
}
|
||||
```
|
||||
|
||||
**涉及文件**: `collect_lineloss.js` — `normalizeMonthRow`
|
||||
|
||||
**是否需要重新编译**: 否
|
||||
|
||||
**通用规则**: 内网 API 返回的 JSON 中数值字段通常是 `number` 类型而非字符串。行规范化函数必须用 `String(v)` 进行类型转换,不能依赖 `typeof === 'string'` 判断。
|
||||
|
||||
---
|
||||
|
||||
### 第 5 阶段:导出问题(架构级)
|
||||
|
||||
#### 问题 8: 导出永久挂起
|
||||
|
||||
**现象**:
|
||||
```
|
||||
tq-lineloss-report 国网兰州供电公司 2026-03 status=pl rows=12
|
||||
```
|
||||
数据采集成功(12 行),但之后永远没有返回,脚本卡死在导出步骤。
|
||||
|
||||
**排查过程**:
|
||||
|
||||
1. `exportWorkbook` 调用 `fetch('http://localhost:13313/...')` — CORS 阻断
|
||||
2. 改用 `$.ajax({ crossDomain: true })` — 同样阻断
|
||||
3. 确认这是浏览器安全模型限制,不是配置问题
|
||||
|
||||
**根因**: 脚本运行在远程页面 `http://20.76.57.61:18080` 上,浏览器禁止从远程页面向 `localhost:13313` 发起请求(同源策略 + Mixed Content)。`crossDomain: true` 只是告诉 jQuery 用跨域模式,并不能绕过浏览器安全策略。
|
||||
|
||||
原始场景的解决方式:有一个本地场景页面(`localhost` 上的 `index.html`)充当代理,先在远程页面采集数据,再通过 `postMessage` 或回调传回本地页面,由本地页面调用 `localhost:13313`。
|
||||
|
||||
Skill 模式没有本地场景页面,因此这种代理机制不存在。
|
||||
|
||||
**解决方案**: 将导出逻辑从浏览器 JS 移到 Rust 侧(方案 A2: Rust 本地生成 XLSX)。
|
||||
|
||||
**最终架构**:
|
||||
```
|
||||
JS (浏览器): 采集数据 → 返回 artifact { rows, column_defs, status }
|
||||
↓
|
||||
Rust (本地): 解析 artifact → 提取 rows + column_defs → 生成 XLSX 文件
|
||||
```
|
||||
|
||||
**具体修改**:
|
||||
|
||||
1. **JS 侧**: 删除 `exportWorkbook()`、`writeReportLog()`、`postJson()`、`buildExportPayload()` 等导出相关代码。artifact 中添加 `column_defs` 字段,export 状态设为 `deferred_to_rust`。
|
||||
|
||||
2. **Rust 侧**: 新增 `lineloss_xlsx_export.rs`,用 `zip` crate + OpenXML XML 生成 XLSX。在 `deterministic_submit.rs` 中,收到 artifact 后调用 XLSX 生成。
|
||||
|
||||
**涉及文件**:
|
||||
- `collect_lineloss.js` — 删除导出代码,添加 `column_defs`
|
||||
- `src/compat/lineloss_xlsx_export.rs` — 新增
|
||||
- `src/compat/deterministic_submit.rs` — 新增导出集成
|
||||
- `src/compat/mod.rs` — 注册新模块
|
||||
|
||||
**是否需要重新编译**: 是
|
||||
|
||||
**通用规则**: 任何从远程页面调用 `localhost` 的操作在 Skill 模式下都不可行。导出/写日志等需要访问本地服务的功能必须放到 Rust 侧实现。
|
||||
|
||||
---
|
||||
|
||||
## 排查方法论总结
|
||||
|
||||
### 1. 诊断信息注入模式
|
||||
|
||||
脚本运行在浏览器中,无法看 F12 console。唯一的信息通道是 artifact JSON 的 `reasons` 字段。
|
||||
|
||||
```javascript
|
||||
// 在 catch 块中注入详细错误
|
||||
reasons: ['api_query_failed:' + String(error?.message || error || 'unknown')]
|
||||
|
||||
// 在规范化失败时注入原始数据摘要
|
||||
reasons: ['row_normalization_failed:rawRows=' + rawRows.length + '|keys=' + Object.keys(rawRows[0]).join(',')]
|
||||
|
||||
// 在页面上下文检查中注入环境信息
|
||||
reason: 'page_context_unavailable:mac_missing|href=' + href + '|host=' + host + '|port=' + port
|
||||
```
|
||||
|
||||
Rust 侧的 summary 输出会包含这些 reasons,直接在日志中可见。
|
||||
|
||||
### 2. 逐层排查顺序
|
||||
|
||||
```
|
||||
Layer 1: 管道层(Rust)
|
||||
├── args 是否正确传入?(expected_domain, target_url, org_code 等)
|
||||
├── 脚本文件是否正确读取?
|
||||
├── async 返回值是否被正确处理?(.then() 模式)
|
||||
└── 回调是否成功返回?
|
||||
|
||||
Layer 2: 页面上下文(JS)
|
||||
├── 脚本注入到了哪个页面?(href, title)
|
||||
├── 页面是否有需要的全局变量?(window.mac 等)
|
||||
└── domain 匹配是否正确?
|
||||
|
||||
Layer 3: API 请求(JS)
|
||||
├── 请求头是否完整?(X-Requested-With)
|
||||
├── 返回格式是否正确?(JSON vs HTML)
|
||||
└── 返回状态码?
|
||||
|
||||
Layer 4: 数据处理(JS)
|
||||
├── API 返回的字段名是否匹配列定义?
|
||||
├── 字段值类型是否兼容?(number vs string)
|
||||
└── 规范化后是否有有效行?
|
||||
|
||||
Layer 5: 导出(架构)
|
||||
├── 是否涉及跨域请求?
|
||||
├── localhost 是否可达?
|
||||
└── 是否需要 Rust 侧处理?
|
||||
```
|
||||
|
||||
### 3. 修改后验证检查清单
|
||||
|
||||
- [ ] JS 文件语法检查:`node -e "require('./collect_lineloss.js')"`
|
||||
- [ ] 如果改了 Rust 代码:`cargo build` 编译通过
|
||||
- [ ] `cargo test` 全部通过(排除已知的 pre-existing failures)
|
||||
- [ ] 替换 JS 文件到部署目录
|
||||
- [ ] 如果改了 Rust:重新部署编译后的 sgclaw 二进制
|
||||
|
||||
---
|
||||
|
||||
## 最终文件清单
|
||||
|
||||
### JS 文件: `collect_lineloss.js`
|
||||
|
||||
**位置**: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
|
||||
|
||||
**功能**: 纯数据采集。注入到浏览器,查询线损平台 API,返回结构化 artifact。
|
||||
|
||||
**不做的事**: 不调 localhost:13313,不导出 Excel,不写 report log。
|
||||
|
||||
### Rust 文件: 修改清单
|
||||
|
||||
| 文件 | 修改内容 | 修改类型 |
|
||||
|------|---------|---------|
|
||||
| `src/browser/callback_backend.rs` | `build_eval_js` 增加 `.then()` 处理 async 返回值 | 管道层通用修复 |
|
||||
| `src/compat/deterministic_submit.rs` | 完整 `target_url`; 解析 artifact 后调 XLSX 导出 | 业务集成 |
|
||||
| `src/compat/lineloss_xlsx_export.rs` | XLSX 生成(zip + OpenXML) | 新增 |
|
||||
| `src/compat/mod.rs` | 注册 `lineloss_xlsx_export` 模块 | 新增 |
|
||||
|
||||
---
|
||||
|
||||
## 快速复用模板
|
||||
|
||||
新建类似 skill 时,直接检查以下要点:
|
||||
|
||||
1. **`build_eval_js` 是否支持 async**:入口函数如果是 `async`,确认 `callback_backend.rs` 中有 `.then()` 处理。
|
||||
2. **`validatePageContext` 不检查页面局部状态**:只检查 host,不检查 `window.mac`、`window.app` 等场景页专属变量。
|
||||
3. **API 请求必须带 `X-Requested-With: XMLHttpRequest`**:内网 Java 后端的标配。
|
||||
4. **列定义从原始场景代码精确复制**:找 `cols1`/`cols2` 或表格 `columns` 配置。
|
||||
5. **`normalizeRow` 用 `String(v)` 而非 `pickFirstNonEmpty`**:API 返回数字不是字符串。
|
||||
6. **导出不走浏览器,走 Rust 侧**:JS 返回 rows + column_defs,Rust 生成 XLSX。
|
||||
425
docs/superpowers/plans/2026-04-01-claw-ws-execution-cards.md
Normal file
425
docs/superpowers/plans/2026-04-01-claw-ws-execution-cards.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Claw-WS 开发执行顺序卡片
|
||||
|
||||
> 配套计划:[`2026-04-01-claw-ws-parallel-transport.md`](./2026-04-01-claw-ws-parallel-transport.md)
|
||||
>
|
||||
> 使用方式:严格按卡片顺序执行。每张卡片完成后先跑卡片内测试,再进入下一张。不要跳卡,不要提前接线,不要先写 service/client 再回头抽象底层。
|
||||
|
||||
---
|
||||
|
||||
## 卡片 0:执行前约束
|
||||
|
||||
**目标**
|
||||
先锁定边界,避免实现过程中把 pipe 模式改坏。
|
||||
|
||||
**必须遵守**
|
||||
- 现有 pipe 模式必须保持可用
|
||||
- 新增的是并行 `claw-ws` 模式,不是替换 pipe
|
||||
- v1 只做单客户端、单任务串行
|
||||
- `browser_action` / `superrpa_browser` 外部命名保持稳定
|
||||
- 如果 WS `Eval` 不完整,先禁用相关 browser-script skill 暴露
|
||||
- 不要提前做多客户端、任务队列、管理接口
|
||||
|
||||
**完成标准**
|
||||
- 开发者明确后续所有改动都围绕“抽象复用 + 并行新增”进行
|
||||
|
||||
---
|
||||
|
||||
## 卡片 1:抽共享 SubmitTask Runner
|
||||
|
||||
**目标**
|
||||
把当前 `BrowserMessage::SubmitTask` 的主执行逻辑从 pipe 入口里抽出来,变成共享执行器。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/task_runner_test.rs`
|
||||
2. 先写失败用例:
|
||||
- 空 instruction
|
||||
- 无 LLM 配置
|
||||
- 日志顺序仍然是 `LogEntry` -> `TaskComplete`
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/agent/mod.rs`
|
||||
- `src/lib.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `tests/task_runner_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 建 `SubmitTaskRequest`
|
||||
2. 建 `AgentEventSink`
|
||||
3. 建 `run_submit_task(...)`
|
||||
4. 让 pipe 入口只做:
|
||||
- 解包 `BrowserMessage::SubmitTask`
|
||||
- 转成 `SubmitTaskRequest`
|
||||
- 调共享 runner
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这一张卡里引入 ws backend
|
||||
- 不要改 tool adapter
|
||||
- 不要碰 service/client
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_task_flow_test --test task_runner_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- 老的 `runtime_task_flow_test` 继续绿
|
||||
- 新的 `task_runner_test` 通过
|
||||
- pipe 行为无变化
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: extract shared submit-task runner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 2:抽 BrowserBackend 抽象
|
||||
|
||||
**目标**
|
||||
把上层 runtime / orchestration / tool adapter 从 `BrowserPipeTool<T>` 解耦,统一依赖浏览器后端接口。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/browser_backend_capability_test.rs`
|
||||
2. 先写失败用例:
|
||||
- pipe backend 元数据不变
|
||||
- pipe backend 支持 `Eval`
|
||||
- `supports_eval() == false` 时不暴露 browser-script tools
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/browser/mod.rs`
|
||||
- `src/browser/backend.rs`
|
||||
- `src/browser/pipe_backend.rs`
|
||||
- `src/compat/browser_tool_adapter.rs`
|
||||
- `src/compat/browser_script_skill_tool.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/lib.rs`
|
||||
- `tests/browser_backend_capability_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 定义 `BrowserBackend`
|
||||
2. 写 `pipe_backend` 包装当前 `BrowserPipeTool`
|
||||
3. 把上层签名改成 `Arc<dyn BrowserBackend>`
|
||||
4. 保持工具名不变:
|
||||
- `browser_action`
|
||||
- `superrpa_browser`
|
||||
5. 增加 `supports_eval()` gating
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这一张卡里接浏览器 ws 协议
|
||||
- 不要建 service
|
||||
- 不要加 client 协议
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test browser_backend_capability_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- 现有 browser tool 相关测试不回归
|
||||
- 新 capability test 通过
|
||||
- 上层逻辑已脱离 `BrowserPipeTool<T>` 的硬耦合
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: abstract browser backend from pipe transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 3:写死浏览器 WS 协议 Codec
|
||||
|
||||
**目标**
|
||||
单独做浏览器固定 WebSocket 协议编解码层,不把协议细节散落到 backend 和 service 里。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/browser_ws_protocol_test.rs`
|
||||
2. 先写失败用例:
|
||||
- outbound frame 精确编码
|
||||
- callback payload 解析
|
||||
- 异常格式拒绝
|
||||
- v1 action 覆盖完整
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `tests/browser_ws_protocol_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 按浏览器文档编码数组消息
|
||||
2. 只支持 v1 必需动作:
|
||||
- `Navigate`
|
||||
- `GetText`
|
||||
- `Click`
|
||||
- `Type`
|
||||
- `Eval`
|
||||
3. 定义 callback 解析和关联规则
|
||||
4. 对 unsupported / malformed 早失败
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这张卡里连真实浏览器
|
||||
- 不要写 service 协议
|
||||
- 不要把网络连接逻辑塞进 codec
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_protocol_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- codec 单测全绿
|
||||
- 无网络依赖
|
||||
- 已能作为 backend 的纯协议层基础
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "test: codify fixed browser websocket protocol"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 4:实现 Browser WS Backend
|
||||
|
||||
**目标**
|
||||
在 codec 之上提供和 pipe backend 类似的阻塞式 `invoke(...)` 能力。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/browser_ws_backend_test.rs`
|
||||
2. 先写失败用例:
|
||||
- `0 + 无 callback` 成功
|
||||
- 非 `0` 失败
|
||||
- `0 + callback` 成功
|
||||
- callback timeout
|
||||
- socket drop
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/browser/mod.rs`
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `tests/browser_ws_backend_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 建长连接管理器
|
||||
2. 先做串行单飞请求
|
||||
3. 发送前过 `MacPolicy`
|
||||
4. 统一即时返回和 callback 返回
|
||||
5. 输出统一 `CommandOutput`
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这一张卡里接 service 层
|
||||
- 不要做多并发 browser request
|
||||
- 不要直接把浏览器 ws 代码散进 runtime
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- backend 在 mocks/fakes 下稳定通过
|
||||
- invoke 语义与 pipe backend 接近
|
||||
- 可供上层 runtime 直接替换使用
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add browser websocket backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 5:实现 sg_claw Service
|
||||
|
||||
**目标**
|
||||
新增本地长驻服务端,承接 client 请求并复用共享 task runner。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/service_ws_session_test.rs`
|
||||
2. 先写失败用例:
|
||||
- 首个客户端接入成功
|
||||
- 第二个客户端收到 busy
|
||||
- 断开后状态释放
|
||||
- 任务重入被拒绝
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/service/mod.rs`
|
||||
- `src/service/protocol.rs`
|
||||
- `src/service/server.rs`
|
||||
- `src/bin/sg_claw.rs`
|
||||
- `src/lib.rs`
|
||||
- `Cargo.toml`
|
||||
- `tests/service_ws_session_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 定义 client/service 协议
|
||||
2. 实现 service 端事件 sink
|
||||
3. 建单活 session 状态机:
|
||||
- `Idle`
|
||||
- `ClientAttached`
|
||||
- `TaskRunning`
|
||||
4. 路由 `SubmitTask` 到共享 runner
|
||||
5. 保持 pipe 入口不变
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这一张卡里做 client 交互体验优化
|
||||
- 不要加任务队列
|
||||
- 不要支持多客户端并发
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- 服务端会话锁生效
|
||||
- 共享 runner 可被 service 复用
|
||||
- pipe 模式入口未受影响
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add claw-ws service entrypoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 6:实现 sg_claw_client
|
||||
|
||||
**目标**
|
||||
新增一个薄客户端,提供类似 `claude/codex` 的交互式命令行体验。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/service_task_flow_test.rs`
|
||||
2. 先写失败用例:
|
||||
- submit-task 送达 service
|
||||
- 日志按顺序流回
|
||||
- completion 只到一次
|
||||
- 完成后断开处理清晰
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/bin/sg_claw_client.rs`
|
||||
- `Cargo.toml`
|
||||
- `tests/service_task_flow_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 建立到本地 service 的 ws 连接
|
||||
2. 读取用户输入
|
||||
3. 发送 `SubmitTask`
|
||||
4. 实时打印日志
|
||||
5. 收到 `TaskComplete` 结束本轮
|
||||
|
||||
**绝对不要做**
|
||||
- 不要把 runtime、skills、browser backend 复制进 client
|
||||
- 不要让 client 直接连浏览器
|
||||
- 不要让 client 承担业务逻辑
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test
|
||||
cargo build --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- client 是薄壳
|
||||
- task flow 正常
|
||||
- 两个新 binary 可编译
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add interactive claw-ws client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 7:最终接线与回归验证
|
||||
|
||||
**目标**
|
||||
把 ws 路径接起来,同时确认 pipe 路径零回归。
|
||||
|
||||
**先做什么**
|
||||
1. 只增加最小配置项:
|
||||
- `browser_ws_url`
|
||||
- `service_ws_listen_addr`
|
||||
2. 检查外部工具命名保持稳定
|
||||
|
||||
**要改哪些文件**
|
||||
- `Cargo.toml`
|
||||
- `src/lib.rs`
|
||||
- `src/config/settings.rs`
|
||||
- `src/runtime/engine.rs`(如确有必要)
|
||||
|
||||
**实现动作**
|
||||
1. 接入最小配置面
|
||||
2. 确保 pipe / ws 下工具命名一致
|
||||
3. 跑旧 pipe 回归
|
||||
4. 跑新 ws 测试
|
||||
5. 跑全量 Rust tests
|
||||
6. 编译所有 binary
|
||||
7. 做一次真实本地 smoke test
|
||||
|
||||
**本卡 pipe 回归命令**
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test runtime_task_flow_test --test pipe_handshake_test --test pipe_protocol_test --test task_protocol_test
|
||||
```
|
||||
|
||||
**本卡 ws 测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test --test browser_ws_protocol_test --test browser_ws_backend_test --test browser_backend_capability_test --test service_ws_session_test --test service_task_flow_test
|
||||
```
|
||||
|
||||
**本卡全量命令**
|
||||
|
||||
```bash
|
||||
cargo test --tests
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
**手工验证**
|
||||
1. 启动浏览器,确认 `ws://127.0.0.1:12345` 可用
|
||||
2. `cargo run --bin sg_claw`
|
||||
3. 新终端运行 `cargo run --bin sg_claw_client`
|
||||
4. 发一个简单浏览器任务
|
||||
5. 确认日志流和单次 completion
|
||||
6. 确认旧 `cargo run` pipe 入口仍可启动
|
||||
|
||||
**通过标准**
|
||||
- pipe 模式零回归
|
||||
- ws 模式可独立工作
|
||||
- 两套模式并行存在
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: wire parallel claw-ws transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一句话执行顺序
|
||||
|
||||
严格按下面顺序做:
|
||||
|
||||
1. 共享 runner
|
||||
2. browser backend 抽象
|
||||
3. ws 协议 codec
|
||||
4. ws backend
|
||||
5. service
|
||||
6. client
|
||||
7. 配置接线 + 回归
|
||||
|
||||
如果顺序乱了,最容易出现的问题是:
|
||||
- 上层重复实现
|
||||
- pipe 被误伤
|
||||
- ws 协议细节扩散到整个工程
|
||||
- service/client 提前写完后又被迫重构
|
||||
687
docs/superpowers/plans/2026-04-01-claw-ws-parallel-transport.md
Normal file
687
docs/superpowers/plans/2026-04-01-claw-ws-parallel-transport.md
Normal file
@@ -0,0 +1,687 @@
|
||||
# Claw-WS Parallel Transport Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a parallel `claw-ws` transport path that keeps the current pipe mode intact while introducing a long-lived `sg_claw` local service, an interactive `sg_claw_client`, and a browser WebSocket backend at `ws://127.0.0.1:12345`.
|
||||
|
||||
**Architecture:** First extract a transport-agnostic submit-task runner and browser backend abstraction from the current pipe-coupled flow. Keep the existing pipe path as one adapter/backend, then add a fixed-protocol browser WebSocket backend plus a small service/session layer and an interactive CLI client that reuse the same runtime, orchestration, and browser-facing tool adapters.
|
||||
|
||||
**Tech Stack:** Rust 2021, current sgclaw compat runtime, zeroclaw runtime engine, `serde`/`serde_json`, existing `MacPolicy`, and a blocking WebSocket crate for v1 (`tungstenite` preferred over a broad async rewrite).
|
||||
|
||||
---
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Keep the current pipe mode entrypoint and behavior working.
|
||||
- Do **not** replace the existing browser pipe path.
|
||||
- Add a **parallel** WebSocket path only.
|
||||
- v1 supports **one active client session** only.
|
||||
- Reuse existing tool names and runtime behavior whenever possible.
|
||||
- If WS `Eval` support is incomplete, disable eval-dependent browser-script skill exposure in WS mode rather than shipping partial behavior.
|
||||
- Do not broaden v1 with task queues, multi-client support, or admin endpoints.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to reuse
|
||||
|
||||
- Modify: `src/lib.rs` — current pipe bootstrap and receive loop; keep as the legacy pipe entrypoint.
|
||||
- Modify: `src/agent/mod.rs` — current `BrowserMessage::SubmitTask` entrypoint and config-loading flow.
|
||||
- Modify: `src/compat/runtime.rs` — compat runtime and tool assembly.
|
||||
- Modify: `src/compat/orchestration.rs` — direct workflow vs compat runtime routing.
|
||||
- Modify: `src/compat/browser_tool_adapter.rs` — exposes `browser_action` and `superrpa_browser`.
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs` — browser-script skill execution.
|
||||
- Modify: `src/compat/workflow_executor.rs` — direct browser workflows such as Zhihu flows.
|
||||
- Reuse: `src/pipe/browser_tool.rs` — current browser command executor; retain as the pipe backend implementation.
|
||||
- Reuse: `src/pipe/protocol.rs` — `BrowserMessage`, `AgentMessage`, `Action`, `ExecutionSurfaceMetadata`.
|
||||
- Reuse: `src/security/mac_policy.rs` — local action/domain guardrails.
|
||||
- Modify: `src/config/settings.rs` — minimal new config surface for WS mode.
|
||||
- Optional modify: `src/runtime/engine.rs` — only if backend capability wiring requires it.
|
||||
|
||||
### New files to create
|
||||
|
||||
- Create: `src/agent/task_runner.rs` — shared submit-task execution entrypoint.
|
||||
- Create: `src/browser/mod.rs` — browser backend exports.
|
||||
- Create: `src/browser/backend.rs` — `BrowserBackend` trait and helpers.
|
||||
- Create: `src/browser/pipe_backend.rs` — wrapper around existing `BrowserPipeTool`.
|
||||
- Create: `src/browser/ws_protocol.rs` — fixed browser WS request/response codec.
|
||||
- Create: `src/browser/ws_backend.rs` — browser WS backend with blocking invoke semantics.
|
||||
- Create: `src/service/mod.rs` — service exports.
|
||||
- Create: `src/service/protocol.rs` — client/service WS message types.
|
||||
- Create: `src/service/server.rs` — single-session `sg_claw` server.
|
||||
- Create: `src/bin/sg_claw.rs` — service binary.
|
||||
- Create: `src/bin/sg_claw_client.rs` — interactive CLI client.
|
||||
- Create: `tests/task_runner_test.rs` — shared submit-task runner regressions.
|
||||
- Create: `tests/browser_backend_capability_test.rs` — backend capability/tool exposure tests.
|
||||
- Create: `tests/browser_ws_protocol_test.rs` — browser WS protocol tests.
|
||||
- Create: `tests/browser_ws_backend_test.rs` — browser WS backend tests.
|
||||
- Create: `tests/service_ws_session_test.rs` — single-session server tests.
|
||||
- Create: `tests/service_task_flow_test.rs` — client/service task flow tests.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extract a shared submit-task runner
|
||||
|
||||
**Files:**
|
||||
- Create: `src/agent/task_runner.rs`
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- Modify: `src/lib.rs`
|
||||
- Test: `tests/task_runner_test.rs`
|
||||
- Reuse: `src/compat/runtime.rs`, `src/compat/orchestration.rs`
|
||||
|
||||
- [ ] **Step 1: Write a failing runner regression test**
|
||||
|
||||
Create `tests/task_runner_test.rs` covering:
|
||||
- empty instruction returns the same `TaskComplete` failure summary
|
||||
- missing LLM config still returns the same summary shape
|
||||
- the pipe adapter still emits `LogEntry` before `TaskComplete`
|
||||
|
||||
- [ ] **Step 2: Run the targeted regression tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_task_flow_test --test task_runner_test
|
||||
```
|
||||
|
||||
Expected: `task_runner_test` fails because the shared runner does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Define the transport-neutral request model**
|
||||
|
||||
Create `src/agent/task_runner.rs` with a request struct that mirrors the current pipe payload:
|
||||
|
||||
```rust
|
||||
pub struct SubmitTaskRequest {
|
||||
pub instruction: String,
|
||||
pub conversation_id: Option<String>,
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
pub page_url: Option<String>,
|
||||
pub page_title: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Normalize empty strings to `None` at the adapter boundary.
|
||||
|
||||
- [ ] **Step 4: Define an event sink abstraction**
|
||||
|
||||
Add a small trait that can emit the current agent events without depending on a specific transport:
|
||||
|
||||
```rust
|
||||
pub trait AgentEventSink {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError>;
|
||||
}
|
||||
```
|
||||
|
||||
The existing pipe transport should implement this first.
|
||||
|
||||
- [ ] **Step 5: Move submit-task execution into a shared function**
|
||||
|
||||
Extract the body currently inside `BrowserMessage::SubmitTask` handling from `src/agent/mod.rs` into a shared function such as:
|
||||
|
||||
```rust
|
||||
pub fn run_submit_task(
|
||||
sink: &dyn AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: SubmitTaskRequest,
|
||||
) -> Result<(), PipeError>
|
||||
```
|
||||
|
||||
This function must still:
|
||||
- validate empty instruction
|
||||
- load sgclaw settings
|
||||
- log runtime/config info
|
||||
- choose orchestration vs compat runtime
|
||||
- emit `AgentMessage::TaskComplete`
|
||||
|
||||
- [ ] **Step 6: Keep pipe mode as a thin adapter**
|
||||
|
||||
Refactor `handle_browser_message_with_context(...)` in `src/agent/mod.rs` so it only:
|
||||
- pattern matches `BrowserMessage`
|
||||
- converts `SubmitTask` into `SubmitTaskRequest`
|
||||
- forwards into `run_submit_task(...)`
|
||||
|
||||
- [ ] **Step 7: Re-run the runner regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_task_flow_test --test task_runner_test
|
||||
```
|
||||
|
||||
Expected: both tests pass and pipe behavior remains unchanged.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent/mod.rs src/agent/task_runner.rs src/lib.rs tests/task_runner_test.rs
|
||||
git commit -m "refactor: extract shared submit-task runner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Introduce a browser backend abstraction and wrap the current pipe implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/mod.rs`
|
||||
- Create: `src/browser/backend.rs`
|
||||
- Create: `src/browser/pipe_backend.rs`
|
||||
- Modify: `src/lib.rs`
|
||||
- Modify: `src/compat/browser_tool_adapter.rs`
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs`
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- Modify: `src/compat/orchestration.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- Test: `tests/browser_backend_capability_test.rs`
|
||||
- Reuse: `src/pipe/browser_tool.rs`, `src/security/mac_policy.rs`
|
||||
|
||||
- [ ] **Step 1: Add a failing backend capability test**
|
||||
|
||||
Create `tests/browser_backend_capability_test.rs` to verify:
|
||||
- pipe backend still exposes privileged surface metadata
|
||||
- pipe backend still supports `Eval`
|
||||
- browser-script tool exposure is disabled when `supports_eval()` is false
|
||||
|
||||
- [ ] **Step 2: Run the current browser adapter tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test browser_backend_capability_test
|
||||
```
|
||||
|
||||
Expected: new capability test fails because the backend abstraction does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Define the shared browser interface**
|
||||
|
||||
Create `src/browser/backend.rs`:
|
||||
|
||||
```rust
|
||||
pub trait BrowserBackend: Send + Sync {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError>;
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata;
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the pipe backend as a wrapper**
|
||||
|
||||
Create `src/browser/pipe_backend.rs` that stores the current `BrowserPipeTool<T>` and forwards `invoke(...)` and `surface_metadata()` unchanged.
|
||||
|
||||
Pipe mode must continue using:
|
||||
- `perform_handshake(...)`
|
||||
- `MacPolicy::load_from_path(...)`
|
||||
- `BrowserPipeTool::new(...).with_response_timeout(...)`
|
||||
|
||||
- [ ] **Step 5: Refactor runtime and tool adapters to depend on `Arc<dyn BrowserBackend>`**
|
||||
|
||||
Update:
|
||||
- `src/compat/browser_tool_adapter.rs`
|
||||
- `src/compat/browser_script_skill_tool.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
|
||||
Preserve external tool names:
|
||||
- `browser_action`
|
||||
- `superrpa_browser`
|
||||
|
||||
- [ ] **Step 6: Add capability gating for eval-dependent script tools**
|
||||
|
||||
If `supports_eval()` is false, do **not** expose browser-script skill tools from `build_browser_script_skill_tools(...)` in that backend mode.
|
||||
|
||||
- [ ] **Step 7: Re-run browser adapter tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test browser_backend_capability_test
|
||||
```
|
||||
|
||||
Expected: all three pass.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser src/lib.rs src/compat/browser_tool_adapter.rs src/compat/browser_script_skill_tool.rs src/compat/runtime.rs src/compat/orchestration.rs src/compat/workflow_executor.rs tests/browser_backend_capability_test.rs
|
||||
git commit -m "refactor: abstract browser backend from pipe transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement the fixed browser WebSocket protocol codec in isolation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/ws_protocol.rs`
|
||||
- Test: `tests/browser_ws_protocol_test.rs`
|
||||
- Reuse: `docs/_tmp_sgbrowser_ws_api_doc.txt`
|
||||
|
||||
- [ ] **Step 1: Write failing protocol codec tests**
|
||||
|
||||
Create `tests/browser_ws_protocol_test.rs` covering:
|
||||
- exact outbound frame encoding
|
||||
- callback payload decoding
|
||||
- unknown callback format rejection
|
||||
- mapping coverage for every supported v1 action
|
||||
|
||||
- [ ] **Step 2: Run the protocol tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_protocol_test
|
||||
```
|
||||
|
||||
Expected: fail because the WS protocol codec does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Encode the exact browser frame shapes**
|
||||
|
||||
Create `src/browser/ws_protocol.rs` so it can build exact array-form payloads such as:
|
||||
|
||||
```rust
|
||||
[requesturl, "sgBrowserExcuteJsCodeByArea", target_url, js_code, area]
|
||||
```
|
||||
|
||||
Serialize to the JSON string format required by the browser service.
|
||||
|
||||
- [ ] **Step 4: Define the v1 action mapping table**
|
||||
|
||||
Support only the actions already needed by current sgclaw flows:
|
||||
- `Navigate`
|
||||
- `GetText`
|
||||
- `Click`
|
||||
- `Type`
|
||||
- `Eval`
|
||||
|
||||
Document which browser functions each one maps to and what assumptions they rely on.
|
||||
|
||||
- [ ] **Step 5: Define callback parsing and correlation rules**
|
||||
|
||||
Represent callback-bearing operations explicitly, including the callback function naming or request-correlation strategy the backend will depend on.
|
||||
|
||||
- [ ] **Step 6: Reject unsupported or malformed shapes early**
|
||||
|
||||
Fail fast for:
|
||||
- unsupported actions
|
||||
- malformed callback payloads
|
||||
- missing request correlation metadata
|
||||
|
||||
- [ ] **Step 7: Re-run the protocol tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_protocol_test
|
||||
```
|
||||
|
||||
Expected: pass with no network dependency.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/ws_protocol.rs tests/browser_ws_protocol_test.rs
|
||||
git commit -m "test: codify fixed browser websocket protocol"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Build the browser WS backend with synchronous invoke semantics
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/ws_backend.rs`
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- Test: `tests/browser_ws_backend_test.rs`
|
||||
- Reuse: `CommandOutput`, `PipeError`, `ExecutionSurfaceMetadata`, `MacPolicy`
|
||||
|
||||
- [ ] **Step 1: Write failing backend behavior tests**
|
||||
|
||||
Create `tests/browser_ws_backend_test.rs` covering:
|
||||
- zero return + no callback => success
|
||||
- non-zero return => failure
|
||||
- zero return + callback => success with normalized `CommandOutput`
|
||||
- callback timeout => timeout error
|
||||
- dropped socket => clear failure
|
||||
|
||||
- [ ] **Step 2: Run backend tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test
|
||||
```
|
||||
|
||||
Expected: fail because the WS backend does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Build a long-lived browser connection manager**
|
||||
|
||||
Implement `src/browser/ws_backend.rs` to connect to `ws://127.0.0.1:12345` and expose blocking `invoke(...)` calls.
|
||||
|
||||
Use a dedicated connection loop plus request/response coordination instead of scattering raw socket calls through the runtime.
|
||||
|
||||
- [ ] **Step 4: Preserve local guardrails before send**
|
||||
|
||||
Validate `MacPolicy` before translating an action into the browser WS protocol, matching current pipe backend behavior.
|
||||
|
||||
- [ ] **Step 5: Normalize immediate status returns and delayed callbacks**
|
||||
|
||||
For each `invoke(...)` call:
|
||||
- fail immediately on non-zero return codes
|
||||
- succeed immediately for operations with no data callback
|
||||
- wait for the matching callback for result-bearing operations
|
||||
- convert the final outcome into `CommandOutput`
|
||||
|
||||
- [ ] **Step 6: Keep v1 concurrency intentionally serialized**
|
||||
|
||||
Allow only one in-flight browser request at a time unless the browser callback protocol proves a stable request-id guarantee.
|
||||
|
||||
- [ ] **Step 7: Re-run backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test
|
||||
```
|
||||
|
||||
Expected: pass using mocks/fakes, not the real browser.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/mod.rs src/browser/ws_backend.rs tests/browser_ws_backend_test.rs
|
||||
git commit -m "feat: add browser websocket backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add the `sg_claw` service protocol and single-session server
|
||||
|
||||
**Files:**
|
||||
- Create: `src/service/mod.rs`
|
||||
- Create: `src/service/protocol.rs`
|
||||
- Create: `src/service/server.rs`
|
||||
- Create: `src/bin/sg_claw.rs`
|
||||
- Modify: `src/lib.rs`
|
||||
- Modify: `Cargo.toml`
|
||||
- Test: `tests/service_ws_session_test.rs`
|
||||
- Reuse: `AgentMessage::LogEntry`, `AgentMessage::TaskComplete`, `SubmitTaskRequest`, `run_submit_task(...)`
|
||||
|
||||
- [ ] **Step 1: Write failing service session tests**
|
||||
|
||||
Create `tests/service_ws_session_test.rs` to verify:
|
||||
- first client attaches
|
||||
- second client gets `Busy`
|
||||
- disconnect resets session state
|
||||
- overlapping task submission is rejected clearly
|
||||
|
||||
- [ ] **Step 2: Run the session tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test
|
||||
```
|
||||
|
||||
Expected: fail because the service layer does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Define a thin client/service WS protocol**
|
||||
|
||||
In `src/service/protocol.rs`, reuse existing task/event shapes as much as possible:
|
||||
|
||||
```rust
|
||||
ClientMessage::SubmitTask { instruction, conversation_id, messages, page_url, page_title }
|
||||
ClientMessage::Ping
|
||||
ServiceMessage::LogEntry { level, message }
|
||||
ServiceMessage::TaskComplete { success, summary }
|
||||
ServiceMessage::Busy { message }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the service event sink adapter**
|
||||
|
||||
Implement `AgentEventSink` for the service session writer so the shared task runner can stream `LogEntry` and `TaskComplete` over the service WebSocket.
|
||||
|
||||
- [ ] **Step 5: Implement single-active-client session state**
|
||||
|
||||
Model explicit states such as:
|
||||
- `Idle`
|
||||
- `ClientAttached`
|
||||
- `TaskRunning`
|
||||
|
||||
Reject a second client with `ServiceMessage::Busy` and close the socket. Reject overlapping tasks instead of queueing them.
|
||||
|
||||
- [ ] **Step 6: Add the service binary**
|
||||
|
||||
Create `src/bin/sg_claw.rs` that:
|
||||
- loads config
|
||||
- creates the browser WS backend
|
||||
- listens for local client connections
|
||||
- routes `SubmitTask` into `run_submit_task(...)`
|
||||
|
||||
Keep `src/main.rs` and the existing `sgclaw::run()` pipe path unchanged.
|
||||
|
||||
- [ ] **Step 7: Re-run the session tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test
|
||||
```
|
||||
|
||||
Expected: pass without the real browser.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/service src/bin/sg_claw.rs src/lib.rs Cargo.toml tests/service_ws_session_test.rs
|
||||
git commit -m "feat: add claw-ws service entrypoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add the `sg_claw_client` interactive CLI
|
||||
|
||||
**Files:**
|
||||
- Create: `src/bin/sg_claw_client.rs`
|
||||
- Modify: `Cargo.toml`
|
||||
- Test: `tests/service_task_flow_test.rs`
|
||||
- Reuse: `src/service/protocol.rs`
|
||||
|
||||
- [ ] **Step 1: Write failing client/service task flow tests**
|
||||
|
||||
Create `tests/service_task_flow_test.rs` to verify:
|
||||
- the submit-task request reaches the service
|
||||
- log entries stream in order
|
||||
- the final summary arrives exactly once
|
||||
- disconnect after task completion is handled cleanly
|
||||
|
||||
- [ ] **Step 2: Run the flow tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test
|
||||
```
|
||||
|
||||
Expected: fail because the client does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement a thin interactive client loop**
|
||||
|
||||
Create `src/bin/sg_claw_client.rs` that:
|
||||
- connects to the local `sg_claw` service
|
||||
- reads a line of user input
|
||||
- sends `ClientMessage::SubmitTask`
|
||||
- prints streamed `LogEntry` events as they arrive
|
||||
- ends the turn on `TaskComplete`
|
||||
|
||||
- [ ] **Step 4: Keep the client intentionally dumb**
|
||||
|
||||
Do **not** duplicate runtime logic in the client. Browser access, skills, orchestration, and task execution remain entirely inside the service.
|
||||
|
||||
- [ ] **Step 5: Re-run the flow tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test
|
||||
```
|
||||
|
||||
Expected: pass without the real browser.
|
||||
|
||||
- [ ] **Step 6: Build the new binaries explicitly**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: both binaries compile successfully.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bin/sg_claw_client.rs Cargo.toml tests/service_task_flow_test.rs
|
||||
git commit -m "feat: add interactive claw-ws client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Finish wiring, preserve pipe mode, and verify end-to-end behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `Cargo.toml`
|
||||
- Modify: `src/lib.rs`
|
||||
- Modify: `src/config/settings.rs`
|
||||
- Optional modify: `src/runtime/engine.rs`
|
||||
- Reuse: `tests/browser_tool_test.rs`, `tests/runtime_task_flow_test.rs`, `tests/compat_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add only the minimum config surface for v1**
|
||||
|
||||
Add settings such as:
|
||||
- `browser_ws_url` defaulting to `ws://127.0.0.1:12345`
|
||||
- `service_ws_listen_addr` defaulting to local loopback
|
||||
|
||||
Do **not** change the meaning of existing browser backend/profile settings just to represent service mode.
|
||||
|
||||
- [ ] **Step 2: Keep external browser tool naming stable**
|
||||
|
||||
Verify that the runtime still exposes:
|
||||
- `superrpa_browser`
|
||||
- `browser_action`
|
||||
|
||||
under both pipe and WS modes where the backend supports them.
|
||||
|
||||
- [ ] **Step 3: Re-run the current pipe regression suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test runtime_task_flow_test --test pipe_handshake_test --test pipe_protocol_test --test task_protocol_test
|
||||
```
|
||||
|
||||
Expected: all existing pipe-oriented tests still pass unchanged.
|
||||
|
||||
- [ ] **Step 4: Run the new WS-focused suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test --test browser_ws_protocol_test --test browser_ws_backend_test --test browser_backend_capability_test --test service_ws_session_test --test service_task_flow_test
|
||||
```
|
||||
|
||||
Expected: all new tests pass without launching the real browser.
|
||||
|
||||
- [ ] **Step 5: Run a full Rust test sweep**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --tests
|
||||
```
|
||||
|
||||
Expected: all Rust tests pass.
|
||||
|
||||
- [ ] **Step 6: Build all three binaries**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: all three binaries compile.
|
||||
|
||||
- [ ] **Step 7: Perform a manual local smoke test**
|
||||
|
||||
Manual test:
|
||||
1. Start the browser app so `ws://127.0.0.1:12345` is available.
|
||||
2. Run `cargo run --bin sg_claw`.
|
||||
3. In another terminal, run `cargo run --bin sg_claw_client`.
|
||||
4. Submit a simple browser task such as opening a page or fetching visible text.
|
||||
5. Confirm the client prints streaming logs and exactly one final completion summary.
|
||||
6. Confirm the old pipe-mode entry still starts via `cargo run`.
|
||||
|
||||
Expected: both modes work side-by-side.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add Cargo.toml src/lib.rs src/config/settings.rs src/runtime/engine.rs
|
||||
git commit -m "feat: wire parallel claw-ws transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Fast regression checks
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test runtime_task_flow_test
|
||||
```
|
||||
|
||||
Expected: current pipe/browser runtime behavior remains green.
|
||||
|
||||
### Full Rust test sweep
|
||||
|
||||
```bash
|
||||
cargo test --tests
|
||||
```
|
||||
|
||||
Expected: all Rust tests pass.
|
||||
|
||||
### Binary build verification
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: all three binaries compile.
|
||||
|
||||
### Manual end-to-end verification
|
||||
|
||||
- Browser app listening on `ws://127.0.0.1:12345`
|
||||
- `cargo run --bin sg_claw`
|
||||
- `cargo run --bin sg_claw_client`
|
||||
- submit one browser task
|
||||
- verify streaming logs, final completion, and single-client lock behavior
|
||||
- verify `cargo run` still preserves old pipe bootstrap
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- Keep the current pipe bootstrap in `src/lib.rs` intact until the shared runner and pipe backend wrapper are both green.
|
||||
- Prefer small commits at each task boundary.
|
||||
- Keep the new WS path additive and isolated.
|
||||
- Do not ship partial browser capabilities under stable tool names.
|
||||
- Treat `docs/_tmp_sgbrowser_ws_api_doc.txt` as the browser WS protocol source of truth while implementing `src/browser/ws_protocol.rs`.
|
||||
@@ -0,0 +1,607 @@
|
||||
# WS Browser Backend Auth Replacement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the ws service path’s empty-session-key `BrowserPipeTool` dependency with a ws-native browser backend path so real browser websocket calls work, while preserving legacy pipe behavior exactly.
|
||||
|
||||
**Architecture:** Keep the existing pipe entry untouched and add a ws-only parallel execution seam. The ws service path will construct a `ServiceBrowserWsClient` plus `WsBrowserBackend`, pass that backend through a new ws-only submit-task entry, and let the existing compat/runtime stack consume `Arc<dyn BrowserBackend>` instead of requiring `BrowserPipeTool` on the ws path.
|
||||
|
||||
**Tech Stack:** Rust 2021, current sgclaw agent/task runner, compat runtime/orchestration stack, `tungstenite`, `serde_json`, existing `MacPolicy`, existing `BrowserBackend`/`WsBrowserBackend`, and the current Rust test suite.
|
||||
|
||||
---
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Only change the ws service path.
|
||||
- Do **not** change `src/lib.rs` pipe runtime behavior.
|
||||
- Do **not** change pipe handshake semantics.
|
||||
- Do **not** introduce fake session keys, fake HMAC seeds, or auth bypasses.
|
||||
- Keep legacy `run_submit_task(...)` available for the pipe entry.
|
||||
- If a shared layer must change, add a parallel ws-only entry instead of weakening the pipe path.
|
||||
- Keep the current single-client, single-task service model.
|
||||
- Do not broaden this slice into browser process launch, queueing, multi-client support, or protocol extensions.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/agent/task_runner.rs` — keep the current pipe-oriented submit path and add the ws-only backend-based submit path.
|
||||
- Modify: `src/compat/runtime.rs` — add a backend-driven execution entry that accepts `Arc<dyn BrowserBackend>` directly, while keeping the current pipe-oriented public functions behaviorally unchanged.
|
||||
- Modify: `src/compat/orchestration.rs` — add a matching backend-driven execution entry for orchestration/direct-route flows, while keeping the current pipe-oriented public functions behaviorally unchanged.
|
||||
- Modify: `src/compat/workflow_executor.rs` — add backend-driven sibling APIs for direct-route/fallback execution, while keeping the current pipe-oriented public functions behaviorally unchanged.
|
||||
- Modify: `src/service/server.rs` — replace the ws service’s `BrowserPipeTool::new(..., vec![])` path with a ws-native `WsClient` + `WsBrowserBackend` path.
|
||||
- Modify: `src/service/mod.rs` — only if minimal re-export or call-signature changes are needed around the new ws-only submit path.
|
||||
- Modify: `src/browser/mod.rs` — only if export cleanup is truly needed for the service wiring.
|
||||
- Reuse: `src/agent/mod.rs` — keep the current pipe routing unchanged unless a tiny internal refactor is strictly needed to reuse shared code.
|
||||
- Reuse: `src/browser/backend.rs` — existing shared browser backend trait.
|
||||
- Reuse: `src/browser/ws_backend.rs` — existing ws-native browser backend implementation.
|
||||
- Reuse: `src/browser/ws_protocol.rs` — existing browser websocket protocol codec.
|
||||
- Reuse: `src/compat/browser_tool_adapter.rs` — should already speak `BrowserBackend`; only touch if a narrow ws regression forces it.
|
||||
- Reuse: `src/compat/browser_script_skill_tool.rs` — eval-capability gating already exists; only touch if a narrow ws regression forces it.
|
||||
- Reuse: `src/lib.rs` — pipe entrypoint must remain behaviorally unchanged; verify only.
|
||||
|
||||
### Existing tests to extend
|
||||
|
||||
- Modify: `tests/browser_ws_backend_test.rs` — keep existing ws backend coverage green after the service adapter wiring lands.
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs` — re-verify eval-gating and browser-script behavior after the shared compat/runtime seam changes.
|
||||
- Modify: `tests/service_ws_session_test.rs` — update service-side unit/session tests to exercise the ws-only submit path.
|
||||
- Modify: `tests/service_task_flow_test.rs` — add client→service chain coverage proving the ws path reaches a browser websocket and no longer emits `invalid hmac seed`.
|
||||
- Modify: `src/service/server.rs` under `#[cfg(test)]` if the private service-side ws adapter cannot be exercised from an integration test crate without changing production visibility.
|
||||
|
||||
### New files to create
|
||||
|
||||
- Create: `tests/browser_ws_service_adapter_test.rs` if the adapter can be exercised through a public seam; otherwise keep the deterministic adapter tests as unit tests in `src/service/server.rs` so no production visibility changes are required.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Lock the ws-only behavior with deterministic failing tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/browser_ws_service_adapter_test.rs`
|
||||
- Modify: `tests/service_task_flow_test.rs`
|
||||
- Reuse: `tests/browser_ws_backend_test.rs`, `src/browser/ws_backend.rs`, `src/service/server.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing backend/adapter test**
|
||||
|
||||
Create `tests/browser_ws_service_adapter_test.rs` with one focused test that directly exercises the ws-service adapter layer, without `sg_claw_client`, without LLM planning, and without natural-language tasks.
|
||||
|
||||
Start with the smallest real behavior from the spec:
|
||||
- fake browser websocket server accepts one connection
|
||||
- the ws-service adapter builds the same kind of client the service will use
|
||||
- `WsBrowserBackend.invoke(Action::Navigate, ...)` succeeds on status `0`
|
||||
- the fake server receives one text frame that decodes as a ws `Navigate` call
|
||||
|
||||
- [ ] **Step 2: Run that single new test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test ws_service_backend_navigate_reaches_browser_websocket -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the service-side ws client/adapter does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the second failing deterministic test**
|
||||
|
||||
In the same file, add a test for the forced-close path:
|
||||
- fake browser websocket server accepts a request, then closes/reset the socket before returning a status frame
|
||||
- observe the error at the `WsBrowserBackend.invoke(...)` call site
|
||||
- assert the outward error is exactly `PipeError::PipeClosed`
|
||||
|
||||
- [ ] **Step 4: Run only the forced-close test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test ws_service_backend_maps_browser_disconnect_to_pipe_closed -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the service-side ws client/adapter still does not exist.
|
||||
|
||||
- [ ] **Step 5: Add the third failing deterministic test**
|
||||
|
||||
In the same file, add a callback-timeout test:
|
||||
- fake browser websocket server returns status `0`
|
||||
- it never returns the callback frame
|
||||
- assert the outward error at `invoke(...)` is exactly `PipeError::Timeout`
|
||||
|
||||
Use a tiny response timeout in the backend under test.
|
||||
|
||||
- [ ] **Step 6: Run only the callback-timeout test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test ws_service_backend_times_out_waiting_for_callback -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the service-side ws client/adapter still does not exist.
|
||||
|
||||
- [ ] **Step 7: Add the end-to-end failing regression for the auth bug**
|
||||
|
||||
Extend `tests/service_task_flow_test.rs` with one client→service integration test that:
|
||||
- starts a fake browser websocket server
|
||||
- starts the real `sg_claw` service binary with a temp config pointing `browserWsUrl` to that fake server
|
||||
- starts the real `sg_claw_client`
|
||||
- submits the fixed instruction `打开知乎热榜并读取页面主区域文本`
|
||||
- captures service/client output
|
||||
- asserts the fake browser server received at least one text frame
|
||||
- asserts output does **not** contain `invalid hmac seed: session key must not be empty`
|
||||
|
||||
Do not assert planner details here. This test only proves the service path no longer goes through the empty-session-key auth failure.
|
||||
|
||||
- [ ] **Step 8: Run the integration regression and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test ws_service_submit_task_no_longer_hits_invalid_hmac_seed -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL on the current code because the ws service still constructs `BrowserPipeTool::new(..., vec![])`.
|
||||
|
||||
- [ ] **Step 9: Commit the red tests only after they are all in place**
|
||||
|
||||
Do not commit yet if any required red test was skipped. The next task will make them pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add a ws-only browser-backend execution seam without changing the pipe path
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent/task_runner.rs`
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- Modify: `src/compat/orchestration.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- Reuse: `src/agent/mod.rs`, `src/browser/backend.rs`
|
||||
- Test: `tests/task_runner_test.rs`, `tests/browser_script_skill_tool_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the smallest failing runner-level ws entry test**
|
||||
|
||||
Extend `tests/task_runner_test.rs` with a focused test that proves there is a ws-only submit entry accepting `Arc<dyn BrowserBackend>` and an arbitrary event sink, while the old `run_submit_task(...)` signature still exists for pipe mode.
|
||||
|
||||
The test can stay on the missing-LLM-config path so it does not need a real browser call. It should compile only once the new ws-only function exists.
|
||||
|
||||
- [ ] **Step 2: Run the targeted runner test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test ws_only_submit_task_entry_accepts_browser_backend -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL to compile or FAIL to link because the ws-only entry does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the new ws-only submit-task entry in `src/agent/task_runner.rs`**
|
||||
|
||||
Keep the current pipe function intact:
|
||||
|
||||
```rust
|
||||
pub fn run_submit_task<T: Transport + 'static>(... browser_tool: &BrowserPipeTool<T>, ...)
|
||||
```
|
||||
|
||||
Add a parallel entry for the service path, for example:
|
||||
|
||||
```rust
|
||||
pub fn run_submit_task_with_browser_backend(
|
||||
sink: &dyn AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: SubmitTaskRequest,
|
||||
) -> Result<(), PipeError>
|
||||
```
|
||||
|
||||
Rules:
|
||||
- share as much internal logic as possible with the pipe path
|
||||
- do not change `run_submit_task(...)` behavior
|
||||
- do not change `src/agent/mod.rs` pipe wiring except, at most, small internal refactoring to reuse common code
|
||||
|
||||
- [ ] **Step 4: Add a backend-driven compat runtime entry**
|
||||
|
||||
In `src/compat/runtime.rs`, add a parallel entry that accepts `Arc<dyn BrowserBackend>` directly instead of `BrowserPipeTool<T>`.
|
||||
|
||||
Keep the existing pipe-oriented public function in place.
|
||||
|
||||
The backend-driven entry must preserve:
|
||||
- existing log emission order
|
||||
- tool names (`superrpa_browser`, `browser_action`)
|
||||
- existing browser-script tool gating behavior
|
||||
- existing office/screen tool attachment logic
|
||||
- existing conversation seeding and provider setup
|
||||
|
||||
- [ ] **Step 5: Add backend-driven orchestration and workflow-executor entries**
|
||||
|
||||
In `src/compat/orchestration.rs`, add the matching backend-driven entry so direct-route flows and fallback flows can run with `Arc<dyn BrowserBackend>` on the ws path.
|
||||
|
||||
In `src/compat/workflow_executor.rs`, add backend-driven sibling APIs for any direct-route/fallback execution that is currently hard-wired to `BrowserPipeTool<T>`.
|
||||
|
||||
Keep the existing pipe-oriented orchestration and workflow-executor public functions in place.
|
||||
|
||||
- [ ] **Step 6: Route the new ws-only submit entry through the backend-driven compat/orchestration/workflow-executor path**
|
||||
|
||||
Inside `src/agent/task_runner.rs`, make the new ws-only submit entry call the new backend-based compat/orchestration functions, while the old pipe entry keeps calling the old pipe-based functions.
|
||||
|
||||
This is the core compatibility seam, and it must cover both normal compat-runtime execution and direct-route/fallback workflow execution.
|
||||
|
||||
- [ ] **Step 7: Re-run the new runner test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test ws_only_submit_task_entry_accepts_browser_backend -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Re-run the full runner, workflow, and browser-script regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test --test browser_script_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Then run the workflow executor unit coverage that protects direct-route behavior:
|
||||
|
||||
```bash
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: all existing runner, workflow, and browser-script tests still pass, proving the pipe-facing path, direct-route behavior, and eval-gating stayed stable.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent/task_runner.rs src/compat/runtime.rs src/compat/orchestration.rs src/compat/workflow_executor.rs tests/task_runner_test.rs tests/browser_script_skill_tool_test.rs
|
||||
git commit -m "refactor: add ws-only browser backend submit path"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Replace the ws service’s empty-session-key browser tool with a ws-native backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs`
|
||||
- Modify: `src/service/mod.rs` only if minimal re-export or signature cleanup is required
|
||||
- Modify: `src/browser/mod.rs` only if export cleanup is needed
|
||||
- Test: `tests/browser_ws_service_adapter_test.rs`
|
||||
- Reuse: `src/browser/ws_backend.rs`, `src/browser/ws_protocol.rs`
|
||||
|
||||
- [ ] **Step 1: Write the smallest failing service-side adapter compile target**
|
||||
|
||||
Add a compile-level or construction-level assertion in `tests/browser_ws_service_adapter_test.rs` that the service path can construct the new service-side ws client type used by `serve_client(...)`.
|
||||
|
||||
This should fail until the type exists in `src/service/server.rs`.
|
||||
|
||||
- [ ] **Step 2: Run the adapter test group and watch the constructor test fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the service-side ws client type does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Introduce `ServiceBrowserWsClient` in `src/service/server.rs`**
|
||||
|
||||
Create a narrow client type that owns the real websocket connection to `browser_ws_url` and implements `WsClient`:
|
||||
|
||||
Required responsibilities only:
|
||||
- lazily connect on first use
|
||||
- send raw text frames
|
||||
- receive raw text frames with timeout
|
||||
- map close/reset to exactly `PipeError::PipeClosed`
|
||||
- map connect failure to exactly `PipeError::Protocol("browser websocket connect failed: ...")`
|
||||
- map timeouts to exactly `PipeError::Timeout`
|
||||
|
||||
Do **not** duplicate `WsBrowserBackend` responsibilities here.
|
||||
|
||||
- [ ] **Step 4: Remove ws-path use of `BrowserPipeTool::new(..., vec![])`**
|
||||
|
||||
In `serve_client(...)`, replace this shape:
|
||||
|
||||
```rust
|
||||
let transport = Arc::new(ServiceBrowserTransport::new(...));
|
||||
let browser_tool = BrowserPipeTool::new(transport.clone(), mac_policy.clone(), vec![])
|
||||
```
|
||||
|
||||
with the ws-native shape:
|
||||
|
||||
```rust
|
||||
let ws_client = Arc::new(ServiceBrowserWsClient::new(...));
|
||||
let browser_backend: Arc<dyn BrowserBackend> = Arc::new(
|
||||
WsBrowserBackend::new(ws_client, mac_policy.clone(), initial_request_url(...))
|
||||
.with_response_timeout(BROWSER_RESPONSE_TIMEOUT)
|
||||
);
|
||||
```
|
||||
|
||||
Then route the task through the new ws-only submit entry from Task 2.
|
||||
|
||||
- [ ] **Step 5: Delete or narrow old ws-path transport code that duplicated protocol handling**
|
||||
|
||||
Remove the service-only callback polling / response queue logic that existed solely to feed `BrowserPipeTool`.
|
||||
|
||||
Keep only what is still needed for:
|
||||
- service client websocket I/O (`sg_claw_client` ↔ `sg_claw`)
|
||||
- browser websocket I/O (`sg_claw` ↔ `browser_ws_url`)
|
||||
|
||||
Do not leave two competing ws protocol implementations in `src/service/server.rs`.
|
||||
|
||||
- [ ] **Step 6: Re-run deterministic adapter/backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, including:
|
||||
- navigate success
|
||||
- disconnect => `PipeError::PipeClosed`
|
||||
- callback timeout => `PipeError::Timeout`
|
||||
|
||||
- [ ] **Step 7: Re-run existing ws backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, confirming the service adapter change did not break the existing ws backend semantics.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs src/service/mod.rs src/browser/mod.rs tests/browser_ws_service_adapter_test.rs
|
||||
git commit -m "feat: switch ws service to ws-native browser backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Prove the auth bug is gone and pipe mode is unchanged
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/service_ws_session_test.rs`
|
||||
- Modify: `tests/service_task_flow_test.rs`
|
||||
- Reuse: `src/lib.rs`, `src/service/mod.rs`, `src/compat/workflow_executor.rs`
|
||||
|
||||
- [ ] **Step 1: Update service session tests for the new ws-only call path**
|
||||
|
||||
Adjust any service session tests that still call `handle_client_message(...)` through the old ws-path `BrowserPipeTool` assumption.
|
||||
|
||||
Prefer one of these narrow approaches:
|
||||
- overload `handle_client_message(...)` with a backend-based service entry used only in ws tests, or
|
||||
- keep `handle_client_message(...)` pipe-oriented and test the ws path through `serve_client(...)` and the real service binary instead
|
||||
|
||||
Choose the option that changes the fewest existing tests and leaves the pipe path simplest.
|
||||
|
||||
- [ ] **Step 2: Run the focused service session file**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Make the auth-regression integration test pass**
|
||||
|
||||
Re-run the exact end-to-end regression from Task 1:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test ws_service_submit_task_no_longer_hits_invalid_hmac_seed -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, with evidence that:
|
||||
- the fake browser websocket server received at least one frame
|
||||
- output no longer contains `invalid hmac seed: session key must not be empty`
|
||||
|
||||
- [ ] **Step 4: Add one explicit mandatory assertion for browser websocket connect failures**
|
||||
|
||||
Add one focused assertion that a browser websocket connect failure surfaces outward as:
|
||||
|
||||
```rust
|
||||
PipeError::Protocol("browser websocket connect failed: ...")
|
||||
```
|
||||
|
||||
Do not leave this semantic implied.
|
||||
|
||||
- [ ] **Step 5: Add one explicit ws direct-route regression**
|
||||
|
||||
Add one focused regression that proves a ws-backed browser backend can traverse a direct-route/fallback path that currently flows through `src/compat/workflow_executor.rs`.
|
||||
|
||||
Keep it deterministic and narrow. Prefer a fake backend plus direct function invocation over a planner-dependent natural-language end-to-end test.
|
||||
|
||||
- [ ] **Step 6: Run the ws-focused regression suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test --test browser_ws_backend_test --test browser_ws_protocol_test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Then run the workflow-executor direct-route coverage:
|
||||
|
||||
```bash
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: all ws-focused and direct-route workflow tests pass.
|
||||
|
||||
- [ ] **Step 7: Run the required pipe and browser-script regression suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: all required pipe and browser-script regressions pass unchanged.
|
||||
|
||||
- [ ] **Step 8: Run the full relevant verification sweep**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test --test browser_ws_backend_test --test browser_ws_protocol_test --test service_ws_session_test --test service_task_flow_test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: full mixed ws+pipe verification passes in fresh runs.
|
||||
|
||||
- [ ] **Step 9: Build the affected binaries**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: all three binaries compile.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/service_ws_session_test.rs tests/service_task_flow_test.rs tests/browser_ws_service_adapter_test.rs src/compat/workflow_executor.rs
|
||||
git commit -m "test: verify ws auth replacement and pipe regressions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Manual smoke verification against the real browser
|
||||
|
||||
**Files:**
|
||||
- Reuse only: no code changes unless a verified bug is found during smoke work
|
||||
|
||||
- [ ] **Step 1: Start the real browser websocket target**
|
||||
|
||||
Confirm the real sgBrowser endpoint is reachable at the configured `browserWsUrl`.
|
||||
|
||||
- [ ] **Step 2: Start the real ws service**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo run --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected: service prints the resolved listen address and browser websocket URL.
|
||||
|
||||
- [ ] **Step 3: Run the minimal browser task through the real client**
|
||||
|
||||
Run from a separate terminal with UTF-8-safe input:
|
||||
|
||||
```bash
|
||||
cargo run --bin sg_claw_client -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Submit:
|
||||
|
||||
```text
|
||||
打开知乎热榜并读取页面主区域文本
|
||||
```
|
||||
|
||||
Expected:
|
||||
- browser actions start executing
|
||||
- no `invalid hmac seed: session key must not be empty`
|
||||
- one final completion is returned
|
||||
|
||||
- [ ] **Step 4: Run the old Zhihu skill smoke**
|
||||
|
||||
Submit:
|
||||
|
||||
```text
|
||||
读取知乎热榜数据,并导出 excel 文件
|
||||
```
|
||||
|
||||
Expected: the task enters the real browser action path instead of dying at auth initialization.
|
||||
|
||||
- [ ] **Step 5: Re-check the legacy pipe entry without modifying it**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
Only verify startup behavior appropriate for the current pipe environment. Do not change pipe code during this smoke step.
|
||||
|
||||
- [ ] **Step 6: If a smoke failure appears, stop and debug before editing**
|
||||
|
||||
Any failure found here must be handled with:
|
||||
- a fresh reproducer
|
||||
- a failing automated test if feasible
|
||||
- the smallest scoped fix
|
||||
|
||||
Do not fold speculative smoke fixes into this slice.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Deterministic ws-only tests
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test --test browser_ws_backend_test --test browser_ws_protocol_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: ws-native backend and service adapter semantics are green without LLM/planner dependencies.
|
||||
|
||||
### Client→service ws chain tests
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: the ws service path reaches the browser websocket and no longer emits the empty-session-key auth failure.
|
||||
|
||||
### Required pipe and browser-script regressions
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: legacy pipe behavior and browser-script eval-gating remain unchanged.
|
||||
|
||||
### Binary build verification
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: all affected binaries compile.
|
||||
|
||||
### Manual end-to-end verification
|
||||
|
||||
- real sgBrowser running at configured `browserWsUrl`
|
||||
- `cargo run --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"`
|
||||
- `cargo run --bin sg_claw_client -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"`
|
||||
- run the Zhihu minimal task
|
||||
- run the old Zhihu export task
|
||||
- verify no `invalid hmac seed` appears
|
||||
- verify pipe startup still behaves as before
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- Keep the current pipe bootstrap in `src/lib.rs` untouched.
|
||||
- Prefer adding ws-only functions over changing existing pipe signatures.
|
||||
- Reuse `WsBrowserBackend` for protocol semantics; do not re-implement callback handling inside the service.
|
||||
- Keep `ServiceBrowserWsClient` narrow: connection lifecycle + raw websocket I/O only.
|
||||
- Preserve exact outward error semantics from the spec:
|
||||
- connect failure => `PipeError::Protocol("browser websocket connect failed: ...")`
|
||||
- non-zero status => `PipeError::Protocol("browser returned non-zero status: ...")`
|
||||
- callback timeout => `PipeError::Timeout`
|
||||
- close/reset => `PipeError::PipeClosed`
|
||||
- Do not claim success until the mixed ws+pipe verification commands have been run fresh.
|
||||
482
docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md
Normal file
482
docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# WS Browser Bridge Path Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the raw-ws-direct browser execution assumption with a bridge-backed browser integration path that matches the validated FunctionsUI / BrowserAction / CommandRouter model while preserving existing pipe behavior.
|
||||
|
||||
**Architecture:** Keep the current Rust-side browser orchestration flow centered on `Arc<dyn BrowserBackend>`, but stop treating `WsBrowserBackend` as the real production browser surface. Model the validated bridge as two explicit layers: Layer 1 session/lifecycle calls (`sgclawConnect`, `sgclawStart`, `sgclawStop`, `sgclawSubmitTask`) and Layer 2 browser-action execution (`window.sgFunctionsUI(...)`, `window.BrowserAction(...)`, `CommandRouter`). The new backend targets Layer 2 only through a narrow repo-local `BridgeActionTransport` seam, while lifecycle/session concerns stay separate from per-action browser execution.
|
||||
|
||||
**Tech Stack:** Rust 2021, existing `BrowserBackend` abstraction, compat/runtime/orchestration stack, current service/task runner integration, existing bridge-oriented design docs, existing Rust unit/integration test suite.
|
||||
|
||||
---
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do **not** continue extending raw external sgBrowser websocket business-frame handling as the mainline path.
|
||||
- Do **not** modify `src/lib.rs`, pipe handshake behavior, or the working `BrowserPipeTool` path.
|
||||
- Do **not** invent a parallel browser-command contract unrelated to the documented bridge surface.
|
||||
- Do **not** rewrite the whole compat/runtime stack when a narrow adapter will do.
|
||||
- Do **not** assume access to the full SuperRPA browser-host codebase from this repository; encode the validated contract at the nearest seam available here.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- export the new bridge contract/transport/backend modules explicitly
|
||||
- Modify: `src/browser/backend.rs`
|
||||
- only if a tiny shared helper or trait documentation update is needed for the new bridge-backed backend
|
||||
- Modify: `src/compat/browser_tool_adapter.rs`
|
||||
- ensure existing browser action mapping remains reusable with the new backend implementation
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- wire the bridge-backed browser backend into the ws service/browser execution path without changing the pipe path
|
||||
- Modify: `src/compat/orchestration.rs`
|
||||
- only where browser backend wiring requires the bridge-backed path to flow through orchestration
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- preserve direct-route/fallback use of `BrowserBackend` when the backend is bridge-backed instead of websocket-backed
|
||||
- Modify: `src/service/server.rs`
|
||||
- replace the current real-browser execution assumption with bridge-backend construction plus a repo-local bridge transport provider seam for the relevant service path
|
||||
- Modify: `tests/compat_browser_tool_test.rs`
|
||||
- extend browser tool mapping coverage if needed for bridge-backed execution
|
||||
- Modify: `tests/service_task_flow_test.rs`
|
||||
- replace raw-ws-direct expectations with bridge-path expectations where appropriate
|
||||
- Modify: `tests/service_ws_session_test.rs`
|
||||
- update service-side tests if they currently assume the real browser path is raw websocket driven
|
||||
|
||||
### New files to create
|
||||
|
||||
- Create: `src/browser/bridge_contract.rs`
|
||||
- narrow, explicit contract types that keep lifecycle/session bridge calls separate from browser-action execution requests/replies
|
||||
- Create: `src/browser/bridge_transport.rs`
|
||||
- repo-local `BridgeActionTransport` seam used by the backend and injected by service/runtime wiring
|
||||
- Create: `src/browser/bridge_backend.rs`
|
||||
- new `BrowserBackend` implementation that maps browser actions onto the Layer-2 bridge action contract through `BridgeActionTransport`
|
||||
- Create: `tests/browser_bridge_backend_test.rs`
|
||||
- deterministic unit tests for action-to-bridge mapping and reply/error normalization using a fake bridge transport
|
||||
- Create: `tests/browser_bridge_contract_test.rs`
|
||||
- narrow tests proving the two bridge layers stay explicit and browser-action requests remain semantic rather than raw-websocket-shaped
|
||||
|
||||
### Evidence files to consult during implementation
|
||||
|
||||
- Read: `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- Read: `frontend/archive/sgClaw验证-已归档/testRunner.js`
|
||||
- Read: `docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md`
|
||||
- Read: `docs/archive/项目管理与排期/协作时间表.md`
|
||||
- Read: `docs/plans/2026-03-27-sgclaw-floating-chat-frontend-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Lock the bridge contract in deterministic tests before adding the backend
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/bridge_contract.rs`
|
||||
- Create: `tests/browser_bridge_contract_test.rs`
|
||||
- Reuse as design evidence:
|
||||
- `frontend/archive/sgClaw验证-已归档/testRunner.js`
|
||||
- `docs/archive/项目管理与排期/协作时间表.md`
|
||||
- `docs/plans/2026-03-27-sgclaw-floating-chat-frontend-design.md`
|
||||
|
||||
- [ ] **Step 1: Write the first failing contract test for named bridge calls**
|
||||
|
||||
Create `tests/browser_bridge_contract_test.rs` with one focused test that encodes the bridge naming expectations already evidenced in the repo.
|
||||
|
||||
Start with a test shape like:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn bridge_contract_names_match_documented_bridge_surface() {
|
||||
// assert the contract contains the exact bridge action names
|
||||
}
|
||||
```
|
||||
|
||||
Required expectations:
|
||||
- `sgclawConnect`
|
||||
- `sgclawStart`
|
||||
- `sgclawStop`
|
||||
- `sgclawSubmitTask`
|
||||
- these names live in an explicit lifecycle/session contract type, not in the browser-action request type
|
||||
|
||||
Do **not** invent additional action names in this first test.
|
||||
|
||||
- [ ] **Step 2: Run the single contract test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test bridge_contract_names_match_documented_bridge_surface -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/browser/bridge_contract.rs` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the second failing contract test for browser-action request shaping**
|
||||
|
||||
In the same file, add one focused test proving the bridge contract can represent a browser action request without leaking raw websocket business-frame semantics.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn bridge_contract_represents_browser_action_requests_without_ws_business_frames() {
|
||||
// create a click/navigate/getText style action request and assert shape
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- request shape identifies the intended browser action semantically
|
||||
- request shape is distinct from the lifecycle/session bridge call type
|
||||
- request shape does **not** embed `sgBrowerserOpenPage`, `callBackJsToCpp`, or other raw websocket business-frame names
|
||||
|
||||
- [ ] **Step 4: Run the second contract test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test bridge_contract_represents_browser_action_requests_without_ws_business_frames -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the bridge contract does not exist yet.
|
||||
|
||||
- [ ] **Step 5: Implement the minimal bridge contract module**
|
||||
|
||||
Create `src/browser/bridge_contract.rs` with only the types needed by the tests.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```rust
|
||||
pub enum BridgeLifecycleCall {
|
||||
Connect,
|
||||
Start,
|
||||
Stop,
|
||||
SubmitTask,
|
||||
}
|
||||
|
||||
impl BridgeLifecycleCall {
|
||||
pub fn bridge_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Connect => "sgclawConnect",
|
||||
Self::Start => "sgclawStart",
|
||||
Self::Stop => "sgclawStop",
|
||||
Self::SubmitTask => "sgclawSubmitTask",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BridgeBrowserActionRequest {
|
||||
pub action: String,
|
||||
pub params: serde_json::Value,
|
||||
pub expected_domain: String,
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- model the documented bridge/lifecycle naming explicitly
|
||||
- keep the browser action request semantic, not websocket-frame-shaped
|
||||
- keep the module small and repository-local
|
||||
|
||||
- [ ] **Step 6: Re-run the contract tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/bridge_contract.rs tests/browser_bridge_contract_test.rs
|
||||
git commit -m "test: define sgClaw bridge contract surface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add the repo-local transport seam and bridge-backed `BrowserBackend`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/bridge_transport.rs`
|
||||
- Create: `src/browser/bridge_backend.rs`
|
||||
- Create: `tests/browser_bridge_backend_test.rs`
|
||||
- Reuse: `src/browser/backend.rs`
|
||||
- Reuse: `src/browser/bridge_contract.rs`
|
||||
- Reuse: `src/compat/browser_tool_adapter.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing backend test for action mapping**
|
||||
|
||||
Create `tests/browser_bridge_backend_test.rs` with one focused test proving a `BrowserBackend` action is translated into the bridge contract request shape.
|
||||
|
||||
Start with a narrow action such as `Action::Navigate`.
|
||||
|
||||
Required assertions:
|
||||
- `Action::Navigate` becomes one semantic bridge browser-action request
|
||||
- the request preserves action parameters and expected domain
|
||||
- the test does **not** assert any raw websocket payload strings
|
||||
|
||||
- [ ] **Step 2: Run the first backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test bridge_backend_maps_navigate_to_bridge_action_request -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/browser/bridge_backend.rs` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the second failing backend test for reply normalization**
|
||||
|
||||
Add one focused test proving the backend can normalize a successful bridge reply into the existing `CommandOutput` shape expected by `BrowserBackend` callers.
|
||||
|
||||
- [ ] **Step 4: Run the second backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test bridge_backend_normalizes_successful_bridge_reply -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the backend does not exist yet.
|
||||
|
||||
- [ ] **Step 5: Add the third failing backend test for bridge-side errors**
|
||||
|
||||
Add one focused test proving a bridge-side error normalizes into the correct outward `PipeError` semantics for backend callers.
|
||||
|
||||
- [ ] **Step 6: Run the error-path test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test bridge_backend_maps_bridge_failure_to_pipe_error -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the backend does not exist yet.
|
||||
|
||||
- [ ] **Step 7: Implement the minimal transport seam and bridge backend**
|
||||
|
||||
Create `src/browser/bridge_transport.rs` and `src/browser/bridge_backend.rs`.
|
||||
|
||||
The transport seam must:
|
||||
- define the repo-local `BridgeActionTransport` contract used for Layer-2 browser-action execution only
|
||||
- accept semantic `BridgeBrowserActionRequest` values and return semantic success/error replies
|
||||
- remain small, explicit, and easy to fake in tests
|
||||
|
||||
The backend must:
|
||||
- implement the existing `BrowserBackend` trait
|
||||
- translate supported actions into `BridgeBrowserActionRequest`
|
||||
- depend on `BridgeActionTransport` instead of raw websocket payload building
|
||||
- normalize success/error replies into existing backend-facing result types
|
||||
|
||||
Rules:
|
||||
- do not embed raw websocket business-frame names
|
||||
- do not change `BrowserBackend` semantics for existing callers
|
||||
- do not pull lifecycle/session bridge calls into this backend layer
|
||||
|
||||
- [ ] **Step 8: Re-run the bridge backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Re-run browser tool adapter coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_browser_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, proving the existing browser action mapping remains reusable with the new backend.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/bridge_transport.rs src/browser/bridge_backend.rs tests/browser_bridge_backend_test.rs src/compat/browser_tool_adapter.rs src/browser/mod.rs
|
||||
git commit -m "feat: add bridge-backed browser backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire the bridge-backed backend into the real-browser service path
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs`
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- Modify: `src/compat/orchestration.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- Modify: `tests/service_task_flow_test.rs`
|
||||
- Modify: `tests/service_ws_session_test.rs`
|
||||
- Reuse: `src/browser/bridge_backend.rs`
|
||||
- Reuse: `src/browser/bridge_contract.rs`
|
||||
- Reuse: `src/browser/bridge_transport.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing service-path test for bridge backend construction**
|
||||
|
||||
Add or update one focused service test proving the real-browser execution path constructs and uses the bridge-backed backend instead of the raw websocket backend assumption.
|
||||
|
||||
The test should observe backend selection at the nearest possible seam.
|
||||
|
||||
- [ ] **Step 2: Run the focused service test and verify it fails**
|
||||
|
||||
Run the narrowest affected service test command.
|
||||
|
||||
Expected: FAIL because the service path is not wired to the bridge backend yet.
|
||||
|
||||
- [ ] **Step 3: Add the minimal service/runtime wiring**
|
||||
|
||||
Change the relevant service/browser execution path so it constructs the new bridge-backed backend, injects the repo-local bridge transport provider at the nearest seam, and passes the backend through the existing runtime/orchestration flow.
|
||||
|
||||
Rules:
|
||||
- keep the pipe path unchanged
|
||||
- keep changes localized
|
||||
- keep lifecycle/session bridge handling separate from per-action browser execution
|
||||
- preserve existing runtime log and task flow behavior where possible
|
||||
|
||||
- [ ] **Step 4: Add one direct-route/fallback regression**
|
||||
|
||||
Add one focused regression proving a bridge-backed backend still works through the direct-route or fallback path exercised by `src/compat/workflow_executor.rs`.
|
||||
|
||||
- [ ] **Step 5: Run the bridge-focused service tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Re-run workflow/runtime regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
cargo test --test compat_browser_tool_test --test browser_script_skill_tool_test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs src/compat/runtime.rs src/compat/orchestration.rs src/compat/workflow_executor.rs tests/service_ws_session_test.rs tests/service_task_flow_test.rs
|
||||
git commit -m "refactor: route real browser path through bridge backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verify bridge-path behavior without pipe regression
|
||||
|
||||
**Files:**
|
||||
- Reuse only unless a failing test proves a minimal fix is still needed
|
||||
|
||||
- [ ] **Step 1: Run bridge/backend unit coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test --test browser_bridge_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run service/runtime bridge-path regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run required pipe regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Build the affected binaries**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Stop if any regression points back to raw websocket assumptions**
|
||||
|
||||
If any test still encodes raw external websocket business-frame assumptions as the real-browser path, update that test to the bridge-backed design rather than patching production code to satisfy the old assumption.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/browser_bridge_contract_test.rs tests/browser_bridge_backend_test.rs tests/service_ws_session_test.rs tests/service_task_flow_test.rs
|
||||
git commit -m "test: verify bridge path and preserve pipe behavior"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Bridge contract tests
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: documented bridge names and semantic browser-action request shaping are locked.
|
||||
|
||||
### Bridge backend tests
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: backend action mapping and reply/error normalization are green.
|
||||
|
||||
### Service/runtime integration tests
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: real-browser path uses the bridge-backed backend and direct-route/fallback behavior remains intact.
|
||||
|
||||
### Pipe regressions
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: pipe path remains unchanged.
|
||||
|
||||
### Binary build verification
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: affected binaries compile.
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- The websocket probe work stays in the repository as diagnostic tooling; do not repurpose it into the bridge adapter.
|
||||
- `docs/_tmp_sgbrowser_ws_probe_transcript.md` is evidence that rejected the raw-ws-direct assumption, not a contract to keep satisfying.
|
||||
- Favor one narrow bridge-backed backend over broad runtime rewrites.
|
||||
- If the nearest repo-local seam is still slightly abstract because the external SuperRPA host code is outside this repository, make that abstraction explicit and test it rather than guessing hidden behavior.
|
||||
@@ -0,0 +1,566 @@
|
||||
# WS Browser Integration Surface Correction Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the unvalidated raw-ws-direct assumption with an evidence-backed decision: either prove a minimal sgBrowser bootstrap sequence for raw websocket control, or pivot to the real browser bridge surface.
|
||||
|
||||
**Architecture:** Treat the existing ws-native backend as a protocol/testing asset, not as a validated production integration surface. First build a narrow probe/validation harness that can run candidate bootstrap sequences and capture exact live transcripts from the real endpoint. Then branch decisively: if a reproducible bootstrap sequence yields real status/callback frames, implement that bootstrap path; otherwise stop raw-ws speculation and write the bridge-first implementation slice.
|
||||
|
||||
**Tech Stack:** Rust 2021, existing `src/browser/ws_protocol.rs` / `src/browser/ws_backend.rs`, service websocket infrastructure, `tungstenite`, `serde_json`, current Rust test suite, local sgBrowser websocket documentation.
|
||||
|
||||
---
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do **not** add more speculative production fixes to `src/service/server.rs` just to “try one more thing.”
|
||||
- Do **not** claim raw websocket is the supported path unless the live probe transcript proves it.
|
||||
- Do **not** modify `src/lib.rs`, pipe handshake behavior, or the pipe browser-tool path.
|
||||
- Do **not** implement both the bootstrap architecture and the bridge architecture in the same branch.
|
||||
- Keep the ws-native code unless and until the bridge decision makes specific pieces obsolete.
|
||||
- Prefer a dedicated probe surface over embedding validation logic into production request handling.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- export the new `ws_probe` module so both tests and the probe binary use the same library surface
|
||||
- Modify: `src/browser/ws_protocol.rs`
|
||||
- only if a tiny helper extraction is required for test/probe readability
|
||||
- do not change existing protocol semantics in this slice
|
||||
- Modify: `tests/browser_ws_protocol_test.rs`
|
||||
- add deterministic coverage for any extracted helper used by the probe harness
|
||||
|
||||
### New files to create
|
||||
|
||||
- Create: `src/bin/sgbrowser_ws_probe.rs`
|
||||
- standalone diagnostic binary for ordered frame-script probing against a live sgBrowser websocket endpoint
|
||||
- Create: `src/browser/ws_probe.rs`
|
||||
- small reusable probe/transcript module, if needed, to keep the binary and tests focused
|
||||
- Create: `tests/browser_ws_probe_test.rs`
|
||||
- deterministic fake-server tests for transcript capture, timeout reporting, and scripted sequence execution
|
||||
- Create: `docs/superpowers/specs/2026-04-03-ws-browser-bridge-path-design.md` **only if Option B wins after probing**
|
||||
- follow-up bridge design, not part of the initial coding slice
|
||||
- Create: `docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md` **only if Option B wins after probing**
|
||||
- follow-up bridge implementation plan, not part of the initial coding slice
|
||||
- Create: `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- temporary evidence artifact capturing the real endpoint probe matrix and outcomes
|
||||
|
||||
### Files deliberately not changed in the initial slice
|
||||
|
||||
- `src/lib.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/browser/ws_backend.rs`
|
||||
|
||||
Unless the probe results prove a real bootstrap contract, these files stay untouched.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Build a deterministic websocket probe harness before touching production behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/ws_probe.rs`
|
||||
- Create: `tests/browser_ws_probe_test.rs`
|
||||
- Reuse: `src/browser/ws_protocol.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing transcript test**
|
||||
|
||||
Create `tests/browser_ws_probe_test.rs` with one focused fake-server test that executes a scripted sequence of outgoing text frames and records all received text frames in order.
|
||||
|
||||
Start with this shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn probe_records_welcome_then_silence_transcript() {
|
||||
// fake server sends one welcome frame and then stays silent
|
||||
// probe result should preserve that exact transcript and mark timeout/silence explicitly
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- the probe can connect to the fake websocket server
|
||||
- it can send a scripted first frame
|
||||
- it records the first inbound text frame exactly
|
||||
- it returns a transcript/result object that distinguishes timeout from protocol parse failure
|
||||
|
||||
- [ ] **Step 2: Run the single new test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test probe_records_welcome_then_silence_transcript -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the probe harness does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the second failing probe test for ordered multi-step scripts**
|
||||
|
||||
In the same file, add a test proving the harness can run multiple outgoing frames in a fixed order and keep the transcript segmented by step.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn probe_runs_ordered_frame_script_and_records_per_step_results() {
|
||||
// send bootstrap frame 1, bootstrap frame 2, then minimal action
|
||||
// fake server replies differently at each step
|
||||
// probe result preserves exact order and outcomes
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- outgoing frames are sent in the configured order
|
||||
- inbound frames are attached to the correct step
|
||||
- the probe can stop the sequence on timeout/close if configured
|
||||
|
||||
- [ ] **Step 4: Run the ordered-script test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test probe_runs_ordered_frame_script_and_records_per_step_results -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the probe harness does not exist yet.
|
||||
|
||||
- [ ] **Step 5: Add the third failing probe test for close/reset visibility**
|
||||
|
||||
Add one focused fake-server test that closes the connection after a script step and asserts the transcript reports close/reset rather than generic timeout.
|
||||
|
||||
- [ ] **Step 6: Run the close/reset test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test probe_reports_socket_close_separately_from_timeout -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the probe harness does not exist yet.
|
||||
|
||||
- [ ] **Step 7: Implement the minimal probe module**
|
||||
|
||||
Create `src/browser/ws_probe.rs` with only the types and behavior needed by the tests.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```rust
|
||||
pub struct ProbeStep {
|
||||
pub label: String,
|
||||
pub payload: String,
|
||||
pub expect_reply: bool,
|
||||
}
|
||||
|
||||
pub enum ProbeOutcome {
|
||||
Received(Vec<String>),
|
||||
TimedOut,
|
||||
Closed,
|
||||
ConnectFailed(String),
|
||||
}
|
||||
|
||||
pub struct ProbeStepResult {
|
||||
pub label: String,
|
||||
pub sent: String,
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
pub fn run_probe_script(/* ws url, timeout, steps */) -> Result<Vec<ProbeStepResult>, ProbeError> {
|
||||
// connect, send ordered frames, collect exact transcript
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- do not parse business meaning yet
|
||||
- do not mix this into normal task execution
|
||||
- preserve exact raw text frames in transcript results
|
||||
- keep the module small and diagnostic-oriented
|
||||
|
||||
- [ ] **Step 8: Re-run the new probe tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/ws_probe.rs tests/browser_ws_probe_test.rs
|
||||
git commit -m "test: add sgBrowser websocket probe harness"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add a standalone probe binary for live sgBrowser evidence collection
|
||||
|
||||
**Files:**
|
||||
- Create: `src/bin/sgbrowser_ws_probe.rs`
|
||||
- Create: `src/browser/ws_probe.rs`
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- Create: `tests/browser_ws_probe_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing helper parser test**
|
||||
|
||||
In `tests/browser_ws_probe_test.rs`, add one focused test for a new helper function in `src/browser/ws_probe.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn parse_probe_args_accepts_ws_url_timeout_and_ordered_steps() {
|
||||
// parse a fixed argv-style slice into a ProbeCliConfig
|
||||
}
|
||||
```
|
||||
|
||||
Create and use this exact helper shape:
|
||||
|
||||
```rust
|
||||
pub struct ProbeCliConfig {
|
||||
pub ws_url: String,
|
||||
pub timeout_ms: u64,
|
||||
pub steps: Vec<ProbeStep>,
|
||||
}
|
||||
|
||||
pub fn parse_probe_args(args: &[String]) -> Result<ProbeCliConfig, ProbeError>
|
||||
```
|
||||
|
||||
The test must assert that these exact arguments parse successfully and preserve step order:
|
||||
|
||||
```text
|
||||
--ws-url ws://127.0.0.1:12345
|
||||
--timeout-ms 1500
|
||||
--step open-agent::["about:blank","sgOpenAgent"]
|
||||
--step open-hot::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the parser test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test parse_probe_args_accepts_ws_url_timeout_and_ordered_steps -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because `parse_probe_args(...)` and `ProbeCliConfig` do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the helper and binary together**
|
||||
|
||||
In `src/browser/ws_probe.rs`, add `ProbeCliConfig` and `parse_probe_args(...)`.
|
||||
|
||||
In `src/browser/mod.rs`, add the module export:
|
||||
|
||||
```rust
|
||||
pub mod ws_probe;
|
||||
```
|
||||
|
||||
In `src/bin/sgbrowser_ws_probe.rs`, implement the binary using only `std::env::args()` plus `parse_probe_args(...)`.
|
||||
|
||||
Required behavior:
|
||||
- accepts a websocket URL
|
||||
- accepts a timeout in milliseconds
|
||||
- accepts repeated ordered steps
|
||||
- runs the probe harness
|
||||
- prints a markdown-friendly transcript including:
|
||||
- step label
|
||||
- exact sent payload
|
||||
- exact received frames, if any
|
||||
- timeout/close outcome
|
||||
|
||||
Output shape can be simple, for example:
|
||||
|
||||
```text
|
||||
STEP 1 bootstrap-open-agent
|
||||
SEND: ["about:blank","sgOpenAgent"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
Rules:
|
||||
- no production/browser-runtime integration
|
||||
- no hidden fallback logic
|
||||
- no “best effort” guessing of next steps
|
||||
|
||||
- [ ] **Step 4: Re-run the parser/helper test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test parse_probe_args_accepts_ws_url_timeout_and_ordered_steps -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Build the probe binary**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgbrowser_ws_probe
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bin/sgbrowser_ws_probe.rs src/browser/ws_probe.rs src/browser/mod.rs tests/browser_ws_probe_test.rs
|
||||
git commit -m "feat: add live sgBrowser websocket probe binary"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Run the real endpoint probe matrix and write the evidence transcript
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- Reuse only: `src/bin/sgbrowser_ws_probe.rs`, `docs/_tmp_sgbrowser_ws_api_doc.txt`
|
||||
|
||||
- [ ] **Step 1: Run the no-bootstrap baseline probe**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "baseline-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## baseline-open` heading in `docs/_tmp_sgbrowser_ws_probe_transcript.md`.
|
||||
|
||||
- [ ] **Step 2: Run the documented `sgOpenAgent` candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "open-agent::[\"about:blank\",\"sgOpenAgent\"]" --step "post-open-agent-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## open-agent` heading.
|
||||
|
||||
- [ ] **Step 3: Run the documented `sgSetAuthInfo` candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "set-auth::[\"about:blank\",\"sgSetAuthInfo\",\"probe-user\",\"probe-token\"]" --step "post-set-auth-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## set-auth` heading.
|
||||
|
||||
- [ ] **Step 4: Run the documented `sgBrowserLogin` candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "browser-login::{\"request\":\"use-json-helper\"}"
|
||||
```
|
||||
|
||||
Before running, replace the placeholder payload with the exact JSON-array frame produced by the helper for:
|
||||
|
||||
```json
|
||||
["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]
|
||||
```
|
||||
|
||||
Then add a second step in the same command:
|
||||
|
||||
```json
|
||||
["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
```
|
||||
|
||||
Append the exact output under a `## browser-login` heading.
|
||||
|
||||
- [ ] **Step 5: Run the documented `sgBrowerserActiveTab` candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "active-tab::[\"about:blank\",\"sgBrowerserActiveTab\",\"https://www.zhihu.com/hot\",\"probeCallback\"]" --step "post-active-tab-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## active-tab` heading.
|
||||
|
||||
- [ ] **Step 6: Run one combined bootstrap candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "combined-open-agent::[\"about:blank\",\"sgOpenAgent\"]" --step "combined-active-tab::[\"about:blank\",\"sgBrowerserActiveTab\",\"https://www.zhihu.com/hot\",\"probeCallback\"]" --step "combined-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## combined-bootstrap` heading.
|
||||
|
||||
- [ ] **Step 7: Run `requesturl` variants for the minimal action**
|
||||
|
||||
Run exactly these two additional commands:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "target-as-requesturl::[\"https://www.zhihu.com/hot\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "baidu-requesturl::[\"https://www.baidu.com\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact outputs under `## requesturl-variants`.
|
||||
|
||||
- [ ] **Step 8: Summarize the matrix in the transcript file**
|
||||
|
||||
At the end of `docs/_tmp_sgbrowser_ws_probe_transcript.md`, add this exact table template and fill it in:
|
||||
|
||||
```markdown
|
||||
| Sequence | Sent frames | First reply | Final outcome | Decision signal |
|
||||
| --- | --- | --- | --- | --- |
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Determine which architecture option wins**
|
||||
|
||||
Decision rule:
|
||||
- if at least one sequence reproducibly yields real numeric status and/or callback frames for a real business action, Option A (bootstrap-validated raw websocket) wins
|
||||
- otherwise, Option B (bridge-first) wins
|
||||
|
||||
Do not weaken this decision rule.
|
||||
|
||||
- [ ] **Step 10: Commit the evidence artifact**
|
||||
|
||||
```bash
|
||||
git add docs/_tmp_sgbrowser_ws_probe_transcript.md
|
||||
git commit -m "docs: capture sgBrowser websocket probe evidence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4A: If Option A wins, write the narrow bootstrap implementation slice
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/specs/2026-04-03-ws-browser-bootstrap-contract-design.md`
|
||||
- Create: `docs/superpowers/plans/2026-04-03-ws-browser-bootstrap-contract-plan.md`
|
||||
- Reuse as evidence input:
|
||||
- `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- `docs/_tmp_sgbrowser_ws_api_doc.txt`
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `src/browser/ws_protocol.rs`
|
||||
|
||||
- [ ] **Step 1: Write one new design doc capturing the proven bootstrap contract**
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
docs/superpowers/specs/2026-04-03-ws-browser-bootstrap-contract-design.md
|
||||
```
|
||||
|
||||
Include:
|
||||
- exact validated sequence
|
||||
- exact required state (`requesturl`, active tab, agent page, auth payload)
|
||||
- exact failure semantics
|
||||
- why this is sufficient evidence to keep raw websocket as the product surface
|
||||
|
||||
- [ ] **Step 2: Write one new implementation plan for the bootstrap path**
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
docs/superpowers/plans/2026-04-03-ws-browser-bootstrap-contract-plan.md
|
||||
```
|
||||
|
||||
Plan only the minimal production changes required to embed the validated bootstrap sequence into the service/browser path.
|
||||
|
||||
- [ ] **Step 3: Commit the bootstrap decision docs**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-03-ws-browser-bootstrap-contract-design.md docs/superpowers/plans/2026-04-03-ws-browser-bootstrap-contract-plan.md
|
||||
git commit -m "docs: capture ws browser bootstrap contract"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Stop after writing the bootstrap plan**
|
||||
|
||||
Do not begin production implementation in the same slice unless the user explicitly asks for execution.
|
||||
|
||||
---
|
||||
|
||||
## Task 4B: If Option B wins, write the bridge-first implementation slice
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/specs/2026-04-03-ws-browser-bridge-path-design.md`
|
||||
- Create: `docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md`
|
||||
- Reuse as evidence input:
|
||||
- `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- `frontend/archive/sgClaw验证-已归档/testRunner.js`
|
||||
- `docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md`
|
||||
- `docs/archive/项目管理与排期/协作时间表.md`
|
||||
- `docs/plans/2026-03-27-sgclaw-floating-chat-frontend-design.md`
|
||||
|
||||
- [ ] **Step 1: Write the bridge-path design doc**
|
||||
|
||||
Create `docs/superpowers/specs/2026-04-03-ws-browser-bridge-path-design.md`.
|
||||
|
||||
The design must specify:
|
||||
- why raw websocket is considered non-validated for external control
|
||||
- which bridge surface becomes authoritative
|
||||
- where sgClaw should integrate (`FunctionsUI`, host bridge, `BrowserAction`, `CommandRouter`, or the nearest validated seam in this repo)
|
||||
- how to preserve pipe behavior and existing abstractions where practical
|
||||
|
||||
- [ ] **Step 2: Write the bridge-path implementation plan**
|
||||
|
||||
Create `docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md`.
|
||||
|
||||
The plan must:
|
||||
- identify exact files to touch
|
||||
- describe the narrowest adapter implementation
|
||||
- keep TDD/task granularity as in this document
|
||||
- avoid speculative work outside the bridge slice
|
||||
|
||||
- [ ] **Step 3: Commit the bridge decision docs**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-03-ws-browser-bridge-path-design.md docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md
|
||||
git commit -m "docs: define bridge-first sgBrowser integration"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Stop after writing the bridge plan**
|
||||
|
||||
Do not start the bridge implementation in the same slice unless the user explicitly asks for execution.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Deterministic probe harness tests
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: transcript capture, ordered scripts, timeout reporting, and close/reset reporting all pass.
|
||||
|
||||
### Probe binary build
|
||||
|
||||
```bash
|
||||
cargo build --bin sgbrowser_ws_probe
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Live evidence collection
|
||||
|
||||
- run the probe matrix against the real configured endpoint
|
||||
- save exact transcripts to `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- make the architecture decision using the documented rule
|
||||
|
||||
### Follow-up branch condition
|
||||
|
||||
- if Option A wins, repository contains a bootstrap-contract design + plan
|
||||
- if Option B wins, repository contains a bridge-path design + plan
|
||||
- no production runtime changes are made until that decision is written down
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- The existing `WsBrowserBackend` fix that remembers the last navigated URL remains valid; do not revert it.
|
||||
- The previous auth-replacement work also remains valid; it removed a real bug but did not prove the raw websocket architecture.
|
||||
- Keep the probe tool brutally literal: exact sent frames, exact received frames, explicit timeout/close outcomes.
|
||||
- Resist the temptation to make the probe “smart.” Smart probes hide evidence.
|
||||
- If the real endpoint still replies only with the welcome banner and then silence across the matrix, treat that as a decision, not as an excuse for more guessing.
|
||||
@@ -0,0 +1,362 @@
|
||||
# WS Browser Welcome Frame Compatibility Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the ws service path tolerate the real sgBrowser welcome banner (`Welcome! You are client #...`) without weakening general ws protocol validation or changing pipe behavior.
|
||||
|
||||
**Architecture:** Keep the shared `WsBrowserBackend` strict and implement the compatibility shim only in `ServiceBrowserWsClient`, which is already the real-browser adapter for the ws service path. Add one positive red test for the known welcome frame and one negative red test proving non-matching first text frames still fail as protocol errors, then make the minimal stateful change in `src/service/server.rs` and verify ws + pipe regressions.
|
||||
|
||||
**Tech Stack:** Rust 2021, tungstenite websocket client/server, existing `WsBrowserBackend`, existing `ServiceBrowserWsClient`, existing Rust unit/integration test suite.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/service/server.rs`
|
||||
- Add the one-time per-connection welcome-skip state to `ServiceBrowserWsClient`
|
||||
- Add the minimal helper(s) for detecting and discarding the first known welcome frame
|
||||
- Add focused service-adapter unit tests in the existing `#[cfg(test)]` module
|
||||
- Reuse: `src/browser/ws_backend.rs`
|
||||
- Do not change protocol parsing rules; only verify behavior remains strict for all non-service callers
|
||||
- Reuse: `tests/service_task_flow_test.rs`
|
||||
- Re-run to confirm the ws service path still reaches the browser websocket after the service-side shim
|
||||
- Reuse: `tests/browser_ws_backend_test.rs`
|
||||
- Re-run to prove the shared backend semantics remain unchanged
|
||||
|
||||
### Files deliberately not changed
|
||||
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/lib.rs`
|
||||
|
||||
The design explicitly keeps the welcome-banner workaround out of the shared backend and out of the pipe path.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Reproduce the real welcome-frame failure with focused unit tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs`
|
||||
|
||||
- [ ] **Step 1: Add the positive failing test for the known welcome frame**
|
||||
|
||||
In the existing `#[cfg(test)] mod tests` inside `src/service/server.rs`, add one focused test next to the current ws adapter tests.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn future_server_side_ws_native_adapter_skips_initial_known_welcome_frame() {
|
||||
// fake server sends:
|
||||
// 1. "Welcome! You are client #1"
|
||||
// 2. "0"
|
||||
// backend.invoke(Action::Navigate, ...) should succeed
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- the fake websocket server accepts one connection
|
||||
- it sends the welcome banner first, then the numeric success status
|
||||
- `WsBrowserBackend.invoke(Action::Navigate, ...)` returns `Ok(CommandOutput { success: true, .. })`
|
||||
|
||||
- [ ] **Step 2: Run only the positive new test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_skips_initial_known_welcome_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL with a protocol error containing `invalid browser status frame: Welcome! You are client #1`.
|
||||
|
||||
- [ ] **Step 3: Add the negative failing test for arbitrary first text**
|
||||
|
||||
In the same `#[cfg(test)]` module, add one negative test proving we do **not** silently skip arbitrary first text frames.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn future_server_side_ws_native_adapter_does_not_skip_unknown_first_text_frame() {
|
||||
// fake server sends:
|
||||
// 1. "Hello from server"
|
||||
// assert invoke(...) fails as PipeError::Protocol(...)
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- the first frame is a non-matching text frame such as `Hello from server`
|
||||
- `invoke(...)` fails
|
||||
- the failure remains a protocol error rather than success or timeout
|
||||
|
||||
- [ ] **Step 4: Run only the negative new test and verify the current behavior is already strict**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_does_not_skip_unknown_first_text_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, proving the current implementation already treats arbitrary first text as a protocol error. Keep that assertion in place before any production change.
|
||||
|
||||
- [ ] **Step 5: Confirm the TDD gate before implementation**
|
||||
|
||||
Do not implement production code before both tests exist and the positive test has failed on current behavior.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add the minimal per-connection welcome-skip state in the service adapter
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs`
|
||||
|
||||
- [ ] **Step 1: Add one-time per-connection state to `ServiceBrowserWsClient`**
|
||||
|
||||
Extend `ServiceBrowserWsClient` with one extra state field that tracks whether the initial welcome candidate has already been consumed for the current websocket connection.
|
||||
|
||||
Allowed shape:
|
||||
|
||||
```rust
|
||||
struct ServiceBrowserWsClient {
|
||||
browser_ws_url: String,
|
||||
browser_socket: Mutex<Option<WebSocket<MaybeTlsStream<TcpStream>>>>,
|
||||
initial_text_frame_checked: Mutex<bool>,
|
||||
}
|
||||
```
|
||||
|
||||
or an equally small equivalent.
|
||||
|
||||
Rules:
|
||||
- state is per connection, not per request
|
||||
- state must survive multiple `invoke(...)` calls while reusing the same socket
|
||||
- do not add broader protocol state machines
|
||||
|
||||
- [ ] **Step 2: Add a narrow welcome-frame matcher**
|
||||
|
||||
In `src/service/server.rs`, add one small helper that recognizes only the known banner prefix:
|
||||
|
||||
```rust
|
||||
fn is_known_welcome_frame(frame: &str) -> bool {
|
||||
frame.starts_with("Welcome! You are client #")
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- no regex needed
|
||||
- no generic “ignore arbitrary text” logic
|
||||
- keep the matcher local to `src/service/server.rs`
|
||||
|
||||
- [ ] **Step 3: Update `recv_text_timeout(...)` to skip at most one initial known banner**
|
||||
|
||||
Modify `impl WsClient for ServiceBrowserWsClient` so that the first text frame received after connection establishment is handled like this:
|
||||
|
||||
1. read the next text frame
|
||||
2. if the initial-frame state is still false:
|
||||
- mark the first-frame check as consumed
|
||||
- if the frame matches `is_known_welcome_frame(...)`, read the next frame and return that next frame instead
|
||||
3. otherwise, return the frame unchanged
|
||||
|
||||
Rules:
|
||||
- skip only once per connection
|
||||
- do not loop indefinitely over multiple text frames
|
||||
- do not swallow unknown first text frames
|
||||
- do not change timeout / close / reset / connect-failure behavior
|
||||
|
||||
- [ ] **Step 4: Reset the one-time state when a fresh socket is created**
|
||||
|
||||
When `with_socket(...)` establishes a brand-new websocket connection, ensure the one-time banner-check state is reset so a new connection can tolerate its own first welcome frame.
|
||||
|
||||
- [ ] **Step 5: Add one reconnect regression in the service adapter tests**
|
||||
|
||||
Add one focused test proving the welcome skip resets on a fresh connection after socket close/reset.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn future_server_side_ws_native_adapter_skips_welcome_again_after_reconnect() {
|
||||
// first connection closes after use
|
||||
// second fresh connection sends the same welcome banner again
|
||||
// both invocations succeed
|
||||
}
|
||||
```
|
||||
|
||||
Required assertion:
|
||||
- the one-time skip is per connection, not global for the client instance
|
||||
|
||||
- [ ] **Step 6: Run the positive new test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_skips_initial_known_welcome_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Run the negative new test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_does_not_skip_unknown_first_text_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, proving unknown first text is still treated as a protocol error.
|
||||
|
||||
- [ ] **Step 8: Run the reconnect regression**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_skips_welcome_again_after_reconnect -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Run the full service adapter unit group**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, including the existing tests for:
|
||||
- status `0` success
|
||||
- connect failure => `PipeError::Protocol("browser websocket connect failed: ...")`
|
||||
- disconnect/reset => `PipeError::PipeClosed`
|
||||
- callback timeout => `PipeError::Timeout`
|
||||
- new known-welcome success path
|
||||
- new unknown-first-frame strictness path
|
||||
- new reconnect reset behavior
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Verify the shared backend stayed strict and the ws service path still works
|
||||
|
||||
**Files:**
|
||||
- Reuse: `tests/browser_ws_backend_test.rs`
|
||||
- Reuse: `tests/service_task_flow_test.rs`
|
||||
- Reuse: `src/browser/ws_backend.rs`
|
||||
|
||||
- [ ] **Step 1: Re-run the shared ws backend tests unchanged**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS. This proves `WsBrowserBackend` semantics remain unchanged for its existing deterministic callers.
|
||||
|
||||
- [ ] **Step 2: Re-run the service task-flow regression**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, including the auth-regression test that proves the ws service path reaches the browser websocket and no longer emits `invalid hmac seed: session key must not be empty`.
|
||||
|
||||
- [ ] **Step 3: Re-run the ws-focused mixed verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test --test browser_ws_protocol_test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Re-run the real manual smoke that originally failed
|
||||
|
||||
**Files:**
|
||||
- Reuse only: no code changes unless a fresh reproducer proves another bug
|
||||
|
||||
- [ ] **Step 1: Confirm real browser websocket reachability**
|
||||
|
||||
Run a reachability check for `ws://127.0.0.1:12345` (or the configured `browserWsUrl`) before starting smoke.
|
||||
|
||||
Expected: reachable.
|
||||
|
||||
- [ ] **Step 2: Start the real ws service**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo run --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected: the service prints:
|
||||
- `sg_claw ready: ...`
|
||||
- the resolved `service_ws_listen_addr`
|
||||
- the configured `browser_ws_url`
|
||||
|
||||
- [ ] **Step 3: Re-run the original failing manual smoke**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
printf '打开知乎热榜并读取页面主区域文本\n' | cargo run --bin sg_claw_client -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected:
|
||||
- no `invalid browser status frame: Welcome! You are client #1`
|
||||
- browser actions proceed past the first status frame
|
||||
- if the browser later fails for another reason, capture that new reason exactly
|
||||
|
||||
- [ ] **Step 4: Re-run the old Zhihu export task smoke**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
printf '读取知乎热榜数据,并导出 excel 文件\n' | cargo run --bin sg_claw_client -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected:
|
||||
- no `invalid browser status frame: Welcome! You are client #1`
|
||||
- the task reaches the real browser action path beyond connection banner handling
|
||||
|
||||
- [ ] **Step 5: Stop and debug if a new real-browser issue appears**
|
||||
|
||||
If smoke now fails for a different reason, do not piggyback a second fix into this slice without:
|
||||
- capturing the exact new output
|
||||
- writing a new focused spec/plan if the issue is materially different
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Service adapter unit tests
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: all service-side ws adapter tests pass, including the new welcome-frame positive/negative cases and reconnect reset case.
|
||||
|
||||
### Shared ws backend + ws service regressions
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test --test browser_ws_protocol_test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Real smoke verification
|
||||
|
||||
- `browserWsUrl` reachable
|
||||
- `sg_claw` starts with real config
|
||||
- `sg_claw_client` no longer fails on `Welcome! You are client #...`
|
||||
- Zhihu minimal read task gets past the first status frame
|
||||
- Zhihu export task gets past the first status frame
|
||||
@@ -0,0 +1,564 @@
|
||||
# Zhihu Release WS Function-Callback Migration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move only the Zhihu direct-execution path to the new Release browser websocket interaction style while keeping the existing pipe protocol and non-Zhihu submit behavior unchanged.
|
||||
|
||||
**Architecture:** Keep `ClientMessage` / `ServiceMessage`, `run_submit_task_with_browser_backend(...)`, and the high-level Zhihu workflow steps unchanged. First prove the exact Release browser interaction contract with transcript-backed probes. Then implement the smallest Zhihu-scoped backend path that follows that proven contract. Do not globally rewire the submit path unless the probe evidence proves there is no narrower safe seam.
|
||||
|
||||
**Tech Stack:** Rust, tungstenite, existing sgclaw service/client pipe protocol, `docs/_tmp_sgbrowser_ws_api_doc.txt`, Release browser websocket at `ws://127.0.0.1:12345`, current Zhihu direct-execution workflow.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The user has now made the target behavior explicit:
|
||||
|
||||
- the browser has changed and the working reference behavior is the user-provided HTML page that connects to `ws://127.0.0.1:12345`
|
||||
- that page sends a bootstrap registration frame: `{"type":"register","role":"web"}`
|
||||
- browser requests are still JSON arrays such as `[window.location.href, "sgBrowserSetTheme", "1"]` and `[window.location.href, "sgBrowerserGetUrls", "showUrls"]`
|
||||
- callback-bearing browser behavior is now centered on page-defined JS callback functions like `showUrls`, not on Rust directly reading a websocket callback frame as the final business result
|
||||
- the existing sgclaw pipe protocol must remain unchanged
|
||||
|
||||
The current sgclaw drift that must be corrected is visible in:
|
||||
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `Action::Navigate` currently emits `sgHideBrowserCallAfterLoaded` with an inline `callBackJsToCpp(...)` string
|
||||
- `src/browser/ws_backend.rs`
|
||||
- Rust currently waits for a browser websocket callback frame and treats that as the action result
|
||||
- `tests/service_ws_session_test.rs:498-605`
|
||||
- `tests/service_task_flow_test.rs:499-635`
|
||||
- existing **generic submit-flow** regressions still lock in the old direct raw-websocket callback-frame assumption
|
||||
- these are useful as non-regression guardrails, but they are not themselves Zhihu-specific regressions
|
||||
|
||||
Zhihu-specific verification must therefore be added explicitly instead of assuming those Baidu-path tests already cover Zhihu.
|
||||
|
||||
The new browser style proves these facts and only these facts so far:
|
||||
|
||||
1. sgclaw must handle a register-first websocket handshake
|
||||
2. browser requests are still `[requesturl, action, ...args]`
|
||||
3. some browser capabilities now return through page-defined callback functions like `showUrls`
|
||||
4. the current direct raw-websocket callback expectation in Zhihu path is no longer a safe assumption
|
||||
|
||||
The production seam is **not** pre-decided here. Task 1 must determine whether Zhihu can be integrated by:
|
||||
- a direct Zhihu-scoped backend with no helper page, or
|
||||
- a helper page / relay design because named page callbacks are the only reliable result path
|
||||
|
||||
Until Task 1 evidence is captured, both remain hypotheses.
|
||||
|
||||
## Evidence to preserve in the implementation
|
||||
|
||||
### Browser websocket API doc
|
||||
From `docs/_tmp_sgbrowser_ws_api_doc.txt`:
|
||||
- `ws://localhost:12345` is the browser websocket endpoint
|
||||
- request frames are array payloads with `requesturl`
|
||||
- `sgBrowerserGetUrls(callback)` uses a callback **function name**: `[requesturl,"sgBrowerserGetUrls", callback]`
|
||||
- `sgBrowserCallAfterLoaded(targetUrl, callback)` and `sgHideBrowserCallAfterLoaded(targetUrl, callback)` use callback strings with parentheses
|
||||
- `callBackJsToCpp(param)` uses `sourceUrl@_@targetUrl@_@callback@_@actionUrl@_@responseTxt`
|
||||
- `sgBrowserRegJsFun(targeturl, funContent)` and `sgBrowserExcuteJsFun(targeturl, funName)` exist and may be useful when the helper page needs durable callback helpers
|
||||
|
||||
### Current working HTML pattern from the user
|
||||
The now-working reference interaction is:
|
||||
|
||||
```html
|
||||
const socket = new WebSocket('ws://127.0.0.1:12345');
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({type: 'register', role: 'web'}));
|
||||
};
|
||||
socket.send(JSON.stringify([window.location.href,"sgBrowerserGetUrls","showUrls"]));
|
||||
function showUrls(urls) {
|
||||
// browser invokes this page-defined callback
|
||||
}
|
||||
```
|
||||
|
||||
That is the browser behavior sgclaw now needs to follow.
|
||||
|
||||
---
|
||||
|
||||
## Critical files
|
||||
|
||||
### Production files to modify
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `src/compat/workflow_executor.rs` (only if a narrow Zhihu-specific correction is required after backend swap)
|
||||
- `src/service/server.rs` (only if the chosen Zhihu-scoped integration seam must be wired here)
|
||||
- `src/service/mod.rs` (only if startup plumbing changes are truly required)
|
||||
- `src/browser/mod.rs`
|
||||
|
||||
### New production files likely needed
|
||||
- `src/browser/zhihu_release_backend.rs`
|
||||
- a Zhihu-scoped `BrowserBackend` adapter that follows the proven Release browser interaction style without changing non-Zhihu routes
|
||||
- `src/service/browser_callback_host.rs` **only if the probe proves a service-controlled helper page is actually required**
|
||||
- service-local helper-page lifecycle and callback relay, if evidence shows the browser cannot be driven safely without it
|
||||
|
||||
### Existing files to preserve
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/service/protocol.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/pipe/*`
|
||||
|
||||
### Existing direct-ws files to review explicitly
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `tests/browser_ws_backend_test.rs`
|
||||
|
||||
These files currently encode the old direct raw-websocket callback expectation. The implementation must either:
|
||||
- leave them untouched as legacy/direct-contract coverage with no Zhihu production callers, or
|
||||
- update/remove the Zhihu-specific assumptions they currently lock in.
|
||||
|
||||
### Primary test files
|
||||
- `tests/browser_ws_probe_test.rs`
|
||||
- `tests/browser_ws_protocol_test.rs`
|
||||
- `tests/service_ws_session_test.rs`
|
||||
- `tests/service_task_flow_test.rs`
|
||||
- `tests/task_runner_test.rs`
|
||||
- `tests/browser_ws_backend_test.rs`
|
||||
|
||||
---
|
||||
|
||||
## File structure decisions
|
||||
|
||||
### `src/browser/zhihu_release_backend.rs`
|
||||
Prefer a Zhihu-scoped backend first.
|
||||
|
||||
Responsibilities:
|
||||
- keep the same `BrowserBackend` trait surface
|
||||
- implement only the behavior needed by the current Zhihu direct-execution route
|
||||
- translate `Action::Navigate`, `Action::GetText`, and `Action::Eval` into the proven Release-browser interaction style
|
||||
- normalize results back into `CommandOutput`
|
||||
- avoid affecting non-Zhihu callers
|
||||
|
||||
This is the preferred seam because the user asked to change the current Zhihu flow, not to redesign the whole submit pipeline.
|
||||
|
||||
### `src/service/browser_callback_host.rs` (conditional)
|
||||
Create this file only if Task 1 probe evidence proves that sgclaw must host or control a page in order to receive named callback-function results.
|
||||
|
||||
If it is needed, the plan must keep the design minimal and specific:
|
||||
- one concrete transport only (choose websocket or HTTP, not “websocket or HTTP”)
|
||||
- explicit readiness handshake
|
||||
- explicit request correlation by `request_id`
|
||||
- explicit cleanup when the submit task ends
|
||||
|
||||
If Task 1 shows a simpler seam, do not create this file.
|
||||
|
||||
### `src/browser/ws_protocol.rs`
|
||||
Do not let this file keep only the old direct-callback assumption.
|
||||
|
||||
It should become the shared place for doc-native request builders such as:
|
||||
- browser bootstrap frames proven by the transcript
|
||||
- `sgBrowserCallAfterLoaded` / `sgHideBrowserCallAfterLoaded`
|
||||
- `sgBrowserExcuteJsCodeByArea`
|
||||
- optional `sgBrowserRegJsFun` / `sgBrowserExcuteJsFun`
|
||||
|
||||
But do **not** let `ws_protocol.rs` absorb service-host lifecycle logic.
|
||||
|
||||
### `src/browser/ws_backend.rs` and `tests/browser_ws_backend_test.rs`
|
||||
Handle these explicitly in the implementation:
|
||||
- if they still describe a valid direct browser contract, keep them as isolated legacy/direct-ws coverage only
|
||||
- if their current navigate/callback assumptions conflict with the proven Release Zhihu path, update or narrow those tests so they no longer describe the active Zhihu integration path
|
||||
|
||||
Do not leave the old direct-callback assumptions ambiguously “reviewed”; the implementation must make their status explicit.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Capture the new Release browser contract in a reproducible probe transcript
|
||||
|
||||
**Files:**
|
||||
- Review/modify: `src/browser/ws_probe.rs`
|
||||
- Review/modify: `src/bin/sgbrowser_ws_probe.rs`
|
||||
- Review/modify: `tests/browser_ws_probe_test.rs`
|
||||
- Create: `docs/_tmp_release_ws_callback_host_transcript.md`
|
||||
|
||||
- [ ] **Step 1: Verify current probe coverage against the Release-browser questions**
|
||||
|
||||
Read the existing probe module and tests and check whether they already prove all of the following:
|
||||
- a register-first websocket script can be expressed
|
||||
- a later array action frame can be expressed in the same script
|
||||
- per-step inbound frames/outcomes are preserved separately
|
||||
- timeout/close remain distinguishable in the transcript
|
||||
|
||||
Required result:
|
||||
- identify the exact existing tests that already prove these behaviors
|
||||
- identify the smallest missing Release-specific coverage, if any
|
||||
|
||||
- [ ] **Step 2: Add only the missing regression coverage**
|
||||
|
||||
If current tests do **not** already prove the Release-browser bootstrap shape, add the narrowest failing regression in `tests/browser_ws_probe_test.rs`.
|
||||
|
||||
Preferred shape if coverage is missing:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn probe_supports_register_then_array_action_script() {
|
||||
// fake server expects:
|
||||
// 1. {"type":"register","role":"web"}
|
||||
// 2. ["http://127.0.0.1/helper.html","sgBrowerserGetUrls","showUrls"]
|
||||
}
|
||||
```
|
||||
|
||||
And, if still missing, add one regression proving per-step transcript separation for the register reply and later action reply.
|
||||
|
||||
If those behaviors are already covered, skip new test creation and record the exact test names to rely on.
|
||||
|
||||
- [ ] **Step 3: Run the relevant probe tests**
|
||||
|
||||
Run the narrowest exact tests that prove the Release bootstrap behavior, or the full file if multiple areas changed:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Make the probe binary ergonomic for the Release transcript if needed**
|
||||
|
||||
Only if the current CLI cannot conveniently express the real Release-browser script, make the smallest change needed in `src/bin/sgbrowser_ws_probe.rs` / `src/browser/ws_probe.rs` so it can capture:
|
||||
- register frame behavior
|
||||
- minimal `sgBrowserSetTheme`
|
||||
- minimal `sgBrowerserGetUrls`
|
||||
- exact inbound websocket text per step
|
||||
|
||||
Do not redesign the probe if it already supports this.
|
||||
|
||||
- [ ] **Step 5: Run the live probe against the Release browser and record the real bootstrap**
|
||||
|
||||
Use the probe binary against the real endpoint to capture at minimum:
|
||||
- register frame behavior
|
||||
- minimal `sgBrowserSetTheme`
|
||||
- minimal `sgBrowerserGetUrls`
|
||||
- whether replies come back as websocket text, page-function invocation only, or both
|
||||
|
||||
Save the exact transcript in `docs/_tmp_release_ws_callback_host_transcript.md`.
|
||||
|
||||
Required output in that temp doc:
|
||||
- exact sent frames
|
||||
- exact received websocket frames
|
||||
- the observed rule for when named callback functions are invoked
|
||||
- whether Option A or Option B is supported by evidence
|
||||
|
||||
- [ ] **Step 6: Commit the probe-only slice if code changed**
|
||||
|
||||
If probe code/tests changed:
|
||||
|
||||
```bash
|
||||
git add src/browser/ws_probe.rs src/bin/sgbrowser_ws_probe.rs tests/browser_ws_probe_test.rs docs/_tmp_release_ws_callback_host_transcript.md
|
||||
git commit -m "test: capture release browser ws bootstrap contract"
|
||||
```
|
||||
|
||||
If only the transcript doc changed, stage only that file and use a docs/test-appropriate commit message.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Choose the narrowest Zhihu-only production seam from the probe evidence
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs` (only if required)
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs` (only if required)
|
||||
- Create: `src/browser/zhihu_release_backend.rs`
|
||||
- Create: `src/service/browser_callback_host.rs` **only if required**
|
||||
- Test: `tests/service_ws_session_test.rs`
|
||||
- Test: `tests/service_task_flow_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write down the seam decision in the plan notes before coding**
|
||||
|
||||
Based on the transcript from Task 1, record which one of these is supported by evidence:
|
||||
- Option A: a Zhihu-scoped backend can talk to the Release browser directly with no service-hosted helper page
|
||||
- Option B: a Zhihu-scoped backend needs a service-controlled helper page because named page callbacks are the only reliable way to get business results
|
||||
|
||||
Do not proceed until one option is chosen explicitly from evidence.
|
||||
|
||||
- [ ] **Step 2: Add a failing service/task-flow regression that proves only the Zhihu path changes**
|
||||
|
||||
Update or add focused tests so that:
|
||||
- Zhihu submit flow uses the new Release-browser interaction seam
|
||||
- non-Zhihu behavior is unchanged
|
||||
- pipe messages remain unchanged
|
||||
|
||||
Required assertions:
|
||||
- the new path is activated only for Zhihu route detection
|
||||
- `ClientMessage` / `ServiceMessage` stay identical
|
||||
- existing non-Zhihu submit behavior is not accidentally rerouted
|
||||
|
||||
- [ ] **Step 3: Run the new focused regression and confirm failure first**
|
||||
|
||||
Run the narrowest exact test names you added in:
|
||||
```bash
|
||||
cargo test --test service_ws_session_test <new_test_name> -- --nocapture
|
||||
cargo test --test service_task_flow_test <new_test_name> -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the Zhihu-specific seam does not exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement the chosen seam with the smallest blast radius**
|
||||
|
||||
If Option A won:
|
||||
- add `src/browser/zhihu_release_backend.rs`
|
||||
- wire it only where the Zhihu direct-execution route is selected
|
||||
- leave global submit-path wiring alone
|
||||
|
||||
If Option B won:
|
||||
- add `src/service/browser_callback_host.rs` with one specific transport and one explicit readiness/correlation model
|
||||
- add `src/browser/zhihu_release_backend.rs` to talk to that helper path
|
||||
- wire it only for the Zhihu route
|
||||
|
||||
In both cases:
|
||||
- do not change non-Zhihu callers
|
||||
- do not redesign `run_submit_task_with_browser_backend(...)`
|
||||
- do not change the pipe protocol
|
||||
|
||||
- [ ] **Step 5: Make the status of old direct-ws code explicit**
|
||||
|
||||
Update `src/browser/ws_backend.rs` / `tests/browser_ws_backend_test.rs` only as needed so they no longer ambiguously describe the active Zhihu path.
|
||||
|
||||
Allowed outcomes:
|
||||
- keep them untouched as legacy/direct-ws coverage with no Zhihu production caller
|
||||
- narrow/update the tests so they no longer claim the active Zhihu integration path
|
||||
|
||||
Not allowed:
|
||||
- leaving the plan and code in a state where both old and new paths appear to be the active Zhihu contract
|
||||
|
||||
- [ ] **Step 6: Run focused integration tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit the seam-selection slice**
|
||||
|
||||
Adjust staged files to match the option actually implemented, for example:
|
||||
|
||||
```bash
|
||||
git add src/browser/zhihu_release_backend.rs src/browser/mod.rs src/service/server.rs src/service/browser_callback_host.rs tests/service_ws_session_test.rs tests/service_task_flow_test.rs tests/browser_ws_backend_test.rs
|
||||
git commit -m "feat: route zhihu flow through release browser ws contract"
|
||||
```
|
||||
|
||||
Only stage files that were truly changed.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement Zhihu action mapping on the chosen Release-browser seam
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/ws_protocol.rs`
|
||||
- Modify: `src/browser/zhihu_release_backend.rs`
|
||||
- Test: `tests/browser_ws_protocol_test.rs`
|
||||
- Create: `tests/browser_zhihu_release_backend_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing backend test for Zhihu navigate mapping**
|
||||
|
||||
Create `tests/browser_zhihu_release_backend_test.rs` with a fake transport/relay and assert that `Action::Navigate` for the Zhihu path becomes the exact browser request shape proven by Task 1.
|
||||
|
||||
Start with this shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn zhihu_release_backend_maps_navigate_to_proven_release_frame() {
|
||||
// invoke Action::Navigate
|
||||
// assert exact outbound frame/opcode chosen from transcript evidence
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- the call site still uses `BrowserBackend::invoke(...)`
|
||||
- the exact outbound frame matches the recorded Release-browser evidence
|
||||
- request correlation stays deterministic
|
||||
|
||||
- [ ] **Step 2: Run the single new backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_zhihu_release_backend_test zhihu_release_backend_maps_navigate_to_proven_release_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the backend does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement minimal `Navigate` support**
|
||||
|
||||
In `src/browser/zhihu_release_backend.rs`:
|
||||
- implement `BrowserBackend`
|
||||
- support `Action::Navigate` first
|
||||
- use `ws_protocol.rs` helpers for exact browser-frame construction
|
||||
- do not hardcode speculative opcodes; follow the transcript from Task 1
|
||||
|
||||
- [ ] **Step 4: Add failing tests for `GetText` and `Eval`**
|
||||
|
||||
Add tests proving:
|
||||
- `Action::GetText` returns `CommandOutput.data == {"text": "..."}`
|
||||
- `Action::Eval` returns `CommandOutput.data == {"text": "..."}`
|
||||
- callback or relay failures become `PipeError::Protocol(...)`
|
||||
|
||||
- [ ] **Step 5: Implement `GetText` and `Eval` on the chosen seam**
|
||||
|
||||
Use the smallest proven mechanism:
|
||||
- if the transcript proves page-defined callback functions are required, route through them
|
||||
- if `callBackJsToCpp(...)` to a page context is still part of the proven path, use it deliberately
|
||||
- if `sgBrowserRegJsFun` / `sgBrowserExcuteJsFun` becomes necessary, add it only with test coverage and only for the Zhihu path
|
||||
|
||||
- [ ] **Step 6: Run focused backend/protocol tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_zhihu_release_backend_test -- --nocapture
|
||||
cargo test --test browser_ws_protocol_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit the Zhihu backend slice**
|
||||
|
||||
```bash
|
||||
git add src/browser/ws_protocol.rs src/browser/zhihu_release_backend.rs src/browser/mod.rs tests/browser_ws_protocol_test.rs tests/browser_zhihu_release_backend_test.rs
|
||||
git commit -m "feat: add zhihu release ws backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Keep the Zhihu workflow logic stable and patch only proven mismatches
|
||||
|
||||
**Files:**
|
||||
- Review: `src/compat/workflow_executor.rs`
|
||||
- Test: `tests/service_task_flow_test.rs`
|
||||
- Test: `tests/compat_runtime_test.rs` (only if a focused direct-execution regression is needed)
|
||||
|
||||
- [ ] **Step 1: Write a failing Zhihu-specific regression only if the chosen seam changes route assumptions**
|
||||
|
||||
If the new Zhihu backend changes request-url or target-url handling enough to break hotlist flow, add one focused failing regression for that exact behavior.
|
||||
|
||||
Candidate assertions:
|
||||
- hotlist navigate still logs `navigate https://www.zhihu.com/hot`
|
||||
- follow-up `GetText body` still targets the Zhihu page, not any helper page
|
||||
- extractor `Eval` still runs against Zhihu, not any helper page
|
||||
|
||||
- [ ] **Step 2: Keep the current high-level Zhihu action sequence unless a test proves otherwise**
|
||||
|
||||
`src/compat/workflow_executor.rs` currently does the right high-level work:
|
||||
- navigate to Zhihu hotlist
|
||||
- poll body text until ready
|
||||
- run the extractor script
|
||||
|
||||
Prefer to keep this file unchanged. Only patch it if the new backend needs a narrow explicit `target_url` fix or similar evidence-backed adjustment.
|
||||
|
||||
- [ ] **Step 3: Run the smallest Zhihu-focused verification sweep**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
cargo test --test compat_runtime_test zhihu -- --nocapture
|
||||
```
|
||||
|
||||
If the `compat_runtime_test zhihu` filter is too broad or unstable, run the exact focused Zhihu cases that cover hotlist extraction.
|
||||
|
||||
- [ ] **Step 4: Commit only if a Zhihu-specific code change was actually required**
|
||||
|
||||
```bash
|
||||
git add src/compat/workflow_executor.rs tests/service_task_flow_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "fix: keep zhihu workflow aligned with release ws backend"
|
||||
```
|
||||
|
||||
Skip this commit if no production change in `workflow_executor.rs` was needed.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Prove that pipe behavior and non-Zhihu behavior stayed unchanged
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/service_ws_session_test.rs`
|
||||
- Test: `tests/service_task_flow_test.rs`
|
||||
- Test: `tests/task_runner_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add or update one regression that proves pipe messages are unchanged**
|
||||
|
||||
Use the smallest existing test seam to assert that `ClientMessage` / `ServiceMessage` payloads remain unchanged while the Zhihu route uses the new browser integration path internally.
|
||||
|
||||
- [ ] **Step 2: Add or update one regression that proves non-Zhihu behavior is unchanged**
|
||||
|
||||
Use a non-Zhihu submit or service-session case and assert it does not take the new Zhihu-specific backend path.
|
||||
|
||||
- [ ] **Step 3: Preserve current runtime regression guards**
|
||||
|
||||
The end-to-end tests must continue asserting that output does **not** contain:
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
- `Cannot drop a runtime in a context where blocking is not allowed`
|
||||
|
||||
- [ ] **Step 4: Run the final focused verification sweep**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit the verification sweep**
|
||||
|
||||
```bash
|
||||
git add tests/service_ws_session_test.rs tests/service_task_flow_test.rs tests/task_runner_test.rs tests/browser_ws_backend_test.rs
|
||||
git commit -m "test: constrain zhihu release ws migration scope"
|
||||
```
|
||||
|
||||
Only stage files that were truly changed.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
Do **not** do these in this slice:
|
||||
- change the pipe protocol
|
||||
- change `ClientMessage` / `ServiceMessage`
|
||||
- redesign `run_submit_task_with_browser_backend(...)`
|
||||
- reintroduce any browser bridge surface
|
||||
- keep adding speculative direct-raw-websocket callback patches to `ws_backend.rs`
|
||||
- redesign non-Zhihu workflows unless the new backend abstraction forces a shared fix
|
||||
- create a long-lived external dependency or third-party server just to host the helper page
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist
|
||||
|
||||
Run at minimum:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test -- --nocapture
|
||||
cargo test --test browser_zhihu_release_backend_test -- --nocapture
|
||||
cargo test --test browser_ws_protocol_test -- --nocapture
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
If Task 2 chose the helper-page / relay design, also run the helper-page-specific backend tests you added for that path.
|
||||
|
||||
Manual verification after code changes:
|
||||
|
||||
1. start the real Release browser/runtime that exposes `ws://127.0.0.1:12345`
|
||||
2. start `sg_claw` with real config
|
||||
3. start `sg_claw_client`
|
||||
4. submit:
|
||||
- `打开知乎热榜,获取前10条数据,并导出 Excel`
|
||||
5. confirm the Zhihu path uses the exact Release-browser interaction seam proven by Task 1
|
||||
6. if Task 2 chose Option B, confirm the helper page / relay path is used only for the Zhihu integration seam
|
||||
7. confirm non-Zhihu behavior is unchanged
|
||||
8. confirm the task completes without:
|
||||
- `timeout while waiting for browser message`
|
||||
- `invalid browser status frame: Welcome! You are client #1`
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
- `Cannot drop a runtime in a context where blocking is not allowed`
|
||||
|
||||
---
|
||||
|
||||
## Expected outcome
|
||||
|
||||
After this slice:
|
||||
- sgclaw still exposes the same pipe/service contract
|
||||
- Zhihu hotlist execution uses the Release-browser websocket contract proven by Task 1
|
||||
- non-Zhihu behavior remains unchanged
|
||||
- old direct-ws Zhihu assumptions are no longer ambiguous in production/tests
|
||||
- if Option A won, Zhihu uses a direct Release-browser backend
|
||||
- if Option B won, Zhihu uses the minimal helper-page / relay seam justified by the probe evidence
|
||||
322
docs/superpowers/plans/2026-04-04-zhihu-ws-submit-realignment.md
Normal file
322
docs/superpowers/plans/2026-04-04-zhihu-ws-submit-realignment.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Zhihu WS Submit Realignment Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Realign Zhihu submit routes to the documented websocket callback model, removing helper-page bootstrap from the mainline while keeping the existing pipe/service contract unchanged.
|
||||
|
||||
**Architecture:** The change stays inside the existing submit-path backend selection and websocket protocol flow. Zhihu routes stop choosing `BrowserCallbackBackend` and instead use `WsBrowserBackend` when a real browser websocket is configured, preserving the existing pipe fallback in direct runtime when no websocket URL is available.
|
||||
|
||||
**Tech Stack:** Rust, tungstenite websocket client/server, serde_json, cargo test
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/service/server.rs`
|
||||
- Change only the Zhihu route-gated submit-path backend selection
|
||||
- Remove Zhihu submit mainline use of `LiveBrowserCallbackHost` / `BrowserCallbackBackend`
|
||||
- Keep service submit path on `WsBrowserBackend`
|
||||
- Preserve initial request URL derivation for Zhihu routes
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- Change only the Zhihu route-gated submit-path backend selection
|
||||
- Remove Zhihu submit mainline use of `LiveBrowserCallbackHost` / `BrowserCallbackBackend`
|
||||
- Keep direct runtime pipe fallback when browser websocket URL is absent
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- Replace helper-page bootstrap regression with direct websocket submit regression
|
||||
- Assert no `/sgclaw/browser-helper.html` bootstrap frames are emitted
|
||||
- Assert real-page request ownership on follow-up Zhihu actions
|
||||
- Modify: `src/browser/callback_host.rs`
|
||||
- Remove or rewrite the now-wrong red test that preserves Option-B callback-host startup behavior
|
||||
- Verify: `tests/browser_ws_backend_test.rs`
|
||||
- Reuse existing websocket request-url behavior coverage; extend only if the new regression proves insufficient
|
||||
- Reference: `docs/superpowers/specs/2026-04-04-zhihu-ws-submit-realignment-design.md`
|
||||
|
||||
### Task 1: Rewrite the stale submit regression around the real websocket mainline
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/agent_runtime_test.rs:507-660`
|
||||
- Test: `tests/agent_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Rename and rewrite the existing helper-page regression so it asserts the new behavior:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
|
||||
// arrange runtime context and fake browser ws server
|
||||
// submit Zhihu hotlist request
|
||||
// assert ws frames never contain "/sgclaw/browser-helper.html"
|
||||
// assert first action is navigate to https://www.zhihu.com/hot
|
||||
// assert follow-up action uses real-page requesturl instead of helper page
|
||||
}
|
||||
```
|
||||
|
||||
Use the existing fake ws helpers in the file where possible. Do not add localhost callback-host HTTP plumbing to this rewritten test.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --nocapture`
|
||||
|
||||
Expected: FAIL because current production code still routes Zhihu submit into `BrowserCallbackBackend` and emits helper-page bootstrap frames.
|
||||
|
||||
- [ ] **Step 3: Keep the regression focused**
|
||||
|
||||
Before touching production code, confirm the rewritten test checks only these behaviors:
|
||||
|
||||
```text
|
||||
- no callback-host bootstrap frame
|
||||
- no helper-page URL
|
||||
- navigate frame still targets https://www.zhihu.com/hot
|
||||
- follow-up websocket action uses real-page request ownership
|
||||
```
|
||||
|
||||
Do not assert unrelated workflow details beyond what is needed to prove the route correction.
|
||||
|
||||
- [ ] **Step 4: Commit the red test**
|
||||
|
||||
```bash
|
||||
git add tests/agent_runtime_test.rs
|
||||
git commit -m "test: rewrite zhihu submit ws routing regression"
|
||||
```
|
||||
|
||||
### Task 2: Switch service Zhihu submit routes off the callback-host backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs:287-328`
|
||||
- Test: `tests/agent_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the minimal production change**
|
||||
|
||||
Replace only the Zhihu-route callback-host branch with direct websocket backend selection.
|
||||
|
||||
Minimal target shape:
|
||||
|
||||
```rust
|
||||
fn browser_backend_for_submit(
|
||||
browser_ws_url: &str,
|
||||
mac_policy: &MacPolicy,
|
||||
request: &SubmitTaskRequest,
|
||||
) -> Result<Arc<dyn BrowserBackend>, PipeError> {
|
||||
if should_use_callback_host_backend(request) {
|
||||
return Ok(Arc::new(WsBrowserBackend::new(
|
||||
Arc::new(ServiceWsClient::connect(browser_ws_url)?),
|
||||
mac_policy.clone(),
|
||||
initial_request_url_for_submit_task(request),
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Arc::new(WsBrowserBackend::new(
|
||||
Arc::new(ServiceWsClient::connect(browser_ws_url)?),
|
||||
mac_policy.clone(),
|
||||
initial_request_url_for_submit_task(request),
|
||||
)))
|
||||
}
|
||||
```
|
||||
|
||||
After the route-gated branch is removed, simplify further only if the branch becomes redundant without changing non-Zhihu behavior.
|
||||
|
||||
- [ ] **Step 2: Run the rewritten regression**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --nocapture`
|
||||
|
||||
Expected: still FAIL or advance to a later assertion until the direct-runtime path is corrected too.
|
||||
|
||||
- [ ] **Step 3: Add or update a service-specific regression if needed**
|
||||
|
||||
If the rewritten `agent_runtime_test` does not exercise the service submit path directly, add one narrow service regression before continuing.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn service_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
|
||||
// fake browser ws
|
||||
// submit Zhihu route through service path
|
||||
// assert no helper bootstrap frame
|
||||
}
|
||||
```
|
||||
|
||||
Run the exact test you end up using:
|
||||
|
||||
`cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test <exact test file> -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit the service-path fix**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs tests/agent_runtime_test.rs
|
||||
git commit -m "fix: route zhihu submit through ws backend"
|
||||
```
|
||||
|
||||
### Task 3: Switch direct runtime Zhihu submit routes off the callback-host backend while keeping pipe fallback
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent/mod.rs:49-100`
|
||||
- Test: `tests/agent_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the minimal production change**
|
||||
|
||||
Remove callback-host backend selection from `browser_backend_for_submit(...)`.
|
||||
|
||||
Minimal target behavior:
|
||||
|
||||
```rust
|
||||
if let Some(browser_ws_url) = configured_browser_ws_url(context) {
|
||||
return Ok(Arc::new(WsBrowserBackend::new(
|
||||
Arc::new(ServiceWsClient::connect(&browser_ws_url)?),
|
||||
browser_tool.mac_policy().clone(),
|
||||
initial_request_url_for_submit_task(request),
|
||||
).with_response_timeout(browser_tool.response_timeout())));
|
||||
}
|
||||
|
||||
Ok(Arc::new(PipeBrowserBackend::from_inner(browser_tool.clone())))
|
||||
```
|
||||
|
||||
If `ServiceWsClient` is not reusable from `src/service/server.rs`, extract the smallest shared websocket client helper into the browser module instead of inventing a new abstraction.
|
||||
|
||||
- [ ] **Step 2: Add a focused fallback assertion only if needed**
|
||||
|
||||
If the rewritten regression does not cover the direct-runtime no-websocket case, add one small test:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn production_submit_task_keeps_pipe_fallback_when_browser_ws_url_is_unset() {
|
||||
// no SGCLAW_BROWSER_WS_URL
|
||||
// blank/no ws config
|
||||
// assert no websocket bootstrap attempt occurs
|
||||
}
|
||||
```
|
||||
|
||||
Only add this test if current coverage is insufficient.
|
||||
|
||||
- [ ] **Step 3: Run tests to verify green**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
If a fallback test was added, run it immediately after and expect PASS.
|
||||
|
||||
- [ ] **Step 4: Commit the direct-runtime fix**
|
||||
|
||||
```bash
|
||||
git add src/agent/mod.rs tests/agent_runtime_test.rs
|
||||
git commit -m "fix: align runtime zhihu submit with ws contract"
|
||||
```
|
||||
|
||||
### Task 4: Reassess stale callback-host regression coverage only if it blocks the approved slice
|
||||
|
||||
**Files:**
|
||||
- Maybe modify: `src/browser/callback_host.rs:793-810`
|
||||
- Test: `src/browser/callback_host.rs`
|
||||
|
||||
- [ ] **Step 1: Check whether the callback-host red test still blocks the approved Option A slice**
|
||||
|
||||
Inspect whether this test still preserves rejected Option-B behavior and whether it fails or becomes misleading after Tasks 1-3:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn live_callback_host_starts_without_bootstrapping_external_helper_page() {
|
||||
// inspect before editing
|
||||
}
|
||||
```
|
||||
|
||||
If the test is unrelated to the approved Zhihu mainline or remains harmless, leave it unchanged in this slice.
|
||||
|
||||
- [ ] **Step 2: Remove or rewrite only if required by the changed submit-path behavior**
|
||||
|
||||
If the test blocks the approved slice, make the smallest change needed:
|
||||
|
||||
- delete it if it exists only to preserve rejected Option B behavior, or
|
||||
- rewrite it so it no longer asserts callback-host startup as the accepted Zhihu mainline
|
||||
|
||||
- [ ] **Step 3: Run focused callback-host tests only if Step 2 changed code**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_host --lib -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit only if Step 2 changed code**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_host.rs
|
||||
git commit -m "test: clean up stale callback host regression"
|
||||
```
|
||||
|
||||
### Task 5: Run the focused verification sweep
|
||||
|
||||
**Files:**
|
||||
- Verify: `tests/agent_runtime_test.rs`
|
||||
- Verify: `tests/compat_runtime_test.rs`
|
||||
- Verify: any directly affected service/browser websocket tests
|
||||
|
||||
- [ ] **Step 1: Run submit-path regression coverage**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Run websocket backend request-url coverage**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" ws_backend_reuses_last_navigated_url_for_followup_requests --test browser_ws_backend_test -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run Zhihu compat runtime coverage**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" zhihu --test compat_runtime_test -- --nocapture`
|
||||
|
||||
Expected: PASS for the changed submit-path surface or clear, directly related failures only.
|
||||
|
||||
- [ ] **Step 4: Run affected service submit regression coverage**
|
||||
|
||||
Run the exact service-specific regression from Task 2 if you added one.
|
||||
|
||||
Otherwise, run the narrowest existing service submit test that covers `ClientMessage::SubmitTask` handling for browser routes.
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the verified slice**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs src/agent/mod.rs tests/agent_runtime_test.rs src/browser/callback_host.rs
|
||||
git commit -m "fix: realign zhihu submit with browser ws callbacks"
|
||||
```
|
||||
|
||||
### Task 6: Run stronger real-browser validation
|
||||
|
||||
**Files:**
|
||||
- Verify live behavior through existing binaries and real config only
|
||||
|
||||
- [ ] **Step 1: Start the service with the real config**
|
||||
|
||||
Run: `cargo run --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"`
|
||||
|
||||
Expected: service starts without failing at callback-host readiness timeout.
|
||||
|
||||
- [ ] **Step 2: Run the client against the started service**
|
||||
|
||||
Run: `cargo run --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sg_claw_client`
|
||||
|
||||
Expected: for `打开知乎热榜,获取前10条数据,并导出 Excel`, the browser proceeds into real Zhihu page work instead of stalling before page open.
|
||||
|
||||
- [ ] **Step 3: Capture the narrow acceptance evidence**
|
||||
|
||||
Verify all of the following from logs/frames/observed behavior:
|
||||
|
||||
```text
|
||||
- no callback-host readiness timeout
|
||||
- no helper-page bootstrap frame
|
||||
- at least one real-page follow-up browser action after navigate
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit only if live verification required code changes**
|
||||
|
||||
```bash
|
||||
git add <only files changed during live-fix follow-up>
|
||||
git commit -m "fix: tighten zhihu ws submit live validation follow-up"
|
||||
```
|
||||
|
||||
If no further code changes were needed, do not create an extra commit.
|
||||
@@ -0,0 +1,406 @@
|
||||
# Service Chat Web Console Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a standalone local HTML console that connects to the existing service websocket, submits natural-language tasks with the current `submit_task` payload, and leaves the browser-helper/runtime path untouched.
|
||||
|
||||
**Architecture:** The change stays fully at the presentation edge. A new self-contained HTML file under `frontend/service-console/` reuses the current websocket protocol from `src/service/protocol.rs`, while one narrow Rust integration test guards the page's protocol shape and forbids any reference to `browser-helper.html`, callback-host endpoints, or the browser websocket. No Rust runtime logic changes are part of this slice.
|
||||
|
||||
**Tech Stack:** HTML, CSS, vanilla JavaScript, Rust integration tests, std::fs, Cargo test
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create: `frontend/service-console/sg_claw_service_console.html`
|
||||
- Standalone local page with inline CSS and JavaScript
|
||||
- Connects to the existing service websocket at `ws://127.0.0.1:42321` by default
|
||||
- Sends existing `ClientMessage::SubmitTask` JSON
|
||||
- Renders inbound `ServiceMessage` rows only
|
||||
- Create: `tests/service_console_html_test.rs`
|
||||
- Source guard for the standalone page
|
||||
- Verifies file location, allowed protocol usage, and forbidden helper/callback references
|
||||
- Reference: `src/service/protocol.rs`
|
||||
- Existing websocket message shape to mirror exactly
|
||||
- Reference: `src/bin/sg_claw_client.rs`
|
||||
- Existing terminal client behavior to mirror for `submit_task`
|
||||
- Reference: `docs/superpowers/specs/2026-04-06-service-chat-web-console-design.md`
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do not modify `src/service/server.rs`.
|
||||
- Do not modify `src/browser/callback_host.rs`.
|
||||
- Do not modify `src/browser/callback_backend.rs`.
|
||||
- Do not modify `src/bin/sg_claw_client.rs`.
|
||||
- Do not add an HTTP server.
|
||||
- Do not connect the new page to `ws://127.0.0.1:12345`.
|
||||
- Do not reference `/sgclaw/browser-helper.html` or `/sgclaw/callback/*` anywhere in the new page.
|
||||
|
||||
### Task 1: Add a failing source-guard test for the standalone page
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/service_console_html_test.rs`
|
||||
- Reference: `docs/superpowers/specs/2026-04-06-service-chat-web-console-design.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create a focused integration test that resolves the HTML path from `CARGO_MANIFEST_DIR` and asserts the file contract.
|
||||
|
||||
```rust
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn service_console_html_stays_on_service_ws_boundary() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let html_path = manifest_dir
|
||||
.join("frontend")
|
||||
.join("service-console")
|
||||
.join("sg_claw_service_console.html");
|
||||
let source = fs::read_to_string(&html_path)
|
||||
.expect("service console html should exist");
|
||||
|
||||
assert!(source.contains("ws://127.0.0.1:42321"));
|
||||
assert!(source.contains("submit_task"));
|
||||
assert!(!source.contains("/sgclaw/browser-helper.html"));
|
||||
assert!(!source.contains("/sgclaw/callback/ready"));
|
||||
assert!(!source.contains("/sgclaw/callback/events"));
|
||||
assert!(!source.contains("/sgclaw/callback/commands/next"));
|
||||
assert!(!source.contains("/sgclaw/callback/commands/ack"));
|
||||
assert!(!source.contains("ws://127.0.0.1:12345"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_console_html_stays_on_service_ws_boundary --test service_console_html_test -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because the HTML file does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Keep the test narrow**
|
||||
|
||||
Before writing production code, confirm the test guards only the approved boundary:
|
||||
|
||||
```text
|
||||
- file exists at frontend/service-console/sg_claw_service_console.html
|
||||
- service websocket default is present
|
||||
- submit_task payload marker is present
|
||||
- no helper-page path
|
||||
- no callback-host endpoints
|
||||
- no browser websocket URL
|
||||
```
|
||||
|
||||
Do not turn this into an end-to-end browser test.
|
||||
|
||||
- [ ] **Step 4: Commit the red test**
|
||||
|
||||
```bash
|
||||
git add tests/service_console_html_test.rs
|
||||
git commit -m "test: add service console html boundary guard"
|
||||
```
|
||||
|
||||
### Task 2: Implement the standalone HTML console with the approved boundary
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/service-console/sg_claw_service_console.html`
|
||||
- Reference: `src/service/protocol.rs:6`
|
||||
- Reference: `src/bin/sg_claw_client.rs:16`
|
||||
- Test: `tests/service_console_html_test.rs`
|
||||
|
||||
- [ ] **Step 1: Create the HTML file with the minimal structure**
|
||||
|
||||
Write one self-contained page with:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>sgClaw Service Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<input id="wsUrl" value="ws://127.0.0.1:42321" />
|
||||
<button id="connectBtn">连接</button>
|
||||
<div id="connectionState">未连接</div>
|
||||
<div id="messageStream"></div>
|
||||
<textarea id="instructionInput"></textarea>
|
||||
<div id="validationText"></div>
|
||||
<button id="sendBtn" disabled>发送任务</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Keep all CSS and JavaScript inline. Do not add external assets or a build step.
|
||||
|
||||
- [ ] **Step 2: Implement websocket connect/disconnect behavior**
|
||||
|
||||
Add the smallest possible JS behavior, including explicit disconnect on the same button so the UI
|
||||
matches the approved connect/disconnect contract:
|
||||
|
||||
```javascript
|
||||
let socket = null;
|
||||
|
||||
function appendRow(kind, text) {
|
||||
// append a visible row to #messageStream
|
||||
}
|
||||
|
||||
function updateUiState() {
|
||||
const connected = socket && socket.readyState === WebSocket.OPEN;
|
||||
document.getElementById('connectBtn').textContent = connected ? '断开' : '连接';
|
||||
document.getElementById('sendBtn').disabled = !connected;
|
||||
document.getElementById('connectionState').textContent = connected ? '已连接' : '未连接';
|
||||
}
|
||||
|
||||
function connectOrDisconnectService() {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = document.getElementById('wsUrl').value.trim() || 'ws://127.0.0.1:42321';
|
||||
socket = new WebSocket(url);
|
||||
updateUiState();
|
||||
socket.addEventListener('open', () => {
|
||||
appendRow('status', 'service websocket connected');
|
||||
updateUiState();
|
||||
});
|
||||
socket.addEventListener('close', () => {
|
||||
appendRow('status', 'service websocket disconnected');
|
||||
updateUiState();
|
||||
});
|
||||
socket.addEventListener('error', () => appendRow('error', 'service websocket error'));
|
||||
socket.addEventListener('message', handleMessage);
|
||||
}
|
||||
```
|
||||
|
||||
Do not add retry loops or background reconnect logic.
|
||||
|
||||
- [ ] **Step 3: Implement submit_task sending with the current message shape**
|
||||
|
||||
Mirror the terminal client payload shape exactly and show inline validation for empty input:
|
||||
|
||||
```javascript
|
||||
function setValidation(message) {
|
||||
document.getElementById('validationText').textContent = message;
|
||||
}
|
||||
|
||||
function sendTask() {
|
||||
const instruction = document.getElementById('instructionInput').value.trim();
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (!instruction) {
|
||||
setValidation('请输入任务内容。');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidation('');
|
||||
socket.send(JSON.stringify({
|
||||
type: 'submit_task',
|
||||
instruction,
|
||||
conversation_id: '',
|
||||
messages: [],
|
||||
page_url: '',
|
||||
page_title: ''
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Do not add new fields. Do not add conversation replay logic in this slice.
|
||||
|
||||
- [ ] **Step 4: Render existing inbound service messages only**
|
||||
|
||||
Handle the current `ServiceMessage` variants with a minimal dispatcher:
|
||||
|
||||
```javascript
|
||||
function handleMessage(event) {
|
||||
const message = JSON.parse(event.data);
|
||||
switch (message.type) {
|
||||
case 'status_changed':
|
||||
appendRow('status', message.state);
|
||||
break;
|
||||
case 'log_entry':
|
||||
appendRow('log', message.message);
|
||||
break;
|
||||
case 'task_complete':
|
||||
appendRow(message.success ? 'complete' : 'error', message.summary);
|
||||
break;
|
||||
case 'busy':
|
||||
appendRow('error', message.message);
|
||||
break;
|
||||
default:
|
||||
appendRow('error', 'unknown service message: ' + event.data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep the composer enabled during in-flight work so repeated submits surface the existing `busy` response instead of inventing a frontend queue.
|
||||
|
||||
- [ ] **Step 5: Keep the helper boundary explicit in the source**
|
||||
|
||||
Before running tests, inspect the HTML source and confirm:
|
||||
|
||||
```text
|
||||
- no /sgclaw/browser-helper.html
|
||||
- no /sgclaw/callback/*
|
||||
- no ws://127.0.0.1:12345
|
||||
- no browser websocket register frame logic
|
||||
```
|
||||
|
||||
If any such string appears, remove it before testing.
|
||||
|
||||
- [ ] **Step 6: Run the source-guard test to verify green**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_console_html_stays_on_service_ws_boundary --test service_console_html_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the standalone page**
|
||||
|
||||
```bash
|
||||
git add frontend/service-console/sg_claw_service_console.html tests/service_console_html_test.rs
|
||||
git commit -m "feat: add standalone service chat console"
|
||||
```
|
||||
|
||||
### Task 3: Run the focused verification sweep
|
||||
|
||||
**Files:**
|
||||
- Verify: `tests/service_console_html_test.rs`
|
||||
- Reference: `src/service/protocol.rs`
|
||||
- Reference: `src/bin/sg_claw_client.rs`
|
||||
|
||||
- [ ] **Step 1: Re-run the source-guard test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_console_html_stays_on_service_ws_boundary --test service_console_html_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Manually inspect disconnected-send and validation markers in the HTML source**
|
||||
|
||||
Before broader verification, confirm the page source clearly contains all three UI-local rules:
|
||||
|
||||
```text
|
||||
- connect button can disconnect an open websocket
|
||||
- send button starts disabled while disconnected
|
||||
- empty instruction shows inline validation text
|
||||
```
|
||||
|
||||
This inspection stays source-level; do not add extra backend tests for it in this slice.
|
||||
|
||||
- [ ] **Step 3: Run an existing service protocol regression for safety**
|
||||
|
||||
Run the narrow existing protocol coverage to prove the page did not require backend changes:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" submit_task_client_message_converts_into_shared_runner_request --test service_ws_session_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Run an existing terminal-client regression for safety**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" client_submits_first_user_line_to_service --test service_task_flow_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit only if verification required any code change**
|
||||
|
||||
```bash
|
||||
git add frontend/service-console/sg_claw_service_console.html tests/service_console_html_test.rs
|
||||
git commit -m "test: tighten service console verification"
|
||||
```
|
||||
|
||||
If verification required no code changes, do not create an extra commit.
|
||||
|
||||
### Task 4: Perform the manual smoke check
|
||||
|
||||
**Files:**
|
||||
- Verify live behavior only; no new code required
|
||||
|
||||
- [ ] **Step 1: Start the existing service binary**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected: service starts and prints its ready line with the service websocket listen address.
|
||||
|
||||
- [ ] **Step 2: Open the standalone page directly**
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
D:/data/ideaSpace/rust/sgClaw/claw-new/frontend/service-console/sg_claw_service_console.html
|
||||
```
|
||||
|
||||
Expected: the page loads through the browser as a local file and shows the default websocket URL `ws://127.0.0.1:42321`.
|
||||
|
||||
- [ ] **Step 3: Connect, disconnect, and reconnect once**
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
- message stream shows websocket connected
|
||||
- clicking the same button disconnects the websocket cleanly
|
||||
- message stream shows websocket disconnected
|
||||
- send button is disabled again while disconnected
|
||||
- reconnect succeeds without reloading the page
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Submit one natural-language task**
|
||||
|
||||
Use a small harmless instruction such as:
|
||||
|
||||
```text
|
||||
打开百度
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
- empty textarea send attempt first shows inline validation without sending a websocket frame
|
||||
- page sends one submit_task payload after valid input
|
||||
- page receives and renders status/log/task_complete or busy rows
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Confirm the helper boundary stayed untouched**
|
||||
|
||||
Verify from the page source and observed behavior:
|
||||
|
||||
```text
|
||||
- the page never loads /sgclaw/browser-helper.html
|
||||
- the page never calls /sgclaw/callback/*
|
||||
- the page never connects to ws://127.0.0.1:12345
|
||||
```
|
||||
|
||||
If the task itself triggers browser automation, that remains owned by the existing Rust runtime rather than by the page.
|
||||
|
||||
- [ ] **Step 6: Commit only if the manual pass required code changes**
|
||||
|
||||
```bash
|
||||
git add frontend/service-console/sg_claw_service_console.html tests/service_console_html_test.rs
|
||||
git commit -m "fix: tighten standalone service console smoke flow"
|
||||
```
|
||||
|
||||
If the manual pass required no code changes, do not create an extra commit.
|
||||
@@ -0,0 +1,637 @@
|
||||
# Zhihu Hotlist Post-Export Auto-Open Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extend the existing Zhihu hotlist Excel and dashboard routes so each route can auto-open its own generated artifact after export, while preserving the current callback-host-backed browser boundary and route exclusivity.
|
||||
|
||||
**Architecture:** Keep orchestration in `src/compat/workflow_executor.rs`, but move post-export side effects into a new `src/compat/artifact_open.rs` helper so workflow routing stays readable. Excel auto-open is a local OS-launch side effect; dashboard auto-open reuses `screen_html_export`'s existing `presentation.url` and sends one narrow, marker-based `Action::Navigate` request through `BrowserCallbackBackend`, with a matching special-case validator in `MacPolicy` so arbitrary `file://` navigation remains blocked.
|
||||
|
||||
**Tech Stack:** Rust, serde_json, std::process::Command, std::path, Cargo tests
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create: `src/compat/artifact_open.rs`
|
||||
- Define the narrow post-export helper surface for this slice only
|
||||
- Parse and validate generated artifact payload fields passed in by the workflow layer
|
||||
- Open generated `.xlsx` files with the local default app
|
||||
- Build the exact approved local-dashboard navigate payload
|
||||
- Keep one testable internal seam, `open_exported_xlsx_with(output_path, opener)`, so unit tests can prove the generated `.xlsx` path is handed to the launcher without starting a real spreadsheet app
|
||||
- Include unit tests in the same file for exact Excel path handoff and launcher-failure reporting
|
||||
- Modify: `src/compat/mod.rs`
|
||||
- Export the new `artifact_open` module
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- Keep route detection and artifact generation where they are now
|
||||
- Change `export_xlsx(...)` and `export_screen(...)` so they parse tool payloads, call the route-specific opener, and produce the new success/failure summaries
|
||||
- Modify: `src/browser/callback_backend.rs`
|
||||
- Recognize only the approved local-dashboard navigate request shape at `Action::Navigate`
|
||||
- Keep normal remote navigate behavior unchanged
|
||||
- Continue emitting `sgBrowerserOpenPage` for the approved local-dashboard case so the helper page stays alive and the dashboard opens in a new visible tab
|
||||
- Add focused callback-backend unit tests in the existing test module for approved and malformed local-dashboard requests
|
||||
- Modify: `src/security/mac_policy.rs`
|
||||
- Add a narrow validator for the approved local-dashboard presentation case
|
||||
- Keep `validate(...)` unchanged for ordinary remote-domain flow
|
||||
- Reject malformed marker payloads, non-HTML local paths, and mismatched `file://` / output-path combinations
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
- Keep the concrete hotlist workflow regressions in this existing integration test file
|
||||
- Extend existing Zhihu hotlist export/screen regressions to assert the new summaries and the dashboard marker payload
|
||||
- Keep the Excel route workflow assertion limited to summary plus “no dashboard navigate marker,” because exact launcher handoff is covered in `src/compat/artifact_open.rs` unit tests
|
||||
- Modify: `tests/browser_tool_test.rs`
|
||||
- Add `MacPolicy` coverage for approved local-dashboard presentation, rejected malformed presentation, and unchanged normal-domain validation in one exact file
|
||||
- Extend the existing `default_rules_allow_zhihu_navigation` area with the new local-dashboard validation tests rather than creating a second policy test location
|
||||
- Reference only if summary wording ripples outward: `tests/agent_runtime_test.rs:173-258`
|
||||
- Existing direct-runtime user-visible summary assertion for Zhihu Excel export
|
||||
- Reference only if summary wording ripples outward: `tests/service_task_flow_test.rs:704-839`
|
||||
- Existing CLI-to-service user-visible summary assertion for Zhihu Excel export
|
||||
- Reference only if summary wording ripples outward: `tests/service_ws_session_test.rs:755-869`
|
||||
- Existing service-binary user-visible summary assertion for Zhihu Excel export
|
||||
- Reference: `tests/compat_screen_html_export_tool_test.rs`
|
||||
- Reuse the exact test seam `screen_html_export_tool_renders_dashboard_html_with_presentation_contract`
|
||||
- Existing proof that `screen_html_export` already returns `presentation.url`
|
||||
- Reference: `docs/superpowers/specs/2026-04-06-zhihu-hotlist-post-export-auto-open-design.md`
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do not modify `frontend/service-console/sg_claw_service_console.html`.
|
||||
- Do not modify `src/service/protocol.rs`.
|
||||
- Do not modify `browser-helper.html`.
|
||||
- Do not modify `/sgclaw/callback/*` endpoint contracts.
|
||||
- Do not modify websocket protocol framing or `src/browser/ws_protocol.rs`.
|
||||
- Do not turn Excel-open and dashboard-open into a combined mode.
|
||||
- Do not add a general-purpose local file browser or generic `file://` allowlist.
|
||||
- Do not move post-export decisions into the frontend service console.
|
||||
- Do not require websocket-backend parity in this slice.
|
||||
|
||||
### Task 1: Add failing workflow tests for route-specific post-export actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/compat_runtime_test.rs:2154-2304`
|
||||
- Reference: `src/compat/workflow_executor.rs:375-446`
|
||||
- Reference: `docs/superpowers/specs/2026-04-06-zhihu-hotlist-post-export-auto-open-design.md`
|
||||
|
||||
- [ ] **Step 1: Rewrite the Excel hotlist assertion as a red test for the new summary only**
|
||||
|
||||
Keep the current flow setup, but tighten the expectation so it proves the workflow route now reports post-export open success while staying exclusive from the dashboard path.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open() {
|
||||
// existing setup
|
||||
assert!(summary.contains("已导出并打开知乎热榜 Excel"));
|
||||
assert!(generated.exists());
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, params, .. }
|
||||
if action == &Action::Navigate
|
||||
&& params.get("sgclaw_local_dashboard_open").is_some()
|
||||
)
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Do not try to prove real OS launching in this workflow test. The exact `.xlsx` path handoff to the launcher belongs in `src/compat/artifact_open.rs` unit tests from Task 2.
|
||||
|
||||
- [ ] **Step 2: Rewrite the dashboard hotlist assertion as a red test for browser auto-open**
|
||||
|
||||
Tighten the existing dashboard test so it proves the workflow consumes `presentation.url` and emits the approved compat marker payload.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open() {
|
||||
// existing setup
|
||||
assert!(summary.contains("已在浏览器中打开知乎热榜大屏"));
|
||||
let navigate = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::Command { action, params, security, .. }
|
||||
if action == &Action::Navigate
|
||||
&& security.expected_domain == "__sgclaw_local_dashboard__" => Some((params, security)),
|
||||
_ => None,
|
||||
}).expect("dashboard route should emit local-dashboard navigate request");
|
||||
|
||||
assert!(navigate.0["url"].as_str().unwrap().starts_with("file://"));
|
||||
assert_eq!(navigate.0["sgclaw_local_dashboard_open"]["source"], json!("compat.workflow_executor"));
|
||||
assert_eq!(navigate.0["sgclaw_local_dashboard_open"]["kind"], json!("zhihu_hotlist_screen"));
|
||||
assert_eq!(navigate.0["sgclaw_local_dashboard_open"]["presentation_url"], navigate.0["url"]);
|
||||
}
|
||||
```
|
||||
|
||||
Also assert that this route still logs `call screen_html_export` and does not invoke the Excel opener path.
|
||||
|
||||
- [ ] **Step 3: Add a missing-`presentation.url` regression in the workflow test module if none exists**
|
||||
|
||||
Put this close to the existing hotlist tests and keep it narrow:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing() {
|
||||
// mock screen_html_export success payload with output_path but no presentation.url
|
||||
// assert summary contains 已生成知乎热榜大屏 <path>,但浏览器自动打开失败:
|
||||
}
|
||||
```
|
||||
|
||||
Use the existing summary/path helpers in the file instead of inventing new parsing helpers.
|
||||
|
||||
- [ ] **Step 4: Run the focused compat runtime tests to verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing --test compat_runtime_test -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because the workflow still returns artifact-only summaries and has no post-export open handling.
|
||||
|
||||
- [ ] **Step 5: Commit the red workflow tests**
|
||||
|
||||
```bash
|
||||
git add tests/compat_runtime_test.rs
|
||||
git commit -m "test: add hotlist post-export auto-open regressions"
|
||||
```
|
||||
|
||||
### Task 2: Implement the compat post-export opener and update workflow summaries
|
||||
|
||||
**Files:**
|
||||
- Create: `src/compat/artifact_open.rs`
|
||||
- Modify: `src/compat/mod.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs:375-446`
|
||||
- Test: `src/compat/artifact_open.rs`
|
||||
- Test: `tests/compat_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add the red unit tests in `src/compat/artifact_open.rs` before writing production code**
|
||||
|
||||
Create the new module with a `#[cfg(test)]` block first so the Excel opener has an exact, non-UI verification seam.
|
||||
|
||||
Target tests:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_passes_generated_path_to_launcher() {
|
||||
let mut seen = None;
|
||||
let result = open_exported_xlsx_with(Path::new("C:/tmp/zhihu-hotlist.xlsx"), |path| {
|
||||
seen = Some(path.to_path_buf());
|
||||
Ok(())
|
||||
});
|
||||
assert!(matches!(result, PostExportOpen::Opened));
|
||||
assert_eq!(seen.unwrap(), PathBuf::from("C:/tmp/zhihu-hotlist.xlsx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_reports_launcher_failure() {
|
||||
let result = open_exported_xlsx_with(Path::new("C:/tmp/zhihu-hotlist.xlsx"), |_path| {
|
||||
Err("launcher failed".to_string())
|
||||
});
|
||||
assert!(matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed")));
|
||||
}
|
||||
```
|
||||
|
||||
Add one matching dashboard payload test in the same file:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn open_local_dashboard_uses_exact_approved_marker_payload() {
|
||||
// FakeBrowserBackend records invoke(action, params, expected_domain)
|
||||
// assert expected_domain == "__sgclaw_local_dashboard__"
|
||||
// assert params.url == params.sgclaw_local_dashboard_open.presentation_url
|
||||
// assert source/kind/output_path all match the approved contract
|
||||
}
|
||||
```
|
||||
|
||||
This step is mandatory so the Excel route is proven to hand the generated path to the opener without launching a real application.
|
||||
|
||||
- [ ] **Step 2: Run the new unit tests to verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_passes_generated_path_to_launcher --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_reports_launcher_failure --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_local_dashboard_uses_exact_approved_marker_payload --lib -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/compat/artifact_open.rs` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Create the small compat opener module**
|
||||
|
||||
Add one focused helper module rather than embedding side effects directly into `workflow_executor.rs`.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
pub const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
pub const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
|
||||
pub const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
|
||||
|
||||
pub enum PostExportOpen {
|
||||
Opened,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub fn open_exported_xlsx(output_path: &Path) -> PostExportOpen {
|
||||
open_exported_xlsx_with(output_path, launch_with_default_xlsx_app)
|
||||
}
|
||||
|
||||
fn open_exported_xlsx_with<F>(output_path: &Path, opener: F) -> PostExportOpen
|
||||
where
|
||||
F: FnOnce(&Path) -> Result<(), String>,
|
||||
{ /* test seam */ }
|
||||
|
||||
pub fn open_local_dashboard(
|
||||
browser_backend: &dyn BrowserBackend,
|
||||
output_path: &Path,
|
||||
presentation_url: &str,
|
||||
) -> PostExportOpen { /* invoke Action::Navigate with exact marker payload */ }
|
||||
```
|
||||
|
||||
Keep the module tiny. The only dedicated test seam in this file should be `open_exported_xlsx_with(...)`; do not introduce a general launcher trait.
|
||||
|
||||
- [ ] **Step 4: Implement the Windows-first `.xlsx` opener minimally**
|
||||
|
||||
Use a focused local launcher that targets the current environment first.
|
||||
|
||||
Preferred target shape:
|
||||
|
||||
```rust
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", output_path_as_windows_string])
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
```text
|
||||
- fail if the path does not exist
|
||||
- do not swallow command-spawn errors
|
||||
- do not open arbitrary user-selected files from outside this workflow
|
||||
- keep cross-platform behavior minimal; only add a fallback branch if required to keep tests/build portable
|
||||
```
|
||||
|
||||
If you need a non-Windows fallback for compilation, keep it obviously minimal and out of the hot path.
|
||||
|
||||
- [ ] **Step 5: Parse payloads in `workflow_executor.rs` and call the new helper**
|
||||
|
||||
Refactor `export_xlsx(...)` and `export_screen(...)` just enough to separate:
|
||||
|
||||
```text
|
||||
- tool execution
|
||||
- payload parsing
|
||||
- route-specific post-export open
|
||||
- summary formatting
|
||||
```
|
||||
|
||||
Minimal target behavior:
|
||||
|
||||
```rust
|
||||
match open_exported_xlsx(&output_path) {
|
||||
PostExportOpen::Opened => format!("已导出并打开知乎热榜 Excel {output_path}"),
|
||||
PostExportOpen::Failed(reason) => format!("已导出知乎热榜 Excel {output_path},但自动打开失败:{reason}"),
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
match open_local_dashboard(browser_backend, &output_path, &presentation_url) {
|
||||
PostExportOpen::Opened => format!("已在浏览器中打开知乎热榜大屏 {output_path}"),
|
||||
PostExportOpen::Failed(reason) => format!("已生成知乎热榜大屏 {output_path},但浏览器自动打开失败:{reason}"),
|
||||
}
|
||||
```
|
||||
|
||||
Change signatures only as much as needed to pass `browser_backend` into the dashboard route. Do not broaden unrelated call chains.
|
||||
|
||||
- [ ] **Step 6: Export the helper module**
|
||||
|
||||
Update `src/compat/mod.rs`:
|
||||
|
||||
```rust
|
||||
pub mod artifact_open;
|
||||
```
|
||||
|
||||
Do not reorder unrelated module exports unless rustfmt does it.
|
||||
|
||||
- [ ] **Step 7: Run the focused library and workflow regressions to verify green**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_passes_generated_path_to_launcher --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_reports_launcher_failure --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_local_dashboard_uses_exact_approved_marker_payload --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing --test compat_runtime_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS for the new library tests and the workflow regressions, unless the dashboard-open path still fails at backend/policy validation.
|
||||
|
||||
- [ ] **Step 8: Commit the compat opener and workflow changes**
|
||||
|
||||
```bash
|
||||
git add src/compat/artifact_open.rs src/compat/mod.rs src/compat/workflow_executor.rs tests/compat_runtime_test.rs
|
||||
git commit -m "feat: auto-open zhihu hotlist export artifacts"
|
||||
```
|
||||
|
||||
### Task 3: Add failing backend and security tests for the narrow local-dashboard allowance
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_backend.rs:536-840`
|
||||
- Modify: `tests/browser_tool_test.rs` (`default_rules_allow_zhihu_navigation` section plus new local-dashboard validation tests)
|
||||
- Reference: `src/security/mac_policy.rs:56-132`
|
||||
|
||||
- [ ] **Step 1: Add a red callback-backend acceptance test for the approved local-dashboard request shape**
|
||||
|
||||
Extend the existing `src/browser/callback_backend.rs` test module with one focused navigate test.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn callback_backend_accepts_approved_local_dashboard_navigate_request() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(json!({ "navigated": true }))]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend.invoke(
|
||||
Action::Navigate,
|
||||
json!({
|
||||
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": "compat.workflow_executor",
|
||||
"kind": "zhihu_hotlist_screen",
|
||||
"output_path": "C:/tmp/zhihu-hotlist-screen.html",
|
||||
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
}
|
||||
}),
|
||||
"__sgclaw_local_dashboard__",
|
||||
);
|
||||
|
||||
assert!(output.unwrap().success);
|
||||
assert_eq!(host.requests()[0].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBrowerserOpenPage",
|
||||
"file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
]));
|
||||
}
|
||||
```
|
||||
|
||||
Do not weaken any existing normal-domain tests.
|
||||
|
||||
- [ ] **Step 2: Add red rejection tests in exact files**
|
||||
|
||||
Put malformed-request rejection in `src/browser/callback_backend.rs` next to the acceptance test:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields() {}
|
||||
```
|
||||
|
||||
Put policy-only validation in `tests/browser_tool_test.rs` so all public `MacPolicy` assertions stay in one place:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn mac_policy_rejects_non_html_local_dashboard_presentation() {}
|
||||
|
||||
#[test]
|
||||
fn default_rules_allow_zhihu_navigation() {
|
||||
let policy = MacPolicy::load_from_path(...).unwrap();
|
||||
policy.validate(&Action::Navigate, "www.zhihu.com").unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
Do not create a second `MacPolicy` regression location.
|
||||
|
||||
- [ ] **Step 3: Run the focused backend/policy tests to verify red**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_accepts_approved_local_dashboard_navigate_request --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" mac_policy_rejects_non_html_local_dashboard_presentation --test browser_tool_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" default_rules_allow_zhihu_navigation --test browser_tool_test -- --exact
|
||||
```
|
||||
|
||||
Expected: the new local-dashboard tests FAIL; `default_rules_allow_zhihu_navigation` should still PASS.
|
||||
|
||||
- [ ] **Step 4: Commit the red backend/security tests**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_backend.rs tests/browser_tool_test.rs
|
||||
git commit -m "test: lock local dashboard navigate boundary"
|
||||
```
|
||||
|
||||
### Task 4: Implement the narrow callback-backend and MacPolicy allowance
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_backend.rs:300-351`
|
||||
- Modify: `src/security/mac_policy.rs:56-132`
|
||||
- Maybe modify: `src/security/mod.rs:9-27`
|
||||
- Test: `src/browser/callback_backend.rs:536-840`
|
||||
- Test: `tests/browser_tool_test.rs` (`default_rules_allow_zhihu_navigation` section plus new local-dashboard validation tests)
|
||||
|
||||
- [ ] **Step 1: Add a narrow local-dashboard validation helper in `MacPolicy`**
|
||||
|
||||
Keep `validate(...)` unchanged for ordinary domain flow. Add one small explicit helper instead.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
pub fn validate_local_dashboard_presentation(
|
||||
&self,
|
||||
action: &Action,
|
||||
expected_domain: &str,
|
||||
presentation_url: &str,
|
||||
output_path: &str,
|
||||
) -> Result<(), SecurityError> {
|
||||
// require Action::Navigate
|
||||
// require expected_domain == "__sgclaw_local_dashboard__"
|
||||
// require file:// URL
|
||||
// require .html path
|
||||
// require normalized file URL path matches output_path
|
||||
}
|
||||
```
|
||||
|
||||
If you need a new `SecurityError` variant for malformed local-dashboard input, add the smallest one that keeps error text clear.
|
||||
|
||||
- [ ] **Step 2: Recognize only the exact approved request shape in `BrowserCallbackBackend::invoke(...)`**
|
||||
|
||||
Before the normal `self.mac_policy.validate(&action, expected_domain)?` path runs, detect the one approved special case.
|
||||
|
||||
Minimal target behavior:
|
||||
|
||||
```rust
|
||||
if let Some(local_dashboard) = approved_local_dashboard_request(&action, ¶ms, expected_domain) {
|
||||
self.mac_policy.validate_local_dashboard_presentation(
|
||||
&action,
|
||||
expected_domain,
|
||||
&local_dashboard.presentation_url,
|
||||
&local_dashboard.output_path,
|
||||
)?;
|
||||
} else {
|
||||
self.mac_policy.validate(&action, expected_domain)?;
|
||||
}
|
||||
```
|
||||
|
||||
The helper should require all of these fields exactly:
|
||||
|
||||
```text
|
||||
- action == Action::Navigate
|
||||
- expected_domain == "__sgclaw_local_dashboard__"
|
||||
- params.url exists
|
||||
- params.sgclaw_local_dashboard_open.source == "compat.workflow_executor"
|
||||
- params.sgclaw_local_dashboard_open.kind == "zhihu_hotlist_screen"
|
||||
- params.sgclaw_local_dashboard_open.output_path exists
|
||||
- params.sgclaw_local_dashboard_open.presentation_url exists and equals params.url
|
||||
```
|
||||
|
||||
Anything else must continue down the normal rejection path.
|
||||
|
||||
- [ ] **Step 3: Keep `build_command(Action::Navigate, ...)` simple**
|
||||
|
||||
Do not add a second browser opcode or change the callback-host runtime contract. The approved local-dashboard case should still flow into the existing navigate command builder so the emitted command stays:
|
||||
|
||||
```rust
|
||||
json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowerserOpenPage",
|
||||
target_url,
|
||||
])
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the focused backend/security tests to verify green**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_accepts_approved_local_dashboard_navigate_request --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" mac_policy_rejects_non_html_local_dashboard_presentation --test browser_tool_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" default_rules_allow_zhihu_navigation --test browser_tool_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Re-run the dashboard workflow regression after backend validation lands**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the backend/security implementation**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_backend.rs src/security/mac_policy.rs src/security/mod.rs tests/browser_tool_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "fix: allow approved local dashboard auto-open"
|
||||
```
|
||||
|
||||
If `src/security/mod.rs` did not change, omit it from the commit.
|
||||
|
||||
### Task 5: Run the focused verification sweep
|
||||
|
||||
**Files:**
|
||||
- Verify: `src/compat/artifact_open.rs`
|
||||
- Verify: `tests/compat_runtime_test.rs`
|
||||
- Verify: `tests/compat_screen_html_export_tool_test.rs`
|
||||
- Verify: `tests/browser_tool_test.rs`
|
||||
- Verify: `src/browser/callback_backend.rs` test module
|
||||
- Reference only if summary wording ripples outward: `tests/agent_runtime_test.rs:173-258`
|
||||
- Reference only if summary wording ripples outward: `tests/service_task_flow_test.rs:704-839`
|
||||
- Reference only if summary wording ripples outward: `tests/service_ws_session_test.rs:755-869`
|
||||
|
||||
- [ ] **Step 1: Re-run the library and workflow regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_passes_generated_path_to_launcher --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_reports_launcher_failure --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_local_dashboard_uses_exact_approved_marker_payload --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing --test compat_runtime_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Re-run the tool contract regression that the dashboard route depends on**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" screen_html_export_tool_renders_dashboard_html_with_presentation_contract --test compat_screen_html_export_tool_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Re-run the callback-backend and policy boundary tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_accepts_approved_local_dashboard_navigate_request --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" mac_policy_rejects_non_html_local_dashboard_presentation --test browser_tool_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" default_rules_allow_zhihu_navigation --test browser_tool_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Re-run outward-facing summary regressions only if needed**
|
||||
|
||||
Only if the updated summary text breaks existing assertions, run exactly these existing regressions and adjust only the affected expectation text:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" client_to_service_regression_routes_zhihu_without_helper_bootstrap_or_invalid_hmac_seed_output --test service_task_flow_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_binary_submit_flow_routes_zhihu_without_helper_bootstrap --test service_ws_session_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS for any test you had to touch. Skip this step entirely if those files needed no edits.
|
||||
|
||||
- [ ] **Step 5: Inspect scope before finishing with exact git commands**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --name-only -- src/compat/artifact_open.rs src/compat/mod.rs src/compat/workflow_executor.rs src/browser/callback_backend.rs src/security/mac_policy.rs src/security/mod.rs tests/compat_runtime_test.rs tests/browser_tool_test.rs tests/agent_runtime_test.rs tests/service_task_flow_test.rs tests/service_ws_session_test.rs
|
||||
git diff --stat -- src/compat/artifact_open.rs src/compat/mod.rs src/compat/workflow_executor.rs src/browser/callback_backend.rs src/security/mac_policy.rs src/security/mod.rs tests/compat_runtime_test.rs tests/browser_tool_test.rs tests/agent_runtime_test.rs tests/service_task_flow_test.rs tests/service_ws_session_test.rs
|
||||
```
|
||||
|
||||
Confirm the diff only touches:
|
||||
|
||||
```text
|
||||
- compat workflow/orchestration
|
||||
- compat post-export helper module
|
||||
- callback backend narrow local-dashboard acceptance
|
||||
- MacPolicy narrow local-dashboard validation
|
||||
- focused related tests
|
||||
```
|
||||
|
||||
Confirm it does **not** touch:
|
||||
|
||||
```text
|
||||
- frontend/service-console/
|
||||
- src/service/protocol.rs
|
||||
- browser-helper.html
|
||||
- callback-host endpoint contracts
|
||||
- websocket transport/protocol files
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit only if verification required additional code changes**
|
||||
|
||||
```bash
|
||||
git add src/compat/artifact_open.rs src/compat/mod.rs src/compat/workflow_executor.rs src/browser/callback_backend.rs src/security/mac_policy.rs tests/compat_runtime_test.rs tests/browser_tool_test.rs tests/agent_runtime_test.rs tests/service_task_flow_test.rs tests/service_ws_session_test.rs
|
||||
git commit -m "test: tighten hotlist post-export auto-open verification"
|
||||
```
|
||||
|
||||
If verification required no further code changes, do not create an extra commit.
|
||||
@@ -0,0 +1,666 @@
|
||||
# WS Branch Scene Cleanup Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Strip `feature/claw-ws` back to websocket plus Zhihu execution only by removing staged scene-skill routing, `skill_staging`-aware loading, and array-style `skillsDir` config behavior from this branch.
|
||||
|
||||
**Architecture:** Treat `feature/claw-ws` as a transport-focused branch, not a business-scene branch. Keep the browser websocket/callback submit path and the existing Zhihu direct workflows, but delete the fault-details / `95598` scene registry, scene-specific prompt injection, staged scene directory expansion, and scene-only docs/tests so the branch stays small and merges cleanly after the real scene implementation lands on `main`.
|
||||
|
||||
**Tech Stack:** Rust 2021, existing sgClaw compat/runtime/orchestration stack, websocket browser backend, callback-host service path, existing `cargo test` suite.
|
||||
|
||||
---
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Execute this plan **only after** `main` already contains the desired clean scene-skill implementation.
|
||||
- Run it on `feature/claw-ws`, not on `main`.
|
||||
- Keep websocket and Zhihu behavior intact; this plan is cleanup, not a redesign.
|
||||
- Keep `docs/_tmp_sgbrowser_ws_api_doc.txt`; it remains the browser integration contract for this branch.
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do **not** change the working Zhihu websocket flow in `tests/agent_runtime_test.rs`.
|
||||
- Do **not** remove `src/browser/ws_backend.rs`, `src/service/server.rs`, or Zhihu routes from `src/compat/workflow_executor.rs`.
|
||||
- Do **not** add a replacement scene abstraction on this branch.
|
||||
- Do **not** keep partial scene plumbing “for future use”; delete it completely if it is scene-only.
|
||||
- Do **not** keep array-style `skillsDir` tests or docs on this branch once the single-path cleanup is complete.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Delete
|
||||
|
||||
- `src/runtime/scene_registry.rs`
|
||||
- staged scene registry, hard-coded `skill_staging` scene root, scene matching helpers
|
||||
- `tests/scene_registry_test.rs`
|
||||
- scene-registry-specific coverage that should disappear with the feature
|
||||
- `docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md`
|
||||
- scene-routing design doc that no longer belongs on the ws-only branch
|
||||
- `docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md`
|
||||
- scene-routing implementation plan that no longer belongs on the ws-only branch
|
||||
|
||||
### Modify
|
||||
|
||||
- `src/runtime/mod.rs`
|
||||
- stop exporting deleted scene registry APIs
|
||||
- `src/runtime/engine.rs`
|
||||
- remove scene-contract prompt injection and staged scene skill loading
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- remove `FaultDetailsReport` route detection/execution while keeping Zhihu routes
|
||||
- `src/compat/orchestration.rs`
|
||||
- keep direct Zhihu orchestration only; remove scene-driven primary routing triggers
|
||||
- `src/config/settings.rs`
|
||||
- collapse `skillsDir` config handling back to single-path semantics
|
||||
- `src/compat/config_adapter.rs`
|
||||
- remove scene-specific skills-dir helpers and keep one resolved skills dir
|
||||
- `src/compat/runtime.rs`
|
||||
- stop carrying scene-expanded skills dirs through compat runtime
|
||||
- `src/agent/task_runner.rs`
|
||||
- update runtime logging and runtime calls to the single skills-dir contract
|
||||
- `tests/compat_runtime_test.rs`
|
||||
- remove fault-details / `95598` assertions and keep Zhihu/direct-route coverage
|
||||
- `tests/runtime_profile_test.rs`
|
||||
- remove `95598` scene-contract expectations and keep normal browser-profile coverage
|
||||
- `tests/compat_config_test.rs`
|
||||
- remove scene-dir / array-config coverage and add single-path cleanup coverage
|
||||
- `tests/agent_runtime_test.rs`
|
||||
- only extend if one extra Zhihu keep-path regression is needed after the config cleanup
|
||||
|
||||
### Keep As-Is Unless A Signature Change Forces A Tiny Edit
|
||||
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `src/browser/callback_backend.rs`
|
||||
- `src/browser/callback_host.rs`
|
||||
- `src/service/server.rs`
|
||||
- `src/agent/mod.rs`
|
||||
- `tests/browser_ws_backend_test.rs`
|
||||
- `tests/service_ws_session_test.rs`
|
||||
- `tests/task_runner_test.rs`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock The Cleanup Contract In Failing Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
- Modify: `tests/runtime_profile_test.rs`
|
||||
- Modify: `tests/compat_config_test.rs`
|
||||
- Reuse: `tests/agent_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add the first failing route-removal test**
|
||||
|
||||
In `tests/compat_runtime_test.rs`, add a focused assertion proving the ws branch no longer recognizes the fault-details scene as a direct route:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn ws_cleanup_no_longer_detects_fault_details_scene_route() {
|
||||
use sgclaw::compat::workflow_executor::detect_route;
|
||||
|
||||
assert_eq!(
|
||||
detect_route(
|
||||
"导出故障明细",
|
||||
Some("https://example.invalid/workbench"),
|
||||
Some("业务台账"),
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused route test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test ws_cleanup_no_longer_detects_fault_details_scene_route -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because `FaultDetailsReport` is still detected today.
|
||||
|
||||
- [ ] **Step 3: Add the second failing orchestration-gate test**
|
||||
|
||||
In `tests/compat_runtime_test.rs`, add one focused assertion proving scene keywords no longer open the primary direct-orchestration path:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn ws_cleanup_scene_keywords_do_not_trigger_primary_orchestration() {
|
||||
assert!(!sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"请处理95598抢修市指监测",
|
||||
Some("https://95598.example.invalid/dispatch"),
|
||||
Some("95598抢修市指监测"),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the orchestration-gate test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test ws_cleanup_scene_keywords_do_not_trigger_primary_orchestration -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the scene matcher still feeds primary orchestration today.
|
||||
|
||||
- [ ] **Step 5: Add the third failing runtime-instruction test**
|
||||
|
||||
In `tests/runtime_profile_test.rs`, add a focused negative assertion proving browser-attached turns no longer receive the `95598` scene execution contract:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn ws_cleanup_browser_profile_does_not_inject_95598_scene_contract() {
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached);
|
||||
let instruction = engine.build_instruction(
|
||||
"请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列",
|
||||
Some("https://95598.example.invalid/dispatch"),
|
||||
Some("95598抢修市指监测"),
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(!instruction.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the runtime-profile test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_profile_test ws_cleanup_browser_profile_does_not_inject_95598_scene_contract -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/runtime/engine.rs` still injects the scene contract today.
|
||||
|
||||
- [ ] **Step 7: Add the fourth failing config-shape test**
|
||||
|
||||
In `tests/compat_config_test.rs`, add one focused assertion proving ws cleanup goes back to a single configured skills path and no longer accepts array-style `skillsDir` JSON:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn ws_cleanup_rejects_array_style_skills_dir_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-config-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-test",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": ["skill_lib", "skill_staging"]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(sgclaw::config::SgClawSettings::load(Some(config_path.as_path())).is_err());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run the config-shape test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test ws_cleanup_rejects_array_style_skills_dir_config -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the current parser still accepts string-or-array `skillsDir` input.
|
||||
|
||||
- [ ] **Step 9: Re-run the existing Zhihu keep-path test as a safety baseline**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, proving the behavior we want to keep is already covered before deletion starts.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Remove Scene Registry, Scene Prompt Injection, And Fault-Details Routing
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/runtime/scene_registry.rs`
|
||||
- Modify: `src/runtime/mod.rs`
|
||||
- Modify: `src/runtime/engine.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- Modify: `src/compat/orchestration.rs`
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
- Modify: `tests/runtime_profile_test.rs`
|
||||
- Delete: `tests/scene_registry_test.rs`
|
||||
|
||||
- [ ] **Step 1: Remove the runtime scene module export surface**
|
||||
|
||||
Update `src/runtime/mod.rs` so it no longer declares or re-exports scene registry items.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
mod engine;
|
||||
mod profile;
|
||||
mod tool_policy;
|
||||
|
||||
pub use engine::{
|
||||
is_zhihu_hotlist_task,
|
||||
is_zhihu_write_task,
|
||||
task_requests_zhihu_article_publish,
|
||||
RuntimeEngine,
|
||||
};
|
||||
pub use profile::RuntimeProfile;
|
||||
pub use tool_policy::ToolPolicy;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete `src/runtime/scene_registry.rs`**
|
||||
|
||||
Remove the file entirely. Do not leave a stub module or comments about future scene support.
|
||||
|
||||
- [ ] **Step 3: Remove scene-aware prompt injection from `src/runtime/engine.rs`**
|
||||
|
||||
Delete:
|
||||
- the `resolve_scene_skills_dir_path` import
|
||||
- the `DispatchMode` / `match_scene_instruction` imports
|
||||
- `REPAIR_CITY_DISPATCH_EXECUTION_PROMPT`
|
||||
- `build_scene_execution_contract(...)`
|
||||
- the `if let Some(scene_contract) = ...` block inside `RuntimeEngine::build_instruction(...)`
|
||||
- staged scene directory loading inside `load_runtime_skills(...)`
|
||||
|
||||
The resulting instruction assembly should keep:
|
||||
- browser tool contract
|
||||
- Zhihu hotlist/export prompts
|
||||
- Zhihu publish guard
|
||||
- page context
|
||||
|
||||
Do **not** change Zhihu prompt text.
|
||||
|
||||
- [ ] **Step 4: Remove the fault-details route from `src/compat/workflow_executor.rs`**
|
||||
|
||||
Shrink `WorkflowRoute` back to Zhihu-only variants:
|
||||
|
||||
```rust
|
||||
pub enum WorkflowRoute {
|
||||
ZhihuHotlistExportXlsx,
|
||||
ZhihuHotlistScreen,
|
||||
ZhihuArticleEntry,
|
||||
ZhihuArticleDraft,
|
||||
ZhihuArticlePublish,
|
||||
ZhihuArticleAutoPublishGenerated,
|
||||
}
|
||||
```
|
||||
|
||||
Delete:
|
||||
- `FAULT_DETAILS_SCENE_ID`
|
||||
- the scene check at the top of `detect_route(...)`
|
||||
- `WorkflowRoute::FaultDetailsReport`
|
||||
- `execute_fault_details_route(...)`
|
||||
- any scene-only helpers used only by that path
|
||||
|
||||
Keep the Zhihu route order unchanged.
|
||||
|
||||
- [ ] **Step 5: Simplify `src/compat/orchestration.rs` to Zhihu-only direct routing**
|
||||
|
||||
After the fault-details route is gone, keep `should_use_primary_orchestration(...)` and the two execute functions focused on:
|
||||
- Zhihu direct routes detected by `detect_route(...)`
|
||||
- existing Zhihu export/dashboard fallback behavior
|
||||
|
||||
Do not add new conditions.
|
||||
|
||||
- [ ] **Step 6: Remove scene-only tests and replace them with cleanup assertions**
|
||||
|
||||
In `tests/compat_runtime_test.rs` and `tests/runtime_profile_test.rs`:
|
||||
- delete `fault-details` assertions that require the old route to exist
|
||||
- delete `95598` scene-contract assertions that require the old prompt injection to exist
|
||||
- keep the new negative cleanup tests from Task 1
|
||||
- keep the existing Zhihu assertions intact
|
||||
|
||||
Delete `tests/scene_registry_test.rs` completely.
|
||||
|
||||
- [ ] **Step 7: Run the focused cleanup tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test ws_cleanup_no_longer_detects_fault_details_scene_route -- --nocapture && cargo test --test compat_runtime_test ws_cleanup_scene_keywords_do_not_trigger_primary_orchestration -- --nocapture && cargo test --test runtime_profile_test ws_cleanup_browser_profile_does_not_inject_95598_scene_contract -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Re-run the focused Zhihu runtime tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test zhihu_ -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, proving the Zhihu direct routes still work after the scene deletion.
|
||||
|
||||
- [ ] **Step 9: Commit Task 2**
|
||||
|
||||
```bash
|
||||
git add src/runtime/mod.rs src/runtime/engine.rs src/compat/workflow_executor.rs src/compat/orchestration.rs tests/compat_runtime_test.rs tests/runtime_profile_test.rs
|
||||
git rm src/runtime/scene_registry.rs tests/scene_registry_test.rs
|
||||
git commit -m "refactor: remove scene routing from ws branch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Collapse `skillsDir` Back To Single-Path Semantics
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config/settings.rs`
|
||||
- Modify: `src/compat/config_adapter.rs`
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- Modify: `src/agent/task_runner.rs`
|
||||
- Modify if needed: `tests/agent_runtime_test.rs`
|
||||
- Modify: `tests/compat_config_test.rs`
|
||||
|
||||
- [ ] **Step 1: Change config parsing to a single configured skills path**
|
||||
|
||||
In `src/config/settings.rs`, replace the string-or-array parser with a single optional string field.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeepSeekSettings {
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SgClawSettings {
|
||||
// ...
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
And in `RawSgClawSettings`:
|
||||
|
||||
```rust
|
||||
#[serde(rename = "skillsDir", alias = "skills_dir", default)]
|
||||
skills_dir: Option<String>,
|
||||
```
|
||||
|
||||
Delete `deserialize_skills_dirs(...)` entirely.
|
||||
|
||||
- [ ] **Step 2: Keep relative-path resolution, but only for one path**
|
||||
|
||||
Replace `resolve_configured_skills_dirs(...) -> Vec<PathBuf>` with a single-path helper such as:
|
||||
|
||||
```rust
|
||||
fn resolve_configured_skills_dir(raw: Option<String>, config_dir: &Path) -> Option<PathBuf> {
|
||||
raw.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.map(|path| if path.is_absolute() { path } else { config_dir.join(path) })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Collapse compat config adapter back to one resolved skills dir**
|
||||
|
||||
In `src/compat/config_adapter.rs`:
|
||||
- keep `zeroclaw_default_skills_dir(...)`
|
||||
- change `resolve_skills_dir(...)` and `resolve_skills_dir_from_sgclaw_settings(...)` to return a single `PathBuf`
|
||||
- delete `resolve_scene_skills_dir_from_sgclaw_settings(...)`
|
||||
- delete `resolve_scene_skills_dir_path(...)`
|
||||
- delete any helper branches that append `skill_staging/skills`
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```rust
|
||||
pub fn resolve_skills_dir_from_sgclaw_settings(
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> PathBuf {
|
||||
settings
|
||||
.skills_dir
|
||||
.as_ref()
|
||||
.map(|dir| normalize_configured_skills_dir(dir))
|
||||
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update runtime callers to the single-path contract**
|
||||
|
||||
In `src/compat/runtime.rs` and `src/agent/task_runner.rs`:
|
||||
- stop passing vectors of skills dirs around
|
||||
- update logging from `skills dirs resolved to [...]` to a single-path message such as `skills dir resolved to ...`
|
||||
- keep the rest of the runtime behavior unchanged
|
||||
|
||||
In `src/runtime/engine.rs`, if the method still needs a collection internally, convert the one path at the call site instead of preserving public multi-root plumbing.
|
||||
|
||||
- [ ] **Step 5: Replace config tests with single-path cleanup coverage**
|
||||
|
||||
In `tests/compat_config_test.rs`:
|
||||
- keep single-string `skillsDir` resolution tests
|
||||
- remove `resolve_scene_skills_dir_path_*` coverage
|
||||
- remove array-acceptance expectations
|
||||
- keep the new rejecting-array test from Task 1
|
||||
|
||||
Add one focused positive test like:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn ws_cleanup_resolves_single_configured_skills_dir() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-skills-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(root.join("skill_lib/skills")).unwrap();
|
||||
|
||||
let settings = DeepSeekSettings {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Some(root.join("skill_lib")),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_skills_dir(&root, &settings),
|
||||
root.join("skill_lib/skills"),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the focused config tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test ws_cleanup_ -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Re-run the Zhihu websocket keep-path test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit Task 3**
|
||||
|
||||
```bash
|
||||
git add src/config/settings.rs src/compat/config_adapter.rs src/compat/runtime.rs src/agent/task_runner.rs tests/compat_config_test.rs tests/agent_runtime_test.rs
|
||||
git commit -m "refactor: restore single skills dir on ws branch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove Scene-Only Docs And Residual Test References
|
||||
|
||||
**Files:**
|
||||
- Delete: `docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md`
|
||||
- Delete: `docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md`
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
- Modify: `tests/runtime_profile_test.rs`
|
||||
- Modify: `tests/compat_config_test.rs`
|
||||
|
||||
- [ ] **Step 1: Delete the two scene-only planning documents**
|
||||
|
||||
Remove exactly these files:
|
||||
- `docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md`
|
||||
- `docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md`
|
||||
|
||||
Keep the websocket/browser docs and Zhihu docs.
|
||||
|
||||
- [ ] **Step 2: Sweep remaining tests for scene-only names**
|
||||
|
||||
Remove or rewrite any remaining test blocks that still require:
|
||||
- `fault-details-report`
|
||||
- `95598-repair-city-dispatch`
|
||||
- `resolve_scene_skills_dir_path`
|
||||
- `resolve_scene_skills_dir_from_sgclaw_settings`
|
||||
- `scene_registry`
|
||||
|
||||
Do not delete Zhihu-related assertions during this sweep.
|
||||
|
||||
- [ ] **Step 3: Run a focused grep-style audit from the shell**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git grep -n "fault-details-report\|95598-repair-city-dispatch\|resolve_scene_skills_dir_path\|resolve_scene_skills_dir_from_sgclaw_settings\|scene_registry" -- src tests docs
|
||||
```
|
||||
|
||||
Expected: no matches in `src/` or `tests/`; doc matches should be gone after the deletions.
|
||||
|
||||
- [ ] **Step 4: Commit Task 4**
|
||||
|
||||
```bash
|
||||
git add tests/compat_runtime_test.rs tests/runtime_profile_test.rs tests/compat_config_test.rs
|
||||
git rm docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md
|
||||
git commit -m "docs: remove ws-only scene planning artifacts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Verify The Branch Is Back To WS Plus Zhihu Only
|
||||
|
||||
**Files:**
|
||||
- Verify only unless a failing test proves one tiny follow-up fix is needed
|
||||
|
||||
- [ ] **Step 1: Run the retained Zhihu websocket regression**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run websocket/backend focused coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test -- --nocapture && cargo test --test service_ws_session_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run direct-route/runtime Zhihu coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test zhihu_ -- --nocapture && cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run config/runtime verification after the single-dir cleanup**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture && cargo test --test runtime_profile_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Build the affected binaries**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Audit the remaining branch diff against `main`**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --stat main...HEAD
|
||||
```
|
||||
|
||||
Expected: the remaining meaningful differences are websocket/browser transport work and Zhihu-related behavior, not scene-routing or staged-scene config churn.
|
||||
|
||||
- [ ] **Step 7: Commit the final verification pass**
|
||||
|
||||
```bash
|
||||
git add src/config/settings.rs src/compat/config_adapter.rs src/compat/runtime.rs src/compat/workflow_executor.rs src/compat/orchestration.rs src/runtime/mod.rs src/runtime/engine.rs tests/compat_config_test.rs tests/runtime_profile_test.rs tests/compat_runtime_test.rs tests/agent_runtime_test.rs tests/task_runner_test.rs
|
||||
git commit -m "test: verify ws branch cleanup preserves zhihu websocket flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Cleanup regressions
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test ws_cleanup_ -- --nocapture
|
||||
cargo test --test runtime_profile_test ws_cleanup_ -- --nocapture
|
||||
cargo test --test compat_config_test ws_cleanup_ -- --nocapture
|
||||
```
|
||||
|
||||
Expected: scene detection, scene prompt injection, and array-style `skillsDir` behavior are gone.
|
||||
|
||||
### Retained Zhihu websocket behavior
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap -- --nocapture
|
||||
cargo test --test browser_ws_backend_test -- --nocapture
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
cargo test --test compat_runtime_test zhihu_ -- --nocapture
|
||||
```
|
||||
|
||||
Expected: websocket submit path and Zhihu direct workflows still pass.
|
||||
|
||||
### Runtime/config verification
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
cargo test --test runtime_profile_test -- --nocapture
|
||||
cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: runtime/config plumbing is stable after the single-dir cleanup.
|
||||
|
||||
### Build verification
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: the branch still compiles cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Notes For The Engineer
|
||||
|
||||
- The current scene support touches three different seams: runtime prompt injection, direct route detection/execution, and multi-root `skillsDir` plumbing. Remove all three; deleting only one leaves conflict-prone leftovers.
|
||||
- If collapsing `skillsDir` to `Option<PathBuf>` creates more churn than expected, keep the internal representation temporarily as a one-element collection, but the public config contract and tests on this branch must still go back to a single configured path.
|
||||
- Do not delete browser websocket or callback-host code just because it is adjacent to the scene work; this plan is about stripping scene behavior, not reworking transport.
|
||||
- If `git diff --stat main...HEAD` still shows scene-specific files after Task 5, stop and remove them before merging `main` back into this branch.
|
||||
551
docs/superpowers/plans/2026-04-11-main-into-ws-merge-v2-plan.md
Normal file
551
docs/superpowers/plans/2026-04-11-main-into-ws-merge-v2-plan.md
Normal file
@@ -0,0 +1,551 @@
|
||||
# Main → WS Merge v2 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把最新 `origin/main` 合并到 `feature/claw-ws`,让 `ws` 分支最终同时保留 **pipe + ws** 两套通信能力、保留 Zhihu 行为,并用 `main` 上正式的 fault-details 实现替换 `ws` 上已 cleanup 删除的旧重复实现。
|
||||
|
||||
**Architecture:** 这次合并不是“把 cleanup 永久保持成没有 fault-details”,而是“先删除 ws 上旧重复实现,再吸收 main 上正式实现”。冲突裁决优先级是:**先保 pipe、再保 ws、再保 Zhihu、同时拒绝 ws 上旧重复 scene/fault-details 实现回流**。整个过程使用 `git merge --no-commit --no-ff origin/main`,冲突解决后只做聚焦验证,停在未提交状态。
|
||||
|
||||
**Tech Stack:** Git, Rust 2021, Cargo test, sgClaw pipe transport, ws transport, compat/runtime/orchestration stack, Zhihu direct workflow tests.
|
||||
|
||||
---
|
||||
|
||||
## Preconditions
|
||||
|
||||
- 当前分支必须是 `feature/claw-ws`
|
||||
- `2026-04-09-ws-branch-scene-cleanup-plan.md` 已完成
|
||||
- 当前不在 merge 状态
|
||||
- 当前没有 tracked 未提交改动
|
||||
- 本次**不创建 worktree**,按当前仓库执行
|
||||
- 本次结束点是:**已合并、已验证、未提交**
|
||||
|
||||
---
|
||||
|
||||
## Final Merge Principles
|
||||
|
||||
### 1) `main` 是 pipe 主线
|
||||
合并后不能把 `main` 上现有的 pipe 管道通信破坏掉。
|
||||
|
||||
### 2) `ws` 分支最终要同时保留 pipe + ws
|
||||
合并后不能让 `ws` 分支丢掉 websocket 路径,也不能只剩 pipe。
|
||||
|
||||
### 3) 两边都有 Zhihu
|
||||
合并后不能把现有 Zhihu 行为合坏,尤其是 ws→Zhihu 保留路径。
|
||||
|
||||
### 4) fault-details 以 `main` 正式实现为准
|
||||
- `ws` 上那套旧重复实现:**不能回流**
|
||||
- `main` 上正式实现:**应被合进来**
|
||||
- 最终结果不是“没有 fault-details”,而是“没有 ws 那套旧 fault-details,只保留 main 正式版本”
|
||||
|
||||
### 5) 不回流旧 scene plumbing
|
||||
以下旧面不能作为最终结果保留:
|
||||
- ws 自己那套旧 scene registry / old scene plumbing
|
||||
- ws cleanup 已删掉的旧重复 route/contract
|
||||
- 仅为旧 `skill_staging` 场景装配服务的残留逻辑
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### A. 合并时重点观察的共享/高风险文件
|
||||
- `Cargo.toml`
|
||||
- `Cargo.lock`
|
||||
- `src/agent/mod.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/config/settings.rs`
|
||||
- `src/compat/config_adapter.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/compat/browser_script_skill_tool.rs`
|
||||
- `src/compat/direct_skill_runtime.rs`
|
||||
- `src/compat/openxml_office_tool.rs`
|
||||
|
||||
### B. pipe / ws / Zhihu 保护面
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `tests/agent_runtime_test.rs`
|
||||
- `tests/browser_ws_backend_test.rs`
|
||||
- `tests/service_ws_session_test.rs`
|
||||
- `tests/task_runner_test.rs`
|
||||
|
||||
### C. cleanup 后仍需防止旧实现回流的文件
|
||||
- `src/runtime/mod.rs`
|
||||
- `src/runtime/engine.rs`
|
||||
- `src/config/settings.rs`
|
||||
- `src/compat/config_adapter.rs`
|
||||
- `tests/compat_runtime_test.rs`
|
||||
- `tests/runtime_profile_test.rs`
|
||||
- `tests/compat_config_test.rs`
|
||||
|
||||
### D. 可能需要随 main 正式 fault-details 一起更新的测试面
|
||||
- `tests/compat_runtime_test.rs`
|
||||
- `tests/compat_config_test.rs`
|
||||
- `tests/browser_script_skill_tool_test.rs`
|
||||
- `tests/compat_openxml_office_tool_test.rs`
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution Rule Table
|
||||
|
||||
| 类别 | 最终保留原则 |
|
||||
|---|---|
|
||||
| pipe 主路径 | **优先保留可工作的 main 版本**,不能被 ws 改坏 |
|
||||
| ws 路径 | **必须继续保留 ws 能力**,不能因吸收 main 而丢失 |
|
||||
| Zhihu | 两边相关能力都不能合坏,至少保住现有 keep-path |
|
||||
| fault-details | **保留 main 正式实现**,不保留 ws 旧重复实现 |
|
||||
| old scene/95598 cleanup 残留 | 不允许以 ws 旧重复实现形式回流 |
|
||||
| `skillsDir` / config | 以最终产品需要为准;若 main 正式实现不要求旧 array-style/scene expansion,则不回流 |
|
||||
| 临时 merge 修补 | 一律不保留 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Confirm Merge Preconditions And Diff Surface
|
||||
|
||||
**Files:**
|
||||
- No code changes expected
|
||||
- Observe repo state and branch diff only
|
||||
|
||||
- [ ] **Step 1: Confirm current branch**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
Expected:
|
||||
```text
|
||||
feature/claw-ws
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm no merge is in progress**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git rev-parse -q --verify MERGE_HEAD
|
||||
```
|
||||
|
||||
Expected: exit code `1`.
|
||||
|
||||
- [ ] **Step 3: Confirm no tracked local changes**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --name-only && printf '\n---STAGED---\n' && git diff --cached --name-only
|
||||
```
|
||||
|
||||
Expected:
|
||||
```text
|
||||
|
||||
---STAGED---
|
||||
```
|
||||
|
||||
- [ ] **Step 4: List current untracked files**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: only known local untracked items, or a clearly understood list.
|
||||
|
||||
- [ ] **Step 5: Update `origin/main`**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git fetch origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Show ws vs main diff surface before merge**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --name-status HEAD...origin/main
|
||||
```
|
||||
|
||||
Expected: clear file list to compare likely merge surface.
|
||||
|
||||
- [ ] **Step 7: Stop if preconditions fail**
|
||||
|
||||
Stop if:
|
||||
- branch is wrong
|
||||
- merge is in progress
|
||||
- tracked changes exist
|
||||
- untracked file collision with `origin/main` is found and unresolved
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Start The Merge Without Committing
|
||||
|
||||
**Files:**
|
||||
- Merge index / working tree only
|
||||
|
||||
- [ ] **Step 1: Start no-commit merge**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git merge --no-commit --no-ff origin/main
|
||||
```
|
||||
|
||||
Expected:
|
||||
- either auto-merge pauses before commit
|
||||
- or Git reports conflicts
|
||||
|
||||
- [ ] **Step 2: Capture merge surface immediately**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Separate results into three buckets**
|
||||
Create a working list of conflicted files under:
|
||||
1. pipe-critical
|
||||
2. ws/Zhihu-critical
|
||||
3. shared infra / tests
|
||||
|
||||
- [ ] **Step 4: If no conflicts, proceed directly to Task 4 verification**
|
||||
|
||||
- [ ] **Step 5: If conflicts exist, proceed to Task 3**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Resolve Conflicts By System Role, Not By Branch Bias
|
||||
|
||||
**Files:**
|
||||
- Only files reported by Git as conflicted
|
||||
|
||||
#### Global conflict policy
|
||||
For every conflicted hunk, answer these four questions in order:
|
||||
|
||||
1. Does this hunk affect **pipe** correctness?
|
||||
2. Does this hunk affect **ws** correctness?
|
||||
3. Does this hunk affect **Zhihu** correctness?
|
||||
4. Is this hunk part of **ws old duplicate fault-details/scene logic** or **main official implementation**?
|
||||
|
||||
Then apply the rule:
|
||||
- **pipe cannot break**
|
||||
- **ws cannot break**
|
||||
- **Zhihu cannot break**
|
||||
- **ws old duplicate fault-details must stay deleted**
|
||||
- **main official fault-details should come in**
|
||||
|
||||
---
|
||||
|
||||
#### Task 3A: Resolve pipe-critical shared runtime files
|
||||
|
||||
**Files:**
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/agent/mod.rs`
|
||||
- `src/config/settings.rs`
|
||||
- `src/compat/config_adapter.rs`
|
||||
|
||||
- [ ] **Step 1: For each conflict, keep the side that preserves main’s pipe behavior**
|
||||
|
||||
- [ ] **Step 2: Reject ws-only duplicate business logic that main already owns**
|
||||
|
||||
- [ ] **Step 3: Keep ws support if the file also serves ws path**
|
||||
This is additive preservation, not “main wins everything”.
|
||||
|
||||
- [ ] **Step 4: Verify each resolved file has no conflict markers**
|
||||
|
||||
Run per file:
|
||||
```bash
|
||||
git diff --check -- <path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 3B: Resolve ws / Zhihu-critical routing files
|
||||
|
||||
**Files:**
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
|
||||
- [ ] **Step 1: Bring in main’s official fault-details path if it lives here**
|
||||
|
||||
- [ ] **Step 2: Do not reintroduce ws’s old duplicate fault-details path**
|
||||
|
||||
- [ ] **Step 3: Preserve ws submit/browser websocket path**
|
||||
|
||||
- [ ] **Step 4: Preserve Zhihu routing path**
|
||||
|
||||
- [ ] **Step 5: Verify each resolved file has no conflict markers**
|
||||
|
||||
Run per file:
|
||||
```bash
|
||||
git diff --check -- <path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 3C: Resolve shared infra files minimally
|
||||
|
||||
**Files:**
|
||||
- `Cargo.toml`
|
||||
- `Cargo.lock`
|
||||
- `src/compat/browser_script_skill_tool.rs`
|
||||
- `src/compat/direct_skill_runtime.rs`
|
||||
- `src/compat/openxml_office_tool.rs`
|
||||
|
||||
- [ ] **Step 1: Keep only the dependency/code shape needed by the merged result**
|
||||
|
||||
- [ ] **Step 2: Do not keep lines from prior failed merge attempts**
|
||||
|
||||
- [ ] **Step 3: Accept main fixes unless they break pipe/ws/Zhihu behavior**
|
||||
|
||||
- [ ] **Step 4: Verify each resolved file has no conflict markers**
|
||||
|
||||
Run per file:
|
||||
```bash
|
||||
git diff --check -- <path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 3D: Resolve tests to reflect final intended product
|
||||
|
||||
**Files:**
|
||||
- `tests/compat_runtime_test.rs`
|
||||
- `tests/runtime_profile_test.rs`
|
||||
- `tests/compat_config_test.rs`
|
||||
- `tests/agent_runtime_test.rs`
|
||||
- `tests/browser_script_skill_tool_test.rs`
|
||||
- `tests/compat_openxml_office_tool_test.rs`
|
||||
|
||||
- [ ] **Step 1: Keep tests proving pipe path still works**
|
||||
|
||||
- [ ] **Step 2: Keep tests proving ws path still works**
|
||||
|
||||
- [ ] **Step 3: Keep Zhihu keep-path regression**
|
||||
|
||||
- [ ] **Step 4: Replace cleanup-only “fault-details absent” assertions if final intended state is now “fault-details present via main official implementation”**
|
||||
|
||||
- [ ] **Step 5: Do not keep assertions that only prove ws’s old duplicate implementation is absent if they now contradict the intended merged product**
|
||||
|
||||
- [ ] **Step 6: Verify each resolved test file has no conflict markers**
|
||||
|
||||
Run per file:
|
||||
```bash
|
||||
git diff --check -- <path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 3E: Confirm merge is fully resolved
|
||||
|
||||
**Files:**
|
||||
- No code changes expected
|
||||
|
||||
- [ ] **Step 1: Confirm no unmerged entries remain**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --name-only --diff-filter=U
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 2: Show final resolved file list**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --cached --name-only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify Final Product Behavior, Not Cleanup Intermediate State
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/agent_runtime_test.rs`
|
||||
- Test: `tests/browser_ws_backend_test.rs`
|
||||
- Test: `tests/service_ws_session_test.rs`
|
||||
- Test: `tests/task_runner_test.rs`
|
||||
- Test: `tests/compat_runtime_test.rs`
|
||||
- Test: `tests/runtime_profile_test.rs`
|
||||
- Test: `tests/compat_config_test.rs`
|
||||
- Conditional: `tests/browser_script_skill_tool_test.rs`
|
||||
- Conditional: `tests/compat_openxml_office_tool_test.rs`
|
||||
|
||||
#### Verification goals
|
||||
This task must prove all four:
|
||||
|
||||
1. **pipe path still works**
|
||||
2. **ws path still works**
|
||||
3. **Zhihu still works**
|
||||
4. **final fault-details implementation is the main version, not ws’s old duplicate**
|
||||
|
||||
---
|
||||
|
||||
#### Task 4A: Verify pipe-related behavior
|
||||
|
||||
- [ ] **Step 1: Run task runner coverage**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run compat runtime suite relevant to main path**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 3: If pipe-specific tests fail, stop and fix merge resolution before continuing**
|
||||
|
||||
---
|
||||
|
||||
#### Task 4B: Verify ws-related behavior
|
||||
|
||||
- [ ] **Step 1: Run browser websocket backend suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run service websocket session suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 3: If ws-specific tests fail, stop and fix merge resolution before continuing**
|
||||
|
||||
---
|
||||
|
||||
#### Task 4C: Verify Zhihu behavior
|
||||
|
||||
- [ ] **Step 1: Re-run ws→Zhihu keep-path regression**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test agent_runtime_test production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
```text
|
||||
1 passed; 0 failed
|
||||
```
|
||||
|
||||
- [ ] **Step 2: If additional Zhihu tests were touched by conflicts, run the smallest affected test target**
|
||||
|
||||
Run as needed:
|
||||
```bash
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 4D: Verify config/runtime contracts
|
||||
|
||||
- [ ] **Step 1: Run runtime profile suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test runtime_profile_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run compat config suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Ensure contracts now reflect final merged product, not the cleanup-only intermediate**
|
||||
|
||||
---
|
||||
|
||||
#### Task 4E: Verify shared infra if touched
|
||||
|
||||
- [ ] **Step 1: If browser-script tool files were touched**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 2: If openxml files were touched**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_openxml_office_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 4F: Compile guard
|
||||
|
||||
- [ ] **Step 1: Run compile-only full test build**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --no-run
|
||||
```
|
||||
|
||||
Expected: exit code `0`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Confirm The Merge Outcome Matches The Principle
|
||||
|
||||
**Files:**
|
||||
- No code changes expected
|
||||
|
||||
- [ ] **Step 1: Show final status**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
- no `UU` / `AA` / `DD`
|
||||
- merged, validated, uncommitted state only
|
||||
|
||||
- [ ] **Step 2: Show final staged summary**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --cached --stat
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Report the four required facts with command-backed evidence**
|
||||
Only if verified:
|
||||
1. pipe 没坏
|
||||
2. ws 没坏
|
||||
3. Zhihu 没坏
|
||||
4. 最终 fault-details 来自 main 正式实现,而不是 ws 旧重复实现
|
||||
|
||||
- [ ] **Step 4: Stop here**
|
||||
Do **not** run:
|
||||
```bash
|
||||
git commit
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
出现以下任一情况立即停止,不擅自扩展处理:
|
||||
|
||||
- `origin/main` 的正式 fault-details 实现依赖 cleanup 已删掉的契约,而这已经超出简单 merge 范围
|
||||
- pipe 与 ws 同时依赖同一段共享代码,但两边要求已结构性冲突
|
||||
- Zhihu keep-path 失败
|
||||
- `cargo test --no-run` 失败且问题超出本次 merge surface
|
||||
- 需要重新设计 pipe/ws 共存方式,而不是单纯合并
|
||||
|
||||
---
|
||||
|
||||
## One-line Execution Rule
|
||||
|
||||
**这次 merge 的最终标准不是“继续保持 ws 没有 fault-details”,而是“保住 pipe、保住 ws、保住 Zhihu,并让 main 的正式 fault-details 替换 ws 旧重复实现”。**
|
||||
@@ -0,0 +1,448 @@
|
||||
# TQ Lineloss WS Dual-Transport Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add ws communication support for the existing `tq-lineloss-report.collect_lineloss` deterministic browser_script path on the `feature/claw-ws` branch while preserving the current pipe path and validated Zhihu ws behavior.
|
||||
|
||||
**Architecture:** Reuse the existing backend-neutral execution seam that already exists for deterministic submit and browser_script execution. Keep lineloss business parsing, canonical args, and artifact interpretation unchanged; only make the ws backend/protocol and submit-path verification complete enough for the same lineloss skill contract to run over both pipe and ws.
|
||||
|
||||
**Tech Stack:** Rust 2021, Cargo tests, existing `BrowserBackend` abstraction, `WsBrowserBackend`, `ws_protocol`, browser websocket contract in `docs/_tmp_sgbrowser_ws_api_doc.txt`, existing staged `browser_script` skill execution seam.
|
||||
|
||||
---
|
||||
|
||||
## Execution Context
|
||||
|
||||
- Follow @superpowers:test-driven-development for each behavior change.
|
||||
- Follow @superpowers:verification-before-completion before claiming each task is done.
|
||||
- Do **not** create a git worktree unless the user explicitly asks.
|
||||
- This plan is **ws enablement only** for the already-added lineloss deterministic skill path.
|
||||
- Do **not** redesign deterministic routing, org parsing, period parsing, staged skill packaging, or artifact contracts unless a failing ws-specific test proves a minimal compatibility fix is required.
|
||||
- Do **not** modify validated Zhihu hotlist/export business behavior; only add regression coverage around it.
|
||||
- Preserve the current pipe execution path as the control implementation.
|
||||
- Preserve the current `BrowserBackend` seam; do not introduce a second lineloss-specific ws execution path.
|
||||
|
||||
## Scope Boundary
|
||||
|
||||
### In scope
|
||||
- Make the existing lineloss deterministic `browser_script` skill path run through ws on this branch.
|
||||
- Keep the same canonical tool args and returned artifact interpretation for both pipe and ws.
|
||||
- Verify ws browser-script execution against the documented browser ws contract.
|
||||
- Add focused tests for ws lineloss execution and regressions for Zhihu ws + pipe lineloss.
|
||||
|
||||
### Out of scope
|
||||
- Changing lineloss trigger semantics (`。。。`).
|
||||
- Changing org/unit normalization semantics or source dictionary shape.
|
||||
- Changing period normalization semantics.
|
||||
- Reworking staged skill docs or JS business collection logic beyond ws-compatibility necessities.
|
||||
- Any Zhihu feature work.
|
||||
- Any pipe-only cleanup/refactor.
|
||||
- Any general scene-registry redesign.
|
||||
|
||||
## File Map
|
||||
|
||||
### Expected code changes
|
||||
- Modify: `src/pipe/protocol.rs:49-78,130-165,192-209`
|
||||
- keep `Action::Eval` encoding aligned with the current transport contract and lineloss skill expectations
|
||||
- Modify: `src/pipe/browser_tool.rs:62-125`
|
||||
- ensure eval response correlation and payload handling remain sufficient for deterministic lineloss execution
|
||||
- Modify only if a focused test proves it is necessary: `src/compat/browser_script_skill_tool.rs:135-255`
|
||||
- preserve browser_script contract; only make minimal output-shape handling fixes if eval payloads differ from the pipe baseline in a way current code cannot consume
|
||||
- Modify only if a focused parity test proves it is necessary: `src/compat/direct_skill_runtime.rs:50-129`
|
||||
- preserve shared backend-neutral execution helper behavior; no business logic changes
|
||||
- Read and normally leave unchanged: `src/compat/deterministic_submit.rs:96-157`
|
||||
- this is the business contract baseline and should not be rewritten for transport parity work
|
||||
- Read and normally leave unchanged: `src/agent/mod.rs:242-285`
|
||||
- this contains the current deterministic dispatch split used by this branch
|
||||
|
||||
### Expected test changes
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- add/extend deterministic lineloss runtime coverage and parity assertions using the current runtime path
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
- add/extend focused pipe lineloss regression assertions so transport work cannot silently break pipe
|
||||
- Modify only if end-to-end submit coverage truly needs it: `tests/runtime_task_flow_test.rs`
|
||||
- verify broader submit-flow expectations remain intact
|
||||
|
||||
### Reference-only files
|
||||
- Read only: `docs/superpowers/plans/2026-04-11-tq-lineloss-deterministic-skill-plan.md`
|
||||
- Read only: `docs/superpowers/specs/2026-04-11-tq-lineloss-deterministic-skill-design.md`
|
||||
- Read only: `docs/_tmp_sgbrowser_ws_api_doc.txt`
|
||||
|
||||
---
|
||||
|
||||
## Locked contracts
|
||||
|
||||
### Contract 1: Same lineloss deterministic business contract on both transports
|
||||
The ws path must reuse the existing values produced by `src/compat/deterministic_submit.rs:84-95` and `src/compat/deterministic_submit.rs:135-166`:
|
||||
- `expected_domain`
|
||||
- `org_label`
|
||||
- `org_code`
|
||||
- `period_mode`
|
||||
- `period_mode_code`
|
||||
- `period_value`
|
||||
- `period_payload`
|
||||
|
||||
No ws-specific lineloss args may be introduced in this slice.
|
||||
|
||||
### Contract 2: Same browser_script execution seam on both transports
|
||||
The ws path must continue to use `execute_browser_script_skill_raw_output_with_browser_backend(...)` from `src/compat/direct_skill_runtime.rs:95-112`, which in turn uses the same browser_script tool path as pipe. Do not add a second lineloss-only ws runner.
|
||||
|
||||
### Contract 3: Same artifact interpretation on both transports
|
||||
The ws path must produce output that remains consumable by `summarize_lineloss_output(...)` / `summarize_lineloss_artifact(...)` in `src/compat/deterministic_submit.rs:168-257` without transport-specific branching.
|
||||
|
||||
### Contract 4: Zhihu ws behavior must stay unchanged
|
||||
The existing ws browser-script / export path already validated by `tests/agent_runtime_test.rs` and `tests/compat_runtime_test.rs` is a hard regression boundary. If a change breaks Zhihu tests, fix the ws seam instead of weakening Zhihu expectations.
|
||||
|
||||
### Contract 5: Pipe remains the baseline
|
||||
For identical lineloss deterministic inputs, the pipe path should continue to succeed without requiring ws configuration.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock the ws contract with failing transport-level tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
- Read: `docs/_tmp_sgbrowser_ws_api_doc.txt`
|
||||
|
||||
- [ ] **Step 1: Add a failing ws lineloss deterministic runtime test**
|
||||
|
||||
Model it after the existing ws harness in `tests/agent_runtime_test.rs:69-166`, but target lineloss deterministic execution instead of Zhihu. The test should:
|
||||
- configure `browserWsUrl`
|
||||
- submit a deterministic lineloss instruction ending with `。。。`
|
||||
- return a ws callback payload representing a lineloss `report-artifact`
|
||||
- assert success summary includes canonical org, period, status, and rows
|
||||
|
||||
Suggested skeleton:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn ws_deterministic_lineloss_submit_executes_browser_script_and_summarizes_artifact() {
|
||||
// arrange ws config + ws server + lineloss artifact callback
|
||||
// act handle_browser_message_with_context(... SubmitTask ...)
|
||||
// assert TaskComplete success summary contains canonical org/period/rows
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a failing pipe regression test for the same lineloss contract**
|
||||
|
||||
In `tests/compat_runtime_test.rs`, add a focused pipe-side assertion that the same deterministic lineloss instruction still succeeds through the current pipe seam and uses the same summary contract.
|
||||
|
||||
Suggested skeleton:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn pipe_deterministic_lineloss_submit_preserves_existing_summary_contract() {
|
||||
// arrange MockTransport responses for browser_script eval
|
||||
// act handle_browser_message_with_context(...)
|
||||
// assert success summary matches canonical contract
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add a failing ws regression assertion for Zhihu**
|
||||
|
||||
Add or tighten a Zhihu ws assertion proving ordinary Zhihu requests still use the existing ws path and do not get intercepted by lineloss deterministic logic.
|
||||
|
||||
- [ ] **Step 4: Run the three focused tests to confirm failure**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test ws_deterministic_lineloss_submit_executes_browser_script_and_summarizes_artifact -- --exact
|
||||
cargo test pipe_deterministic_lineloss_submit_preserves_existing_summary_contract -- --exact
|
||||
cargo test ws_zhihu_submit_path_remains_unchanged_after_lineloss_transport_work -- --exact
|
||||
```
|
||||
|
||||
Expected: at least the new ws lineloss test fails before the seam is completed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/agent_runtime_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "test: lock ws and pipe lineloss transport contracts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Make the current eval transport contract explicitly satisfy browser-script requirements
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pipe/protocol.rs:49-78,130-165,192-209`
|
||||
- Modify: `src/pipe/browser_tool.rs:62-124`
|
||||
- Modify only if tests prove necessary: `src/compat/browser_script_skill_tool.rs:99-180,214-255`
|
||||
- Modify: `tests/pipe_protocol_test.rs`
|
||||
- Modify: `tests/browser_tool_test.rs`
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add failing protocol/result-contract tests first**
|
||||
|
||||
Extend or add focused tests to lock the current branch's real transport contract:
|
||||
- `Action::Eval` remains supported by the line protocol and command encoding
|
||||
- eval request/response correlation remains stable via `seq` matching for lineloss-style target URLs
|
||||
- eval/browser_script result handling preserves the full JSON artifact string without truncation before deterministic lineloss summarization consumes it
|
||||
|
||||
Suggested skeletons:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn eval_action_remains_supported_in_protocol() {}
|
||||
|
||||
#[test]
|
||||
fn browser_tool_matches_eval_response_by_seq_for_lineloss_flow() {}
|
||||
|
||||
#[test]
|
||||
fn browser_script_tool_preserves_json_artifact_string_for_lineloss() {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused Task 2 tests to confirm failure**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test eval_action_remains_supported_in_protocol -- --exact
|
||||
cargo test browser_tool_matches_eval_response_by_seq_for_lineloss_flow -- --exact
|
||||
cargo test browser_script_tool_preserves_json_artifact_string_for_lineloss -- --exact
|
||||
```
|
||||
|
||||
Expected: at least one test fails if the current protocol/correlation/result handling is still insufficient for the lineloss artifact path.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal transport-contract fix**
|
||||
|
||||
Allowed changes:
|
||||
- adjust only the `Action::Eval` protocol/encoding support in `src/pipe/protocol.rs`
|
||||
- adjust only request/response correlation in `src/pipe/browser_tool.rs`
|
||||
- if and only if tests still prove it necessary, make a tiny result-shape/stringification fix in `src/compat/browser_script_skill_tool.rs`
|
||||
- keep existing Zhihu-compatible behavior intact
|
||||
|
||||
Not allowed:
|
||||
- adding lineloss-only transport fields
|
||||
- adding a second lineloss-specific execution path
|
||||
- changing deterministic lineloss business parsing or summary rules
|
||||
|
||||
- [ ] **Step 4: Re-run the focused Task 2 tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test eval_action_remains_supported_in_protocol -- --exact
|
||||
cargo test browser_tool_matches_eval_response_by_seq_for_lineloss_flow -- --exact
|
||||
cargo test browser_script_tool_preserves_json_artifact_string_for_lineloss -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Re-run the focused ws lineloss runtime test from Task 1**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test ws_deterministic_lineloss_submit_executes_browser_script_and_summarizes_artifact -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pipe/protocol.rs src/pipe/browser_tool.rs src/compat/browser_script_skill_tool.rs tests/pipe_protocol_test.rs tests/browser_tool_test.rs tests/browser_script_skill_tool_test.rs
|
||||
git commit -m "fix: align eval transport contract with lineloss browser script flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Make eval result-shape handling surface the lineloss artifact cleanly
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pipe/browser_tool.rs:62-125`
|
||||
- Modify only if tests prove necessary: `src/compat/browser_script_skill_tool.rs:159-180,248-255`
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add a failing result-shape test**
|
||||
|
||||
Lock that an eval response carrying a JSON string report artifact is surfaced as the same browser_script tool output shape expected by `execute_browser_script_tool(...)`.
|
||||
|
||||
Suggested skeleton:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn ws_backend_eval_returns_text_payload_consumable_by_browser_script_tool() {
|
||||
// arrange an eval response whose data.text is a JSON string artifact
|
||||
// assert execute_browser_script_tool(...) returns the full artifact text without truncation
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the result-shape test to confirm failure**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test ws_backend_eval_returns_text_payload_consumable_by_browser_script_tool -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL only if current eval/result handling is not sufficient for full lineloss artifact output.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal result-shape fix**
|
||||
|
||||
Allowed fixes:
|
||||
- adjust `BrowserPipeTool::invoke(...)` only if response packaging itself is wrong
|
||||
- if and only if still required, make a tiny output-shape compatibility fix in `src/compat/browser_script_skill_tool.rs` so JSON string `data.text` payloads are preserved identically to the pipe baseline
|
||||
|
||||
Not allowed:
|
||||
- transport-specific lineloss parsing
|
||||
- changes to deterministic business logic
|
||||
- adding a second lineloss-specific execution path
|
||||
|
||||
- [ ] **Step 4: Re-run the result-shape test**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test ws_backend_eval_returns_text_payload_consumable_by_browser_script_tool -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Re-run the focused ws lineloss runtime test from Task 1**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test ws_deterministic_lineloss_submit_executes_browser_script_and_summarizes_artifact -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pipe/browser_tool.rs src/compat/browser_script_skill_tool.rs tests/browser_script_skill_tool_test.rs
|
||||
git commit -m "fix: make eval result shape match browser script contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify the current backend-neutral deterministic execution path without changing business rules
|
||||
|
||||
**Files:**
|
||||
- Read baseline: `src/agent/mod.rs:242-285`
|
||||
- Read baseline: `src/compat/deterministic_submit.rs:96-157`
|
||||
- Modify only if a focused parity test proves it is necessary: `src/compat/direct_skill_runtime.rs:50-129`
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add a failing integration test for backend-neutral parity**
|
||||
|
||||
Add a test proving these two current-branch paths produce the same lineloss summary contract for equivalent artifact payloads:
|
||||
- pipe path via the existing deterministic submit flow in `tests/compat_runtime_test.rs`
|
||||
- runtime path via `handle_browser_message_with_context(...)` deterministic submit routing in `tests/agent_runtime_test.rs`
|
||||
|
||||
Suggested skeleton:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn deterministic_lineloss_pipe_and_ws_paths_share_summary_contract() {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the parity test to confirm failure or gap**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test deterministic_lineloss_pipe_and_ws_paths_share_summary_contract -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL only if a remaining shared execution seam gap still exists.
|
||||
|
||||
- [ ] **Step 3: Apply the smallest shared execution fix if needed**
|
||||
|
||||
Allowed changes:
|
||||
- tiny helper extraction or result handling in `src/compat/direct_skill_runtime.rs`
|
||||
- no new lineloss-specific branch
|
||||
- no change to deterministic lineloss business parsing or summary rules
|
||||
- no change to configured direct-submit behavior for non-lineloss skills
|
||||
|
||||
- [ ] **Step 4: Re-run the parity test**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test deterministic_lineloss_pipe_and_ws_paths_share_summary_contract -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/direct_skill_runtime.rs tests/agent_runtime_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "fix: preserve shared deterministic execution across pipe and ws"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Run the full focused verification set and stop if any Zhihu or pipe regression appears
|
||||
|
||||
**Files:**
|
||||
- Reuse: `tests/agent_runtime_test.rs`
|
||||
- Reuse: `tests/compat_runtime_test.rs`
|
||||
- Reuse: `tests/runtime_task_flow_test.rs`
|
||||
|
||||
- [ ] **Step 1: Run focused ws + lineloss + Zhihu regression tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test agent_runtime_test
|
||||
cargo test --test compat_runtime_test
|
||||
cargo test --test runtime_task_flow_test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run targeted protocol/backend unit tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test eval_action_remains_supported_in_protocol -- --exact
|
||||
cargo test browser_tool_matches_eval_response_by_seq_for_lineloss_flow -- --exact
|
||||
cargo test browser_script_tool_preserves_json_artifact_string_for_lineloss -- --exact
|
||||
cargo test ws_backend_eval_returns_text_payload_consumable_by_browser_script_tool -- --exact
|
||||
cargo test deterministic_lineloss_pipe_and_ws_paths_share_summary_contract -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run the full Rust suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Manual review of diff scope**
|
||||
|
||||
Confirm the diff only touches:
|
||||
- current transport/result seam files (`src/pipe/protocol.rs`, `src/pipe/browser_tool.rs`)
|
||||
- narrow shared browser_script/result compatibility helpers if strictly necessary
|
||||
- tests
|
||||
|
||||
If diff includes Zhihu business logic, lineloss parsing rules, staged skill business JS, or unrelated cleanup, remove those changes before completion.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pipe/protocol.rs src/pipe/browser_tool.rs src/compat/browser_script_skill_tool.rs src/compat/direct_skill_runtime.rs tests/pipe_protocol_test.rs tests/browser_tool_test.rs tests/browser_script_skill_tool_test.rs tests/agent_runtime_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "test: verify lineloss ws transport without regressing pipe or zhihu"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification checklist
|
||||
|
||||
- [ ] The same lineloss deterministic instruction works on pipe and ws.
|
||||
- [ ] Pipe still works without any ws configuration.
|
||||
- [ ] Eval transport support remains available for deterministic lineloss execution.
|
||||
- [ ] Eval response payloads preserve the full lineloss artifact JSON string.
|
||||
- [ ] `src/compat/deterministic_submit.rs` business rules remain transport-neutral.
|
||||
- [ ] No ws-specific lineloss args were introduced.
|
||||
- [ ] Zhihu ws tests still pass unchanged in behavior.
|
||||
- [ ] No ordinary Zhihu request is intercepted by lineloss deterministic routing.
|
||||
- [ ] No new transport-specific business branch was added for lineloss.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Default to changing the current transport/result seam first: `src/pipe/protocol.rs` and `src/pipe/browser_tool.rs`.
|
||||
- Treat `src/compat/browser_script_skill_tool.rs` and `src/compat/direct_skill_runtime.rs` as shared seams: change them only if a focused failing test shows a transport-neutral compatibility bug.
|
||||
- If a proposed fix requires changing `src/compat/deterministic_submit.rs` business logic, stop and re-evaluate; that likely means the seam fix is happening at the wrong layer.
|
||||
- If a proposed fix changes Zhihu expectations, stop and repair the seam instead.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Async Browser Script 支持实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修改 `build_eval_js` 函数支持异步脚本,解决 Promise 被 JSON.stringify 序列化为 `{}` 的问题。
|
||||
|
||||
**Architecture:** 将 `build_eval_js` 生成的 JavaScript 代码从同步 IIFE 改为 async IIFE,用 await 等待脚本执行结果,并检测 Promise-like 对象进行二次等待。
|
||||
|
||||
**Tech Stack:** Rust, JavaScript (生成代码)
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/browser/callback_backend.rs` | 修改 | 修改 `build_eval_js` 函数 |
|
||||
| `tests/browser_script_skill_tool_test.rs` | 新增测试 | 添加异步脚本测试用例 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 修改 build_eval_js 支持异步脚本
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_backend.rs:433-447`
|
||||
|
||||
**当前代码:**
|
||||
```rust
|
||||
fn build_eval_js(source_url: &str, script: &str) -> String {
|
||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||
let callback = EVAL_CALLBACK_NAME;
|
||||
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||
|
||||
format!(
|
||||
"(function(){{try{{var v=(function(){{return {script}}})();\
|
||||
var t=(typeof v==='string')?v:JSON.stringify(v);\
|
||||
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\
|
||||
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\
|
||||
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||
}}catch(e){{}}}})()"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**修改后代码:**
|
||||
```rust
|
||||
fn build_eval_js(source_url: &str, script: &str) -> String {
|
||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||
let callback = EVAL_CALLBACK_NAME;
|
||||
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||
|
||||
format!(
|
||||
"(async function(){{try{{\
|
||||
var v=await (async function(){{return {script}}})();\
|
||||
if(v&&typeof v.then==='function'){{v=await v;}}\
|
||||
var t=(typeof v==='string')?v:JSON.stringify(v);\
|
||||
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\
|
||||
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\
|
||||
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||
}}catch(e){{}}}})()"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**关键变更说明:**
|
||||
1. `(function()` → `(async function()` - 整个 IIFE 变为异步
|
||||
2. `var v=(function(){return {script}})()` → `var v=await (async function(){return {script}})()` - 内部包装也变为异步并 await
|
||||
3. 新增 `if(v&&typeof v.then==='function'){v=await v;}` - 检测并等待 Promise-like 对象
|
||||
|
||||
- [ ] **Step 1: 修改 build_eval_js 函数**
|
||||
|
||||
编辑 `src/browser/callback_backend.rs` 第 433-447 行,替换为上述新代码。
|
||||
|
||||
- [ ] **Step 2: 编译验证**
|
||||
|
||||
Run: `cargo build`
|
||||
Expected: 编译成功,无错误
|
||||
|
||||
- [ ] **Step 3: 运行现有测试**
|
||||
|
||||
Run: `cargo test browser_script_skill_tool`
|
||||
Expected: 所有测试通过
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_backend.rs
|
||||
git commit -m "fix: support async browser scripts in build_eval_js
|
||||
|
||||
Wrap eval script in async IIFE and await Promise-like results.
|
||||
Fixes Promise serialization returning '{}' for async skill scripts.
|
||||
|
||||
🤖 Generated with [Qoder][https://qoder.com]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 添加异步脚本测试用例
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||
|
||||
- [ ] **Step 1: 添加异步脚本测试用例**
|
||||
|
||||
在 `tests/browser_script_skill_tool_test.rs` 文件末尾添加新测试:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_awaits_async_script() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-async");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
// 异步脚本,返回 Promise
|
||||
fs::write(
|
||||
scripts_dir.join("async_extract.js"),
|
||||
"return (async function() { return { async: true, args: args }; })();\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"async": true,
|
||||
"args": { "expected_domain": "example.com" }
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 5,
|
||||
},
|
||||
}]));
|
||||
|
||||
let mut policy_json = test_policy();
|
||||
// 允许 example.com
|
||||
policy_json = MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com", "example.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_json,
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let skill_tool = SkillTool {
|
||||
name: "async_extract".to_string(),
|
||||
description: "Extract data asynchronously".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/async_extract.js".to_string(),
|
||||
args: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
&PipeBrowserBackend::from_inner(browser_tool),
|
||||
json!({
|
||||
"expected_domain": "example.com"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
let output = serde_json::from_str::<serde_json::Value>(&result.output).unwrap();
|
||||
assert_eq!(output["async"], true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行新测试**
|
||||
|
||||
Run: `cargo test execute_browser_script_tool_awaits_async_script`
|
||||
Expected: 测试通过
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/browser_script_skill_tool_test.rs
|
||||
git commit -m "test: add async browser script test case
|
||||
|
||||
🤖 Generated with [Qoder][https://qoder.com]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 端到端验证
|
||||
|
||||
**Files:**
|
||||
- 无文件修改,仅验证
|
||||
|
||||
- [ ] **Step 1: 完整构建**
|
||||
|
||||
Run: `cargo build`
|
||||
Expected: 编译成功
|
||||
|
||||
- [ ] **Step 2: 运行全部测试**
|
||||
|
||||
Run: `cargo test`
|
||||
Expected: 所有测试通过
|
||||
|
||||
- [ ] **Step 3: 手动端到端测试**
|
||||
|
||||
使用 service console 测试 `tq-lineloss-report.collect_lineloss`:
|
||||
1. 启动 sgclaw: `target/debug/sg_claw.exe`
|
||||
2. 在 service console 输入: `兰州公司 台区线损大数据 月累计线损率统计分析。。。`
|
||||
3. 预期结果: 返回实际报表数据,而非 `{}`
|
||||
|
||||
---
|
||||
|
||||
## 自检清单
|
||||
|
||||
- [x] Spec 覆盖: 设计文档中所有要点都有对应任务
|
||||
- [x] 无占位符: 所有代码都是完整的
|
||||
- [x] 类型一致性: 函数签名无变化
|
||||
73
docs/superpowers/plans/2026-04-13-async-eval-then-fix.md
Normal file
73
docs/superpowers/plans/2026-04-13-async-eval-then-fix.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Async Eval .then() Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix `build_eval_js` to handle async script return values using `.then()` instead of `async IIFE`.
|
||||
|
||||
**Architecture:** Extract callback-sending logic into a `_s` helper function inside the generated JS. If the script returns a Promise, call `_s` via `.then()`; otherwise call `_s` synchronously. This keeps the outer IIFE synchronous for C++ injection compatibility.
|
||||
|
||||
**Tech Stack:** Rust, JavaScript
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `src/browser/callback_backend.rs:433-447` - `build_eval_js` function
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Modify build_eval_js to support async via .then()
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_backend.rs:433-447`
|
||||
|
||||
- [ ] **Step 1: Replace build_eval_js implementation**
|
||||
|
||||
Replace the entire `build_eval_js` function body (lines 433-447) with:
|
||||
|
||||
```rust
|
||||
fn build_eval_js(source_url: &str, script: &str) -> String {
|
||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||
let callback = EVAL_CALLBACK_NAME;
|
||||
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||
|
||||
format!(
|
||||
"(function(){{try{{\
|
||||
var v=(function(){{return {script}}})();\
|
||||
function _s(v){{\
|
||||
var t=(typeof v==='string')?v:JSON.stringify(v);\
|
||||
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\
|
||||
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\
|
||||
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||
}}\
|
||||
if(v&&typeof v.then==='function'){{v.then(_s).catch(function(){{}});}}else{{_s(v);}}\
|
||||
}}catch(e){{}}}})()"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `cargo test browser_script_skill_tool --no-fail-fast`
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Run full test suite**
|
||||
|
||||
Run: `cargo test`
|
||||
|
||||
Expected: All tests pass (except pre-existing `lineloss_period_resolver_prompts_for_missing_period` failure which is unrelated).
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cargo build`
|
||||
|
||||
Expected: Compiles with no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_backend.rs
|
||||
git commit -m "fix: support async browser scripts via .then() in build_eval_js"
|
||||
```
|
||||
52
docs/superpowers/plans/2026-04-13-expected-domain-arg-fix.md
Normal file
52
docs/superpowers/plans/2026-04-13-expected-domain-arg-fix.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Expected Domain Arg Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix browser_script_skill_tool to pass expected_domain to wrapped JS scripts.
|
||||
|
||||
**Architecture:** Insert the normalized expected_domain back into args HashMap after domain normalization, before script wrapping.
|
||||
|
||||
**Tech Stack:** Rust, serde_json
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs:210` - Insert expected_domain back into args
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Insert expected_domain into args
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs:210`
|
||||
|
||||
- [ ] **Step 1: Add expected_domain to args after normalization**
|
||||
|
||||
Edit `src/compat/browser_script_skill_tool.rs`, insert after line 209 (`eprintln!("[execute_browser_script_impl] expected_domain: {}", expected_domain);`):
|
||||
|
||||
```rust
|
||||
args.insert("expected_domain".to_string(), Value::String(expected_domain.clone()));
|
||||
```
|
||||
|
||||
The context around line 209-211 should look like this after the edit:
|
||||
|
||||
```rust
|
||||
eprintln!("[execute_browser_script_impl] expected_domain: {}", expected_domain);
|
||||
args.insert("expected_domain".to_string(), Value::String(expected_domain.clone()));
|
||||
|
||||
for required_arg in tool.args.keys() {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify the fix**
|
||||
|
||||
Run: `cargo test browser_script_skill_tool --no-fail-fast -- --nocapture`
|
||||
|
||||
Expected: All tests pass, including `execute_browser_script_tool_runs_packaged_script_with_expected_domain`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/browser_script_skill_tool.rs
|
||||
git commit -m "fix: pass expected_domain to wrapped browser scripts"
|
||||
```
|
||||
163
docs/superpowers/plans/2026-04-13-lineloss-requesturl-fix.md
Normal file
163
docs/superpowers/plans/2026-04-13-lineloss-requesturl-fix.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 台区线损 requesturl 快速修复 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 `derive_request_url_from_instruction` 中添加台区线损 URL 映射,使 `sgHideBrowerserOpenPage` 命令能正确执行。
|
||||
|
||||
**Architecture:** 在现有知乎 URL 映射模式后追加台区线损场景的硬编码映射。
|
||||
|
||||
**Tech Stack:** Rust
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 添加测试用例
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs:828` (tests 模块)
|
||||
|
||||
- [ ] **Step 1: 在 tests 模块中添加台区线损 URL 映射测试**
|
||||
|
||||
在 `initial_request_url_falls_back_to_zhihu_origin_for_generated_article_publish_routes` 测试后添加新测试:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn initial_request_url_falls_back_to_lineloss_origin_for_lineloss_instructions() {
|
||||
let request = SubmitTaskRequest {
|
||||
instruction: "兰州公司 台区线损大数据 月累计线损率统计分析。。。".to_string(),
|
||||
..SubmitTaskRequest::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
initial_request_url_for_submit_task(&request),
|
||||
"http://20.76.57.61:18080"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试验证失败**
|
||||
|
||||
Run: `cargo test initial_request_url_falls_back_to_lineloss_origin_for_lineloss_instructions -- --nocapture`
|
||||
|
||||
Expected: FAIL - 测试应该失败,因为还未实现映射逻辑
|
||||
|
||||
- [ ] **Step 3: 提交测试文件**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs
|
||||
git commit -m "test: add lineloss requesturl mapping test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 实现台区线损 URL 映射
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs:354-382` (derive_request_url_from_instruction 函数)
|
||||
|
||||
- [ ] **Step 1: 在 derive_request_url_from_instruction 中添加台区线损映射**
|
||||
|
||||
在第二个知乎判断块后、`None` 之前添加:
|
||||
|
||||
```rust
|
||||
// 台区线损相关
|
||||
// TODO: 临时方案,后续应从 skill 配置或 deterministic_submit 解析结果中获取
|
||||
if instruction.contains("线损") || instruction.contains("lineloss") {
|
||||
return Some("http://20.76.57.61:18080".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
```
|
||||
|
||||
完整函数应为:
|
||||
|
||||
```rust
|
||||
fn derive_request_url_from_instruction(instruction: &str) -> Option<String> {
|
||||
if crate::compat::workflow_executor::detect_route(instruction, None, None)
|
||||
.is_some_and(|route| {
|
||||
matches!(
|
||||
route,
|
||||
crate::compat::workflow_executor::WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuHotlistScreen
|
||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleEntry
|
||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleAutoPublishGenerated
|
||||
)
|
||||
})
|
||||
{
|
||||
return Some("https://www.zhihu.com".to_string());
|
||||
}
|
||||
|
||||
if crate::compat::workflow_executor::detect_route(instruction, None, None)
|
||||
.is_some_and(|route| {
|
||||
matches!(
|
||||
route,
|
||||
crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleDraft
|
||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticlePublish
|
||||
)
|
||||
})
|
||||
{
|
||||
return Some("https://zhuanlan.zhihu.com".to_string());
|
||||
}
|
||||
|
||||
// 台区线损相关
|
||||
// TODO: 临时方案,后续应从 skill 配置或 deterministic_submit 解析结果中获取
|
||||
if instruction.contains("线损") || instruction.contains("lineloss") {
|
||||
return Some("http://20.76.57.61:18080".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试验证通过**
|
||||
|
||||
Run: `cargo test initial_request_url_falls_back_to_lineloss_origin_for_lineloss_instructions -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: 运行所有相关测试**
|
||||
|
||||
Run: `cargo test initial_request_url -- --nocapture`
|
||||
|
||||
Expected: 所有测试通过
|
||||
|
||||
- [ ] **Step 4: 构建项目**
|
||||
|
||||
Run: `cargo build`
|
||||
|
||||
Expected: 编译成功,无错误
|
||||
|
||||
- [ ] **Step 5: 提交实现**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs
|
||||
git commit -m "feat: add lineloss URL mapping in derive_request_url_from_instruction
|
||||
|
||||
临时方案:检测指令中包含'线损'或'lineloss'时返回台区线损平台 URL
|
||||
|
||||
🤖 Generated with [Qoder][https://qoder.com]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 端到端验证
|
||||
|
||||
**Files:**
|
||||
- 无文件修改,仅运行验证
|
||||
|
||||
- [ ] **Step 1: 停止现有 sgclaw 进程**
|
||||
|
||||
确保没有 `sg_claw.exe` 在运行
|
||||
|
||||
- [ ] **Step 2: 启动 sgclaw 服务**
|
||||
|
||||
Run: `target\debug\sg_claw.exe --config-path ..\sgclaw_config.json service`
|
||||
|
||||
- [ ] **Step 3: 在 service console 发送测试指令**
|
||||
|
||||
指令: `兰州公司 台区线损大数据 月累计线损率统计分析。。。`
|
||||
|
||||
Expected: 日志显示 `bootstrap_url=http://20.76.57.61:18080`,而非 `about:blank`
|
||||
|
||||
- [ ] **Step 4: 验证 helper page 打开成功**
|
||||
|
||||
Expected: 日志显示 `helper_loaded=true, ready=true`,不再超时
|
||||
76
docs/superpowers/plans/2026-04-13-lineloss-target-url-fix.md
Normal file
76
docs/superpowers/plans/2026-04-13-lineloss-target-url-fix.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 台区线损 target_url 缺失修复 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 `browser_script_skill_tool.rs` 调用 `Action::Eval` 时添加 `target_url` 参数。
|
||||
|
||||
**Architecture:** 从 `expected_domain` 构造完整 URL(`http://{expected_domain}`),添加到 invoke 的 params 中。
|
||||
|
||||
**Tech Stack:** Rust, serde_json
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 添加 target_url 参数
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs:238-241` (invoke 调用)
|
||||
|
||||
- [ ] **Step 1: 修改 invoke 调用,添加 target_url**
|
||||
|
||||
将:
|
||||
```rust
|
||||
let result = match browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({ "script": wrapped_script }),
|
||||
&expected_domain,
|
||||
) {
|
||||
```
|
||||
|
||||
改为:
|
||||
```rust
|
||||
let target_url = format!("http://{}", expected_domain);
|
||||
let result = match browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": wrapped_script,
|
||||
"target_url": target_url,
|
||||
}),
|
||||
&expected_domain,
|
||||
) {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 构建项目**
|
||||
|
||||
Run: `cargo build`
|
||||
|
||||
Expected: 编译成功,无错误
|
||||
|
||||
- [ ] **Step 3: 提交修改**
|
||||
|
||||
```bash
|
||||
git add src/compat/browser_script_skill_tool.rs
|
||||
git commit -m "fix: add target_url param for Action::Eval in browser_script_skill_tool
|
||||
|
||||
🤖 Generated with [Qoder][https://qoder.com]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 端到端验证
|
||||
|
||||
**Files:**
|
||||
- 无文件修改,仅运行验证
|
||||
|
||||
- [ ] **Step 1: 停止现有 sgclaw 进程**
|
||||
|
||||
确保没有 `sg_claw.exe` 在运行
|
||||
|
||||
- [ ] **Step 2: 启动 sgclaw 服务**
|
||||
|
||||
Run: `target\debug\sg_claw.exe --config-path ..\sgclaw_config.json service`
|
||||
|
||||
- [ ] **Step 3: 在 service console 发送测试指令**
|
||||
|
||||
指令: `兰州公司 台区线损大数据 月累计线损率统计分析。。。`
|
||||
|
||||
Expected: 日志显示 `invoke 成功`,不再出现 `target_url is required for eval` 错误
|
||||
@@ -0,0 +1,912 @@
|
||||
# Rust-Side Lineloss XLSX Export Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move XLSX export from browser JS (blocked by CORS) to Rust side, so `collect_lineloss.js` only collects data and Rust generates the `.xlsx` file locally.
|
||||
|
||||
**Architecture:** JS collects API data and returns a `report-artifact` JSON with `rows`, `column_defs`, and metadata. Rust parses the artifact, extracts rows + column definitions, and generates a standard `.xlsx` file using the `zip` crate + OpenXML XML strings (same pattern as `openxml_office_tool.rs`). Report log is deferred.
|
||||
|
||||
**Tech Stack:** Rust, `zip` 0.6.6, `serde_json`, OpenXML Spreadsheet ML, JavaScript (browser-injected)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-13-rust-side-lineloss-xlsx-export.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `src/compat/lineloss_xlsx_export.rs` | **New.** Pure XLSX generation: takes column defs + row data, produces `.xlsx` file. No business logic. |
|
||||
| `src/compat/deterministic_submit.rs` | **Modify.** After receiving JS artifact, extract rows + column_defs, call XLSX export, attach path to outcome. |
|
||||
| `src/compat/mod.rs` | **Modify.** Register `lineloss_xlsx_export` module. |
|
||||
| `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js` | **Modify.** Remove `exportWorkbook`/`writeReportLog` calls. Add `column_defs` to artifact. |
|
||||
| `tests/lineloss_xlsx_export_test.rs` | **New.** Unit tests for XLSX generation. |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create `lineloss_xlsx_export.rs` with Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `src/compat/lineloss_xlsx_export.rs`
|
||||
- Create: `tests/lineloss_xlsx_export_test.rs`
|
||||
- Modify: `src/compat/mod.rs`
|
||||
|
||||
- [ ] **Step 1: Register the new module in `src/compat/mod.rs`**
|
||||
|
||||
Add the module declaration in alphabetical order. In `src/compat/mod.rs`, insert after `pub mod event_bridge;`:
|
||||
|
||||
```rust
|
||||
pub mod lineloss_xlsx_export;
|
||||
```
|
||||
|
||||
The full file becomes:
|
||||
|
||||
```rust
|
||||
pub mod artifact_open;
|
||||
pub mod browser_script_skill_tool;
|
||||
pub mod browser_tool_adapter;
|
||||
pub mod config_adapter;
|
||||
pub mod cron_adapter;
|
||||
pub mod deterministic_submit;
|
||||
pub mod direct_skill_runtime;
|
||||
pub mod event_bridge;
|
||||
pub mod lineloss_xlsx_export;
|
||||
pub mod memory_adapter;
|
||||
pub mod openxml_office_tool;
|
||||
pub mod orchestration;
|
||||
pub mod runtime;
|
||||
pub mod screen_html_export_tool;
|
||||
pub mod tq_lineloss;
|
||||
pub mod workflow_executor;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test for XLSX generation**
|
||||
|
||||
Create `tests/lineloss_xlsx_export_test.rs`:
|
||||
|
||||
```rust
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::json;
|
||||
use sgclaw::compat::lineloss_xlsx_export::{export_lineloss_xlsx, LinelossExportRequest};
|
||||
|
||||
fn temp_output_path(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join("sgclaw-test-xlsx");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_month_lineloss_produces_valid_xlsx() {
|
||||
let output_path = temp_output_path("month-test.xlsx");
|
||||
if output_path.exists() {
|
||||
fs::remove_file(&output_path).unwrap();
|
||||
}
|
||||
|
||||
let request = LinelossExportRequest {
|
||||
sheet_name: "国网兰州供电公司月度线损分析报表(2026-03)".to_string(),
|
||||
column_defs: vec![
|
||||
("ORG_NAME".to_string(), "供电单位".to_string()),
|
||||
("YGDL".to_string(), "累计供电量".to_string()),
|
||||
("YYDL".to_string(), "累计售电量".to_string()),
|
||||
("YXSL".to_string(), "线损完成率(%)".to_string()),
|
||||
("RAT_SCOPE".to_string(), "线损率累计目标值".to_string()),
|
||||
("BLANK3".to_string(), "目标完成率".to_string()),
|
||||
("BLANK2".to_string(), "排行".to_string()),
|
||||
],
|
||||
rows: vec![
|
||||
serde_json::from_value(json!({
|
||||
"ORG_NAME": "城关供电",
|
||||
"YGDL": "12345.67",
|
||||
"YYDL": "11234.56",
|
||||
"YXSL": "9.00",
|
||||
"RAT_SCOPE": "9.50",
|
||||
"BLANK3": "94.74",
|
||||
"BLANK2": "1"
|
||||
}))
|
||||
.unwrap(),
|
||||
serde_json::from_value(json!({
|
||||
"ORG_NAME": "七里河供电",
|
||||
"YGDL": "9876.54",
|
||||
"YYDL": "8765.43",
|
||||
"YXSL": "11.24",
|
||||
"RAT_SCOPE": "10.00",
|
||||
"BLANK3": "112.40",
|
||||
"BLANK2": "2"
|
||||
}))
|
||||
.unwrap(),
|
||||
],
|
||||
output_path: output_path.clone(),
|
||||
};
|
||||
|
||||
let result_path = export_lineloss_xlsx(&request).unwrap();
|
||||
assert_eq!(result_path, output_path);
|
||||
assert!(output_path.exists());
|
||||
|
||||
// Verify it's a valid ZIP (xlsx is a zip archive)
|
||||
let file = fs::File::open(&output_path).unwrap();
|
||||
let mut archive = zip::ZipArchive::new(file).unwrap();
|
||||
|
||||
// Must contain the standard OpenXML entries
|
||||
let entry_names: Vec<String> = (0..archive.len())
|
||||
.map(|i| archive.by_index(i).unwrap().name().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(entry_names.contains(&"[Content_Types].xml".to_string()));
|
||||
assert!(entry_names.contains(&"xl/worksheets/sheet1.xml".to_string()));
|
||||
assert!(entry_names.contains(&"xl/workbook.xml".to_string()));
|
||||
|
||||
// Read sheet1.xml and verify it contains our data
|
||||
let mut sheet = archive.by_name("xl/worksheets/sheet1.xml").unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut sheet, &mut xml).unwrap();
|
||||
|
||||
assert!(xml.contains("供电单位"), "header row should contain 供电单位");
|
||||
assert!(xml.contains("累计供电量"), "header row should contain 累计供电量");
|
||||
assert!(xml.contains("城关供电"), "data should contain 城关供电");
|
||||
assert!(xml.contains("12345.67"), "data should contain 12345.67");
|
||||
assert!(xml.contains("七里河供电"), "data should contain second row");
|
||||
|
||||
// Cleanup
|
||||
fs::remove_file(&output_path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_empty_rows_returns_error() {
|
||||
let output_path = temp_output_path("empty-test.xlsx");
|
||||
|
||||
let request = LinelossExportRequest {
|
||||
sheet_name: "test".to_string(),
|
||||
column_defs: vec![("A".to_string(), "ColA".to_string())],
|
||||
rows: vec![],
|
||||
output_path: output_path.clone(),
|
||||
};
|
||||
|
||||
let result = export_lineloss_xlsx(&request);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().to_string().contains("rows must not be empty"),
|
||||
"should reject empty rows"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test to verify it fails**
|
||||
|
||||
Run: `cargo test --test lineloss_xlsx_export_test -- --nocapture`
|
||||
|
||||
Expected: compilation error — `lineloss_xlsx_export` module doesn't exist yet or `export_lineloss_xlsx` / `LinelossExportRequest` not defined.
|
||||
|
||||
- [ ] **Step 4: Implement `src/compat/lineloss_xlsx_export.rs`**
|
||||
|
||||
```rust
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use zip::write::FileOptions;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
pub struct LinelossExportRequest {
|
||||
pub sheet_name: String,
|
||||
pub column_defs: Vec<(String, String)>,
|
||||
pub rows: Vec<Map<String, Value>>,
|
||||
pub output_path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn export_lineloss_xlsx(request: &LinelossExportRequest) -> anyhow::Result<PathBuf> {
|
||||
if request.rows.is_empty() {
|
||||
anyhow::bail!("rows must not be empty");
|
||||
}
|
||||
if request.column_defs.is_empty() {
|
||||
anyhow::bail!("column_defs must not be empty");
|
||||
}
|
||||
|
||||
let sheet_xml = build_worksheet_xml(&request.column_defs, &request.rows);
|
||||
|
||||
write_xlsx(
|
||||
&request.output_path,
|
||||
&request.sheet_name,
|
||||
&sheet_xml,
|
||||
)?;
|
||||
|
||||
Ok(request.output_path.clone())
|
||||
}
|
||||
|
||||
fn build_worksheet_xml(
|
||||
column_defs: &[(String, String)],
|
||||
rows: &[Map<String, Value>],
|
||||
) -> String {
|
||||
let mut xml_rows = Vec::with_capacity(rows.len() + 1);
|
||||
|
||||
// Header row (row 1)
|
||||
let header_cells: Vec<String> = column_defs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(col_idx, (_key, label))| {
|
||||
let col_letter = column_letter(col_idx);
|
||||
format!(
|
||||
"<c r=\"{col_letter}1\" t=\"inlineStr\"><is><t>{}</t></is></c>",
|
||||
xml_escape(label)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
xml_rows.push(format!("<row r=\"1\">{}</row>", header_cells.join("")));
|
||||
|
||||
// Data rows (row 2+)
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let excel_row = row_idx + 2;
|
||||
let cells: Vec<String> = column_defs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(col_idx, (key, _label))| {
|
||||
let col_letter = column_letter(col_idx);
|
||||
let value = row
|
||||
.get(key)
|
||||
.map(|v| value_to_string(v))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"<c r=\"{col_letter}{excel_row}\" t=\"inlineStr\"><is><t>{}</t></is></c>",
|
||||
xml_escape(&value)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
xml_rows.push(format!("<row r=\"{excel_row}\">{}</row>", cells.join("")));
|
||||
}
|
||||
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
|
||||
<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
|
||||
<sheetData>{}</sheetData>\
|
||||
</worksheet>",
|
||||
xml_rows.join("")
|
||||
)
|
||||
}
|
||||
|
||||
fn column_letter(index: usize) -> String {
|
||||
let mut result = String::new();
|
||||
let mut n = index;
|
||||
loop {
|
||||
result.insert(0, (b'A' + (n % 26) as u8) as char);
|
||||
if n < 26 {
|
||||
break;
|
||||
}
|
||||
n = n / 26 - 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
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 xml_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn write_xlsx(output_path: &Path, sheet_name: &str, sheet_xml: &str) -> anyhow::Result<()> {
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if output_path.exists() {
|
||||
fs::remove_file(output_path)?;
|
||||
}
|
||||
|
||||
let file = fs::File::create(output_path)?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
|
||||
zip.start_file("[Content_Types].xml", options)?;
|
||||
zip.write_all(content_types_xml().as_bytes())?;
|
||||
|
||||
zip.start_file("_rels/.rels", options)?;
|
||||
zip.write_all(root_rels_xml().as_bytes())?;
|
||||
|
||||
zip.start_file("docProps/app.xml", options)?;
|
||||
zip.write_all(app_xml().as_bytes())?;
|
||||
|
||||
zip.start_file("docProps/core.xml", options)?;
|
||||
zip.write_all(core_xml().as_bytes())?;
|
||||
|
||||
zip.start_file("xl/workbook.xml", options)?;
|
||||
zip.write_all(workbook_xml(&xml_escape(sheet_name)).as_bytes())?;
|
||||
|
||||
zip.start_file("xl/_rels/workbook.xml.rels", options)?;
|
||||
zip.write_all(workbook_rels_xml().as_bytes())?;
|
||||
|
||||
zip.start_file("xl/worksheets/sheet1.xml", options)?;
|
||||
zip.write_all(sheet_xml.as_bytes())?;
|
||||
|
||||
zip.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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>台区线损报表</dc:title>
|
||||
</cp:coreProperties>"#
|
||||
}
|
||||
|
||||
fn workbook_xml(sheet_name: &str) -> String {
|
||||
format!(
|
||||
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="{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>"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::column_letter;
|
||||
|
||||
#[test]
|
||||
fn column_letter_maps_indices_correctly() {
|
||||
assert_eq!(column_letter(0), "A");
|
||||
assert_eq!(column_letter(1), "B");
|
||||
assert_eq!(column_letter(6), "G");
|
||||
assert_eq!(column_letter(25), "Z");
|
||||
assert_eq!(column_letter(26), "AA");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run: `cargo test --test lineloss_xlsx_export_test -- --nocapture`
|
||||
|
||||
Expected: both `export_month_lineloss_produces_valid_xlsx` and `export_empty_rows_returns_error` PASS.
|
||||
|
||||
Also run the internal unit test:
|
||||
|
||||
Run: `cargo test lineloss_xlsx_export -- --nocapture`
|
||||
|
||||
Expected: `column_letter_maps_indices_correctly` PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/lineloss_xlsx_export.rs src/compat/mod.rs tests/lineloss_xlsx_export_test.rs
|
||||
git commit -m "feat(lineloss): add Rust-side XLSX generation for lineloss reports"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Integrate XLSX Export into `deterministic_submit.rs`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/compat/deterministic_submit.rs`
|
||||
|
||||
- [ ] **Step 1: Add imports and helper function to extract export data from artifact**
|
||||
|
||||
At the top of `src/compat/deterministic_submit.rs`, add the import:
|
||||
|
||||
```rust
|
||||
use crate::compat::lineloss_xlsx_export::{export_lineloss_xlsx, LinelossExportRequest};
|
||||
```
|
||||
|
||||
Then add a new helper function after `summarize_lineloss_artifact`:
|
||||
|
||||
```rust
|
||||
struct LinelossArtifactExportData {
|
||||
sheet_name: String,
|
||||
column_defs: Vec<(String, String)>,
|
||||
rows: Vec<Map<String, Value>>,
|
||||
}
|
||||
|
||||
fn extract_export_data(output: &str) -> Option<LinelossArtifactExportData> {
|
||||
let payload: Value = serde_json::from_str(output).ok()?;
|
||||
let artifact = payload
|
||||
.as_object()
|
||||
.and_then(|object| object.get("text"))
|
||||
.unwrap_or(&payload);
|
||||
let artifact = artifact.as_object()?;
|
||||
|
||||
if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = artifact.get("status").and_then(Value::as_str).unwrap_or("");
|
||||
if !matches!(status, "ok" | "partial") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rows = artifact
|
||||
.get("rows")
|
||||
.and_then(Value::as_array)?;
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let rows: Vec<Map<String, Value>> = rows
|
||||
.iter()
|
||||
.filter_map(|row| row.as_object().cloned())
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let column_defs: Vec<(String, String)> = artifact
|
||||
.get("column_defs")
|
||||
.and_then(Value::as_array)
|
||||
.map(|defs| {
|
||||
defs.iter()
|
||||
.filter_map(|def| {
|
||||
let arr = def.as_array()?;
|
||||
let key = arr.first()?.as_str()?.to_string();
|
||||
let label = arr.get(1)?.as_str()?.to_string();
|
||||
Some((key, label))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Fallback: if column_defs not in artifact, try "columns" array as keys
|
||||
let column_defs = if column_defs.is_empty() {
|
||||
let columns = artifact
|
||||
.get("columns")
|
||||
.and_then(Value::as_array)?;
|
||||
columns
|
||||
.iter()
|
||||
.filter_map(|col| {
|
||||
let key = col.as_str()?.to_string();
|
||||
Some((key.clone(), key))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
column_defs
|
||||
};
|
||||
|
||||
if column_defs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let org_label = artifact
|
||||
.get("org")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|org| org.get("label"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("lineloss");
|
||||
let period_mode = artifact
|
||||
.get("period")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|p| p.get("mode"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("month");
|
||||
let period_value = artifact
|
||||
.get("period")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|p| p.get("value"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let mode_label = if period_mode == "week" { "周度" } else { "月度" };
|
||||
let sheet_name = format!("{org_label}{mode_label}线损分析报表({period_value})");
|
||||
|
||||
Some(LinelossArtifactExportData {
|
||||
sheet_name,
|
||||
column_defs,
|
||||
rows,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the export-after-collection function**
|
||||
|
||||
Add a new function that wraps the existing flow with XLSX export:
|
||||
|
||||
```rust
|
||||
fn try_export_lineloss_xlsx(
|
||||
output: &str,
|
||||
workspace_root: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
let data = extract_export_data(output)?;
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or_default();
|
||||
let out_dir = workspace_root.join("out");
|
||||
let output_path = out_dir.join(format!("tq-lineloss-{nanos}.xlsx"));
|
||||
|
||||
let request = LinelossExportRequest {
|
||||
sheet_name: data.sheet_name,
|
||||
column_defs: data.column_defs,
|
||||
rows: data.rows,
|
||||
output_path,
|
||||
};
|
||||
|
||||
match export_lineloss_xlsx(&request) {
|
||||
Ok(path) => {
|
||||
eprintln!("[deterministic_submit] XLSX exported to: {}", path.display());
|
||||
Some(path)
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[deterministic_submit] XLSX export failed: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Modify `execute_deterministic_submit_with_browser_backend` to call export**
|
||||
|
||||
Replace the body of `execute_deterministic_submit_with_browser_backend` (lines 119-136 of the original file):
|
||||
|
||||
```rust
|
||||
pub fn execute_deterministic_submit_with_browser_backend(
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
plan: &DeterministicExecutionPlan,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<DirectSubmitOutcome, PipeError> {
|
||||
let args = deterministic_submit_args(plan);
|
||||
let output =
|
||||
crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output_with_browser_backend(
|
||||
browser_backend,
|
||||
&plan.tool_name,
|
||||
workspace_root,
|
||||
settings,
|
||||
args,
|
||||
)?;
|
||||
|
||||
let export_path = try_export_lineloss_xlsx(&output, workspace_root);
|
||||
Ok(summarize_lineloss_output_with_export(&output, export_path.as_deref()))
|
||||
}
|
||||
```
|
||||
|
||||
Apply the same change to `execute_deterministic_submit` (the non-backend variant, lines 101-117):
|
||||
|
||||
```rust
|
||||
pub fn execute_deterministic_submit<T: Transport + 'static>(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
plan: &DeterministicExecutionPlan,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<DirectSubmitOutcome, PipeError> {
|
||||
let args = deterministic_submit_args(plan);
|
||||
let output = crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output(
|
||||
browser_tool,
|
||||
&plan.tool_name,
|
||||
workspace_root,
|
||||
settings,
|
||||
args,
|
||||
)?;
|
||||
|
||||
let export_path = try_export_lineloss_xlsx(&output, workspace_root);
|
||||
Ok(summarize_lineloss_output_with_export(&output, export_path.as_deref()))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `summarize_lineloss_output_with_export` function**
|
||||
|
||||
Add this new function. It wraps the existing `summarize_lineloss_output` and appends the export path:
|
||||
|
||||
```rust
|
||||
fn summarize_lineloss_output_with_export(output: &str, export_path: Option<&Path>) -> DirectSubmitOutcome {
|
||||
let mut outcome = summarize_lineloss_output(output);
|
||||
|
||||
if let Some(path) = export_path {
|
||||
outcome.summary.push_str(&format!(" export_path={}", path.display()));
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run existing tests to ensure nothing breaks**
|
||||
|
||||
Run: `cargo test --test deterministic_submit_test -- --nocapture`
|
||||
|
||||
Expected: all existing tests PASS (the tests don't call `execute_deterministic_submit`, they test `decide_deterministic_submit` and parsing logic which is unchanged).
|
||||
|
||||
Run: `cargo test deterministic_submit -- --nocapture`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/deterministic_submit.rs
|
||||
git commit -m "feat(lineloss): integrate Rust-side XLSX export into deterministic submit pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Modify `collect_lineloss.js` to Skip Browser-Side Export
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
|
||||
|
||||
- [ ] **Step 1: Add `column_defs` to the artifact returned by `buildArtifact`**
|
||||
|
||||
In the `buildArtifact` function (around line 198), the `columns` field currently contains just column keys (e.g., `["ORG_NAME", "YGDL", ...]`). Add a `column_defs` field that includes the full key+label pairs. Change the `buildArtifact` function to also accept and emit `column_defs`:
|
||||
|
||||
Find this block in `buildArtifact` (line 198-242):
|
||||
|
||||
```javascript
|
||||
function buildArtifact({
|
||||
status,
|
||||
blockedReason = '',
|
||||
fatalError = '',
|
||||
org_label = '',
|
||||
org_code = '',
|
||||
period_mode = '',
|
||||
period_mode_code = '',
|
||||
period_value = '',
|
||||
period_payload = {},
|
||||
columns = [],
|
||||
rows = [],
|
||||
export: exportState,
|
||||
reasons = []
|
||||
}) {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```javascript
|
||||
function buildArtifact({
|
||||
status,
|
||||
blockedReason = '',
|
||||
fatalError = '',
|
||||
org_label = '',
|
||||
org_code = '',
|
||||
period_mode = '',
|
||||
period_mode_code = '',
|
||||
period_value = '',
|
||||
period_payload = {},
|
||||
columns = [],
|
||||
column_defs = [],
|
||||
rows = [],
|
||||
export: exportState,
|
||||
reasons = []
|
||||
}) {
|
||||
```
|
||||
|
||||
In the returned object (the `return { ... }` block inside `buildArtifact`), add `column_defs` after `columns`:
|
||||
|
||||
```javascript
|
||||
columns: [...columns],
|
||||
column_defs: [...column_defs],
|
||||
rows: [...rows],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Pass `column_defs` from `buildBrowserEntrypointResult`**
|
||||
|
||||
In `buildBrowserEntrypointResult`, after the `columns` assignment (around line 452), add:
|
||||
|
||||
```javascript
|
||||
const columns = normalizedArgs.period_mode === 'week' ? WEEK_COLUMNS : MONTH_COLUMNS;
|
||||
const columnDefs = normalizedArgs.period_mode === 'week' ? WEEK_COLUMN_DEFS : MONTH_COLUMN_DEFS;
|
||||
```
|
||||
|
||||
Then in every call to `buildArtifact` inside `buildBrowserEntrypointResult`, add `column_defs: columnDefs` alongside `columns`. There are 5 calls:
|
||||
|
||||
**Call 1** (API error, around line 466):
|
||||
```javascript
|
||||
columns,
|
||||
column_defs: columnDefs,
|
||||
rows: [],
|
||||
```
|
||||
|
||||
**Call 2** (empty rows, around line 483):
|
||||
```javascript
|
||||
columns,
|
||||
column_defs: columnDefs,
|
||||
rows: []
|
||||
```
|
||||
|
||||
**Call 3** (normalization failure, around line 497):
|
||||
```javascript
|
||||
columns,
|
||||
column_defs: columnDefs,
|
||||
rows: [],
|
||||
```
|
||||
|
||||
**Call 4** (success, around line 558):
|
||||
```javascript
|
||||
columns,
|
||||
column_defs: columnDefs,
|
||||
rows,
|
||||
```
|
||||
|
||||
Note: the two `buildArtifact` calls before the `columns` variable is assigned (validation failure and page context failure, around lines 422 and 439) don't need `column_defs` since they don't have data.
|
||||
|
||||
- [ ] **Step 3: Remove the `exportWorkbook` and `writeReportLog` calls from the success path**
|
||||
|
||||
In `buildBrowserEntrypointResult`, replace the entire export block (lines 518-556) with a simplified version:
|
||||
|
||||
Find:
|
||||
```javascript
|
||||
const exportState = {
|
||||
attempted: false,
|
||||
status: 'skipped',
|
||||
message: null
|
||||
};
|
||||
|
||||
if (typeof deps.exportWorkbook === 'function') {
|
||||
exportState.attempted = true;
|
||||
try {
|
||||
const exportPayload = buildExportPayload({
|
||||
mode: normalizedArgs.period_mode,
|
||||
orgLabel: normalizedArgs.org_label,
|
||||
periodValue: normalizedArgs.period_value,
|
||||
rows
|
||||
});
|
||||
const exportResult = await deps.exportWorkbook(exportPayload);
|
||||
const exportPath = pickFirstNonEmpty(exportResult?.path, exportResult?.data?.path, exportResult?.data?.data);
|
||||
if (!exportPath) {
|
||||
throw new Error('export_failed');
|
||||
}
|
||||
exportState.status = 'ok';
|
||||
exportState.message = exportPath;
|
||||
|
||||
if (typeof deps.writeReportLog === 'function') {
|
||||
try {
|
||||
const reportLog = await deps.writeReportLog(buildReportName(normalizedArgs), exportPath);
|
||||
if (reportLog?.success === false) {
|
||||
reasons.push('report_log_failed');
|
||||
}
|
||||
} catch (_error) {
|
||||
reasons.push('report_log_failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
reasons.push('export_failed');
|
||||
exportState.status = 'failed';
|
||||
exportState.message = pickFirstNonEmpty(error?.message, 'export_failed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```javascript
|
||||
// Export is handled by Rust side after receiving the artifact.
|
||||
// JS only provides rows + column_defs in the artifact.
|
||||
const exportState = {
|
||||
attempted: false,
|
||||
status: 'deferred_to_rust',
|
||||
message: null
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove unused constants and functions**
|
||||
|
||||
Remove these constants (lines 5-6) since they are no longer called from JS:
|
||||
|
||||
```javascript
|
||||
const EXPORT_SERVICE_URL = 'http://localhost:13313/SurfaceServices/personalBread/export/faultDetailsExportXLSX';
|
||||
const REPORT_LOG_URL = 'http://localhost:13313/ReportServices/Api/setReportLog';
|
||||
```
|
||||
|
||||
Remove the `postJson` function (lines 264-294) — it is no longer needed since no JS-side HTTP calls are made to localhost.
|
||||
|
||||
Remove these functions from `defaultBrowserDeps()`:
|
||||
- `exportWorkbook` (lines 350-373)
|
||||
- `writeReportLog` (lines 375-409)
|
||||
|
||||
Remove these now-unused functions:
|
||||
- `buildExportTitles` (lines 244-254)
|
||||
- `buildExportPayload` (lines 256-262)
|
||||
- `buildReportName` (lines 413-415)
|
||||
|
||||
- [ ] **Step 5: Update the module.exports to remove unused exports**
|
||||
|
||||
Update the `module.exports` block (lines 572-586). Remove `buildBrowserEntrypointResult` from exports if it was only used for testing with full deps, or keep it for test compatibility. The final exports block:
|
||||
|
||||
```javascript
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
MONTH_COLUMNS,
|
||||
WEEK_COLUMNS,
|
||||
MONTH_COLUMN_DEFS,
|
||||
WEEK_COLUMN_DEFS,
|
||||
validateArgs,
|
||||
buildMonthRequest,
|
||||
buildWeekRequest,
|
||||
normalizeRows,
|
||||
determineArtifactStatus,
|
||||
buildArtifact,
|
||||
buildBrowserEntrypointResult
|
||||
};
|
||||
} else {
|
||||
return buildBrowserEntrypointResult(args);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify the JS file has no syntax errors**
|
||||
|
||||
Run: `node -c "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js"`
|
||||
|
||||
Expected: no syntax errors. (Note: the file uses `return` at top level inside a wrapped IIFE when injected into the browser, so Node syntax check may warn — the important thing is no parse errors.)
|
||||
|
||||
Alternatively, check the test file still works:
|
||||
|
||||
Run: `node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"`
|
||||
|
||||
Expected: tests pass (or at least no JS parse errors).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js"
|
||||
git commit -m "feat(lineloss): remove browser-side export, defer to Rust-side XLSX generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Full Build Verification
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run full cargo build**
|
||||
|
||||
Run: `cargo build`
|
||||
|
||||
Expected: successful compilation with no errors.
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run: `cargo test -- --nocapture`
|
||||
|
||||
Expected: all tests pass, including:
|
||||
- `lineloss_xlsx_export_test::export_month_lineloss_produces_valid_xlsx`
|
||||
- `lineloss_xlsx_export_test::export_empty_rows_returns_error`
|
||||
- `lineloss_xlsx_export::tests::column_letter_maps_indices_correctly`
|
||||
- All existing `deterministic_submit_test` tests
|
||||
|
||||
- [ ] **Step 3: Commit (if any fixups needed)**
|
||||
|
||||
Only if compilation or test fixes were required in this step.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Helper Page Lifecycle Fix v2 — Same-Connection Close + Open
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Prevent orphaned helper pages across process restarts by closing existing ones before opening new ones, all on the same WebSocket connection.
|
||||
|
||||
**Architecture:** In `bootstrap_helper_page`, after registering with the browser WS, send `sgHideBrowerserClosePage` (best-effort, silently ignored if no page exists), then send `sgHideBrowerserOpenPage`. Change `use_hidden_domain` to `true`.
|
||||
|
||||
**Tech Stack:** Rust, tungstenite, SuperRPA browser WS protocol
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add close-before-open in bootstrap_helper_page
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_host.rs:345-374` (bootstrap_helper_page function)
|
||||
|
||||
- [ ] **Step 1: Add close command before open command in bootstrap_helper_page**
|
||||
|
||||
Replace the current `bootstrap_helper_page` function. After `recv_bootstrap_prelude`, send the close command first, then the open command:
|
||||
|
||||
```rust
|
||||
fn bootstrap_helper_page(
|
||||
browser_ws_url: &str,
|
||||
request_url: &str,
|
||||
helper_url: &str,
|
||||
use_hidden_domain: bool,
|
||||
) -> Result<(), PipeError> {
|
||||
let (mut websocket, _) = connect(browser_ws_url)
|
||||
.map_err(|err| PipeError::Protocol(format!("browser websocket connect failed: {err}")))?;
|
||||
configure_bootstrap_socket(&mut websocket)?;
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
r#"{"type":"register","role":"web"}"#.to_string().into(),
|
||||
))
|
||||
.map_err(|err| PipeError::Protocol(format!("browser websocket register failed: {err}")))?;
|
||||
let _ = recv_bootstrap_prelude(&mut websocket);
|
||||
|
||||
// Close any orphaned helper page from a previous process run.
|
||||
// Best-effort: if no page exists, the browser silently ignores this.
|
||||
let (open_action, close_action) = if use_hidden_domain {
|
||||
("sgHideBrowerserOpenPage", "sgHideBrowerserClosePage")
|
||||
} else {
|
||||
("sgBrowerserOpenPage", "sgBrowserClosePage")
|
||||
};
|
||||
let close_payload = json!([request_url, close_action, helper_url]).to_string();
|
||||
let _ = websocket.send(Message::Text(close_payload.into()));
|
||||
|
||||
let payload = json!([
|
||||
request_url,
|
||||
open_action,
|
||||
helper_url,
|
||||
])
|
||||
.to_string();
|
||||
websocket
|
||||
.send(Message::Text(payload.into()))
|
||||
.map_err(|err| PipeError::Protocol(format!("helper bootstrap send failed: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Key changes from current code:
|
||||
- After `recv_bootstrap_prelude`, add the close command (best-effort, ignore errors)
|
||||
- Compute both `open_action` and `close_action` from `use_hidden_domain` flag
|
||||
- Send close first, then open on the same WebSocket connection
|
||||
|
||||
- [ ] **Step 2: Change `use_hidden_domain` to `true` in server.rs**
|
||||
|
||||
In `src/service/server.rs`, at the `start_with_browser_ws_url` call, change `false` to `true`:
|
||||
|
||||
```rust
|
||||
match LiveBrowserCallbackHost::start_with_browser_ws_url(
|
||||
browser_ws_url,
|
||||
&bootstrap_url,
|
||||
Duration::from_secs(15),
|
||||
BROWSER_RESPONSE_TIMEOUT,
|
||||
true, // use_hidden_domain: hidden domain for invisible helper
|
||||
) {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cargo build 2>&1`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Run callback_host tests**
|
||||
|
||||
Run: `cargo test --lib -- callback_host 2>&1`
|
||||
Expected: 12 tests pass (including `live_callback_host_sends_bootstrap_open_page_command` which still checks for `sgBrowerserOpenPage` because the test passes `false`, and `live_callback_host_hidden_domain_sends_hide_open_page_command` which passes `true`).
|
||||
|
||||
Note: The test passes `false` for `use_hidden_domain`, so the close command will use `sgBrowserClosePage`. The test's fake WebSocket server will receive both the close and open frames. The test only checks that `sgBrowerserOpenPage` is present, which is still true.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_host.rs src/service/server.rs
|
||||
git commit -m "fix(callback_host): close orphaned helper page before opening new one on same WS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Full verification
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Full test suite**
|
||||
|
||||
Run: `cargo test 2>&1`
|
||||
Expected: All tests pass except pre-existing `lineloss_period_resolver_prompts_for_missing_period` failure.
|
||||
|
||||
- [ ] **Step 2: Verify key behavioral changes**
|
||||
|
||||
Manually confirm:
|
||||
1. `bootstrap_helper_page` sends close command before open command (both on same WS connection)
|
||||
2. `use_hidden_domain` is `true` in `server.rs` — helper page opens in hidden domain
|
||||
3. `Drop for LiveBrowserCallbackHost` remains simple (shutdown only, no close attempt)
|
||||
4. `cached_host` is still in `mod.rs` outer loop (process-internal deduplication)
|
||||
@@ -0,0 +1,475 @@
|
||||
# Helper Page Lifecycle Fix & Hidden Domain Support — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix duplicate browser-helper.html pages caused by WebSocket reconnections, add cleanup on Drop, and introduce a config switch for hidden-domain page opening.
|
||||
|
||||
**Architecture:** Three changes: (1) lift `cached_host` from `serve_client()` to the outer `run()` loop so reconnections share one host, (2) enhance `Drop for LiveBrowserCallbackHost` to send a close-page command via browser WS, (3) add `use_hidden_domain: bool` parameter that selects between `sgBrowerserOpenPage`/`sgHideBrowerserOpenPage` and their corresponding close APIs.
|
||||
|
||||
**Tech Stack:** Rust, tungstenite WebSocket crate, SuperRPA browser WS protocol
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `use_hidden_domain` field and update `bootstrap_helper_page`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_host.rs:28` (constant), `:44-51` (struct), `:215-252` (constructor), `:340-359` (bootstrap fn)
|
||||
|
||||
- [ ] **Step 1: Change `HELPER_BOOTSTRAP_ACTION` from constant to a function of `use_hidden_domain`**
|
||||
|
||||
Replace the constant and update `bootstrap_helper_page` to accept and use the flag:
|
||||
|
||||
```rust
|
||||
// DELETE this line:
|
||||
// const HELPER_BOOTSTRAP_ACTION: &str = "sgBrowerserOpenPage";
|
||||
|
||||
// REPLACE bootstrap_helper_page signature and body:
|
||||
fn bootstrap_helper_page(
|
||||
browser_ws_url: &str,
|
||||
request_url: &str,
|
||||
helper_url: &str,
|
||||
use_hidden_domain: bool,
|
||||
) -> Result<(), PipeError> {
|
||||
let (mut websocket, _) = connect(browser_ws_url)
|
||||
.map_err(|err| PipeError::Protocol(format!("browser websocket connect failed: {err}")))?;
|
||||
configure_bootstrap_socket(&mut websocket)?;
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
r#"{"type":"register","role":"web"}"#.to_string().into(),
|
||||
))
|
||||
.map_err(|err| PipeError::Protocol(format!("browser websocket register failed: {err}")))?;
|
||||
let _ = recv_bootstrap_prelude(&mut websocket);
|
||||
let open_action = if use_hidden_domain {
|
||||
"sgHideBrowerserOpenPage"
|
||||
} else {
|
||||
"sgBrowerserOpenPage"
|
||||
};
|
||||
let payload = json!([
|
||||
request_url,
|
||||
open_action,
|
||||
helper_url,
|
||||
])
|
||||
.to_string();
|
||||
websocket
|
||||
.send(Message::Text(payload.into()))
|
||||
.map_err(|err| PipeError::Protocol(format!("helper bootstrap send failed: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add new fields to `LiveBrowserCallbackHost`**
|
||||
|
||||
```rust
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LiveBrowserCallbackHost {
|
||||
host: Arc<BrowserCallbackHost>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
server_thread: Mutex<Option<JoinHandle<()>>>,
|
||||
command_lock: Mutex<()>,
|
||||
result_timeout: Duration,
|
||||
browser_ws_url: String,
|
||||
use_hidden_domain: bool,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `start_with_browser_ws_url` to accept and store the new parameter**
|
||||
|
||||
```rust
|
||||
impl LiveBrowserCallbackHost {
|
||||
pub(crate) fn start_with_browser_ws_url(
|
||||
browser_ws_url: &str,
|
||||
bootstrap_request_url: &str,
|
||||
ready_timeout: Duration,
|
||||
result_timeout: Duration,
|
||||
use_hidden_domain: bool,
|
||||
) -> Result<Self, PipeError> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| {
|
||||
PipeError::Protocol(format!("failed to bind callback host listener: {err}"))
|
||||
})?;
|
||||
listener.set_nonblocking(true).map_err(|err| {
|
||||
PipeError::Protocol(format!("failed to configure callback host listener: {err}"))
|
||||
})?;
|
||||
let origin = format!(
|
||||
"http://{}",
|
||||
listener.local_addr().map_err(|err| {
|
||||
PipeError::Protocol(format!(
|
||||
"failed to resolve callback host listener address: {err}"
|
||||
))
|
||||
})?
|
||||
);
|
||||
let host = Arc::new(BrowserCallbackHost::with_urls(&origin, browser_ws_url));
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let thread_host = host.clone();
|
||||
let thread_shutdown = shutdown.clone();
|
||||
let server_thread = thread::spawn(move || serve_loop(listener, thread_host, thread_shutdown));
|
||||
|
||||
bootstrap_helper_page(browser_ws_url, bootstrap_request_url, host.helper_url(), use_hidden_domain)?;
|
||||
wait_for_helper_ready(host.as_ref(), ready_timeout)?;
|
||||
|
||||
let live_host = Self {
|
||||
host,
|
||||
shutdown,
|
||||
server_thread: Mutex::new(Some(server_thread)),
|
||||
command_lock: Mutex::new(()),
|
||||
result_timeout,
|
||||
browser_ws_url: browser_ws_url.to_string(),
|
||||
use_hidden_domain,
|
||||
};
|
||||
Ok(live_host)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fix the inline test struct literal that constructs `LiveBrowserCallbackHost` directly**
|
||||
|
||||
In the `live_callback_host_treats_simulated_mouse_command_as_fire_and_forget` test (around line 1110), add the new fields:
|
||||
|
||||
```rust
|
||||
let host = LiveBrowserCallbackHost {
|
||||
host: Arc::new(BrowserCallbackHost::new()),
|
||||
shutdown: Arc::new(AtomicBool::new(false)),
|
||||
server_thread: Mutex::new(None),
|
||||
command_lock: Mutex::new(()),
|
||||
result_timeout: Duration::from_millis(10),
|
||||
browser_ws_url: "ws://127.0.0.1:12345".to_string(),
|
||||
use_hidden_domain: false,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run build to verify compilation**
|
||||
|
||||
Run: `cargo build 2>&1`
|
||||
Expected: 0 errors. The `HELPER_BOOTSTRAP_ACTION` constant removal and signature changes should all be internally consistent.
|
||||
|
||||
- [ ] **Step 6: Run tests to verify existing behavior is preserved**
|
||||
|
||||
Run: `cargo test -- callback_host 2>&1`
|
||||
Expected: All existing callback_host tests pass (including `live_callback_host_sends_bootstrap_open_page_command` which still checks for `sgBrowerserOpenPage` since no caller passes `true` yet).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_host.rs
|
||||
git commit -m "feat(callback_host): add use_hidden_domain param to bootstrap_helper_page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Enhance `Drop` to close the helper page
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_host.rs:321-328` (Drop impl)
|
||||
|
||||
- [ ] **Step 1: Add `close_helper_page` helper function**
|
||||
|
||||
Add this function near `bootstrap_helper_page` (after line ~360):
|
||||
|
||||
```rust
|
||||
/// Best-effort attempt to close the helper page tab via browser WebSocket.
|
||||
/// Silently ignores all errors — this runs during Drop and must not panic.
|
||||
fn close_helper_page(browser_ws_url: &str, helper_url: &str, use_hidden_domain: bool) {
|
||||
let close_action = if use_hidden_domain {
|
||||
"sgHideBrowerserClosePage"
|
||||
} else {
|
||||
"sgBrowserClosePage"
|
||||
};
|
||||
|
||||
let result: Result<(), Box<dyn std::error::Error>> = (|| {
|
||||
// Use a raw TcpStream with timeouts instead of tungstenite::connect
|
||||
// which does not expose a connection timeout.
|
||||
let addr = browser_ws_url
|
||||
.trim_start_matches("ws://")
|
||||
.trim_start_matches("wss://");
|
||||
let stream = TcpStream::connect_timeout(
|
||||
&addr.parse().map_err(|e| format!("addr parse: {e}"))?,
|
||||
Duration::from_millis(100),
|
||||
)?;
|
||||
stream.set_read_timeout(Some(Duration::from_millis(200)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(200)))?;
|
||||
let (mut websocket, _) = tungstenite::client(
|
||||
browser_ws_url,
|
||||
stream,
|
||||
)?;
|
||||
websocket.send(Message::Text(
|
||||
r#"{"type":"register","role":"web"}"#.to_string().into(),
|
||||
))?;
|
||||
// Drain the welcome prelude (best-effort, ignore timeout).
|
||||
let _ = websocket.read();
|
||||
let payload = json!([helper_url, close_action, helper_url]).to_string();
|
||||
websocket.send(Message::Text(payload.into()))?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(err) = result {
|
||||
eprintln!("close_helper_page best-effort failed (harmless): {err}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `Drop for LiveBrowserCallbackHost` to call `close_helper_page`**
|
||||
|
||||
```rust
|
||||
impl Drop for LiveBrowserCallbackHost {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort: tell the browser to close the helper page tab.
|
||||
close_helper_page(
|
||||
&self.browser_ws_url,
|
||||
self.host.helper_url(),
|
||||
self.use_hidden_domain,
|
||||
);
|
||||
|
||||
self.shutdown.store(true, Ordering::Relaxed);
|
||||
if let Some(handle) = self.server_thread.lock().unwrap().take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run build to verify compilation**
|
||||
|
||||
Run: `cargo build 2>&1`
|
||||
Expected: 0 errors. `close_helper_page` uses types already imported (`TcpStream`, `Duration`, `json!`, `Message`).
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cargo test -- callback_host 2>&1`
|
||||
Expected: All pass. The Drop enhancement is best-effort and the test helper constructs hosts with `server_thread: Mutex::new(None)`, so Drop completes cleanly.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_host.rs
|
||||
git commit -m "feat(callback_host): close helper page on Drop via browser WS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Lift `cached_host` to outer loop and update `serve_client` signature
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/mod.rs:72-96` (run loop)
|
||||
- Modify: `src/service/server.rs:232-241` (serve_client signature and cached_host init)
|
||||
|
||||
- [ ] **Step 1: Change `serve_client` to accept `cached_host` as a parameter**
|
||||
|
||||
In `src/service/server.rs`, change the function signature and remove the local `cached_host` variable:
|
||||
|
||||
```rust
|
||||
pub fn serve_client(
|
||||
context: &AgentRuntimeContext,
|
||||
session: &ServiceSession,
|
||||
sink: Arc<ServiceEventSink>,
|
||||
browser_ws_url: &str,
|
||||
mac_policy: &MacPolicy,
|
||||
cached_host: &mut Option<Arc<LiveBrowserCallbackHost>>,
|
||||
) -> Result<(), PipeError> {
|
||||
// DELETE the line: let mut cached_host: Option<Arc<LiveBrowserCallbackHost>> = None;
|
||||
|
||||
loop {
|
||||
// ... rest of function body unchanged, `cached_host` is now the parameter
|
||||
```
|
||||
|
||||
The body references to `cached_host` remain identical — they just operate on the borrowed mutable reference instead of a local variable.
|
||||
|
||||
- [ ] **Step 2: Update `start_with_browser_ws_url` call to pass `false` for `use_hidden_domain`**
|
||||
|
||||
In `src/service/server.rs`, at the `LiveBrowserCallbackHost::start_with_browser_ws_url` call (around line 288), add the `false` argument:
|
||||
|
||||
```rust
|
||||
match LiveBrowserCallbackHost::start_with_browser_ws_url(
|
||||
browser_ws_url,
|
||||
&bootstrap_url,
|
||||
Duration::from_secs(15),
|
||||
BROWSER_RESPONSE_TIMEOUT,
|
||||
false, // use_hidden_domain: visible tab for now
|
||||
) {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Lift `cached_host` into `run()` in `mod.rs`**
|
||||
|
||||
In `src/service/mod.rs`, declare `cached_host` before the loop and pass it to `serve_client`:
|
||||
|
||||
```rust
|
||||
// Add this import at the top of the function or file:
|
||||
use crate::browser::callback_host::LiveBrowserCallbackHost;
|
||||
|
||||
// Before the loop (after line 64, after `let session = ...`):
|
||||
let mut cached_host: Option<Arc<LiveBrowserCallbackHost>> = None;
|
||||
|
||||
loop {
|
||||
let (stream, _) = listener.accept()?;
|
||||
let websocket = accept(stream)
|
||||
.map_err(|err| PipeError::Protocol(format!("service websocket accept failed: {err}")))?;
|
||||
let sink = Arc::new(ServiceEventSink::from_websocket(websocket));
|
||||
match session.try_attach_client() {
|
||||
Ok(()) => {
|
||||
let result = serve_client(
|
||||
&runtime_context,
|
||||
&session,
|
||||
sink.clone(),
|
||||
browser_ws_url,
|
||||
&mac_policy,
|
||||
&mut cached_host,
|
||||
);
|
||||
session.detach_client();
|
||||
match result {
|
||||
Ok(()) | Err(PipeError::PipeClosed) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
sink.send_service_message(message)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the `pub use` export if needed**
|
||||
|
||||
Check `src/service/mod.rs:17`:
|
||||
```rust
|
||||
pub use server::{serve_client, ServiceEventSink, ServiceSession};
|
||||
```
|
||||
The signature change is compatible — `serve_client` is still public with an added parameter. Any external callers will get a compile error guiding them to add the parameter, which is the desired behavior.
|
||||
|
||||
- [ ] **Step 5: Run build to verify compilation**
|
||||
|
||||
Run: `cargo build 2>&1`
|
||||
Expected: 0 errors. If there are external test files calling `serve_client`, they will fail here and need the new parameter added.
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
Run: `cargo test 2>&1`
|
||||
Expected: All tests pass. External test files that call `serve_client` indirectly through the service protocol tests should still work because they use the WS protocol layer, not `serve_client` directly. (Verified: grep found 0 test files referencing `serve_client` or `LiveBrowserCallbackHost`.)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/service/mod.rs src/service/server.rs
|
||||
git commit -m "fix(service): lift cached_host to outer loop to prevent duplicate helper pages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add tests for hidden domain bootstrap
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_host.rs` (inline tests module, around line 1071)
|
||||
|
||||
- [ ] **Step 1: Update existing `live_callback_host_sends_bootstrap_open_page_command` test**
|
||||
|
||||
The test currently calls `start_with_browser_ws_url` with 4 args. Add the 5th arg `false`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn live_callback_host_sends_bootstrap_open_page_command() {
|
||||
let (ws_url, frames, handle) = start_fake_browser_status_server();
|
||||
|
||||
let result = LiveBrowserCallbackHost::start_with_browser_ws_url(
|
||||
&ws_url,
|
||||
"https://www.zhihu.com",
|
||||
Duration::from_millis(100),
|
||||
Duration::from_millis(50),
|
||||
false,
|
||||
);
|
||||
assert!(result.is_err(), "expected timeout because no real helper page loads");
|
||||
drop(result);
|
||||
handle.join().unwrap();
|
||||
|
||||
let sent = frames.lock().unwrap().clone();
|
||||
assert!(
|
||||
sent.iter().any(|frame| frame.contains("sgBrowerserOpenPage")),
|
||||
"bootstrap should send sgBrowerserOpenPage to the browser WS; sent frames: {sent:?}"
|
||||
);
|
||||
assert!(
|
||||
sent.iter().any(|frame| frame.contains("/sgclaw/browser-helper.html")),
|
||||
"bootstrap should include the helper page URL; sent frames: {sent:?}"
|
||||
);
|
||||
assert!(
|
||||
sent.iter().any(|frame| frame.contains("https://www.zhihu.com")),
|
||||
"bootstrap requestUrl should be the provided page URL; sent frames: {sent:?}"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add new test for hidden domain bootstrap**
|
||||
|
||||
Add this test after the existing one:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn live_callback_host_hidden_domain_sends_hide_open_page_command() {
|
||||
let (ws_url, frames, handle) = start_fake_browser_status_server();
|
||||
|
||||
let result = LiveBrowserCallbackHost::start_with_browser_ws_url(
|
||||
&ws_url,
|
||||
"https://www.zhihu.com",
|
||||
Duration::from_millis(100),
|
||||
Duration::from_millis(50),
|
||||
true,
|
||||
);
|
||||
assert!(result.is_err(), "expected timeout because no real helper page loads");
|
||||
drop(result);
|
||||
handle.join().unwrap();
|
||||
|
||||
let sent = frames.lock().unwrap().clone();
|
||||
assert!(
|
||||
sent.iter().any(|frame| frame.contains("sgHideBrowerserOpenPage")),
|
||||
"hidden domain bootstrap should send sgHideBrowerserOpenPage; sent frames: {sent:?}"
|
||||
);
|
||||
assert!(
|
||||
!sent.iter().any(|frame| {
|
||||
frame.contains("\"sgBrowerserOpenPage\"")
|
||||
}),
|
||||
"hidden domain bootstrap should NOT send visible sgBrowerserOpenPage; sent frames: {sent:?}"
|
||||
);
|
||||
assert!(
|
||||
sent.iter().any(|frame| frame.contains("/sgclaw/browser-helper.html")),
|
||||
"bootstrap should include the helper page URL; sent frames: {sent:?}"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run all callback_host tests**
|
||||
|
||||
Run: `cargo test -- callback_host 2>&1`
|
||||
Expected: All 3 tests pass:
|
||||
- `live_callback_host_sends_bootstrap_open_page_command` — regression, visible domain
|
||||
- `live_callback_host_hidden_domain_sends_hide_open_page_command` — new, hidden domain
|
||||
- `live_callback_host_treats_simulated_mouse_command_as_fire_and_forget` — unchanged
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
Run: `cargo test 2>&1`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_host.rs
|
||||
git commit -m "test(callback_host): add hidden domain bootstrap test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Full build verification
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Clean build**
|
||||
|
||||
Run: `cargo build 2>&1`
|
||||
Expected: 0 errors. Warnings about dead code in unrelated modules are acceptable.
|
||||
|
||||
- [ ] **Step 2: Full test suite**
|
||||
|
||||
Run: `cargo test 2>&1`
|
||||
Expected: All tests pass. The pre-existing `lineloss_period_resolver_prompts_for_missing_period` failure (from previous work) is known and unrelated.
|
||||
|
||||
- [ ] **Step 3: Verify the key behavioral changes in code**
|
||||
|
||||
Manually confirm:
|
||||
1. `src/service/mod.rs` — `cached_host` is declared BEFORE the `loop`, not inside `serve_client`
|
||||
2. `src/browser/callback_host.rs` — `Drop::drop` calls `close_helper_page` before shutdown
|
||||
3. `src/browser/callback_host.rs` — `bootstrap_helper_page` uses `"sgHideBrowerserOpenPage"` when `use_hidden_domain == true` and `"sgBrowerserOpenPage"` when `false`
|
||||
4. `src/service/server.rs` — `start_with_browser_ws_url` call passes `false` as `use_hidden_domain`
|
||||
762
docs/superpowers/plans/2026-04-14-service-console-enhancement.md
Normal file
762
docs/superpowers/plans/2026-04-14-service-console-enhancement.md
Normal file
@@ -0,0 +1,762 @@
|
||||
# Service Console Enhancement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add auto-connect on page load and a settings panel to sg_claw_service_console.html, with config save via WebSocket to the sgClaw service.
|
||||
|
||||
**Architecture:** The HTML page auto-connects on load and provides a settings modal. When user saves, the page sends an `update_config` WebSocket message. The Rust service receives it, merges with existing config, writes to `sgclaw_config.json`, and responds.
|
||||
|
||||
**Tech Stack:** Rust (serde, tungstenite), vanilla JavaScript/HTML/CSS
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `UpdateConfig` and `ConfigUpdated` protocol types
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/protocol.rs`
|
||||
|
||||
- [ ] **Step 1: Add `ConfigUpdatePayload` struct and `UpdateConfig` variant to `ClientMessage`**
|
||||
|
||||
Add this struct above the `ClientMessage` enum, and add the `UpdateConfig` variant to the enum:
|
||||
|
||||
```rust
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigUpdatePayload {
|
||||
#[serde(rename = "apiKey", default)]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(rename = "baseUrl", default)]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(rename = "skillsDir", default)]
|
||||
pub skills_dir: Option<String>,
|
||||
#[serde(rename = "directSubmitSkill", default)]
|
||||
pub direct_submit_skill: Option<String>,
|
||||
#[serde(rename = "runtimeProfile", default)]
|
||||
pub runtime_profile: Option<String>,
|
||||
#[serde(rename = "browserBackend", default)]
|
||||
pub browser_backend: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Add `UpdateConfig` variant to `ClientMessage` enum (after `Ping`):
|
||||
|
||||
```rust
|
||||
UpdateConfig {
|
||||
config: ConfigUpdatePayload,
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `ConfigUpdated` variant to `ServiceMessage`**
|
||||
|
||||
Add after `Pong`:
|
||||
|
||||
```rust
|
||||
ConfigUpdated {
|
||||
success: bool,
|
||||
message: String,
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `into_submit_task_request` to handle `UpdateConfig`**
|
||||
|
||||
In the match arm, add `ClientMessage::UpdateConfig { .. }` to the list that returns `None`:
|
||||
|
||||
```rust
|
||||
ClientMessage::Connect
|
||||
| ClientMessage::Start
|
||||
| ClientMessage::Stop
|
||||
| ClientMessage::Ping
|
||||
| ClientMessage::UpdateConfig { .. } => None,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify protocol compiles**
|
||||
|
||||
Run: `cargo test --lib service::protocol`
|
||||
Expected: PASS (no protocol-specific tests yet, but it should compile)
|
||||
|
||||
### Task 2: Add `config_path()` getter to `AgentRuntimeContext`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent/task_runner.rs`
|
||||
|
||||
- [ ] **Step 1: Add public getter method**
|
||||
|
||||
In the `impl AgentRuntimeContext` block, add after `load_sgclaw_settings()`:
|
||||
|
||||
```rust
|
||||
pub fn config_path(&self) -> Option<&Path> {
|
||||
self.config_path.as_deref()
|
||||
}
|
||||
```
|
||||
|
||||
Add the import at the top of the file if not present:
|
||||
|
||||
```rust
|
||||
use std::path::Path;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify**
|
||||
|
||||
Run: `cargo test agent::task_runner`
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Add `save_to_path()` method to `SgClawSettings`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config/settings.rs`
|
||||
|
||||
- [ ] **Step 1: Add Serialize derive to SgClawSettings and related types**
|
||||
|
||||
The `RawSgClawSettings` struct uses `Deserialize` only. We need to add `Serialize` to `SgClawSettings` for writing. Add `use serde::Serialize;` at the top.
|
||||
|
||||
Add `Serialize` derive to `SgClawSettings`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct SgClawSettings {
|
||||
```
|
||||
|
||||
But wait - `SgClawSettings` has enum fields (`RuntimeProfile`, `SkillsPromptMode`, `PlannerMode`, `BrowserBackend`, `OfficeBackend`) that don't implement `Serialize`. We need to add Serialize derives to those types too.
|
||||
|
||||
Instead, the simpler approach is to write a `to_raw()` method that converts `SgClawSettings` to a serializable struct, then serialize that.
|
||||
|
||||
- [ ] **Step 2: Create serializable raw config struct**
|
||||
|
||||
Add a new struct at the bottom of the file (before tests if any):
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SerializableRawSgClawSettings {
|
||||
#[serde(rename = "apiKey")]
|
||||
api_key: String,
|
||||
#[serde(rename = "baseUrl")]
|
||||
base_url: String,
|
||||
model: String,
|
||||
#[serde(rename = "skillsDir", skip_serializing_if = "Option::is_none")]
|
||||
skills_dir: Option<String>,
|
||||
#[serde(rename = "directSubmitSkill", skip_serializing_if = "Option::is_none")]
|
||||
direct_submit_skill: Option<String>,
|
||||
#[serde(rename = "skillsPromptMode", skip_serializing_if = "Option::is_none")]
|
||||
skills_prompt_mode: Option<String>,
|
||||
#[serde(rename = "runtimeProfile", skip_serializing_if = "Option::is_none")]
|
||||
runtime_profile: Option<String>,
|
||||
#[serde(rename = "plannerMode", skip_serializing_if = "Option::is_none")]
|
||||
planner_mode: Option<String>,
|
||||
#[serde(rename = "activeProvider", skip_serializing_if = "Option::is_none")]
|
||||
active_provider: Option<String>,
|
||||
#[serde(rename = "browserBackend", skip_serializing_if = "Option::is_none")]
|
||||
browser_backend: Option<String>,
|
||||
#[serde(rename = "officeBackend", skip_serializing_if = "Option::is_none")]
|
||||
office_backend: Option<String>,
|
||||
#[serde(rename = "browserWsUrl", skip_serializing_if = "Option::is_none")]
|
||||
browser_ws_url: Option<String>,
|
||||
#[serde(rename = "serviceWsListenAddr", skip_serializing_if = "Option::is_none")]
|
||||
service_ws_listen_addr: Option<String>,
|
||||
#[serde(default)]
|
||||
providers: Vec<SerializableProviderSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SerializableProviderSettings {
|
||||
id: String,
|
||||
provider: Option<String>,
|
||||
#[serde(rename = "apiKey")]
|
||||
api_key: String,
|
||||
#[serde(rename = "baseUrl", skip_serializing_if = "Option::is_none")]
|
||||
base_url: Option<String>,
|
||||
model: String,
|
||||
#[serde(rename = "apiPath", skip_serializing_if = "Option::is_none")]
|
||||
api_path: Option<String>,
|
||||
#[serde(rename = "wireApi", skip_serializing_if = "Option::is_none")]
|
||||
wire_api: Option<String>,
|
||||
#[serde(rename = "requiresOpenaiAuth")]
|
||||
requires_openai_auth: bool,
|
||||
}
|
||||
```
|
||||
|
||||
Add `use serde::Serialize;` at the top of the file (combine with existing `use serde::Deserialize;`):
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `to_serializable()` method to `SgClawSettings`**
|
||||
|
||||
In the `impl SgClawSettings` block, add:
|
||||
|
||||
```rust
|
||||
fn to_serializable(&self) -> SerializableRawSgClawSettings {
|
||||
let format_enum_value = |s: &str| s.to_string();
|
||||
|
||||
SerializableRawSgClawSettings {
|
||||
api_key: self.provider_api_key.clone(),
|
||||
base_url: self.provider_base_url.clone(),
|
||||
model: self.provider_model.clone(),
|
||||
skills_dir: self.skills_dir.as_ref().map(|p| p.to_string_lossy().into_owned()),
|
||||
direct_submit_skill: self.direct_submit_skill.clone(),
|
||||
skills_prompt_mode: Some(format_enum_value(match self.skills_prompt_mode {
|
||||
SkillsPromptMode::Full => "full",
|
||||
SkillsPromptMode::Compact => "compact",
|
||||
})),
|
||||
runtime_profile: Some(format_enum_value(match self.runtime_profile {
|
||||
RuntimeProfile::BrowserAttached => "browser-attached",
|
||||
RuntimeProfile::BrowserHeavy => "browser-heavy",
|
||||
RuntimeProfile::GeneralAssistant => "general-assistant",
|
||||
})),
|
||||
planner_mode: Some(format_enum_value(match self.planner_mode {
|
||||
PlannerMode::ZeroclawPlanFirst => "zeroclaw-plan-first",
|
||||
PlannerMode::LegacyDeterministic => "legacy-deterministic",
|
||||
})),
|
||||
active_provider: Some(self.active_provider.clone()),
|
||||
browser_backend: Some(format_enum_value(match self.browser_backend {
|
||||
BrowserBackend::SuperRpa => "super-rpa",
|
||||
BrowserBackend::AgentBrowser => "agent-browser",
|
||||
BrowserBackend::RustNative => "rust-native",
|
||||
BrowserBackend::ComputerUse => "computer-use",
|
||||
BrowserBackend::Auto => "auto",
|
||||
})),
|
||||
office_backend: Some(format_enum_value(match self.office_backend {
|
||||
OfficeBackend::OpenXml => "openxml",
|
||||
OfficeBackend::Disabled => "disabled",
|
||||
})),
|
||||
browser_ws_url: self.browser_ws_url.clone(),
|
||||
service_ws_listen_addr: self.service_ws_listen_addr.clone(),
|
||||
providers: self
|
||||
.providers
|
||||
.iter()
|
||||
.map(|p| SerializableProviderSettings {
|
||||
id: p.id.clone(),
|
||||
provider: Some(p.provider.clone()),
|
||||
api_key: p.api_key.clone(),
|
||||
base_url: p.base_url.clone(),
|
||||
model: p.model.clone(),
|
||||
api_path: p.api_path.clone(),
|
||||
wire_api: p.wire_api.clone(),
|
||||
requires_openai_auth: p.requires_openai_auth,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `save_to_path()` method**
|
||||
|
||||
In the same `impl SgClawSettings` block, add:
|
||||
|
||||
```rust
|
||||
pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
|
||||
let serializable = self.to_serializable();
|
||||
let json = serde_json::to_string_pretty(&serializable)
|
||||
.map_err(|err| ConfigError::ConfigParse(path.to_path_buf(), err.to_string()))?;
|
||||
std::fs::write(path, json)
|
||||
.map_err(|err| ConfigError::ConfigRead(path.to_path_buf(), err.to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify compilation**
|
||||
|
||||
Run: `cargo test --lib config::settings`
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: Handle `UpdateConfig` in the service server
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs`
|
||||
- Modify: `src/service/mod.rs` (if needed for imports)
|
||||
|
||||
- [ ] **Step 1: Add `UpdateConfig` match arm in `serve_client`**
|
||||
|
||||
In the `match message` block in `serve_client`, after the `SubmitTask` arm, add:
|
||||
|
||||
```rust
|
||||
ClientMessage::UpdateConfig { config } => {
|
||||
let Some(config_path) = context.config_path() else {
|
||||
sink.send_service_message(ServiceMessage::ConfigUpdated {
|
||||
success: false,
|
||||
message: "未找到配置文件路径。请通过 --config-path 参数启动 sg_claw 后再使用此功能。".to_string(),
|
||||
})?;
|
||||
continue;
|
||||
};
|
||||
|
||||
if !config_path.exists() {
|
||||
sink.send_service_message(ServiceMessage::ConfigUpdated {
|
||||
success: false,
|
||||
message: format!("配置文件不存在: {}", config_path.display()),
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = update_config_file(config_path, config);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
sink.send_service_message(ServiceMessage::ConfigUpdated {
|
||||
success: true,
|
||||
message: "配置已保存。重启 sg_claw 以应用新配置。".to_string(),
|
||||
})?;
|
||||
}
|
||||
Err(err) => {
|
||||
sink.send_service_message(ServiceMessage::ConfigUpdated {
|
||||
success: false,
|
||||
message: format!("保存配置失败: {}", err),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `update_config_file` helper function**
|
||||
|
||||
Add this function above `serve_client` in `server.rs`:
|
||||
|
||||
```rust
|
||||
use crate::config::settings::{ConfigError, SgClawSettings};
|
||||
use crate::service::protocol::ConfigUpdatePayload;
|
||||
use std::path::Path;
|
||||
|
||||
fn update_config_file(config_path: &Path, config: ConfigUpdatePayload) -> Result<(), String> {
|
||||
let mut settings = SgClawSettings::load(Some(config_path))
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "无法读取现有配置".to_string())?;
|
||||
|
||||
if let Some(v) = config.api_key {
|
||||
settings.provider_api_key = v;
|
||||
}
|
||||
if let Some(v) = config.base_url {
|
||||
settings.provider_base_url = v;
|
||||
}
|
||||
if let Some(v) = config.model {
|
||||
settings.provider_model = v;
|
||||
}
|
||||
if let Some(v) = config.skills_dir {
|
||||
settings.skills_dir = Some(PathBuf::from(&v));
|
||||
}
|
||||
if let Some(v) = config.direct_submit_skill {
|
||||
settings.direct_submit_skill = Some(v);
|
||||
}
|
||||
if let Some(v) = config.runtime_profile {
|
||||
settings.runtime_profile = match v.as_str() {
|
||||
"browser-attached" => crate::config::settings::RuntimeProfile::BrowserAttached,
|
||||
"browser-heavy" => crate::config::settings::RuntimeProfile::BrowserHeavy,
|
||||
"general-assistant" => crate::config::settings::RuntimeProfile::GeneralAssistant,
|
||||
_ => return Err(format!("无效的 runtimeProfile: {}", v)),
|
||||
};
|
||||
}
|
||||
if let Some(v) = config.browser_backend {
|
||||
settings.browser_backend = match v.as_str() {
|
||||
"super-rpa" => crate::config::settings::BrowserBackend::SuperRpa,
|
||||
"agent-browser" => crate::config::settings::BrowserBackend::AgentBrowser,
|
||||
"rust-native" => crate::config::settings::BrowserBackend::RustNative,
|
||||
"computer-use" => crate::config::settings::BrowserBackend::ComputerUse,
|
||||
"auto" => crate::config::settings::BrowserBackend::Auto,
|
||||
_ => return Err(format!("无效的 browserBackend: {}", v)),
|
||||
};
|
||||
}
|
||||
|
||||
settings
|
||||
.save_to_path(config_path)
|
||||
.map_err(|e| format!("写入配置文件失败: {}", e))
|
||||
}
|
||||
```
|
||||
|
||||
Add the import at the top of server.rs:
|
||||
|
||||
```rust
|
||||
use std::path::PathBuf;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests to verify compilation**
|
||||
|
||||
Run: `cargo build`
|
||||
Expected: SUCCESS
|
||||
|
||||
### Task 5: Add auto-connect and settings UI to the service console HTML
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/service-console/sg_claw_service_console.html`
|
||||
|
||||
- [ ] **Step 1: Add auto-connect on page load**
|
||||
|
||||
At the very end of the `<script>` section, after the existing event listeners and `updateUiState()`, add:
|
||||
|
||||
```javascript
|
||||
// Auto-connect on page load
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
connectOrDisconnectService(true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Settings button HTML**
|
||||
|
||||
In the sidebar section of the HTML, after the connect button and before the "Composer" section label, add:
|
||||
|
||||
```html
|
||||
<button id="settingsBtn" class="ghost-btn" style="margin-top: 8px;">⚙ 设置</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add Settings modal HTML**
|
||||
|
||||
Before the closing `</body>` tag, add the modal HTML:
|
||||
|
||||
```html
|
||||
<!-- Settings Modal -->
|
||||
<div id="settingsModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
|
||||
<div style="background: var(--panel); border-radius: 20px; padding: 28px; width: min(520px, 90%); max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);">
|
||||
<h3 style="margin: 0 0 20px; font-size: 1.2rem;">sgClaw 配置</h3>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingApiKey">API 密钥 *</label>
|
||||
<input id="settingApiKey" type="password" placeholder="输入模型 API 密钥" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingBaseUrl">模型服务地址 *</label>
|
||||
<input id="settingBaseUrl" type="url" placeholder="例如:https://api.deepseek.com" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingModel">模型名称 *</label>
|
||||
<input id="settingModel" type="text" placeholder="例如:deepseek-chat" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingSkillsDir">Skills 目录路径</label>
|
||||
<input id="settingSkillsDir" type="text" placeholder="例如:D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingDirectSubmitSkill">直接提交技能</label>
|
||||
<input id="settingDirectSubmitSkill" type="text" placeholder="例如:tq-lineloss-report.collect_lineloss" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingRuntimeProfile">运行模式</label>
|
||||
<select id="settingRuntimeProfile" style="width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; background: rgba(255, 255, 255, 0.92); color: var(--text); font: inherit;">
|
||||
<option value="browser-attached">browser-attached</option>
|
||||
<option value="browser-heavy">browser-heavy</option>
|
||||
<option value="general-assistant">general-assistant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingBrowserBackend">浏览器后端</label>
|
||||
<select id="settingBrowserBackend" style="width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; background: rgba(255, 255, 255, 0.92); color: var(--text); font: inherit;">
|
||||
<option value="super-rpa">super-rpa</option>
|
||||
<option value="agent-browser">agent-browser</option>
|
||||
<option value="rust-native">rust-native</option>
|
||||
<option value="computer-use">computer-use</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="settingsValidation" style="color: var(--error); font-size: 0.92rem; min-height: 1.4em; margin: 10px 0;"></div>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-top: 16px;">
|
||||
<button id="settingsSaveBtn" class="primary-btn" style="flex: 1;">保存</button>
|
||||
<button id="settingsCancelBtn" class="ghost-btn" style="flex: 1;">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add settings modal CSS**
|
||||
|
||||
Add these CSS rules inside the `<style>` block, before the `@media` query:
|
||||
|
||||
```css
|
||||
/* Settings modal elements */
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: rgba(15, 118, 110, 0.5);
|
||||
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add settings modal JavaScript logic**
|
||||
|
||||
Add this JavaScript at the end of the `<script>` section, before the closing `</script>` tag:
|
||||
|
||||
```javascript
|
||||
// Settings modal state
|
||||
const settingsElements = {
|
||||
modal: document.getElementById("settingsModal"),
|
||||
apiKey: document.getElementById("settingApiKey"),
|
||||
baseUrl: document.getElementById("settingBaseUrl"),
|
||||
model: document.getElementById("settingModel"),
|
||||
skillsDir: document.getElementById("settingSkillsDir"),
|
||||
directSubmitSkill: document.getElementById("settingDirectSubmitSkill"),
|
||||
runtimeProfile: document.getElementById("settingRuntimeProfile"),
|
||||
browserBackend: document.getElementById("settingBrowserBackend"),
|
||||
validation: document.getElementById("settingsValidation"),
|
||||
saveBtn: document.getElementById("settingsSaveBtn"),
|
||||
cancelBtn: document.getElementById("settingsCancelBtn"),
|
||||
};
|
||||
let settingsOpenBtn = null; // will be set below
|
||||
|
||||
function openSettingsModal() {
|
||||
// Pre-fill with current values from wsUrl field (for baseUrl hint)
|
||||
settingsElements.apiKey.value = "";
|
||||
settingsElements.baseUrl.value = "";
|
||||
settingsElements.model.value = "";
|
||||
settingsElements.skillsDir.value = "";
|
||||
settingsElements.directSubmitSkill.value = "";
|
||||
settingsElements.runtimeProfile.value = "browser-attached";
|
||||
settingsElements.browserBackend.value = "super-rpa";
|
||||
settingsElements.validation.textContent = "";
|
||||
settingsElements.modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
settingsElements.modal.style.display = "none";
|
||||
}
|
||||
|
||||
function validateSettings() {
|
||||
const apiKey = settingsElements.apiKey.value.trim();
|
||||
const baseUrl = settingsElements.baseUrl.value.trim();
|
||||
const model = settingsElements.model.value.trim();
|
||||
|
||||
if (!apiKey) {
|
||||
return "API 密钥不能为空";
|
||||
}
|
||||
if (!model) {
|
||||
return "模型名称不能为空";
|
||||
}
|
||||
if (!baseUrl) {
|
||||
return "模型服务地址不能为空";
|
||||
}
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
} catch {
|
||||
return "模型服务地址格式无效,请输入有效的 URL";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const error = validateSettings();
|
||||
if (error) {
|
||||
settingsElements.validation.textContent = error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
settingsElements.validation.textContent = "请先连接服务";
|
||||
return;
|
||||
}
|
||||
|
||||
settingsElements.validation.textContent = "";
|
||||
settingsElements.saveBtn.disabled = true;
|
||||
settingsElements.saveBtn.textContent = "保存中...";
|
||||
|
||||
const config = {
|
||||
apiKey: settingsElements.apiKey.value.trim(),
|
||||
baseUrl: settingsElements.baseUrl.value.trim(),
|
||||
model: settingsElements.model.value.trim(),
|
||||
};
|
||||
|
||||
const skillsDir = settingsElements.skillsDir.value.trim();
|
||||
if (skillsDir) config.skillsDir = skillsDir;
|
||||
|
||||
const directSubmitSkill = settingsElements.directSubmitSkill.value.trim();
|
||||
if (directSubmitSkill) config.directSubmitSkill = directSubmitSkill;
|
||||
|
||||
config.runtimeProfile = settingsElements.runtimeProfile.value;
|
||||
config.browserBackend = settingsElements.browserBackend.value;
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: "update_config",
|
||||
config,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleConfigResponse(message) {
|
||||
settingsElements.saveBtn.disabled = false;
|
||||
settingsElements.saveBtn.textContent = "保存";
|
||||
|
||||
if (message.success) {
|
||||
settingsElements.validation.textContent = message.message;
|
||||
settingsElements.validation.style.color = "var(--success)";
|
||||
// Auto-close after 2 seconds on success
|
||||
setTimeout(closeSettingsModal, 2000);
|
||||
} else {
|
||||
settingsElements.validation.textContent = message.message;
|
||||
settingsElements.validation.style.color = "var(--error)";
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners for settings
|
||||
settingsOpenBtn = document.getElementById("settingsBtn");
|
||||
settingsOpenBtn.addEventListener("click", openSettingsModal);
|
||||
settingsElements.cancelBtn.addEventListener("click", closeSettingsModal);
|
||||
settingsElements.saveBtn.addEventListener("click", saveSettings);
|
||||
|
||||
// Close modal on background click
|
||||
settingsElements.modal.addEventListener("click", (e) => {
|
||||
if (e.target === settingsElements.modal) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Handle `config_updated` message in `handleMessage`**
|
||||
|
||||
In the existing `handleMessage` function, add a new case in the switch statement:
|
||||
|
||||
```javascript
|
||||
case "config_updated":
|
||||
handleConfigResponse(message);
|
||||
break;
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify the HTML is well-formed**
|
||||
|
||||
Open the file in a browser and visually check that:
|
||||
- The settings button appears below the connect button
|
||||
- Clicking it opens the modal
|
||||
- The modal closes on Cancel or background click
|
||||
|
||||
### Task 6: Add protocol tests for new message types
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/service_console_html_test.rs`
|
||||
- Create: `tests/service_protocol_update_config_test.rs`
|
||||
|
||||
- [ ] **Step 1: Create protocol serialization test**
|
||||
|
||||
Create `tests/service_protocol_update_config_test.rs`:
|
||||
|
||||
```rust
|
||||
use sgclaw::service::protocol::{ClientMessage, ConfigUpdatePayload, ServiceMessage};
|
||||
|
||||
#[test]
|
||||
fn update_config_serializes_correctly() {
|
||||
let config = ConfigUpdatePayload {
|
||||
api_key: Some("test-key".to_string()),
|
||||
base_url: Some("https://api.example.com".to_string()),
|
||||
model: Some("test-model".to_string()),
|
||||
skills_dir: Some("/path/to/skills".to_string()),
|
||||
direct_submit_skill: Some("my-skill.my-tool".to_string()),
|
||||
runtime_profile: Some("browser-attached".to_string()),
|
||||
browser_backend: Some("super-rpa".to_string()),
|
||||
};
|
||||
|
||||
let msg = ClientMessage::UpdateConfig { config };
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
assert!(json.contains("\"type\":\"update_config\""));
|
||||
assert!(json.contains("\"apiKey\":\"test-key\""));
|
||||
assert!(json.contains("\"baseUrl\":\"https://api.example.com\""));
|
||||
assert!(json.contains("\"model\":\"test-model\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_config_deserializes_correctly() {
|
||||
let json = r#"{
|
||||
"type": "update_config",
|
||||
"config": {
|
||||
"apiKey": "key123",
|
||||
"baseUrl": "https://api.test.com",
|
||||
"model": "gpt-4"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let msg: ClientMessage = serde_json::from_str(json).unwrap();
|
||||
match msg {
|
||||
ClientMessage::UpdateConfig { config } => {
|
||||
assert_eq!(config.api_key, Some("key123".to_string()));
|
||||
assert_eq!(config.base_url, Some("https://api.test.com".to_string()));
|
||||
assert_eq!(config.model, Some("gpt-4".to_string()));
|
||||
assert!(config.skills_dir.is_none());
|
||||
}
|
||||
_ => panic!("expected UpdateConfig variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_updated_serializes_correctly() {
|
||||
let msg = ServiceMessage::ConfigUpdated {
|
||||
success: true,
|
||||
message: "配置已保存".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
assert!(json.contains("\"type\":\"config_updated\""));
|
||||
assert!(json.contains("\"success\":true"));
|
||||
assert!(json.contains("配置已保存"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_updated_deserializes_correctly() {
|
||||
let json = r#"{"type":"config_updated","success":false,"message":"保存失败"}"#;
|
||||
let msg: ServiceMessage = serde_json::from_str(json).unwrap();
|
||||
|
||||
match msg {
|
||||
ServiceMessage::ConfigUpdated { success, message } => {
|
||||
assert!(!success);
|
||||
assert_eq!(message, "保存失败");
|
||||
}
|
||||
_ => panic!("expected ConfigUpdated variant"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update service console HTML test**
|
||||
|
||||
Add to `tests/service_console_html_test.rs`, at the end of the existing test:
|
||||
|
||||
```rust
|
||||
// New enhancement assertions
|
||||
assert!(source.contains("DOMContentLoaded"));
|
||||
assert!(source.contains("settingsBtn"));
|
||||
assert!(source.contains("settingsModal"));
|
||||
assert!(source.contains("update_config"));
|
||||
assert!(source.contains("config_updated"));
|
||||
assert!(source.contains("settingApiKey"));
|
||||
assert!(source.contains("settingBaseUrl"));
|
||||
assert!(source.contains("settingModel"));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run all new tests**
|
||||
|
||||
Run: `cargo test --test service_protocol_update_config_test`
|
||||
Run: `cargo test --test service_console_html_test`
|
||||
Expected: All PASS
|
||||
|
||||
### Task 7: Full build and test verification
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `cargo test 2>&1`
|
||||
Expected: All tests pass (except pre-existing `lineloss_period_resolver_prompts_for_missing_period` which was already failing before our changes)
|
||||
|
||||
- [ ] **Step 2: Build release binary**
|
||||
|
||||
Run: `cargo build --release 2>&1`
|
||||
Expected: SUCCESS
|
||||
|
||||
### Task 8: Manual smoke test instructions
|
||||
|
||||
After implementation, verify manually:
|
||||
|
||||
1. Start sg_claw with config path: `sg_claw.exe --config-path sgclaw_config.json`
|
||||
2. Open `sg_claw_service_console.html` in browser
|
||||
3. Verify: Page auto-connects (should show "已连接" within a few seconds)
|
||||
4. Click "设置" button
|
||||
5. Fill in API Key, Base URL, Model
|
||||
6. Click "保存"
|
||||
7. Verify: Modal shows "配置已保存。重启 sg_claw 以应用新配置。" and auto-closes after 2 seconds
|
||||
8. Verify: `sgclaw_config.json` file contains the new values
|
||||
9. Verify: Existing task submission still works (send a test instruction)
|
||||
@@ -0,0 +1,810 @@
|
||||
# Multi-Scene-Kind Generator Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 扩展 `sg_scene_generate` 支持多种场景类型,让用户在 Web UI 上手动选择场景类型(报表收集类/监测类),不再依赖第三方场景目录中的 meta 标签。
|
||||
|
||||
**Architecture:** 放宽 analyzer.rs 的 meta 校验,让 meta 标签变为可选;在 CLI 增加 `--scene-kind` 参数;在 generator.rs 根据场景类型选择不同模板;在 Web UI 增加场景类型下拉框。
|
||||
|
||||
**Tech Stack:** Rust, Node.js, HTML/CSS/JS
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Core Rust files (backend)
|
||||
|
||||
- **Modify:** `src/generated_scene/analyzer.rs` — 放宽 meta 校验,新增 `SceneKind::Monitoring`,函数签名增加 `scene_kind_hint` 参数
|
||||
- **Modify:** `src/generated_scene/generator.rs` — 多模板支持,根据 `SceneKind` 路由到不同模板函数
|
||||
- **Modify:** `src/bin/sg_scene_generate.rs` — 新增 `--scene-kind` CLI 参数
|
||||
|
||||
### Frontend files (Web UI)
|
||||
|
||||
- **Modify:** `frontend/scene-generator/sg_scene_generator.html` — 新增场景类型下拉框
|
||||
- **Modify:** `frontend/scene-generator/server.js` — `/generate` 接口传递 `sceneKind` 参数
|
||||
- **Modify:** `frontend/scene-generator/generator-runner.js` — `runGenerator` 增加 `sceneKind` 参数
|
||||
|
||||
### Test files
|
||||
|
||||
- **Modify:** `tests/scene_generator_test.rs` — 新增监测类场景测试
|
||||
- **Create:** `tests/fixtures/generated_scene/monitoring/index.html` — 监测类 fixture
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 扩展 SceneKind 枚举和 analyzer 函数签名
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/analyzer.rs:1-127`
|
||||
- Test: `tests/scene_generator_test.rs`
|
||||
|
||||
- [ ] **Step 1: 写失败测试 — analyzer 接受 scene_kind_hint 参数**
|
||||
|
||||
修改 `tests/scene_generator_test.rs`,新增测试:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn analyzer_accepts_missing_meta_with_scene_kind_hint() {
|
||||
// non_report fixture 没有 scene-kind meta 标签
|
||||
let analysis = analyze_scene_source_with_hint(
|
||||
Path::new("tests/fixtures/generated_scene/non_report"),
|
||||
Some(SceneKind::ReportCollection),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 应该成功,使用 hint 参数作为类型
|
||||
assert_eq!(analysis.scene_kind, SceneKind::ReportCollection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyzer_uses_hint_when_meta_missing() {
|
||||
let analysis = analyze_scene_source_with_hint(
|
||||
Path::new("tests/fixtures/generated_scene/non_report"),
|
||||
Some(SceneKind::Monitoring),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(analysis.scene_kind, SceneKind::Monitoring);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyzer_uses_meta_when_present_and_no_hint() {
|
||||
// report_collection fixture 有正确的 meta 标签
|
||||
let analysis = analyze_scene_source_with_hint(
|
||||
Path::new("tests/fixtures/generated_scene/report_collection"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(analysis.scene_kind, SceneKind::ReportCollection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyzer_hint_overrides_meta() {
|
||||
// 用户选择优先于 meta 标签
|
||||
let analysis = analyze_scene_source_with_hint(
|
||||
Path::new("tests/fixtures/generated_scene/report_collection"),
|
||||
Some(SceneKind::Monitoring),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(analysis.scene_kind, SceneKind::Monitoring);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test scene_generator_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL,因为 `analyze_scene_source_with_hint` 函数不存在
|
||||
|
||||
- [ ] **Step 3: 实现 SceneKind::Monitoring 枚举变体**
|
||||
|
||||
修改 `src/generated_scene/analyzer.rs`,扩展枚举:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SceneKind {
|
||||
ReportCollection,
|
||||
Monitoring,
|
||||
}
|
||||
|
||||
impl SceneKind {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"report_collection" => Some(Self::ReportCollection),
|
||||
"monitoring" => Some(Self::Monitoring),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ReportCollection => "report_collection",
|
||||
Self::Monitoring => "monitoring",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现带 hint 参数的新函数**
|
||||
|
||||
在 `src/generated_scene/analyzer.rs` 添加新函数:
|
||||
|
||||
```rust
|
||||
pub fn analyze_scene_source_with_hint(
|
||||
source_dir: &Path,
|
||||
scene_kind_hint: Option<SceneKind>,
|
||||
) -> Result<SceneSourceAnalysis, AnalyzeSceneError> {
|
||||
let index_path = source_dir.join("index.html");
|
||||
let html = fs::read_to_string(&index_path).map_err(|err| {
|
||||
AnalyzeSceneError::new(format!(
|
||||
"failed to read scene source {}: {err}",
|
||||
index_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
// 从 meta 标签读取类型(可选)
|
||||
let meta_scene_kind = meta_content(&html, "sgclaw-scene-kind");
|
||||
let meta_tool_kind = meta_content(&html, "sgclaw-tool-kind");
|
||||
|
||||
// 用户 hint 优先于 meta 标签,默认为 ReportCollection
|
||||
let scene_kind = scene_kind_hint
|
||||
.or_else(|| meta_scene_kind.as_deref().and_then(SceneKind::from_str))
|
||||
.unwrap_or(SceneKind::ReportCollection);
|
||||
|
||||
// tool_kind 固定为 BrowserScript(V1 只支持这一种)
|
||||
let tool_kind = ToolKind::BrowserScript;
|
||||
|
||||
// 验证 meta 标签中的类型(如果存在)是否与最终类型兼容
|
||||
if let Some(meta) = meta_scene_kind.as_deref() {
|
||||
if SceneKind::from_str(meta).is_none() {
|
||||
return Err(AnalyzeSceneError::new(format!(
|
||||
"unknown sgclaw-scene-kind: {}",
|
||||
meta
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let target_url = meta_content(&html, "sgclaw-target-url");
|
||||
let expected_domain = meta_content(&html, "sgclaw-expected-domain");
|
||||
let entry_script = meta_content(&html, "sgclaw-entry-script");
|
||||
|
||||
// 对于 report_collection 类型,要求必须有 target_url、expected_domain、entry_script
|
||||
// 对于 monitoring 类型,这些字段可选(生成简化模板)
|
||||
if scene_kind == SceneKind::ReportCollection {
|
||||
if target_url.as_deref().unwrap_or_default().trim().is_empty()
|
||||
|| expected_domain
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.is_empty()
|
||||
|| entry_script
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(AnalyzeSceneError::new(
|
||||
"report_collection scene source must declare target url, expected domain, and entry script",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SceneSourceAnalysis {
|
||||
scene_kind,
|
||||
tool_kind,
|
||||
bootstrap: BootstrapAnalysis {
|
||||
target_url,
|
||||
expected_domain,
|
||||
},
|
||||
collection_entry_script: entry_script,
|
||||
source_dir: source_dir.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
// 保留原函数签名以兼容现有调用
|
||||
pub fn analyze_scene_source(source_dir: &Path) -> Result<SceneSourceAnalysis, AnalyzeSceneError> {
|
||||
analyze_scene_source_with_hint(source_dir, None)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试确认通过**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test scene_generator_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: 提交 analyzer 改动**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add src/generated_scene/analyzer.rs tests/scene_generator_test.rs
|
||||
git commit -m "feat: add SceneKind::Monitoring and scene_kind_hint param to analyzer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修改 generator 支持多模板
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs:1-204`
|
||||
|
||||
- [ ] **Step 1: 写失败测试 — generator 生成监测类模板**
|
||||
|
||||
修改 `tests/scene_generator_test.rs`,新增测试:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn generator_emits_monitoring_template() {
|
||||
let output_root = temp_workspace("sgclaw-monitoring-generator");
|
||||
|
||||
generate_scene_package(GenerateSceneRequest {
|
||||
source_dir: PathBuf::from("tests/fixtures/generated_scene/monitoring"),
|
||||
scene_id: "sample-monitor-scene".to_string(),
|
||||
scene_name: "示例监测场景".to_string(),
|
||||
scene_kind: Some(SceneKind::Monitoring),
|
||||
output_root: output_root.clone(),
|
||||
lessons_path: PathBuf::from("docs/superpowers/references/tq-lineloss-lessons-learned.toml"),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let skill_root = output_root.join("skills/sample-monitor-scene");
|
||||
assert!(skill_root.join("SKILL.toml").exists());
|
||||
assert!(skill_root.join("scene.toml").exists());
|
||||
|
||||
let generated_manifest = fs::read_to_string(skill_root.join("scene.toml")).unwrap();
|
||||
assert!(generated_manifest.contains("category = \"monitoring\""));
|
||||
// 监测类不应该有 org/period resolver
|
||||
assert!(!generated_manifest.contains("resolver = \"dictionary_entity\""));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test scene_generator_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL,因为 `GenerateSceneRequest` 没有 `scene_kind` 字段
|
||||
|
||||
- [ ] **Step 3: 修改 GenerateSceneRequest 增加 scene_kind 字段**
|
||||
|
||||
修改 `src/generated_scene/generator.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GenerateSceneRequest {
|
||||
pub source_dir: PathBuf,
|
||||
pub scene_id: String,
|
||||
pub scene_name: String,
|
||||
pub scene_kind: Option<SceneKind>, // 新增
|
||||
pub output_root: PathBuf,
|
||||
pub lessons_path: PathBuf,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 修改 generate_scene_package 使用新 analyzer 函数**
|
||||
|
||||
修改 `src/generated_scene/generator.rs`:
|
||||
|
||||
```rust
|
||||
use crate::generated_scene::analyzer::{analyze_scene_source_with_hint, AnalyzeSceneError, SceneKind};
|
||||
|
||||
pub fn generate_scene_package(
|
||||
request: GenerateSceneRequest,
|
||||
) -> Result<PathBuf, GenerateSceneError> {
|
||||
let analysis = analyze_scene_source_with_hint(&request.source_dir, request.scene_kind.clone())?;
|
||||
// ... 后续代码
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现监测类模板函数**
|
||||
|
||||
在 `src/generated_scene/generator.rs` 添加:
|
||||
|
||||
```rust
|
||||
fn scene_toml_monitoring(
|
||||
request: &GenerateSceneRequest,
|
||||
analysis: &SceneSourceAnalysis,
|
||||
tool_name: &str,
|
||||
) -> String {
|
||||
let expected_domain = analysis.bootstrap.expected_domain.as_deref().unwrap_or("");
|
||||
let target_url = analysis.bootstrap.target_url.as_deref().unwrap_or("");
|
||||
|
||||
format!(
|
||||
"[scene]\nid = \"{}\"\nskill = \"{}\"\ntool = \"{}\"\nkind = \"browser_script\"\nversion = \"0.1.0\"\ncategory = \"monitoring\"\n\n[manifest]\nschema_version = \"1\"\n\n[bootstrap]\nexpected_domain = \"{}\"\ntarget_url = \"{}\"\nrequires_target_page = true\n\n[deterministic]\nsuffix = \"。。。\"\ninclude_keywords = [\"{}\"]\nexclude_keywords = []\n\n# 参数部分留空,用户手动编辑\n# [[params]]\n# name = \"xxx\"\n# resolver = \"literal_passthrough\"\n\n[artifact]\ntype = \"monitoring-status\"\nsuccess_status = [\"ok\", \"running\"]\nfailure_status = [\"error\", \"timeout\"]\n\n# 后处理留空,用户手动编辑\n",
|
||||
request.scene_id,
|
||||
request.scene_id,
|
||||
tool_name,
|
||||
expected_domain,
|
||||
target_url,
|
||||
request.scene_name
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 修改 scene_toml 函数路由到不同模板**
|
||||
|
||||
修改 `src/generated_scene/generator.rs` 的 `scene_toml` 函数:
|
||||
|
||||
```rust
|
||||
fn scene_toml(
|
||||
request: &GenerateSceneRequest,
|
||||
analysis: &SceneSourceAnalysis,
|
||||
tool_name: &str,
|
||||
) -> String {
|
||||
match analysis.scene_kind {
|
||||
SceneKind::ReportCollection => scene_toml_report_collection(request, analysis, tool_name),
|
||||
SceneKind::Monitoring => scene_toml_monitoring(request, analysis, tool_name),
|
||||
}
|
||||
}
|
||||
|
||||
fn scene_toml_report_collection(
|
||||
request: &GenerateSceneRequest,
|
||||
analysis: &SceneSourceAnalysis,
|
||||
tool_name: &str,
|
||||
) -> String {
|
||||
let expected_domain = analysis.bootstrap.expected_domain.as_deref().unwrap_or("");
|
||||
let target_url = analysis.bootstrap.target_url.as_deref().unwrap_or("");
|
||||
|
||||
// 现有的 report_collection 模板代码
|
||||
format!(
|
||||
"[scene]\nid = \"{}\"\nskill = \"{}\"\ntool = \"{}\"\nkind = \"browser_script\"\nversion = \"0.1.0\"\ncategory = \"report_collection\"\n\n[manifest]\nschema_version = \"1\"\n\n[bootstrap]\nexpected_domain = \"{}\"\ntarget_url = \"{}\"\npage_title_keywords = [\"报表\", \"线损\"]\nrequires_target_page = true\n\n[deterministic]\nsuffix = \"。。。\"\ninclude_keywords = [\"{}\", \"报表\", \"统计\"]\nexclude_keywords = [\"知乎\"]\n\n[[params]]\nname = \"org\"\nresolver = \"dictionary_entity\"\nrequired = true\nprompt_missing = \"已命中{},但缺少供电单位。\"\nprompt_ambiguous = \"已命中{},但供电单位存在歧义。\"\n\n[params.resolver_config]\ndictionary_ref = \"references/org-dictionary.json\"\noutput_label_field = \"org_label\"\noutput_code_field = \"org_code\"\n\n[[params]]\nname = \"period\"\nresolver = \"month_week_period\"\nrequired = true\nprompt_missing = \"已命中{},但缺少统计周期。\"\nprompt_ambiguous = \"已命中{},但统计周期存在歧义。\"\n\n[artifact]\ntype = \"report-artifact\"\nsuccess_status = [\"ok\", \"partial\", \"empty\"]\nfailure_status = [\"blocked\", \"error\"]\n\n[postprocess]\nexporter = \"xlsx_report\"\nauto_open = \"excel\"\n",
|
||||
request.scene_id,
|
||||
request.scene_id,
|
||||
tool_name,
|
||||
expected_domain,
|
||||
target_url,
|
||||
request.scene_name,
|
||||
request.scene_name,
|
||||
request.scene_name,
|
||||
request.scene_name,
|
||||
request.scene_name
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 创建监测类 fixture**
|
||||
|
||||
创建 `tests/fixtures/generated_scene/monitoring/index.html`:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>设备监测状态</title>
|
||||
<!-- 注意:没有 sgclaw-scene-kind meta 标签,测试 hint 参数 -->
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>设备监测状态</h1>
|
||||
<div id="monitor-status">running</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 运行测试确认通过**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test scene_generator_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 9: 提交 generator 改动**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs tests/scene_generator_test.rs tests/fixtures/generated_scene/monitoring
|
||||
git commit -m "feat: add monitoring template support to generator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 修改 CLI 增加 --scene-kind 参数
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bin/sg_scene_generate.rs:1-82`
|
||||
|
||||
- [ ] **Step 1: 修改 CliArgs 结构体增加 scene_kind 字段**
|
||||
|
||||
修改 `src/bin/sg_scene_generate.rs`:
|
||||
|
||||
```rust
|
||||
use sgclaw::generated_scene::analyzer::SceneKind;
|
||||
|
||||
struct CliArgs {
|
||||
source_dir: PathBuf,
|
||||
scene_id: String,
|
||||
scene_name: String,
|
||||
scene_kind: Option<SceneKind>, // 新增
|
||||
output_root: PathBuf,
|
||||
lessons_path: PathBuf,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 parse_args 解析 --scene-kind 参数**
|
||||
|
||||
修改 `src/bin/sg_scene_generate.rs`:
|
||||
|
||||
```rust
|
||||
fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
|
||||
let mut source_dir = None;
|
||||
let mut scene_id = None;
|
||||
let mut scene_name = None;
|
||||
let mut scene_kind = None; // 新增
|
||||
let mut output_root = None;
|
||||
let mut lessons_path = None;
|
||||
let mut pending_flag: Option<String> = None;
|
||||
|
||||
for arg in args {
|
||||
if let Some(flag) = pending_flag.take() {
|
||||
match flag.as_str() {
|
||||
"--source-dir" => source_dir = Some(PathBuf::from(arg)),
|
||||
"--scene-id" => scene_id = Some(arg),
|
||||
"--scene-name" => scene_name = Some(arg),
|
||||
"--scene-kind" => {
|
||||
scene_kind = Some(SceneKind::from_str(&arg).ok_or_else(|| {
|
||||
format!("invalid scene-kind: {}, expected report_collection or monitoring", arg)
|
||||
})?);
|
||||
}
|
||||
"--output-root" => output_root = Some(PathBuf::from(arg)),
|
||||
"--lessons" => lessons_path = Some(PathBuf::from(arg)),
|
||||
_ => return Err(format!("unsupported argument {flag}")),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match arg.as_str() {
|
||||
"--source-dir" | "--scene-id" | "--scene-name" | "--scene-kind" | "--output-root" | "--lessons" => {
|
||||
pending_flag = Some(arg);
|
||||
}
|
||||
"--help" | "-h" => return Err(usage()),
|
||||
_ => return Err(format!("unsupported argument {arg}\n{}", usage())),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(flag) = pending_flag {
|
||||
return Err(format!("missing value for {flag}"));
|
||||
}
|
||||
|
||||
Ok(CliArgs {
|
||||
source_dir: source_dir.ok_or_else(usage)?,
|
||||
scene_id: scene_id.ok_or_else(usage)?,
|
||||
scene_name: scene_name.ok_or_else(usage)?,
|
||||
scene_kind, // 可选,默认 None
|
||||
output_root: output_root.ok_or_else(usage)?,
|
||||
lessons_path: lessons_path.ok_or_else(usage)?,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 run 函数传递 scene_kind**
|
||||
|
||||
修改 `src/bin/sg_scene_generate.rs`:
|
||||
|
||||
```rust
|
||||
fn run() -> Result<(), String> {
|
||||
let args = parse_args(env::args().skip(1))?;
|
||||
let skill_root = generate_scene_package(GenerateSceneRequest {
|
||||
source_dir: args.source_dir,
|
||||
scene_id: args.scene_id,
|
||||
scene_name: args.scene_name,
|
||||
scene_kind: args.scene_kind, // 新增
|
||||
output_root: args.output_root,
|
||||
lessons_path: args.lessons_path,
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
println!("generated scene package: {}", skill_root.display());
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 更新 usage 函数**
|
||||
|
||||
修改 `src/bin/sg_scene_generate.rs`:
|
||||
|
||||
```rust
|
||||
fn usage() -> String {
|
||||
"usage: sg_scene_generate --source-dir <scenario-dir> --scene-id <scene-id> --scene-name <display-name> [--scene-kind <report_collection|monitoring>] --output-root <skill-staging-root> --lessons <lessons-toml>".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试确认编译通过**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo build --bin sg_scene_generate
|
||||
```
|
||||
|
||||
Expected: 编译成功
|
||||
|
||||
- [ ] **Step 6: 手动测试 CLI**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo run --bin sg_scene_generate -- --source-dir tests/fixtures/generated_scene/monitoring --scene-id test-monitor --scene-name "测试监测" --scene-kind monitoring --output-root ./tmp_test --lessons docs/superpowers/references/tq-lineloss-lessons-learned.toml
|
||||
```
|
||||
|
||||
Expected: 生成成功,scene.toml 包含 `category = "monitoring"`
|
||||
|
||||
- [ ] **Step 7: 提交 CLI 改动**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add src/bin/sg_scene_generate.rs
|
||||
git commit -m "feat: add --scene-kind CLI param to sg_scene_generate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 修改 Node.js generator-runner 传递 sceneKind
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/scene-generator/generator-runner.js:1-175`
|
||||
|
||||
- [ ] **Step 1: 修改 runGenerator 函数签名和 args 数组**
|
||||
|
||||
修改 `frontend/scene-generator/generator-runner.js`:
|
||||
|
||||
```javascript
|
||||
function runGenerator(params, sseWriter, projectRoot) {
|
||||
const { sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons } = params;
|
||||
|
||||
const normalize = (p) => p.replace(/\\/g, "/");
|
||||
|
||||
const args = [
|
||||
"run",
|
||||
"--bin",
|
||||
"sg_scene_generate",
|
||||
"--",
|
||||
"--source-dir",
|
||||
normalize(sourceDir),
|
||||
"--scene-id",
|
||||
sceneId,
|
||||
"--scene-name",
|
||||
sceneName,
|
||||
];
|
||||
|
||||
// 只有明确指定 sceneKind 时才添加参数(否则使用默认值 report_collection)
|
||||
if (sceneKind) {
|
||||
args.push("--scene-kind", sceneKind);
|
||||
}
|
||||
|
||||
args.push(
|
||||
"--output-root",
|
||||
normalize(outputRoot),
|
||||
"--lessons",
|
||||
normalize(lessons)
|
||||
);
|
||||
|
||||
// ... 后续代码不变
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交 generator-runner 改动**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add frontend/scene-generator/generator-runner.js
|
||||
git commit -m "feat: add sceneKind param to generator-runner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 修改 Node.js server 传递 sceneKind
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/scene-generator/server.js:119-154`
|
||||
|
||||
- [ ] **Step 1: 修改 handleGenerate 解构 sceneKind**
|
||||
|
||||
修改 `frontend/scene-generator/server.js`:
|
||||
|
||||
```javascript
|
||||
async function handleGenerate(req, res) {
|
||||
let body;
|
||||
try {
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons } = body;
|
||||
if (!sourceDir || !sceneId || !sceneName || !outputRoot || !lessons) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error:
|
||||
"All fields required: sourceDir, sceneId, sceneName, outputRoot, lessons",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sseWriter = initSSE(res);
|
||||
|
||||
try {
|
||||
await runGenerator(
|
||||
{ sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons }, // 增加 sceneKind
|
||||
sseWriter,
|
||||
config.projectRoot
|
||||
);
|
||||
} catch (err) {
|
||||
writeSSE(sseWriter, "error", { message: `Server error: ${err.message}` });
|
||||
}
|
||||
|
||||
sseWriter.end();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交 server 改动**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add frontend/scene-generator/server.js
|
||||
git commit -m "feat: pass sceneKind from /generate request to generator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 修改 Web UI 增加场景类型下拉框
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/scene-generator/sg_scene_generator.html`
|
||||
|
||||
- [ ] **Step 1: 在 HTML 中增加场景类型下拉框**
|
||||
|
||||
在 `sg_scene_generator.html` 的表单区域,scene-name 输入框后面添加:
|
||||
|
||||
```html
|
||||
<div class="form-group">
|
||||
<label for="sceneKind">场景类型</label>
|
||||
<select id="sceneKind">
|
||||
<option value="report_collection" selected>报表收集类</option>
|
||||
<option value="monitoring">监测类</option>
|
||||
</select>
|
||||
<span class="hint">报表类:查询数据导出 Excel;监测类:定时检查状态</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 generate() 函数读取 sceneKind**
|
||||
|
||||
修改 `sg_scene_generator.html` 中的 `generate()` 函数:
|
||||
|
||||
```javascript
|
||||
async function generate() {
|
||||
const sourceDir = document.getElementById('sourceDir').value.trim();
|
||||
const sceneId = document.getElementById('sceneId').value.trim();
|
||||
const sceneName = document.getElementById('sceneName').value.trim();
|
||||
const sceneKind = document.getElementById('sceneKind').value; // 新增
|
||||
const outputRoot = document.getElementById('outputRoot').value.trim();
|
||||
const lessons = document.getElementById('lessons').value.trim();
|
||||
|
||||
// ... 验证逻辑不变
|
||||
|
||||
const response = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceDir,
|
||||
sceneId,
|
||||
sceneName,
|
||||
sceneKind, // 新增
|
||||
outputRoot,
|
||||
lessons
|
||||
})
|
||||
});
|
||||
|
||||
// ... 后续代码不变
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交 HTML 改动**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add frontend/scene-generator/sg_scene_generator.html
|
||||
git commit -m "feat: add sceneKind dropdown to Web UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 端到端测试和最终验证
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
- [ ] **Step 1: 运行所有 Rust 测试**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test scene_generator_test -- --nocapture
|
||||
cargo test --test scene_registry_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: 重启 Node.js 服务器**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd frontend/scene-generator && node server.js
|
||||
```
|
||||
|
||||
Expected: 服务启动成功
|
||||
|
||||
- [ ] **Step 3: 手动测试 Web UI 报表类场景**
|
||||
|
||||
1. 打开 `http://127.0.0.1:3210/`
|
||||
2. 输入场景路径 `D:\desk\智能体资料\场景\营销2.0零度户报表数据生成`
|
||||
3. 场景类型选择"报表收集类"
|
||||
4. 点击"分析" → 等待 LLM 提取 scene-id/scene-name
|
||||
5. 点击"生成 Skill" → 等待生成完成
|
||||
6. 检查输出目录下生成的文件
|
||||
|
||||
Expected: 生成成功,scene.toml 包含 `category = "report_collection"`
|
||||
|
||||
- [ ] **Step 4: 提交最终验证**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add -A
|
||||
git status
|
||||
```
|
||||
|
||||
确认无未提交改动。
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Rust 层
|
||||
|
||||
```bash
|
||||
cargo test --test scene_generator_test -- --nocapture
|
||||
cargo build --bin sg_scene_generate
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `analyze_scene_source_with_hint` 接受可选的 `SceneKind` 参数
|
||||
- `GenerateSceneRequest` 包含 `scene_kind` 字段
|
||||
- generator 根据类型生成不同模板
|
||||
- CLI 支持 `--scene-kind` 参数
|
||||
|
||||
### Node.js 层
|
||||
|
||||
```bash
|
||||
node frontend/scene-generator/server.js
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `/generate` 接口接受 `sceneKind` 参数
|
||||
- `runGenerator` 正确传递参数给 CLI
|
||||
|
||||
### Web UI 层
|
||||
|
||||
手动测试:
|
||||
- 场景类型下拉框正常显示
|
||||
- 选择报表类生成 `category = "report_collection"`
|
||||
- 选择监测类生成 `category = "monitoring"`
|
||||
|
||||
---
|
||||
|
||||
## Notes For The Engineer
|
||||
|
||||
- 配对的 spec 文件是 `docs/superpowers/specs/2026-04-16-multi-scene-kind-generator-design.md`
|
||||
- 用户选择 `scene_kind_hint` 优先于 meta 标签
|
||||
- 监测类模板是简化版,用户需要手动编辑参数部分
|
||||
- V1 不修改 `registry.rs` 的运行时校验逻辑
|
||||
1121
docs/superpowers/plans/2026-04-16-scene-skill-generator.md
Normal file
1121
docs/superpowers/plans/2026-04-16-scene-skill-generator.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,839 @@
|
||||
# Enhanced LLM Extraction Schema - Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Enhance the LLM extraction schema to support multi-mode business logic, enabling automatic generation of scripts like tq-lineloss-report that switch between month/week modes.
|
||||
|
||||
**Architecture:** Extend existing `SceneInfoJson` in Rust with new mode-related structs. Enhance LLM prompt in `llm-client.js` to detect multi-mode patterns. Add new template function `browser_script_with_modes()` for generating mode-aware JavaScript.
|
||||
|
||||
**Tech Stack:** Rust (serde_json), JavaScript (Node.js), LLM API
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `src/generated_scene/generator.rs` | Modify | Add mode-related schema structs and multi-mode template |
|
||||
| `frontend/scene-generator/llm-client.js` | Modify | Enhance DEEP_SYSTEM_PROMPT for mode detection |
|
||||
| `frontend/scene-generator/server.js` | Modify | Handle enhanced schema in deep analysis endpoint |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Rust Schema Structs for Multi-Mode Support
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs` (after line 21)
|
||||
|
||||
**Goal:** Add new Rust structs to parse the enhanced JSON schema with modes support.
|
||||
|
||||
- [ ] **Step 1: Add ModeConditionJson struct**
|
||||
|
||||
Add after `ApiEndpointJson` struct (line 21):
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModeConditionJson {
|
||||
pub field: String,
|
||||
#[serde(default = "default_equals")]
|
||||
pub operator: String,
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
fn default_equals() -> String {
|
||||
"equals".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add NormalizeRulesJson struct**
|
||||
|
||||
Add after `ModeConditionJson`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct NormalizeRulesJson {
|
||||
#[serde(rename = "type", default = "default_validate_all")]
|
||||
pub rules_type: String,
|
||||
#[serde(default)]
|
||||
pub required_fields: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub filter_null: bool,
|
||||
}
|
||||
|
||||
fn default_validate_all() -> String {
|
||||
"validate_all_columns".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add ModeConfigJson struct**
|
||||
|
||||
Add after `NormalizeRulesJson`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModeConfigJson {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
pub condition: ModeConditionJson,
|
||||
#[serde(rename = "apiEndpoint")]
|
||||
pub api_endpoint: ApiEndpointEnhancedJson,
|
||||
#[serde(rename = "columnDefs", default)]
|
||||
pub column_defs: Vec<(String, String)>,
|
||||
#[serde(rename = "requestTemplate", default)]
|
||||
pub request_template: Option<serde_json::Value>,
|
||||
#[serde(rename = "normalizeRules", default)]
|
||||
pub normalize_rules: Option<NormalizeRulesJson>,
|
||||
#[serde(rename = "responsePath", default)]
|
||||
pub response_path: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add ApiEndpointEnhancedJson struct**
|
||||
|
||||
Add before `ModeConfigJson`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ApiEndpointEnhancedJson {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub method: String,
|
||||
#[serde(rename = "contentType", default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Enhance SceneInfoJson struct**
|
||||
|
||||
Modify `SceneInfoJson` to add mode fields (add after line 54, before the closing brace):
|
||||
|
||||
```rust
|
||||
// Multi-mode support (new fields)
|
||||
#[serde(default)]
|
||||
pub modes: Vec<ModeConfigJson>,
|
||||
#[serde(rename = "defaultMode", default)]
|
||||
pub default_mode: Option<String>,
|
||||
#[serde(rename = "modeSwitchField", default)]
|
||||
pub mode_switch_field: Option<String>,
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify the changes**
|
||||
|
||||
Run `cargo check` to verify:
|
||||
|
||||
```bash
|
||||
cargo check
|
||||
```
|
||||
|
||||
Expected: No compilation errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs
|
||||
git commit -m "feat(generator): add multi-mode schema structs for enhanced LLM extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Enhance LLM Extraction Prompt
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/scene-generator/llm-client.js` (lines 16-46)
|
||||
|
||||
**Goal:** Enhance `DEEP_SYSTEM_PROMPT` to instruct LLM to detect multi-mode business logic.
|
||||
|
||||
- [ ] **Step 1: Replace DEEP_SYSTEM_PROMPT with enhanced version**
|
||||
|
||||
Replace the entire `DEEP_SYSTEM_PROMPT` constant (lines 16-46):
|
||||
|
||||
```javascript
|
||||
const DEEP_SYSTEM_PROMPT = `你是一个场景代码分析专家。分析场景源码,提取关键业务信息。
|
||||
|
||||
## 分析目标
|
||||
|
||||
1. **多模式识别** (关键):
|
||||
- 查找条件分支逻辑 (if/switch) 中基于 period_mode、reportType 等字段的分支
|
||||
- 识别不同分支对应的 API 端点、列定义、请求格式
|
||||
- 如果发现多模式,使用 modes 数组格式输出
|
||||
|
||||
2. **API 端点**: 识别所有 HTTP 请求地址 (URL, method, contentType, 用途)
|
||||
- 从 $.ajax/fetch 调用中提取 contentType
|
||||
- 检测请求格式: application/json 或 application/x-www-form-urlencoded
|
||||
|
||||
3. **请求模板**: 识别请求参数结构
|
||||
- 提取硬编码的分页参数 (rows, page, sidx, sord)
|
||||
- 识别模板变量如 \${args.org_code}
|
||||
|
||||
4. **数据归一化**: 识别数据处理规则
|
||||
- 查找数据渲染/表格填充逻辑
|
||||
- 检测数据验证条件 (哪些字段不能为空)
|
||||
|
||||
5. **响应路径**: 识别数据在响应中的位置
|
||||
- 如 response.content 或 response.data
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 单模式场景 (无 modes 数组):
|
||||
{
|
||||
"sceneId": "string",
|
||||
"sceneName": "string",
|
||||
"sceneKind": "report_collection | monitoring",
|
||||
"expectedDomain": "string",
|
||||
"targetUrl": "string",
|
||||
"apiEndpoints": [{"name": "", "url": "", "method": "POST"}],
|
||||
"staticParams": {"key": "value"},
|
||||
"columnDefs": [["fieldName", "中文列名"]]
|
||||
}
|
||||
|
||||
### 多模式场景 (有 modes 数组):
|
||||
{
|
||||
"sceneId": "tq-lineloss-report",
|
||||
"sceneName": "台区线损报表",
|
||||
"sceneKind": "report_collection",
|
||||
"modes": [
|
||||
{
|
||||
"name": "month",
|
||||
"label": "月度报表",
|
||||
"condition": {"field": "period_mode", "operator": "equals", "value": "month"},
|
||||
"apiEndpoint": {
|
||||
"name": "月度线损查询",
|
||||
"url": "http://...",
|
||||
"method": "POST",
|
||||
"contentType": "application/x-www-form-urlencoded"
|
||||
},
|
||||
"columnDefs": [["ORG_NAME", "供电单位"], ...],
|
||||
"requestTemplate": {"orgno": "\${args.org_code}", "rows": 1000, "page": 1},
|
||||
"normalizeRules": {"type": "validate_all_columns", "filterNull": true},
|
||||
"responsePath": "content"
|
||||
},
|
||||
{
|
||||
"name": "week",
|
||||
"label": "周报表",
|
||||
"condition": {"field": "period_mode", "operator": "equals", "value": "week"},
|
||||
"apiEndpoint": {...},
|
||||
"columnDefs": [...],
|
||||
...
|
||||
}
|
||||
],
|
||||
"defaultMode": "month",
|
||||
"modeSwitchField": "period_mode"
|
||||
}
|
||||
|
||||
**重要**: 如果发现代码中有基于 period_mode 的 if/switch 分支,必须使用多模式格式输出!`;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify JavaScript syntax**
|
||||
|
||||
```bash
|
||||
node --check frontend/scene-generator/llm-client.js
|
||||
```
|
||||
|
||||
Expected: No syntax errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/scene-generator/llm-client.js
|
||||
git commit -m "feat(llm): enhance DEEP_SYSTEM_PROMPT for multi-mode detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement Multi-Mode Template in Rust
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs` (add new function after `browser_script_with_business_logic`)
|
||||
|
||||
**Goal:** Add a new template function that generates mode-aware JavaScript.
|
||||
|
||||
- [ ] **Step 1: Add browser_script_with_modes function**
|
||||
|
||||
Add after `browser_script_with_business_logic` function (after line 476):
|
||||
|
||||
```rust
|
||||
fn browser_script_with_modes(scene_id: &str, scene_info: &SceneInfoJson) -> String {
|
||||
let modes_json = serde_json::to_string_pretty(&scene_info.modes).unwrap_or_else(|_| "[]".to_string());
|
||||
let default_mode = scene_info.default_mode.as_deref().unwrap_or("month");
|
||||
let mode_switch_field = scene_info.mode_switch_field.as_deref().unwrap_or("period_mode");
|
||||
|
||||
format!(r#"const REPORT_NAME = '{scene_id}';
|
||||
const MODES = {modes_json};
|
||||
const DEFAULT_MODE = '{default_mode}';
|
||||
const MODE_SWITCH_FIELD = '{mode_switch_field}';
|
||||
|
||||
function normalizePayload(payload) {{
|
||||
if (typeof payload === 'string') {{
|
||||
try {{ return JSON.parse(payload); }} catch (_) {{ return {{}}; }}
|
||||
}}
|
||||
return payload && typeof payload === 'object' ? payload : {{}};
|
||||
}}
|
||||
|
||||
function validateArgs(args) {{
|
||||
const errors = [];
|
||||
if (!args.org_code) errors.push('Missing org_code');
|
||||
if (!args.period_value) errors.push('Missing period_value');
|
||||
return {{ valid: errors.length === 0, errors }};
|
||||
}}
|
||||
|
||||
function detectMode(args) {{
|
||||
const modeValue = args[MODE_SWITCH_FIELD] || DEFAULT_MODE;
|
||||
return MODES.find(m => m.condition.value === modeValue) || MODES[0];
|
||||
}}
|
||||
|
||||
function buildModeRequest(args, mode) {{
|
||||
const endpoint = mode.apiEndpoint;
|
||||
const template = mode.requestTemplate || {{}};
|
||||
const contentType = endpoint.contentType || 'application/json';
|
||||
const url = endpoint.url;
|
||||
const method = endpoint.method || 'POST';
|
||||
|
||||
let body;
|
||||
if (contentType === 'application/x-www-form-urlencoded') {{
|
||||
body = {{ ...template }};
|
||||
for (const [key, value] of Object.entries(body)) {{
|
||||
if (typeof value === 'string' && value.startsWith('${{') && value.endsWith('}}')) {{
|
||||
const expr = value.slice(2, -1);
|
||||
try {{
|
||||
body[key] = eval(expr);
|
||||
}} catch (e) {{
|
||||
body[key] = args.org_code;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
body.orgno = args.org_code;
|
||||
}} else {{
|
||||
body = JSON.stringify({{ ...template, ...args }});
|
||||
}}
|
||||
|
||||
return {{ url, method, headers: {{ 'Content-Type': contentType }}, body }};
|
||||
}}
|
||||
|
||||
function normalizeModeRows(data, mode) {{
|
||||
const rules = mode.normalizeRules || {{ type: 'validate_all_columns', filterNull: true }};
|
||||
const columns = mode.columnDefs.map(([key]) => key);
|
||||
|
||||
if (!Array.isArray(data)) return [];
|
||||
|
||||
return data.map(row => {{
|
||||
const result = {{}};
|
||||
for (const key of columns) {{
|
||||
const v = row[key];
|
||||
result[key] = (v === null || v === undefined || v === '') ? '' : String(v).trim();
|
||||
}}
|
||||
return result;
|
||||
}}).filter(row => {{
|
||||
if (!rules.filterNull) return true;
|
||||
if (rules.type === 'validate_required' && rules.requiredFields) {{
|
||||
return rules.requiredFields.every(f => row[f] !== '');
|
||||
}}
|
||||
return columns.every(k => row[k] !== '');
|
||||
}});
|
||||
}}
|
||||
|
||||
function determineArtifactStatus({{ blockedReason = '', fatalError = '', reasons = [], rows = [] }}) {{
|
||||
if (blockedReason) return 'blocked';
|
||||
if (fatalError) return 'error';
|
||||
if (reasons.length > 0) return 'partial';
|
||||
if (!rows.length) return 'empty';
|
||||
return 'ok';
|
||||
}}
|
||||
|
||||
function buildArtifact({{ status, blockedReason = '', fatalError = '', reasons = [], rows = [], args, columnDefs, columns }}) {{
|
||||
return {{
|
||||
type: 'report-artifact',
|
||||
report_name: REPORT_NAME,
|
||||
status: status || determineArtifactStatus({{ blockedReason, fatalError, reasons, rows }}),
|
||||
period: {{
|
||||
mode: args.period_mode,
|
||||
mode_code: args.period_mode_code,
|
||||
value: args.period_value,
|
||||
payload: normalizePayload(args.period_payload)
|
||||
}},
|
||||
org: {{ label: args.org_label, code: args.org_code }},
|
||||
column_defs: columnDefs || [],
|
||||
columns: columns || [],
|
||||
rows,
|
||||
counts: {{ detail_rows: rows.length }},
|
||||
partial_reasons: reasons.filter(r => r && !r.startsWith('api_') && !r.startsWith('validation_')),
|
||||
reasons: Array.from(new Set(reasons.filter(Boolean)))
|
||||
}};
|
||||
}}
|
||||
|
||||
const defaultDeps = {{
|
||||
validatePageContext(args) {{
|
||||
const host = (globalThis.location?.hostname || '').trim();
|
||||
const expected = (args.expected_domain || '').trim();
|
||||
if (!host) return {{ ok: false, reason: 'page_context_unavailable' }};
|
||||
if (host !== expected) return {{ ok: false, reason: 'page_context_mismatch' }};
|
||||
return {{ ok: true }};
|
||||
}},
|
||||
|
||||
async queryModeData(args, mode) {{
|
||||
const endpoint = mode.apiEndpoint;
|
||||
const request = buildModeRequest(args, mode);
|
||||
const contentType = endpoint.contentType || 'application/json';
|
||||
|
||||
// Prefer jQuery
|
||||
if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {{
|
||||
return new Promise((resolve, reject) => {{
|
||||
$.ajax({{
|
||||
url: request.url,
|
||||
type: request.method,
|
||||
data: request.body,
|
||||
contentType: contentType,
|
||||
dataType: 'json',
|
||||
success: resolve,
|
||||
error: (xhr, status, err) => reject(new Error(
|
||||
`API failed (${{xhr.status}}): ${{err}} | body=${{(xhr.responseText || '').substring(0, 200)}}`
|
||||
))
|
||||
}});
|
||||
}});
|
||||
}}
|
||||
|
||||
// Fallback: fetch
|
||||
if (typeof fetch === 'function') {{
|
||||
const response = await fetch(request.url, {{
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.method !== 'GET' ? request.body : undefined
|
||||
}});
|
||||
if (!response.ok) {{
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${{response.status}}: ${{text.substring(0, 200)}}`);
|
||||
}}
|
||||
return response.json();
|
||||
}}
|
||||
|
||||
throw new Error('No HTTP client available (need jQuery or fetch)');
|
||||
}}
|
||||
}};
|
||||
|
||||
async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{
|
||||
// 1. Parameter validation
|
||||
const validation = validateArgs(args);
|
||||
if (!validation.valid) {{
|
||||
const mode = detectMode(args);
|
||||
return buildArtifact({{
|
||||
status: 'blocked',
|
||||
blockedReason: 'validation_failed',
|
||||
reasons: validation.errors,
|
||||
rows: [],
|
||||
args,
|
||||
columnDefs: mode.columnDefs,
|
||||
columns: mode.columnDefs.map(([key]) => key)
|
||||
}});
|
||||
}}
|
||||
|
||||
// 2. Page context validation
|
||||
const pageValidation = typeof deps.validatePageContext === 'function'
|
||||
? deps.validatePageContext(args)
|
||||
: {{ ok: true }};
|
||||
if (!pageValidation?.ok) {{
|
||||
const mode = detectMode(args);
|
||||
return buildArtifact({{
|
||||
status: 'blocked',
|
||||
blockedReason: pageValidation?.reason || 'page_context_mismatch',
|
||||
reasons: [pageValidation?.reason || 'page_context_mismatch'],
|
||||
rows: [],
|
||||
args,
|
||||
columnDefs: mode.columnDefs,
|
||||
columns: mode.columnDefs.map(([key]) => key)
|
||||
}});
|
||||
}}
|
||||
|
||||
// 3. Detect mode
|
||||
const mode = detectMode(args);
|
||||
|
||||
// 4. Data fetching
|
||||
const reasons = [];
|
||||
let rawData = null;
|
||||
try {{
|
||||
rawData = await (deps.queryModeData ? deps.queryModeData(args, mode) : Promise.resolve([]));
|
||||
}} catch (error) {{
|
||||
return buildArtifact({{
|
||||
status: 'error',
|
||||
fatalError: error.message,
|
||||
reasons: ['api_query_failed:' + error.message],
|
||||
rows: [],
|
||||
args,
|
||||
columnDefs: mode.columnDefs,
|
||||
columns: mode.columnDefs.map(([key]) => key)
|
||||
}});
|
||||
}}
|
||||
|
||||
// 5. Extract response data
|
||||
const responsePath = mode.responsePath || '';
|
||||
let data = rawData;
|
||||
if (responsePath && rawData) {{
|
||||
data = rawData[responsePath] || rawData;
|
||||
}}
|
||||
|
||||
// 6. Row normalization
|
||||
const rows = normalizeModeRows(data, mode);
|
||||
if (rows.length === 0 && Array.isArray(data) && data.length > 0) {{
|
||||
reasons.push('row_normalization_partial');
|
||||
}}
|
||||
|
||||
// 7. Build artifact
|
||||
return buildArtifact({{
|
||||
reasons,
|
||||
rows,
|
||||
args,
|
||||
columnDefs: mode.columnDefs,
|
||||
columns: mode.columnDefs.map(([key]) => key)
|
||||
}});
|
||||
}}
|
||||
|
||||
if (typeof module !== 'undefined') {{
|
||||
module.exports = {{ buildBrowserEntrypointResult, normalizePayload, validateArgs, detectMode, buildModeRequest, normalizeModeRows, buildArtifact, determineArtifactStatus, MODES, REPORT_NAME }};
|
||||
}}
|
||||
|
||||
if (typeof args !== 'undefined') {{
|
||||
return buildBrowserEntrypointResult(args);
|
||||
}}
|
||||
"#, scene_id = scene_id, modes_json = modes_json, default_mode = default_mode, mode_switch_field = mode_switch_field)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Modify browser_script function to use multi-mode template**
|
||||
|
||||
Replace the `browser_script` function (lines 270-277):
|
||||
|
||||
```rust
|
||||
fn browser_script(scene_id: &str, analysis: &SceneSourceAnalysis, scene_info: Option<&SceneInfoJson>) -> String {
|
||||
match scene_info {
|
||||
Some(info) if !info.modes.is_empty() => {
|
||||
browser_script_with_modes(scene_id, info)
|
||||
}
|
||||
Some(info) if !info.api_endpoints.is_empty() || !info.column_defs.is_empty() => {
|
||||
browser_script_with_business_logic(scene_id, analysis, info)
|
||||
}
|
||||
_ => browser_script_skeleton(scene_id, analysis),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify compilation**
|
||||
|
||||
```bash
|
||||
cargo check
|
||||
```
|
||||
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs
|
||||
git commit -m "feat(generator): add multi-mode template for mode-aware script generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add Unit Tests for Schema Parsing
|
||||
|
||||
**Files:**
|
||||
- Create: `src/generated_scene/generator_test.rs`
|
||||
|
||||
**Goal:** Add tests to verify the enhanced schema parses correctly.
|
||||
|
||||
- [ ] **Step 1: Create test file**
|
||||
|
||||
Create `src/generated_scene/generator_test.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_mode_condition() {
|
||||
let json = r#"{"field": "period_mode", "operator": "equals", "value": "month"}"#;
|
||||
let condition: ModeConditionJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(condition.field, "period_mode");
|
||||
assert_eq!(condition.operator, "equals");
|
||||
assert_eq!(condition.value.as_str().unwrap(), "month");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_normalize_rules() {
|
||||
let json = r#"{"type": "validate_required", "requiredFields": ["ORG_NAME"], "filterNull": true}"#;
|
||||
let rules: NormalizeRulesJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(rules.rules_type, "validate_required");
|
||||
assert_eq!(rules.required_fields, vec!["ORG_NAME"]);
|
||||
assert!(rules.filter_null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mode_config() {
|
||||
let json = r#"{
|
||||
"name": "month",
|
||||
"label": "月度报表",
|
||||
"condition": {"field": "period_mode", "operator": "equals", "value": "month"},
|
||||
"apiEndpoint": {"name": "test", "url": "http://example.com", "method": "POST"},
|
||||
"columnDefs": [["ORG_NAME", "供电单位"]],
|
||||
"responsePath": "content"
|
||||
}"#;
|
||||
let mode: ModeConfigJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(mode.name, "month");
|
||||
assert_eq!(mode.column_defs.len(), 1);
|
||||
assert_eq!(mode.response_path, Some("content".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_scene_info_with_modes() {
|
||||
let json = r#"{
|
||||
"sceneId": "test-report",
|
||||
"sceneName": "测试报表",
|
||||
"sceneKind": "report_collection",
|
||||
"modes": [
|
||||
{"name": "month", "condition": {"field": "period_mode", "value": "month"}, "apiEndpoint": {"name": "m", "url": "http://a"}, "columnDefs": []},
|
||||
{"name": "week", "condition": {"field": "period_mode", "value": "week"}, "apiEndpoint": {"name": "w", "url": "http://b"}, "columnDefs": []}
|
||||
],
|
||||
"defaultMode": "month",
|
||||
"modeSwitchField": "period_mode"
|
||||
}"#;
|
||||
let info: SceneInfoJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info.scene_id, "test-report");
|
||||
assert_eq!(info.modes.len(), 2);
|
||||
assert_eq!(info.default_mode, Some("month".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_scene_info_backward_compatible() {
|
||||
// Old format without modes should still work
|
||||
let json = r#"{
|
||||
"sceneId": "old-report",
|
||||
"sceneName": "旧格式报表",
|
||||
"apiEndpoints": [{"name": "test", "url": "http://example.com"}],
|
||||
"columnDefs": [["col1", "列1"]]
|
||||
}"#;
|
||||
let info: SceneInfoJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info.scene_id, "old-report");
|
||||
assert!(info.modes.is_empty());
|
||||
assert_eq!(info.api_endpoints.len(), 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add test module to generator.rs**
|
||||
|
||||
Add at the end of `generator.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod generator_test;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
```bash
|
||||
cargo test --lib generator
|
||||
```
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator_test.rs src/generated_scene/generator.rs
|
||||
git commit -m "test(generator): add unit tests for multi-mode schema parsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Integration Test with tq-lineloss-report
|
||||
|
||||
**Files:**
|
||||
- Test: Generate skill from tq-lineloss-report source
|
||||
|
||||
**Goal:** Verify the enhanced template can generate a multi-mode script.
|
||||
|
||||
- [ ] **Step 1: Build the project**
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 2: Create a test multi-mode scene-info JSON**
|
||||
|
||||
Create a test JSON file to simulate LLM output:
|
||||
|
||||
```json
|
||||
{
|
||||
"sceneId": "tq-lineloss-test",
|
||||
"sceneName": "台区线损测试报表",
|
||||
"sceneKind": "report_collection",
|
||||
"modes": [
|
||||
{
|
||||
"name": "month",
|
||||
"label": "月度报表",
|
||||
"condition": {"field": "period_mode", "operator": "equals", "value": "month"},
|
||||
"apiEndpoint": {
|
||||
"name": "月度线损查询",
|
||||
"url": "http://20.76.57.61:18080/gsllys/fourVerEightHor/fourVerEightHorLinelossRateList",
|
||||
"method": "POST",
|
||||
"contentType": "application/x-www-form-urlencoded"
|
||||
},
|
||||
"columnDefs": [["ORG_NAME", "供电单位"], ["YGDL", "供电量"], ["YYDL", "售电量"]],
|
||||
"requestTemplate": {"orgno": "${args.org_code}", "rows": 1000, "page": 1, "sidx": "ORG_NO", "sord": "asc"},
|
||||
"normalizeRules": {"type": "validate_all_columns", "filterNull": true},
|
||||
"responsePath": "content"
|
||||
},
|
||||
{
|
||||
"name": "week",
|
||||
"label": "周报表",
|
||||
"condition": {"field": "period_mode", "operator": "equals", "value": "week"},
|
||||
"apiEndpoint": {
|
||||
"name": "周线损查询",
|
||||
"url": "http://20.76.57.61:18080/gsllys/tqLinelossStatis/getYearMonWeekLinelossAnalysisList",
|
||||
"method": "POST",
|
||||
"contentType": "application/x-www-form-urlencoded"
|
||||
},
|
||||
"columnDefs": [["ORG_NAME", "供电单位"], ["LINE_LOSS_RATE", "线损率"]],
|
||||
"requestTemplate": {"orgno": "${args.org_code}", "tjzq": "week", "rows": 1000},
|
||||
"normalizeRules": {"type": "validate_required", "requiredFields": ["ORG_NAME", "LINE_LOSS_RATE"], "filterNull": true},
|
||||
"responsePath": "content"
|
||||
}
|
||||
],
|
||||
"defaultMode": "month",
|
||||
"modeSwitchField": "period_mode"
|
||||
}
|
||||
```
|
||||
|
||||
Save to `tmp_multi_mode_test.json`.
|
||||
|
||||
- [ ] **Step 3: Run generator with the multi-mode JSON**
|
||||
|
||||
```bash
|
||||
cargo run --bin sg_scene_generate -- --source-dir "examples/test-scene" --scene-id "tq-lineloss-test" --scene-name "台区线损测试" --output-root "tmp_multi_test" --scene-info-json "$(cat tmp_multi_mode_test.json)"
|
||||
```
|
||||
|
||||
Expected: Skill package generated without errors.
|
||||
|
||||
- [ ] **Step 4: Verify generated script syntax**
|
||||
|
||||
```bash
|
||||
node --check tmp_multi_test/skills/tq-lineloss-test/scripts/collect_tq_lineloss_test.js
|
||||
```
|
||||
|
||||
Expected: No syntax errors.
|
||||
|
||||
- [ ] **Step 5: Verify generated script has multi-mode logic**
|
||||
|
||||
Check that the generated script contains:
|
||||
- `detectMode()` function
|
||||
- `MODES` constant with mode configurations
|
||||
- `buildModeRequest()` function
|
||||
- `normalizeModeRows()` function
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "test: verify multi-mode template generates valid JavaScript"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update Web UI to Display Mode Information
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/scene-generator/sg_scene_generator.html`
|
||||
|
||||
**Goal:** Add mode information display in the extraction preview panel.
|
||||
|
||||
- [ ] **Step 1: Add mode display section**
|
||||
|
||||
Add after the column defs display in the preview panel:
|
||||
|
||||
```html
|
||||
<div id="modes-preview" class="preview-section" style="display: none;">
|
||||
<h4>业务模式</h4>
|
||||
<div id="modes-list" class="preview-list"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add JavaScript to populate mode info**
|
||||
|
||||
In the `showExtractionPreview` function, add:
|
||||
|
||||
```javascript
|
||||
// Show modes if present
|
||||
const modesSection = document.getElementById('modes-preview');
|
||||
const modesList = document.getElementById('modes-list');
|
||||
|
||||
if (data.modes && data.modes.length > 0) {
|
||||
modesSection.style.display = 'block';
|
||||
modesList.innerHTML = data.modes.map(mode => {
|
||||
const name = escapeHtml(mode.name || 'unknown');
|
||||
const label = escapeHtml(mode.label || '');
|
||||
const api = escapeHtml(mode.apiEndpoint?.url || '');
|
||||
return `<div class="preview-list-item">
|
||||
<strong>${name}</strong>${label ? ` (${label})` : ''}: ${api}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
modesSection.style.display = 'none';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify changes**
|
||||
|
||||
```bash
|
||||
node --check frontend/scene-generator/sg_scene_generator.html
|
||||
```
|
||||
|
||||
Note: HTML files can't be syntax-checked directly, just verify the server starts.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/scene-generator/sg_scene_generator.html
|
||||
git commit -m "feat(ui): add mode information display in extraction preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
**1. Spec Coverage:**
|
||||
- [x] Multi-mode schema structs → Task 1
|
||||
- [x] Enhanced LLM prompt → Task 2
|
||||
- [x] Multi-mode template → Task 3
|
||||
- [x] Unit tests → Task 4
|
||||
- [x] Integration test → Task 5
|
||||
- [x] UI update → Task 6
|
||||
|
||||
**2. Placeholder Scan:**
|
||||
- No TBD, TODO, or placeholder text found
|
||||
- All code snippets are complete
|
||||
- All commands have expected output
|
||||
|
||||
**3. Type Consistency:**
|
||||
- `ModeConditionJson` field names match JSON schema
|
||||
- `ModeConfigJson` uses `apiEndpoint` (camelCase) matching JSON
|
||||
- `NormalizeRulesJson` uses `rules_type` with serde rename
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-04-17-enhanced-llm-extraction-schema-plan.md`. Two execution options:
|
||||
|
||||
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||||
|
||||
Which approach?
|
||||
@@ -0,0 +1,627 @@
|
||||
# Progressive Browser Script Template Enhancement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Enhance the `browser_script_with_business_logic` template in Rust to generate complete, runnable browser scripts with proper HTTP handling, status determination, and error handling.
|
||||
|
||||
**Architecture:** Modify `src/generated_scene/generator.rs` to replace the current incomplete JavaScript template with an enhanced version that includes: direct URL usage (fixing the URL construction bug), jQuery + fetch dual HTTP client support, complete status determination (blocked/error/partial/empty/ok), and enhanced entrypoint with page context validation.
|
||||
|
||||
**Tech Stack:** Rust, JavaScript (browser script), serde_json
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `src/generated_scene/generator.rs` | Modify | Replace `browser_script_with_business_logic` function with enhanced template |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix URL Building in buildRequest()
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs:308-321` (current `buildRequest` function in template)
|
||||
|
||||
**Current bug:** The template uses `new URL(endpoint.url, window.location.origin)` which incorrectly constructs URLs based on the current page's origin instead of using the complete endpoint URL directly.
|
||||
|
||||
**Goal:** Replace the buggy URL construction with direct URL usage.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create a test file to verify URL construction behavior:
|
||||
|
||||
```javascript
|
||||
// Test that URL is used directly without window.location.origin
|
||||
const assert = require('assert');
|
||||
|
||||
// Mock a complete URL in endpoint
|
||||
const endpoint = { url: 'http://20.76.57.61:18080/gsllys/api/test', method: 'POST' };
|
||||
|
||||
// Expected: buildRequest should return the URL directly
|
||||
// NOT: new URL(endpoint.url, 'http://different-origin.com')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement the fix**
|
||||
|
||||
Replace the `buildRequest` function in `browser_script_with_business_logic` (lines 308-321 in the generated template):
|
||||
|
||||
**Current (buggy):**
|
||||
```javascript
|
||||
function buildRequest(args, endpoint) {
|
||||
const url = new URL(endpoint.url, window.location.origin);
|
||||
const params = { ...STATIC_PARAMS, ...args };
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: url.toString(),
|
||||
method: endpoint.method || 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Fixed:**
|
||||
```javascript
|
||||
function buildRequest(args, endpoint) {
|
||||
// Use endpoint.url directly - it's already a complete URL
|
||||
const url = endpoint.url;
|
||||
const method = endpoint.method || 'POST';
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const body = JSON.stringify({ ...STATIC_PARAMS, ...args });
|
||||
return { url, method, headers, body };
|
||||
}
|
||||
```
|
||||
|
||||
Locate this in `src/generated_scene/generator.rs` within the `browser_script_with_business_logic` function (around line 308 in the format! string). Replace the entire `buildRequest` function definition.
|
||||
|
||||
- [ ] **Step 3: Verify the change**
|
||||
|
||||
Run `cargo build` to verify the Rust code compiles:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
Expected: Build succeeds without errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs
|
||||
git commit -m "fix(generator): use endpoint.url directly in buildRequest to fix URL construction bug"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add jQuery + fetch Dual HTTP Client Support
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs:355-368` (current `defaultDeps` object in template)
|
||||
|
||||
**Goal:** Add jQuery `$.ajax` as primary HTTP client with fetch as fallback for environments without jQuery.
|
||||
|
||||
- [ ] **Step 1: Replace defaultDeps with enhanced version**
|
||||
|
||||
Replace the current `defaultDeps` object in the template with enhanced jQuery + fetch support:
|
||||
|
||||
**Current:**
|
||||
```javascript
|
||||
const defaultDeps = {
|
||||
validatePageContext: async () => true,
|
||||
queryData: async (args) => {
|
||||
const endpoint = API_ENDPOINTS[0];
|
||||
if (!endpoint) throw new Error('No API endpoint configured');
|
||||
const request = buildRequest(args, endpoint);
|
||||
const response = await fetch(request.url, {
|
||||
method: request.method,
|
||||
headers: request.headers
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Enhanced:**
|
||||
```javascript
|
||||
const defaultDeps = {
|
||||
validatePageContext(args) {
|
||||
const host = (globalThis.location?.hostname || '').trim();
|
||||
const expected = (args.expected_domain || '').trim();
|
||||
if (!host) return { ok: false, reason: 'page_context_unavailable' };
|
||||
if (host !== expected) return { ok: false, reason: 'page_context_mismatch' };
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async queryData(args) {
|
||||
const endpoint = API_ENDPOINTS[0];
|
||||
if (!endpoint) throw new Error('No API endpoint configured');
|
||||
const request = buildRequest(args, endpoint);
|
||||
|
||||
// Prefer jQuery (internal pages typically have it)
|
||||
if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {
|
||||
return new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: request.url,
|
||||
type: request.method,
|
||||
data: request.body,
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
success: resolve,
|
||||
error: (xhr, status, err) => reject(new Error(
|
||||
`API failed (${xhr.status}): ${err} | body=${(xhr.responseText || '').substring(0, 200)}`
|
||||
))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: fetch API
|
||||
if (typeof fetch === 'function') {
|
||||
const response = await fetch(request.url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.method !== 'GET' ? request.body : undefined
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}: ${text.substring(0, 200)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
throw new Error('No HTTP client available (need jQuery or fetch)');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This code goes into the format! string in `browser_script_with_business_logic` function in `src/generated_scene/generator.rs`.
|
||||
|
||||
- [ ] **Step 2: Verify the change**
|
||||
|
||||
Run `cargo build` to verify:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs
|
||||
git commit -m "feat(generator): add jQuery + fetch dual HTTP client support in template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add determineArtifactStatus Function
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs` (add new function to template before `buildArtifact`)
|
||||
|
||||
**Goal:** Add complete status determination logic supporting blocked/error/partial/empty/ok statuses.
|
||||
|
||||
- [ ] **Step 1: Add determineArtifactStatus function to template**
|
||||
|
||||
Insert the following function into the template, before the `buildArtifact` function:
|
||||
|
||||
```javascript
|
||||
function determineArtifactStatus({ blockedReason = '', fatalError = '', reasons = [], rows = [] }) {
|
||||
if (blockedReason) return 'blocked';
|
||||
if (fatalError) return 'error';
|
||||
if (reasons.length > 0) return 'partial';
|
||||
if (!rows.length) return 'empty';
|
||||
return 'ok';
|
||||
}
|
||||
```
|
||||
|
||||
This should be placed in the template string between `normalizeRows` and `buildArtifact` functions.
|
||||
|
||||
- [ ] **Step 2: Verify the change**
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs
|
||||
git commit -m "feat(generator): add determineArtifactStatus for complete status determination"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Enhance buildArtifact Function
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs:334-353` (current `buildArtifact` function in template)
|
||||
|
||||
**Goal:** Enhance `buildArtifact` to use `determineArtifactStatus` and accept additional parameters.
|
||||
|
||||
- [ ] **Step 1: Replace buildArtifact function**
|
||||
|
||||
Replace the current `buildArtifact` function with enhanced version:
|
||||
|
||||
**Current:**
|
||||
```javascript
|
||||
function buildArtifact(args, rows) {
|
||||
return {
|
||||
type: 'report-artifact',
|
||||
report_name: '{scene_id}',
|
||||
status: rows.length > 0 ? 'ok' : 'empty',
|
||||
period: {
|
||||
mode: args.period_mode,
|
||||
mode_code: args.period_mode_code,
|
||||
value: args.period_value,
|
||||
payload: normalizePayload(args.period_payload)
|
||||
},
|
||||
org: { label: args.org_label, code: args.org_code },
|
||||
column_defs: COLUMN_DEFS,
|
||||
columns: {columns_json},
|
||||
rows,
|
||||
counts: { detail_rows: rows.length },
|
||||
partial_reasons: [],
|
||||
reasons: []
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Enhanced:**
|
||||
```javascript
|
||||
function buildArtifact({ status, blockedReason = '', fatalError = '', reasons = [], rows = [], args }) {
|
||||
return {
|
||||
type: 'report-artifact',
|
||||
report_name: REPORT_NAME,
|
||||
status: status || determineArtifactStatus({ blockedReason, fatalError, reasons, rows }),
|
||||
period: {
|
||||
mode: args.period_mode,
|
||||
mode_code: args.period_mode_code,
|
||||
value: args.period_value,
|
||||
payload: normalizePayload(args.period_payload)
|
||||
},
|
||||
org: { label: args.org_label, code: args.org_code },
|
||||
column_defs: COLUMN_DEFS,
|
||||
columns: COLUMNS,
|
||||
rows,
|
||||
counts: { detail_rows: rows.length },
|
||||
partial_reasons: reasons.filter(r => r && !r.startsWith('api_') && !r.startsWith('validation_')),
|
||||
reasons: Array.from(new Set(reasons.filter(Boolean)))
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the change**
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs
|
||||
git commit -m "feat(generator): enhance buildArtifact with determineArtifactStatus integration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Enhance buildBrowserEntrypointResult Function
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs:370-405` (current `buildBrowserEntrypointResult` function in template)
|
||||
|
||||
**Goal:** Complete rewrite of entrypoint with proper validation flow, page context check, and error handling.
|
||||
|
||||
- [ ] **Step 1: Replace buildBrowserEntrypointResult function**
|
||||
|
||||
Replace the entire `buildBrowserEntrypointResult` function:
|
||||
|
||||
**Current:**
|
||||
```javascript
|
||||
async function buildBrowserEntrypointResult(args, deps = defaultDeps) {
|
||||
const validation = validateArgs(args);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
type: 'report-artifact',
|
||||
report_name: '{scene_id}',
|
||||
status: 'error',
|
||||
error: 'Validation failed: ' + validation.errors.join(', '),
|
||||
column_defs: COLUMN_DEFS,
|
||||
columns: {columns_json},
|
||||
rows: [],
|
||||
counts: { detail_rows: 0 },
|
||||
partial_reasons: [],
|
||||
reasons: validation.errors
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const rawData = await (deps.queryData ? deps.queryData(args) : Promise.resolve([]));
|
||||
const rows = normalizeRows(rawData);
|
||||
return buildArtifact(args, rows);
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'report-artifact',
|
||||
report_name: '{scene_id}',
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
column_defs: COLUMN_DEFS,
|
||||
columns: {columns_json},
|
||||
rows: [],
|
||||
counts: { detail_rows: 0 },
|
||||
partial_reasons: [],
|
||||
reasons: [error.message]
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Enhanced:**
|
||||
```javascript
|
||||
async function buildBrowserEntrypointResult(args, deps = defaultDeps) {
|
||||
// 1. Parameter validation
|
||||
const validation = validateArgs(args);
|
||||
if (!validation.valid) {
|
||||
return buildArtifact({
|
||||
status: 'blocked',
|
||||
blockedReason: 'validation_failed',
|
||||
reasons: validation.errors,
|
||||
rows: [],
|
||||
args
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Page context validation
|
||||
const pageValidation = typeof deps.validatePageContext === 'function'
|
||||
? deps.validatePageContext(args)
|
||||
: { ok: true };
|
||||
if (!pageValidation?.ok) {
|
||||
return buildArtifact({
|
||||
status: 'blocked',
|
||||
blockedReason: pageValidation?.reason || 'page_context_mismatch',
|
||||
reasons: [pageValidation?.reason || 'page_context_mismatch'],
|
||||
rows: [],
|
||||
args
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Data fetching
|
||||
const reasons = [];
|
||||
let rawData = null;
|
||||
try {
|
||||
rawData = await (deps.queryData ? deps.queryData(args) : Promise.resolve([]));
|
||||
} catch (error) {
|
||||
return buildArtifact({
|
||||
status: 'error',
|
||||
fatalError: error.message,
|
||||
reasons: ['api_query_failed:' + error.message],
|
||||
rows: [],
|
||||
args
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Row normalization
|
||||
const rows = normalizeRows(rawData);
|
||||
if (rows.length === 0 && Array.isArray(rawData) && rawData.length > 0) {
|
||||
reasons.push('row_normalization_partial');
|
||||
}
|
||||
|
||||
// 5. Build artifact
|
||||
return buildArtifact({ reasons, rows, args });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the change**
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs
|
||||
git commit -m "feat(generator): enhance buildBrowserEntrypointResult with validation flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add Helper Functions and Constants
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs` (add helper functions to template)
|
||||
|
||||
**Goal:** Add utility functions used by the enhanced template.
|
||||
|
||||
- [ ] **Step 1: Add helper functions after COLUMN_DEFS constant**
|
||||
|
||||
Add these utility functions to the template after the constant definitions:
|
||||
|
||||
```javascript
|
||||
const REPORT_NAME = '{scene_id}';
|
||||
const COLUMNS = {columns_json};
|
||||
|
||||
function pickFirstNonEmpty(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() !== '';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the change**
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs
|
||||
git commit -m "feat(generator): add helper functions for enhanced template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Update Module Exports
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/generated_scene/generator.rs:407-409` (current module.exports in template)
|
||||
|
||||
**Goal:** Update module exports to include new functions.
|
||||
|
||||
- [ ] **Step 1: Update module.exports**
|
||||
|
||||
Replace the current export block:
|
||||
|
||||
**Current:**
|
||||
```javascript
|
||||
if (typeof module !== 'undefined') {
|
||||
module.exports = { buildBrowserEntrypointResult, normalizePayload, validateArgs, buildRequest, normalizeRows, buildArtifact, API_ENDPOINTS, STATIC_PARAMS, COLUMN_DEFS };
|
||||
}
|
||||
```
|
||||
|
||||
**Enhanced:**
|
||||
```javascript
|
||||
if (typeof module !== 'undefined') {
|
||||
module.exports = {
|
||||
buildBrowserEntrypointResult,
|
||||
normalizePayload,
|
||||
validateArgs,
|
||||
buildRequest,
|
||||
normalizeRows,
|
||||
determineArtifactStatus,
|
||||
buildArtifact,
|
||||
API_ENDPOINTS,
|
||||
STATIC_PARAMS,
|
||||
COLUMN_DEFS,
|
||||
COLUMNS,
|
||||
REPORT_NAME
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the change**
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/generated_scene/generator.rs
|
||||
git commit -m "feat(generator): update module exports for enhanced template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Integration Test - Generate and Verify Script
|
||||
|
||||
**Files:**
|
||||
- Test: Generate a skill package and verify the output
|
||||
|
||||
**Goal:** Verify the enhanced template generates valid JavaScript.
|
||||
|
||||
- [ ] **Step 1: Build the project**
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 2: Generate a test skill package**
|
||||
|
||||
Use the scene generator to create a test skill:
|
||||
|
||||
```bash
|
||||
# Assuming you have a test scene directory
|
||||
cargo run --bin sg_scene_generate -- --source-dir "examples/test-scene" --scene-id "test-enhanced" --scene-name "Test Enhanced" --output-root "tmp_test_enhanced" --scene-info-json '{"sceneId":"test-enhanced","sceneName":"Test Enhanced","apiEndpoints":[{"name":"testApi","url":"http://example.com/api/test","method":"POST"}],"staticParams":{},"columnDefs":[["col1","Column 1"]]}'
|
||||
```
|
||||
|
||||
Expected: Skill package generated without errors.
|
||||
|
||||
- [ ] **Step 3: Verify generated script syntax**
|
||||
|
||||
Check the generated JavaScript for syntax errors:
|
||||
|
||||
```bash
|
||||
node --check tmp_test_enhanced/skills/test-enhanced/scripts/collect_test_enhanced.js
|
||||
```
|
||||
|
||||
Expected: No syntax errors.
|
||||
|
||||
- [ ] **Step 4: Run the generated test**
|
||||
|
||||
```bash
|
||||
node tmp_test_enhanced/skills/test-enhanced/scripts/collect_test_enhanced.test.js
|
||||
```
|
||||
|
||||
Expected: Test passes (may fail on API call, but artifact structure should be valid).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "test: verify enhanced template generates valid JavaScript"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
**1. Spec Coverage:**
|
||||
- [x] URL construction bug fix → Task 1
|
||||
- [x] jQuery + fetch dual support → Task 2
|
||||
- [x] determineArtifactStatus function → Task 3
|
||||
- [x] Enhanced buildArtifact → Task 4
|
||||
- [x] Enhanced buildBrowserEntrypointResult → Task 5
|
||||
- [x] Helper functions → Task 6
|
||||
- [x] Module exports → Task 7
|
||||
- [x] Integration testing → Task 8
|
||||
|
||||
**2. Placeholder Scan:**
|
||||
- No TBD, TODO, or placeholder text found
|
||||
- All code snippets are complete
|
||||
- All commands have expected output
|
||||
|
||||
**3. Type Consistency:**
|
||||
- `buildArtifact` parameter signature consistent across all call sites
|
||||
- `args` object properties consistently named
|
||||
- Status values: blocked/error/partial/empty/ok consistently used
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-04-17-progressive-template-enhancement-plan.md`. Two execution options:
|
||||
|
||||
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||||
|
||||
Which approach?
|
||||
@@ -0,0 +1,482 @@
|
||||
# sgClaw 场景生成器质量提升 — 实施计划
|
||||
|
||||
> 对应设计文档: `docs/superpowers/specs/2026-04-17-scene-generator-quality-improvement-design.md`
|
||||
|
||||
## 总览
|
||||
|
||||
3 个阶段,8 个任务。每个任务包含:改动文件、具体步骤、验证方式、提交信息。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 修基础
|
||||
|
||||
### Task 1: 统一生成路径(废弃 browser_script_with_business_logic)
|
||||
|
||||
**文件**: `src/generated_scene/generator.rs`
|
||||
|
||||
**当前状态** (line 728-735):
|
||||
```rust
|
||||
fn compile_scene(scene_ir: &SceneIr, analysis: &SceneSourceAnalysis, tool_name: &str) -> CompiledScene {
|
||||
let scene_toml = render_scene_toml(scene_ir, analysis, tool_name);
|
||||
let browser_script = match scene_ir.workflow_archetype() {
|
||||
WorkflowArchetype::SingleRequestTable => compile_single_request_table(scene_ir),
|
||||
WorkflowArchetype::MultiModeRequest => compile_multi_mode_request(scene_ir),
|
||||
WorkflowArchetype::PaginatedEnrichment => compile_paginated_enrichment(scene_ir),
|
||||
WorkflowArchetype::PageStateEval => compile_page_state_eval(scene_ir),
|
||||
};
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. **修改 `compile_scene` 路由逻辑** (line 730-735):
|
||||
- `SingleRequestTable` 不再调用 `compile_simple_request_script`(`compile_single_request_table` 的底层),改为将单模式场景包装为一个 mode 后走 `compile_multi_mode_request`
|
||||
- 新增辅助函数 `ensure_modes_populated(scene_ir: &SceneIr) -> SceneIr`:
|
||||
- 如果 `scene_ir.modes` 为空但 `scene_ir.api_endpoints` 非空,生成一个 default mode
|
||||
- 将 `SingleRequestTable` 和 `PageStateEval` 场景的 `workflow_archetype` 改为 `MultiModeRequest`(因为统一走 modes 路径)
|
||||
- 修改 match 分支:
|
||||
```rust
|
||||
let browser_script = match scene_ir.workflow_archetype() {
|
||||
WorkflowArchetype::MultiModeRequest => compile_multi_mode_request(scene_ir),
|
||||
WorkflowArchetype::PaginatedEnrichment => compile_paginated_enrichment(scene_ir),
|
||||
_ => {
|
||||
// SingleRequestTable, PageStateEval — fallback to multi-mode with default mode
|
||||
let adapted = ensure_modes_populated(scene_ir);
|
||||
compile_multi_mode_request(&adapted)
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. **实现 `ensure_modes_populated`**:
|
||||
- 接收 `&SceneIr`,返回 `SceneIr`(clone)
|
||||
- 如果 `modes` 已非空,直接返回 clone
|
||||
- 如果 `modes` 为空但 `api_endpoints` 非空:
|
||||
- 取第一个 endpoint 构造默认 mode
|
||||
- 设置 `name: "default"`, `label: Some("default")`
|
||||
- `condition`: `{ field: "period_mode", operator: "equals", value: "default" }`
|
||||
- `apiEndpoint`: 复制第一个 endpoint
|
||||
- `requestTemplate`: 取 `scene_ir.request_template`
|
||||
- `responsePath`: 取 `scene_ir.response_path`
|
||||
- `normalizeRules`: 取 `scene_ir.normalize_rules` 或默认
|
||||
- `columnDefs`: 取 `scene_ir.column_defs`
|
||||
- 同时设置 `default_mode = Some("default")`, `mode_switch_field = Some("period_mode")`
|
||||
|
||||
3. **标记 `browser_script_with_business_logic` 为废弃**(如果仍存在于代码中):
|
||||
- 在当前代码中,该函数已不存在(已被 `compile_simple_request_script` 替代)。在注释中标注 "legacy path, superseded by multi-mode unified path"
|
||||
|
||||
**验证**:
|
||||
- `cargo check` 无编译错误
|
||||
- 单模式场景生成的 JS 脚本包含 `const MODES =` 和 `detectMode` 逻辑
|
||||
|
||||
**提交信息**:
|
||||
```
|
||||
feat(generator): unify all scene types through multi-mode path
|
||||
|
||||
Single-mode and page-state-eval scenes now get auto-wrapped into a
|
||||
default mode and compiled through compile_multi_mode_request. This
|
||||
eliminates the old browser_script_with_business_logic code path and
|
||||
ensures all scenes get responsePath extraction, requestTemplate, and
|
||||
contentType support.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修复 jQuery processData 参数
|
||||
|
||||
**文件**: `src/generated_scene/generator.rs`(`compile_multi_mode_request` 函数,line 1069-1253)
|
||||
|
||||
**当前状态**: 模板中 `buildModeRequest` 函数(line 1098-1118)根据 `contentType` 区分了 body 序列化方式(form-urlencoded 用 `Object.entries().join('&')`,JSON 用 `JSON.stringify`),但 jQuery ajax 调用(line 1185-1196)**没有**设置 `processData` 参数。
|
||||
|
||||
jQuery 对 form-urlencoded body 会默认再次序列化(将字符串当作 query string 处理),导致双重编码。
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 修改 `compile_multi_mode_request` 中的 jQuery ajax 调用模板(line 1185-1196 区域):
|
||||
- 在 `$.ajax({...})` 中增加 `processData` 参数:
|
||||
```javascript
|
||||
$.ajax({
|
||||
url: request.url,
|
||||
type: request.method,
|
||||
data: request.body,
|
||||
contentType: request.headers['Content-Type'],
|
||||
processData: contentType !== 'application/x-www-form-urlencoded',
|
||||
dataType: 'json',
|
||||
success: resolve,
|
||||
error: (xhr, status, err) => reject(new Error(`API failed (${xhr.status}): ${err}`))
|
||||
});
|
||||
```
|
||||
- 需要将 `contentType` 变量在 Promise 回调中可访问,从 `request` 对象中提取
|
||||
|
||||
2. 同理修改 `compile_simple_request_script` 中的 jQuery ajax 调用(line 994-1004 区域),增加相同的 `processData` 逻辑
|
||||
|
||||
**验证**:
|
||||
- 生成的 JS 中 `$.ajax` 调用包含 `processData` 参数
|
||||
- form-urlencoded 请求不会双重编码
|
||||
|
||||
**提交信息**:
|
||||
```
|
||||
fix(generator): add processData to jQuery ajax for form-urlencoded requests
|
||||
|
||||
jQuery default processData:true re-serializes string bodies, causing
|
||||
double-encoding for form-urlencoded payloads. Set processData:false
|
||||
when contentType is application/x-www-form-urlencoded.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 单模式场景自动包装为 mode 配置
|
||||
|
||||
**文件**: `frontend/scene-generator/llm-client.js`
|
||||
|
||||
**当前状态**: `analyzeSceneDeep` (line 729-769) 调用 LLM 后直接 `normalizeSceneIr` 返回。如果 LLM 输出 `modes: []` 但有 `apiEndpoints`,不会自动包装。
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 在 `analyzeSceneDeep` 函数中,`normalizeSceneIr(...)` 之后、返回之前,增加自动包装逻辑:
|
||||
|
||||
```javascript
|
||||
async function analyzeSceneDeep(sourceDir, dirContents, config) {
|
||||
const content = await requestChatCompletionWithRetry(...);
|
||||
const normalized = normalizeSceneIr(await extractJsonFromResponseWithRepair(content, config));
|
||||
|
||||
// ... existing sceneId validation ...
|
||||
|
||||
// AUTO-WRAP: single-mode scenes → modes array
|
||||
if (normalized.modes.length === 0 && normalized.apiEndpoints.length > 0) {
|
||||
normalized.modes.push({
|
||||
name: "default",
|
||||
label: "default",
|
||||
condition: { field: "period_mode", operator: "equals", value: "default" },
|
||||
apiEndpoint: normalized.apiEndpoints[0],
|
||||
columnDefs: normalized.columnDefs || [],
|
||||
requestTemplate: normalized.requestTemplate || {},
|
||||
normalizeRules: normalized.normalizeRules || { type: "validate_required", requiredFields: [], filterNull: true },
|
||||
responsePath: normalized.responsePath || "",
|
||||
});
|
||||
normalized.defaultMode = "default";
|
||||
normalized.modeSwitchField = "period_mode";
|
||||
// Upgrade archetype if it was single_request_table
|
||||
if (normalized.workflowArchetype === "single_request_table") {
|
||||
normalized.workflowArchetype = "multi_mode_request";
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
```
|
||||
|
||||
2. 同时在 `normalizeSceneIr` 中确保 `defaultMode` 和 `modeSwitchField` 有正确的默认值(已有 line 477-478 处理)
|
||||
|
||||
**验证**:
|
||||
- 对单模式场景(如 `用户日电量监测`)运行生成,确认 `modes` 数组包含一个 default mode
|
||||
- 确认 `workflowArchetype` 被正确升级为 `multi_mode_request`
|
||||
|
||||
**提交信息**:
|
||||
```
|
||||
feat(llm-client): auto-wrap single-mode scenes into modes array
|
||||
|
||||
When the LLM returns an empty modes array but has apiEndpoints,
|
||||
automatically create a default mode with the first endpoint,
|
||||
requestTemplate, responsePath, and normalizeRules. This ensures all
|
||||
scenes compile through the multi-mode path.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 增强提取
|
||||
|
||||
### Task 4: 增强 LLM prompt 的强制约束
|
||||
|
||||
**文件**: `frontend/scene-generator/llm-client.js`(`DEEP_SYSTEM_PROMPT`,line 19-82)
|
||||
|
||||
**当前状态**: prompt 中已列出 schema 但没有强调哪些字段是**必须**填充的。LLM 经常跳过 `contentType`、`responsePath`、`requestTemplate`。
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 在 `DEEP_SYSTEM_PROMPT` 的 schema 定义后,增加**强制字段约束**段落:
|
||||
|
||||
```
|
||||
MANDATORY FIELDS (never leave empty):
|
||||
- apiEndpoints[].contentType: detect from source code.
|
||||
* For $.ajax({}): look for 'contentType' property. Default 'application/json' if absent.
|
||||
* For $http.sendByAxios(): contentType is 'application/json' (axios default).
|
||||
* For XMLHttpRequest: look for setRequestHeader('Content-Type', ...).
|
||||
* For form submissions: 'application/x-www-form-urlencoded'.
|
||||
- modes[].responsePath: the JSON path from raw API response to the data array.
|
||||
* Common patterns: 'data.list', 'data.rcvblAcctSumAll.rcvblAcctVOS', 'content', 'data.records'
|
||||
* If response is the array itself, use empty string "".
|
||||
- modes[].requestTemplate: the static request body shape from the source code.
|
||||
* Extract ALL keys that appear in the request body object.
|
||||
* Mark dynamic values as "${args.fieldName}" and static values as literals.
|
||||
- apiEndpoints[].url: the full API URL as seen in the source code.
|
||||
|
||||
RULES:
|
||||
- If you cannot determine contentType, default to 'application/json'.
|
||||
- If you cannot determine responsePath, default to '' (empty string).
|
||||
- If you cannot determine requestTemplate, use {} (empty object).
|
||||
- NEVER leave these fields as null or undefined.
|
||||
```
|
||||
|
||||
2. 将这段文字插入到 `DEEP_SYSTEM_PROMPT` 中 schema 定义之后、`Instructions` 之前
|
||||
|
||||
**验证**:
|
||||
- 对 `营销2.0零度户报表数据生成` 场景运行生成,确认 LLM 输出的 `contentType` 和 `responsePath` 不再为空
|
||||
- 确认 `requestTemplate` 包含了业务必需字段
|
||||
|
||||
**提交信息**:
|
||||
```
|
||||
feat(llm-client): add mandatory field constraints to DEEP_SYSTEM_PROMPT
|
||||
|
||||
Explicitly require LLM to fill contentType, responsePath, and
|
||||
requestTemplate with detected values or defaults. Reduces empty-field
|
||||
rate from ~60% to target ~10%.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 增加业务 JS 文件提取
|
||||
|
||||
**文件**:
|
||||
- `frontend/scene-generator/server.js`
|
||||
- `frontend/scene-generator/generator-runner.js`
|
||||
|
||||
**当前状态**: `readDirectory` 在 `generator-runner.js` 中已经读取所有文件到 `dirContents`,但 `buildDeepAnalyzePrompt`(`llm-client.js` line 125-157)主要推送 `index.html` 的 fragments。业务 JS 文件(如 `js/mca.js`, `js/sgApi.js`)的内容没有被单独提取推送。
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. **在 `generator-runner.js` 中增加业务 JS 文件识别**:
|
||||
- 在 `buildAnalysisContext` 函数中,增加一个 `businessJsFragments` 数组
|
||||
- 识别 `js/` 目录下的 `.js` 文件(排除 `vue.js`, `element-ui` 等第三方库)
|
||||
- 对每个业务 JS 文件,提取前 600 字符的关键片段(函数定义、API 调用、配置对象)
|
||||
- 将结果放入 `analysisContext.businessJsFragments`
|
||||
|
||||
2. **在 `llm-client.js` 的 `buildDeepAnalyzePrompt` 中推送业务 JS 片段**:
|
||||
- 在现有的 `pushFragments` 调用后增加:
|
||||
```javascript
|
||||
pushFragments(parts, "business JS files", context.businessJsFragments, 4);
|
||||
```
|
||||
- 确保总 prompt 大小不超过 `MAX_DEEP_PROMPT_CHARS`(60000)
|
||||
|
||||
3. **在 `server.js` 中确保业务 JS 文件被读取**:
|
||||
- 检查 `/handle-analyze-deep` 端点中 `readDirectory` 的调用是否已经读取了 `js/` 目录下的文件
|
||||
- 如果没有,增加对 `js/*.js` 文件的读取逻辑
|
||||
|
||||
**验证**:
|
||||
- 对 `台区线损大数据` 场景运行,确认 `js/mca.js` 或类似业务文件的内容被推送给 LLM
|
||||
- 确认 prompt 总大小不超过 60000 字符
|
||||
|
||||
**提交信息**:
|
||||
```
|
||||
feat(scene-generator): extract business JS files for LLM analysis
|
||||
|
||||
Identify and push js/ directory business logic files (mca.js, sgApi.js,
|
||||
etc.) to the LLM prompt. Exclude third-party libraries. Capped at 4
|
||||
fragments to stay within MAX_DEEP_PROMPT_CHARS budget.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 提取后验证与二次追问
|
||||
|
||||
**文件**: `frontend/scene-generator/llm-client.js`
|
||||
|
||||
**当前状态**: `analyzeSceneDeep` 拿到 LLM 返回后直接 `normalizeSceneIr` 然后返回,没有检查关键字段是否缺失。
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 新增 `validateExtractedSceneInfo(sceneIr)` 函数:
|
||||
```javascript
|
||||
function validateExtractedSceneInfo(sceneIr) {
|
||||
const issues = [];
|
||||
|
||||
// Check: at least one apiEndpoint has contentType
|
||||
const endpointsWithCt = (sceneIr.apiEndpoints || []).filter(
|
||||
ep => ep && ep.contentType
|
||||
);
|
||||
if ((sceneIr.apiEndpoints || []).length > 0 && endpointsWithCt.length === 0) {
|
||||
issues.push("missing_contentType_on_endpoints");
|
||||
}
|
||||
|
||||
// Check: at least one mode has responsePath (if modes exist)
|
||||
if ((sceneIr.modes || []).length > 0) {
|
||||
const modesWithPath = sceneIr.modes.filter(m => m.responsePath !== undefined && m.responsePath !== null);
|
||||
if (modesWithPath.length === 0) {
|
||||
issues.push("missing_responsePath_on_modes");
|
||||
}
|
||||
}
|
||||
|
||||
// Check: workflowArchetype is set
|
||||
if (!sceneIr.workflowArchetype) {
|
||||
issues.push("missing_workflowArchetype");
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `analyzeSceneDeep` 中,`normalizeSceneIr` 之后调用验证:
|
||||
```javascript
|
||||
const issues = validateExtractedSceneInfo(normalized);
|
||||
if (issues.length > 0) {
|
||||
// Secondary prompt
|
||||
const followUpPrompt = `The previous extraction has these issues:\n${issues.join('\n')}\nPlease re-analyze the source snippets and fill in the missing fields. Use defaults if truly unavailable.`;
|
||||
|
||||
const followUpContent = await requestChatCompletionWithRetry(
|
||||
[
|
||||
{ role: "system", content: DEEP_SYSTEM_PROMPT },
|
||||
{ role: "user", content: followUpPrompt },
|
||||
],
|
||||
{ ...config, maxTokens: 2400, timeoutMs: DEEP_REQUEST_TIMEOUT_MS, retryAttempts: 1 }
|
||||
);
|
||||
|
||||
const repaired = normalizeSceneIr(await extractJsonFromResponseWithRepair(followUpContent, config));
|
||||
// Merge repaired fields into normalized (only fill empty fields)
|
||||
Object.assign(normalized, mergeSceneIrFields(repaired, normalized));
|
||||
}
|
||||
```
|
||||
|
||||
3. 新增 `mergeSceneIrFields(repaired, original)` 辅助函数:
|
||||
- 仅当 original 的字段为空/默认值时,才用 repaired 的值覆盖
|
||||
- 避免丢失第一次提取的有效信息
|
||||
|
||||
**验证**:
|
||||
- 模拟一个 LLM 返回缺少 `contentType` 的场景,确认二次追问触发
|
||||
- 确认最多追问 1 次,不会无限循环
|
||||
|
||||
**提交信息**:
|
||||
```
|
||||
feat(llm-client): add post-extraction validation with one-shot retry
|
||||
|
||||
After LLM returns scene IR, validate that critical fields (contentType,
|
||||
responsePath, workflowArchetype) are present. If missing, send one
|
||||
follow-up prompt to fill gaps. Merges repaired fields without overwriting
|
||||
valid data from the first extraction.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 测试验证
|
||||
|
||||
### Task 7: 单元测试
|
||||
|
||||
**文件**: `tests/scene_generator_modes_test.rs`(新增)
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 创建测试文件 `tests/scene_generator_modes_test.rs`
|
||||
|
||||
2. 编写 5 个测试用例:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*; // adjust imports as needed
|
||||
use crate::generated_scene::generator::*;
|
||||
use crate::generated_scene::ir::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_single_mode_generates_modes_array() {
|
||||
// Create a SingleRequestTable scene with one endpoint
|
||||
let scene_ir = make_test_scene_ir();
|
||||
// ... assertions: generated JS contains "const MODES ="
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_mode_generates_mode_routing() {
|
||||
// Create a MultiModeRequest scene with two modes
|
||||
// ... assertions: generated JS contains "detectMode"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snake_camel_consistency() {
|
||||
// Verify field name serialization is consistent
|
||||
// between Rust (snake_case) and JS (camelCase)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_form_urlencoded_request_body() {
|
||||
// Create a mode with contentType = "application/x-www-form-urlencoded"
|
||||
// ... assertions: body is Object.entries().join('&'), not JSON.stringify
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_path_extraction_in_template() {
|
||||
// Create a mode with responsePath = "data.list"
|
||||
// ... assertions: generated JS contains "safeGet(raw, mode.responsePath"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 每个测试构造一个 `SceneIr` 实例,调用 `compile_multi_mode_request`,然后检查生成的字符串包含预期的代码片段
|
||||
|
||||
**验证**:
|
||||
- `cargo test scene_generator_modes_test` 全部通过
|
||||
|
||||
**提交信息**:
|
||||
```
|
||||
test: add unit tests for multi-mode generation path
|
||||
|
||||
Covers: single-mode auto-wrap, multi-mode routing, snake/camel
|
||||
consistency, form-urlencoded body format, and responsePath extraction.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 集成测试
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. **选择两个代表性场景跑完整生成**:
|
||||
- 简单场景: `用户日电量监测`(模式 C,直接 AJAX)
|
||||
- 复杂场景: `台区线损大数据-月_周累计线损率统计分析`(模式 A,双模式)
|
||||
|
||||
2. **对比生成结果与 tq-lineloss-report**:
|
||||
- 对比 `SKILL.toml` 结构
|
||||
- 对比 `scripts/*.js` 的关键函数(`buildModeRequest`, `detectMode`, `normalizeRows`)
|
||||
- 对比 `scene.toml` 的 bootstrap 和 params 配置
|
||||
|
||||
3. **产出集成测试报告**:
|
||||
- 文件: `docs/superpowers/reports/2026-04-17-integration-test-report.md`
|
||||
- 内容: 差距清单、质量评分、遗留问题
|
||||
|
||||
4. **记录差距清单**:
|
||||
- 哪些字段仍未正确提取
|
||||
- 哪些逻辑仍需手动修正
|
||||
- 哪些场景仍不适合自动化
|
||||
|
||||
**验证**:
|
||||
- 集成测试报告已写入
|
||||
- 至少一个场景的生成质量达到 tq-lineloss-report 的 80% 以上
|
||||
|
||||
**提交信息**:
|
||||
```
|
||||
docs: add integration test report for scene generator quality
|
||||
|
||||
Generated skills for user-daily-power and tq-lineloss scenes. Compared
|
||||
against manually-authored tq-lineloss-report. Quality assessment and
|
||||
gap analysis documented.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
```
|
||||
Task 1 → Task 2 → Task 3 → Task 4 → Task 5 → Task 6 → Task 7 → Task 8
|
||||
├──── Phase 1: 修基础 ────┤ ├───── Phase 2: 增强提取 ─────┤ ├─ Phase 3 ─┤
|
||||
```
|
||||
|
||||
Phase 1 的三个任务有依赖关系(Task 1 必须先完成,Task 2 和 Task 3 可并行)。
|
||||
Phase 2 的三个任务可并行(Task 4/5/6 修改不同文件)。
|
||||
Phase 3 依赖 Phase 1+2 全部完成。
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
|------|------|------|
|
||||
| LLM 二次追问增加生成时间 | 用户体验下降 | 限制追问 1 次,超时 120s |
|
||||
| 统一路径后 SingleRequestTable 场景生成的 JS 包含不必要的 mode 逻辑 | 脚本体积增大 | default mode 条件判断简单,性能影响可忽略 |
|
||||
| 业务 JS 文件过多导致 prompt 超限 | LLM 无法处理 | 限制 4 个文件,每个 600 字符 |
|
||||
| `processData` 修改影响现有正常场景 | 回归问题 | 仅对 form-urlencoded 设置 false,JSON 不受影响 |
|
||||
@@ -0,0 +1,506 @@
|
||||
# WS 浏览器后端认证替换设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `sg_claw` 的 websocket service 路径已经能接收 `sg_claw_client` 请求、复用共享 task runner、连接真实浏览器 websocket 地址 `browser_ws_url`,并进入真实 skill 执行链路。但真实联调时,所有浏览器相关调用都会失败并返回:
|
||||
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
|
||||
根因已经定位:
|
||||
|
||||
- pipe 模式在 [src/lib.rs](src/lib.rs) 中通过 handshake 拿到 `session_key`,并用它构造 `BrowserPipeTool`
|
||||
- ws service 模式在 [src/service/server.rs](src/service/server.rs) 中仍然构造 `BrowserPipeTool::new(..., vec![])`
|
||||
- `BrowserPipeTool` 的认证模型要求非空 session key,因此 ws service 路径虽然使用的是浏览器 websocket 协议,仍错误地依赖了 pipe 特有的 HMAC/session-key 语义
|
||||
|
||||
这会导致:
|
||||
|
||||
1. `sg_claw_client -> sg_claw` 连接正常
|
||||
2. skill 加载与模型调用正常
|
||||
3. 真实浏览器动作开始执行
|
||||
4. 但所有 browser tool 调用在认证层统一失败
|
||||
|
||||
## 目标
|
||||
|
||||
在 **仅限 ws 模式改动** 的前提下,让 `sg_claw` service 路径改为使用 **ws-native browser backend**,不再依赖 `BrowserPipeTool` 的 pipe session-key 认证模型,从而让真实浏览器联调可用。
|
||||
|
||||
## 约束
|
||||
|
||||
必须满足:
|
||||
|
||||
- 只改 ws 模式相关实现
|
||||
- 不破坏 legacy pipe 模式
|
||||
- 不修改 pipe handshake 语义
|
||||
- 不修改 `src/lib.rs` 的 pipe 主入口行为
|
||||
- 不引入临时绕过认证或 fake seed
|
||||
- 不扩大到多客户端、多任务、队列、守护进程管理
|
||||
|
||||
## 非目标
|
||||
|
||||
本次不做:
|
||||
|
||||
- 自动拉起 sgBrowser
|
||||
- 浏览器进程管理
|
||||
- 多浏览器实例支持
|
||||
- service/client UX 优化
|
||||
- browser ws 协议扩展
|
||||
- pipe 模式重构
|
||||
- 统一重构所有 runtime 层去完全依赖 `BrowserBackend`
|
||||
|
||||
## 现状分析
|
||||
|
||||
### 正常 pipe 路径
|
||||
|
||||
pipe 模式当前在 [src/lib.rs](src/lib.rs) 中:
|
||||
|
||||
1. 通过 `perform_handshake(...)` 读取浏览器侧初始化消息
|
||||
2. 从 handshake 中拿到 `session_key`
|
||||
3. 用 `BrowserPipeTool::new(transport.clone(), mac_policy, handshake.session_key)` 构造浏览器工具
|
||||
4. 后续 browser action 使用 pipe/HMAC 语义
|
||||
|
||||
该路径已经可用,本次不能动。
|
||||
|
||||
### 当前 ws service 路径
|
||||
|
||||
当前 ws 模式在 [src/service/server.rs](src/service/server.rs) 中:
|
||||
|
||||
1. `sg_claw_client` 将任务发给 `sg_claw` service
|
||||
2. service 构造 `ServiceBrowserTransport`
|
||||
3. service 用 `BrowserPipeTool::new(transport.clone(), mac_policy.clone(), vec![])`
|
||||
4. browser action 经 `ServiceBrowserTransport` 编码为 browser websocket 请求并发给 `browser_ws_url`
|
||||
|
||||
问题在于第 3 步:
|
||||
|
||||
- service 走的是 browser websocket 协议
|
||||
- 但却仍使用 `BrowserPipeTool`
|
||||
- `BrowserPipeTool` 内部仍坚持要求 pipe session key
|
||||
- 因此真实 ws 联调时直接失败
|
||||
|
||||
### 现有 ws-native 能力
|
||||
|
||||
代码中已经存在:
|
||||
|
||||
- [src/browser/ws_protocol.rs](src/browser/ws_protocol.rs):固定 browser websocket 协议 codec
|
||||
- [src/browser/ws_backend.rs](src/browser/ws_backend.rs):`WsBrowserBackend`
|
||||
- [src/browser/mod.rs](src/browser/mod.rs):已导出 `WsBrowserBackend`
|
||||
|
||||
`WsBrowserBackend` 本身不依赖 pipe session key,而是:
|
||||
|
||||
- 使用 `WsClient` 发送/接收文本帧
|
||||
- 使用 `MacPolicy` 做动作级校验
|
||||
- 通过 `encode_v1_action(...)` 与 `decode_callback_frame(...)` 处理 ws 协议
|
||||
|
||||
这正是 ws service 模式应该使用的模型。
|
||||
|
||||
## 关键集成缝隙
|
||||
|
||||
当前共享 runner 的真实缝隙已经确认:
|
||||
|
||||
- [src/agent/task_runner.rs](src/agent/task_runner.rs) 的 `run_submit_task(...)` 仍直接要求 `&BrowserPipeTool<T>`
|
||||
- [src/compat/runtime.rs](src/compat/runtime.rs) 与 [src/compat/orchestration.rs](src/compat/orchestration.rs) 也继续以 `BrowserPipeTool<T>` 作为主浏览器调用对象
|
||||
- 同时 compat runtime 内部已经存在 `Arc<dyn BrowserBackend>` 的工具适配层,只是它目前是从 `PipeBrowserBackend::from_inner(browser_tool)` 包出来的
|
||||
|
||||
这意味着本次实现不能只在 `src/service/server.rs` 里替换构造逻辑,而必须在 **ws 专用调用面** 增加一个最小适配缝隙,让 service 模式能把 `WsBrowserBackend` 传入 compat/runtime/orchestration,而 pipe 继续保持 `BrowserPipeTool` 原样。
|
||||
|
||||
允许的最小缝隙定义如下:
|
||||
|
||||
1. `run_submit_task(...)` 的 pipe 版本保持不动,供 pipe 入口继续使用
|
||||
2. 新增一个 **仅供 ws service 使用** 的并行入口,例如:
|
||||
- `run_submit_task_with_browser_backend(...)`
|
||||
- 或 service 侧调用的等价 ws-only adapter
|
||||
3. ws-only 入口内部允许把浏览器依赖类型降到 `Arc<dyn BrowserBackend>`
|
||||
4. `src/lib.rs`、pipe handshake、pipe `BrowserPipeTool` 构造逻辑不允许改行为
|
||||
|
||||
## 设计决策
|
||||
|
||||
### 决策 1:ws service 路径弃用 `BrowserPipeTool`
|
||||
|
||||
在 ws service 路径中,不再构造 `BrowserPipeTool`。
|
||||
|
||||
替代方案:
|
||||
|
||||
- service 侧提供一个 `WsClient` 实现
|
||||
- 直接构造 `WsBrowserBackend`
|
||||
- 让 ws service 的 browser action 通过 `WsBrowserBackend` 执行
|
||||
|
||||
### 决策 2:pipe 路径保持原样
|
||||
|
||||
pipe 模式继续:
|
||||
|
||||
- handshake
|
||||
- `session_key`
|
||||
- `BrowserPipeTool`
|
||||
|
||||
不做语义调整,不引入兼容层,不改动已存在的验证路径。
|
||||
|
||||
### 决策 3:runner 只在 ws 调用面做最小接线
|
||||
|
||||
当前共享 task runner 复用已经存在,本次不做大重构。
|
||||
|
||||
策略是:
|
||||
|
||||
- 只在 ws service 用到的调用面,改成可使用 `WsBrowserBackend`
|
||||
- 如果必须扩共享调用接口,则仅做**最小、兼容、对 pipe 零影响**的改动
|
||||
- 任何涉及 pipe 行为变更的改动都不允许
|
||||
|
||||
### 决策 4:保留现有 browser websocket 连接生命周期
|
||||
|
||||
本次不重做连接管理架构。
|
||||
|
||||
继续维持:
|
||||
|
||||
- 单客户端
|
||||
- 单任务串行
|
||||
- 按现有 service 生命周期维护 browser websocket 连接
|
||||
|
||||
只替换认证错误的执行路径,不顺手做生命周期优化。
|
||||
|
||||
## 目标架构
|
||||
|
||||
### 目标调用链
|
||||
|
||||
```text
|
||||
sg_claw_client
|
||||
-> sg_claw service
|
||||
-> ws-native browser backend
|
||||
-> browser_ws_url
|
||||
-> sgBrowser
|
||||
```
|
||||
|
||||
### 与 pipe 的并行关系
|
||||
|
||||
```text
|
||||
pipe mode:
|
||||
browser process <-> stdio/pipe <-> sgclaw::run() <-> BrowserPipeTool
|
||||
|
||||
ws mode:
|
||||
sg_claw_client <-> sg_claw service <-> WsBrowserBackend <-> sgBrowser websocket
|
||||
```
|
||||
|
||||
两条路径并行存在,互不混用认证模型。
|
||||
|
||||
## 模块设计
|
||||
|
||||
### 1. `src/service/server.rs`
|
||||
|
||||
这是本次核心改动文件。
|
||||
|
||||
#### 当前职责
|
||||
|
||||
- 管理 service client websocket 收发
|
||||
- 将 service 请求转入共享 runner
|
||||
- 维护 service->browser 的 websocket 传输桥
|
||||
|
||||
#### 本次改动
|
||||
|
||||
- 将“service->browser 的桥”从 `Transport + BrowserPipeTool` 组合改为 `WsClient + WsBrowserBackend`
|
||||
- 删除 ws service 路径中对空 `session_key` 的依赖
|
||||
- 继续保留 service socket 生命周期与 session 状态机
|
||||
|
||||
#### 目标结构
|
||||
|
||||
可接受的目标形态:
|
||||
|
||||
- `ServiceBrowserWsClient`:实现 `WsClient`
|
||||
- 内部继续维护真实 browser websocket 连接
|
||||
- `serve_client(...)` 在处理任务时构造 `WsBrowserBackend`
|
||||
- 共享 runner 或其 ws 调用包装层通过该 backend 执行 browser action
|
||||
|
||||
### 2. 共享 runner / ws 调用包装层
|
||||
|
||||
本次不要求把全项目统一改成 `BrowserBackend`。
|
||||
|
||||
但 ws service 模式必须能把 browser action 接到 `WsBrowserBackend`。
|
||||
|
||||
可接受的最小方案:
|
||||
|
||||
- 在 ws service 使用的一层引入一个只服务 ws 模式的 adapter
|
||||
- 该 adapter 把 runner 所需的 browser 调用能力委托给 `WsBrowserBackend`
|
||||
|
||||
要求:
|
||||
|
||||
- pipe 现有调用签名不变,或即使扩展也必须保证 pipe 行为完全一致
|
||||
- 不允许为了 ws 把 pipe 入口重写
|
||||
|
||||
### 3. `src/browser/ws_backend.rs`
|
||||
|
||||
原则上复用现有实现。
|
||||
|
||||
只有在以下情况下才允许最小补改:
|
||||
|
||||
- service 真实联调发现它缺一个 ws service 必需但当前未暴露的能力
|
||||
- 该补改只服务 ws-native 路径
|
||||
- 不影响现有测试语义
|
||||
|
||||
## 连接职责与边界
|
||||
|
||||
为避免 service 侧与 `WsBrowserBackend` 重复实现责任,本次显式约束如下:
|
||||
|
||||
### `WsBrowserBackend` 负责
|
||||
|
||||
- 单次 `invoke(...)` 的请求串行化
|
||||
- 调用 `encode_v1_action(...)`
|
||||
- 发送 websocket 文本帧
|
||||
- 等待即时状态帧
|
||||
- 如有 callback,等待 callback 帧并做名称匹配
|
||||
- 将结果统一为 `CommandOutput`
|
||||
- 按现有 `WsBrowserBackend` 语义产出 timeout / protocol 错误
|
||||
|
||||
### service 侧 `WsClient` 适配器负责
|
||||
|
||||
- 持有真实 browser websocket 连接
|
||||
- 在第一次请求时建立到 `browser_ws_url` 的连接
|
||||
- 把 `send_text(...)` / `recv_text_timeout(...)` 委托到真实 websocket
|
||||
- 将底层关闭、reset、timeout 统一映射为既有 `PipeError` 语义
|
||||
- 不实现 request/response correlation,不解析 browser ws 协议 payload
|
||||
|
||||
### 明确不允许
|
||||
|
||||
- service 侧继续手写 callback 轮询逻辑
|
||||
- service 侧继续直接调用 `encode_v1_action(...)` 组包作为主路径
|
||||
- 在 service 侧复制 `WsBrowserBackend` 的协议处理逻辑
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- `src/service/server.rs` 只负责“连线”
|
||||
- `src/browser/ws_backend.rs` 继续负责“ws 浏览器调用语义”
|
||||
|
||||
## 数据流设计
|
||||
|
||||
### 成功路径
|
||||
|
||||
1. `sg_claw_client` 向 `sg_claw` 发 `SubmitTask`
|
||||
2. service 收到任务并进入共享 runner
|
||||
3. 当 runner 需要浏览器动作时:
|
||||
- ws service 调用 `WsBrowserBackend.invoke(...)`
|
||||
4. `WsBrowserBackend`:
|
||||
- 用 `MacPolicy` 校验动作
|
||||
- 用 `encode_v1_action(...)` 编码请求
|
||||
- 发往 `browser_ws_url`
|
||||
- 等待状态帧
|
||||
- 如有 callback,继续等 callback 帧
|
||||
5. 结果返回到 runner
|
||||
6. runner 继续执行并向 client 流式输出日志和 completion
|
||||
|
||||
### 失败路径
|
||||
|
||||
#### browser websocket 不可连
|
||||
|
||||
- 返回明确的 browser websocket connect 错误
|
||||
- 不冒充认证错误
|
||||
|
||||
#### 浏览器返回非 0 状态
|
||||
|
||||
- 返回明确协议错误:`browser returned non-zero status`
|
||||
|
||||
#### callback 超时
|
||||
|
||||
- 返回 timeout
|
||||
|
||||
#### websocket 断开
|
||||
|
||||
- 返回 `PipeError::PipeClosed`
|
||||
- 由 service 生命周期逻辑处理
|
||||
|
||||
#### 不再允许的错误
|
||||
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
|
||||
该错误在 ws 模式下应彻底消失。
|
||||
|
||||
## 失败语义
|
||||
|
||||
为便于测试与实现,ws-only 路径的 outward error 语义固定如下:
|
||||
|
||||
### browser websocket connect 失败
|
||||
|
||||
- outward: `PipeError::Protocol("browser websocket connect failed: ...")`
|
||||
|
||||
### 浏览器返回非 0 状态码
|
||||
|
||||
- outward: `PipeError::Protocol("browser returned non-zero status: ...")`
|
||||
|
||||
### callback 超时
|
||||
|
||||
- outward: `PipeError::Timeout`
|
||||
- timeout 来源:沿用 `WsBrowserBackend` / ws service 当前 response timeout 配置,默认 30 秒
|
||||
|
||||
### websocket 被对端正常关闭或 reset
|
||||
|
||||
- outward: `PipeError::PipeClosed`
|
||||
- 不允许使用“等价错误”这类不精确表述
|
||||
|
||||
### 本次必须消除的错误
|
||||
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
|
||||
任何 ws service 联调路径再出现该错误,都视为实现未完成。
|
||||
|
||||
## 测试设计
|
||||
|
||||
### 分层测试策略
|
||||
|
||||
为避免依赖 LLM/planner 的非确定性行为,本次测试必须分成两层,且各自断言不同目标:
|
||||
|
||||
#### A. backend / adapter 层测试(确定性)
|
||||
|
||||
这一层不经过 `sg_claw_client`、不经过真实模型规划,直接验证 ws-only 技术行为。
|
||||
|
||||
目标:
|
||||
|
||||
1. `ServiceBrowserWsClient` 与 `WsBrowserBackend` 的组合可以:
|
||||
- 发送 `Navigate`
|
||||
- 接收 `0` 状态
|
||||
- 在 callback 场景下读取 callback 文本
|
||||
2. 当 fake browser server 主动关闭/reset 时:
|
||||
- 在 `WsClient` / `WsBrowserBackend.invoke(...)` 观察层断言 outward error 必须是 `PipeError::PipeClosed`
|
||||
3. 当 fake browser server 不返回 callback 时:
|
||||
- 在 `WsBrowserBackend.invoke(...)` 观察层断言 outward error 必须是 `PipeError::Timeout`
|
||||
4. 该层测试完全不依赖 LLM、planner、skills 路由
|
||||
|
||||
建议:
|
||||
|
||||
- 新增 focused ws service/backend test
|
||||
- 输入动作固定为代码直接调用 `invoke(Action::Navigate, ...)` 等,而不是自然语言任务
|
||||
|
||||
#### B. client -> service 集成测试(链路验证)
|
||||
|
||||
这一层验证 ws-only 接线已经替换掉空 session key 路径,但不承担细粒度协议语义断言。
|
||||
|
||||
目标:
|
||||
|
||||
1. 通过真实 `sg_claw_client -> sg_claw service` 发起一个最小自然语言任务
|
||||
2. fake browser websocket server 至少收到一个来自 ws-only 路径的文本帧
|
||||
3. client/service 输出中不再出现:
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
4. 该层只证明:
|
||||
- ws service 已不再走空 session key 的 pipe 认证路径
|
||||
- 真实端到端链路已能到达 browser websocket
|
||||
|
||||
该层不用于断言精确 enum 身份,也不用于覆盖 callback timeout / reset 细节。
|
||||
|
||||
### 新增红测 1:ws-only backend/adapter 基本调用可用
|
||||
|
||||
目标:
|
||||
|
||||
- 不走自然语言任务
|
||||
- 直接构造 ws service 使用的 `WsClient` + `WsBrowserBackend`
|
||||
- 调用固定动作:`Action::Navigate`,目标 url 固定为 `https://www.zhihu.com/hot`
|
||||
- fake browser websocket server 返回 `0`
|
||||
- 断言:
|
||||
- `invoke(...)` 成功
|
||||
- fake server 收到的首个文本帧可按 `ws_protocol` 语义解释为 `Navigate`
|
||||
|
||||
### 新增红测 2:ws-only backend/adapter 断链语义固定
|
||||
|
||||
目标:
|
||||
|
||||
- 不走自然语言任务
|
||||
- fake browser websocket server 在接受请求后主动关闭或 reset
|
||||
- 在 `invoke(...)` 观察层断言:
|
||||
- outward error 固定为 `PipeError::PipeClosed`
|
||||
|
||||
### 新增红测 3:ws-only backend/adapter callback timeout 语义固定
|
||||
|
||||
目标:
|
||||
|
||||
- 不走自然语言任务
|
||||
- fake browser websocket server 返回 `0` 但不返回 callback 帧
|
||||
- 在 `invoke(...)` 观察层断言:
|
||||
- outward error 固定为 `PipeError::Timeout`
|
||||
|
||||
### 新增红测 4:client->service 链路不再触发空 session key 错误
|
||||
|
||||
目标:
|
||||
|
||||
- 通过真实 `sg_claw_client -> sg_claw service` 链路触发浏览器动作
|
||||
- 用 fake browser websocket 服务端接住请求
|
||||
- 任务输入固定为:`打开知乎热榜并读取页面主区域文本`
|
||||
- 断言 client/service 输出中不再出现:
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
- 断言 fake browser server 至少收到了一个文本帧
|
||||
|
||||
### 回归测试
|
||||
|
||||
必须重新运行并保持通过:
|
||||
|
||||
#### pipe 回归
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test -- --nocapture
|
||||
```
|
||||
|
||||
如实现涉及 browser tool 上层接线,还需补跑:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
#### ws 回归
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test --test browser_ws_protocol_test --test browser_ws_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
## 手工验收
|
||||
|
||||
使用真实配置和真实已启动 sgBrowser:
|
||||
|
||||
1. 启动 sgBrowser,并确保 `browserWsUrl` 可用
|
||||
2. 启动 `sg_claw`
|
||||
3. 运行:
|
||||
- `sg_claw_client`
|
||||
4. 发送知乎最小任务:
|
||||
- 打开知乎热榜并读取页面主区域文本
|
||||
5. 观察:
|
||||
- 不再出现 `invalid hmac seed`
|
||||
- 出现真实 browser action 日志
|
||||
- 能返回单次 completion
|
||||
6. 再运行旧知乎 skill:
|
||||
- `读取知乎热榜数据,并导出 excel 文件`
|
||||
7. 验证旧知乎 skill 进入真实 browser 执行路径
|
||||
8. 最后确认 legacy pipe 入口仍可启动(仅验证,不允许为此修改 pipe 实现)
|
||||
|
||||
## 风险
|
||||
|
||||
### 风险 1:ws service 与共享 runner 接口耦合过深
|
||||
|
||||
控制:
|
||||
|
||||
- 只在 ws 使用面做 adapter
|
||||
- 不对 pipe 主入口做结构性改造
|
||||
|
||||
### 风险 2:为适配 ws-native backend 误改 pipe 调用链
|
||||
|
||||
控制:
|
||||
|
||||
- 所有 pipe 回归必须在每轮修改后重跑
|
||||
- `src/lib.rs` 不允许改行为
|
||||
|
||||
### 风险 3:ws service 内联连接逻辑与 `WsBrowserBackend` 责任重复
|
||||
|
||||
控制:
|
||||
|
||||
- 本次先以最小变更消除认证阻塞
|
||||
- 不顺手做大规模整理
|
||||
|
||||
## 通过标准
|
||||
|
||||
满足以下全部条件才算完成:
|
||||
|
||||
1. ws service 路径不再依赖空 session key
|
||||
2. 不再出现 `invalid hmac seed: session key must not be empty`
|
||||
3. 真实 browser websocket 请求能发到 sgBrowser/fake browser server
|
||||
4. 旧知乎 skill 至少能进入真实 browser action 执行链路
|
||||
5. pipe 模式零回归
|
||||
6. 所有新增/相关测试通过
|
||||
|
||||
## 实施建议
|
||||
|
||||
按以下顺序实施:
|
||||
|
||||
1. 先补红测,锁定“ws 不再触发 invalid hmac seed”
|
||||
2. 再把 ws service 路径切到 `WsBrowserBackend`
|
||||
3. 跑 ws 测试
|
||||
4. 跑 pipe 回归
|
||||
5. 做真实知乎最小任务 smoke
|
||||
6. 再做旧知乎 skill smoke
|
||||
@@ -0,0 +1,276 @@
|
||||
# WS Browser Bridge Path Design
|
||||
|
||||
## Background
|
||||
|
||||
The repository now has explicit live evidence that the real sgBrowser websocket endpoint at `ws://127.0.0.1:12345` is **reachable** but is **not validated as an external-control surface**.
|
||||
|
||||
The probe transcript in `docs/_tmp_sgbrowser_ws_probe_transcript.md` shows a stable outcome across the full bootstrap matrix:
|
||||
|
||||
- direct open-page frame
|
||||
- `sgOpenAgent`
|
||||
- `sgSetAuthInfo`
|
||||
- `sgBrowserLogin`
|
||||
- `sgBrowerserActiveTab`
|
||||
- combined bootstrap attempts
|
||||
- alternate `requesturl` values
|
||||
|
||||
Across all of those sequences, the endpoint behaved like this:
|
||||
|
||||
1. websocket connection succeeds
|
||||
2. first inbound text frame is always the banner `Welcome! You are client #1`
|
||||
3. no sequence produced a reproducible numeric status frame for a real business action
|
||||
4. no sequence produced a reproducible callback frame for a real business action
|
||||
5. follow-on business frames timed out or produced no further usable protocol traffic
|
||||
|
||||
That means the current project can no longer treat raw external websocket business frames as the default production integration surface.
|
||||
|
||||
## Why the raw websocket path is now considered non-validated
|
||||
|
||||
The decision is not based on a guess. It is based on both live evidence and repository evidence.
|
||||
|
||||
### Live evidence
|
||||
|
||||
`docs/_tmp_sgbrowser_ws_probe_transcript.md` proves that the real endpoint did **not** yield the one thing raw external control needs:
|
||||
|
||||
- a reproducible status/callback response for a real browser action
|
||||
|
||||
Because that never happened, the bootstrap hypothesis did not clear the acceptance bar.
|
||||
|
||||
### Repository evidence
|
||||
|
||||
The rest of the repository already points to a different product integration model.
|
||||
|
||||
#### 1. Historical frontend code uses browser-host bridge surfaces
|
||||
|
||||
In `frontend/archive/sgClaw验证-已归档/testRunner.js:15-26`:
|
||||
|
||||
- the runtime checks for `window.sgFunctionsUI`
|
||||
- the runtime checks for `window.BrowserAction`
|
||||
- the working path uses `window.sgFunctionsUI(action, params, callback)`
|
||||
|
||||
That is a host/browser bridge contract, not an external raw websocket RPC contract.
|
||||
|
||||
#### 2. Prior architecture docs make `CommandRouter` the execution entry
|
||||
|
||||
In `docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md:16-18` and `:36-50`:
|
||||
|
||||
- reuse SuperRPA `CommandRouter` as the browser execution entry
|
||||
- keep browser-side hosting, security re-check, and dispatch in SuperRPA
|
||||
- avoid building parallel browser automation APIs
|
||||
|
||||
That is directly incompatible with treating raw external websocket business frames as the primary control plane.
|
||||
|
||||
#### 3. Project planning docs describe FunctionsUI IPC as the supported frontend seam
|
||||
|
||||
In `docs/archive/项目管理与排期/协作时间表.md:419-430`:
|
||||
|
||||
- Vue/FunctionsUI calls browser-host methods such as `window.superrpa.sgclaw.start()` and `sendCommand(...)`
|
||||
- browser host pushes callbacks such as `onStatusChange(...)` and `onLog(...)`
|
||||
|
||||
Again, this is a bridge and host IPC model.
|
||||
|
||||
#### 4. Floating-chat planning already preserves named bridge calls
|
||||
|
||||
In `docs/plans/2026-03-27-sgclaw-floating-chat-frontend-design.md:289-293`:
|
||||
|
||||
- `connect()` issues `sgclawConnect`
|
||||
- `start()` issues `sgclawStart`
|
||||
- `stop()` issues `sgclawStop`
|
||||
- `submitTask()` issues `sgclawSubmitTask`
|
||||
|
||||
That design work assumes a named browser bridge, not direct raw websocket frames.
|
||||
|
||||
## Decision
|
||||
|
||||
**Authoritative browser integration surface: the browser-host bridge path, not the raw external sgBrowser websocket business-frame path.**
|
||||
|
||||
More concretely, sgClaw should target this chain:
|
||||
|
||||
```text
|
||||
sgClaw runtime
|
||||
-> existing browser-facing bridge contract
|
||||
-> FunctionsUI / host IPC
|
||||
-> BrowserAction / sgclaw host callbacks
|
||||
-> existing SuperRPA CommandRouter dispatch
|
||||
```
|
||||
|
||||
## Authoritative seams for future implementation
|
||||
|
||||
Because this repository does not contain the full SuperRPA browser host source tree, the bridge-first implementation must integrate at the **nearest validated seam available in this repo**, while staying aligned with the external browser-host contract already documented.
|
||||
|
||||
The future implementation must model **two different bridge layers** explicitly instead of mixing them together.
|
||||
|
||||
### Layer 1: session/lifecycle bridge contract
|
||||
|
||||
This layer is evidenced by the named calls already present in repo documentation:
|
||||
|
||||
- `sgclawConnect`
|
||||
- `sgclawStart`
|
||||
- `sgclawStop`
|
||||
- `sgclawSubmitTask`
|
||||
|
||||
This layer manages session setup, task submission, and host/UI lifecycle behavior.
|
||||
|
||||
It is important evidence that a browser-host bridge exists, but it is **not** the per-browser-action contract that a new `BrowserBackend` implementation should target.
|
||||
|
||||
### Layer 2: browser-action execution contract
|
||||
|
||||
This is the authoritative target for the new browser backend.
|
||||
|
||||
It is evidenced by:
|
||||
|
||||
- `window.BrowserAction(...)` in archived frontend code
|
||||
- `FunctionsUI` / host IPC integration in archived planning docs
|
||||
- browser-side dispatch through `CommandRouter` in `docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md`
|
||||
|
||||
In this repository, the concrete boundary must be a **repo-local semantic transport seam** that can be implemented and tested without access to the external SuperRPA host code.
|
||||
|
||||
That seam should be a narrow Rust-side contract such as `BridgeActionTransport`:
|
||||
|
||||
- input: semantic browser action request (`navigate`, `click`, `getText`, etc.) plus params and expected domain
|
||||
- output: semantic success/error reply that can be normalized back into `BrowserBackend` results
|
||||
|
||||
`BridgeBrowserBackend` should target **Layer 2 only**.
|
||||
|
||||
### Explicit out-of-scope boundary
|
||||
|
||||
The following are outside this repository and therefore outside the immediate Rust implementation slice:
|
||||
|
||||
- actual SuperRPA C++ host/browser code
|
||||
- actual `FunctionsUI` TypeScript host plumbing in the external browser repository
|
||||
- actual `CommandRouter` implementation in the external browser repository
|
||||
|
||||
This repository should implement only:
|
||||
|
||||
- the Rust-side bridge contract types
|
||||
- the Rust-side bridge transport/provider seam
|
||||
- the Rust-side bridge-backed browser adapter
|
||||
- deterministic tests against those seams
|
||||
|
||||
### What this means practically
|
||||
|
||||
The next implementation slice should **not** continue trying to make `WsBrowserBackend` drive the real browser endpoint directly.
|
||||
|
||||
Instead, the next implementation slice should introduce a **bridge-backed browser adapter** that:
|
||||
|
||||
- preserves the Rust-side `BrowserBackend` contract where practical
|
||||
- translates browser actions onto the Layer-2 semantic bridge surface
|
||||
- keeps lifecycle/session bridge calls separate from per-action browser execution
|
||||
- leaves the raw websocket probe code as diagnostic infrastructure only
|
||||
|
||||
## Chosen architecture
|
||||
|
||||
Use a bridge-backed adapter design.
|
||||
|
||||
### Target shape
|
||||
|
||||
```text
|
||||
compat/runtime/orchestration
|
||||
-> Arc<dyn BrowserBackend>
|
||||
-> BridgeBrowserBackend (new)
|
||||
-> BridgeActionTransport (new repo-local seam)
|
||||
-> external browser-host bridge / FunctionsUI IPC
|
||||
-> BrowserAction / CommandRouter path
|
||||
```
|
||||
|
||||
### Why this shape
|
||||
|
||||
- It preserves the already-useful Rust-side browser abstraction (`BrowserBackend`) instead of re-plumbing the entire runtime.
|
||||
- It keeps raw websocket probing available for diagnostics without letting it dictate production architecture.
|
||||
- It matches the architecture already documented for SuperRPA integration.
|
||||
- It keeps future work narrow: one new adapter layer instead of rewriting all runtime behavior.
|
||||
|
||||
## What stays the same
|
||||
|
||||
### Pipe path remains unchanged
|
||||
|
||||
The existing pipe path must remain behaviorally unchanged:
|
||||
|
||||
- `src/lib.rs`
|
||||
- pipe handshake behavior
|
||||
- `BrowserPipeTool`
|
||||
- existing HMAC/domain validation semantics
|
||||
|
||||
The bridge-first work is about the **ws service / real browser integration path**, not about replacing or weakening the pipe path.
|
||||
|
||||
### Existing compat/runtime abstractions should be preserved where practical
|
||||
|
||||
The next slice should reuse:
|
||||
|
||||
- `BrowserBackend`
|
||||
- existing browser tool adapters in compat/runtime
|
||||
- existing task runner/orchestration flow
|
||||
|
||||
The new work should be concentrated in a bridge adapter and its wiring, not spread through unrelated layers.
|
||||
|
||||
## What does not stay the same
|
||||
|
||||
### Raw websocket is no longer the mainline production assumption
|
||||
|
||||
The repository may keep:
|
||||
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `src/browser/ws_probe.rs`
|
||||
- `src/bin/sgbrowser_ws_probe.rs`
|
||||
|
||||
But those should now be treated as:
|
||||
|
||||
- protocol tooling
|
||||
- fake-server test tooling
|
||||
- live diagnostic/probe tooling
|
||||
- possibly constrained compatibility code
|
||||
|
||||
They should remain diagnostic-only in this repository and must not be treated as the production path for reaching the real browser.
|
||||
|
||||
## Design constraints for the bridge slice
|
||||
|
||||
The bridge-path implementation must follow these constraints:
|
||||
|
||||
1. **No parallel browser API invention.** Reuse the real bridge/browser action surface already evidenced in docs and archived frontend code.
|
||||
2. **No pipe regression.** Do not alter the working pipe entry path.
|
||||
3. **Adapter-first design.** Prefer one bridge-backed backend implementation over broad runtime rewrites.
|
||||
4. **TDD first.** Add focused bridge adapter tests before production wiring.
|
||||
5. **Repository-local seam only.** Where external SuperRPA browser-host code is unavailable here, encode the contract in narrow adapters and tests instead of guessing internals.
|
||||
|
||||
## Testing implications
|
||||
|
||||
The bridge path changes what “proof” looks like.
|
||||
|
||||
### Required proof for the next slice
|
||||
|
||||
The next implementation slice must prove:
|
||||
|
||||
- a browser action can be emitted onto the bridge contract deterministically
|
||||
- the bridge adapter maps replies/errors back into `BrowserBackend` semantics
|
||||
- compat/runtime can use the bridge-backed backend without pipe regression
|
||||
|
||||
### No longer required for acceptance
|
||||
|
||||
The next slice does **not** need to prove that raw websocket business frames work directly against `ws://127.0.0.1:12345`, because the current evidence rejected that path as the mainline assumption.
|
||||
|
||||
## Acceptance criteria for this design decision
|
||||
|
||||
This design is correct only if future implementation follows all of these:
|
||||
|
||||
1. The next production slice targets the browser-host bridge path rather than raw external websocket business frames.
|
||||
2. The raw websocket probe tooling remains diagnostic only.
|
||||
3. Existing pipe behavior stays unchanged.
|
||||
4. The next implementation plan identifies a narrow bridge-backed adapter, not a broad architecture rewrite.
|
||||
5. Future success claims are based on bridge-path execution evidence, not on reinterpreting the existing raw-websocket transcript.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Aligns implementation with the strongest evidence already in the repo
|
||||
- Stops further speculative coding on the wrong control surface
|
||||
- Preserves existing ws probe work as useful diagnostics
|
||||
- Keeps the next slice narrow and testable
|
||||
|
||||
### Trade-off
|
||||
|
||||
- Requires an additional adapter design step before more production code can land
|
||||
- Defers any hope that a small websocket tweak alone will unlock the real browser path
|
||||
|
||||
That trade-off is correct, because the current blocker is no longer a small protocol bug. It is an integration-surface mismatch.
|
||||
@@ -0,0 +1,288 @@
|
||||
# WS Browser Integration Surface Correction Design
|
||||
|
||||
## Background
|
||||
|
||||
The current websocket service path already proved two things:
|
||||
|
||||
1. `sg_claw_client -> sg_claw` request handling works.
|
||||
2. The ws-native backend/auth replacement removed the old pipe/HMAC mismatch that produced `invalid hmac seed: session key must not be empty`.
|
||||
|
||||
However, real sgBrowser smoke still does not work.
|
||||
|
||||
Manual probing against the configured real browser websocket endpoint (`ws://127.0.0.1:12345`) produced a stable pattern:
|
||||
|
||||
- the connection succeeds
|
||||
- the server sends one banner text frame such as `Welcome! You are client #1`
|
||||
- after that, business frames receive no status frame and no callback frame
|
||||
- this remains true for:
|
||||
- valid-looking `sgBrowerserOpenPage` frames
|
||||
- callback-based APIs
|
||||
- no-arg/context-light APIs
|
||||
- malformed or obviously wrong frames
|
||||
|
||||
At the same time, local documentation and archived frontend code point to a different integration model:
|
||||
|
||||
- the websocket API doc describes the websocket service as a transport replacement for page-context JavaScript calls, and requires the current page URL (`requesturl`) in each message
|
||||
- archived frontend/product code uses `window.sgFunctionsUI(...)` and `window.BrowserAction(...)`
|
||||
- archived architecture docs describe the supported product path as `FunctionsUI -> browser host bridge -> BrowserAction/CommandRouter`, not an arbitrary external process speaking raw browser websocket frames
|
||||
|
||||
This means the current assumption is no longer acceptable as the default architecture hypothesis:
|
||||
|
||||
- **Rejected default assumption:** `sg_claw` can directly control the real browser by speaking raw business frames to `browserWsUrl` as an external client, with no additional browser-host bridge, page context, or bootstrap/session contract.
|
||||
|
||||
That assumption may still turn out to be partially true, but it is no longer justified enough to continue coding against as the mainline design.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The project currently has a functioning ws-native transport implementation, but it does **not** have a validated real integration surface for sgBrowser.
|
||||
|
||||
The unresolved question is now architectural rather than syntactic:
|
||||
|
||||
### Possibility A: raw websocket is valid, but requires hidden bootstrap/preconditions
|
||||
|
||||
Examples suggested by the local API document:
|
||||
|
||||
- a real browser page must already exist and `requesturl` must refer to that page
|
||||
- one or more setup calls such as `sgSetAuthInfo`, `sgBrowserLogin`, `sgOpenAgent`, or `sgBrowerserActiveTab` must happen first
|
||||
- callbacks may require a browser-side JS/page context that an external process does not automatically have
|
||||
- some APIs may only work against agent/show/hide areas after browser-side initialization
|
||||
|
||||
### Possibility B: raw websocket is not the supported external control surface
|
||||
|
||||
Instead, the real product path may require:
|
||||
|
||||
- `FunctionsUI` / browser-host IPC
|
||||
- host-side security and routing
|
||||
- `BrowserAction` / `CommandRouter` dispatch
|
||||
- page-injected or browser-embedded execution context
|
||||
|
||||
If this is true, continuing to invest in raw external websocket business-frame handling as the main integration surface would be architectural drift.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current unvalidated ws-native-direct assumption with a decision-backed integration strategy.
|
||||
|
||||
The next implementation slice must do exactly one of these two things based on evidence:
|
||||
|
||||
1. **Bootstrap path:** prove that raw websocket control is real and supported once the missing bootstrap/precondition sequence is performed, then codify that bootstrap sequence and keep `WsBrowserBackend` as the execution surface.
|
||||
2. **Bridge path:** prove that raw websocket is not the real supported surface for external control, then pivot the runtime design so sgClaw targets the actual browser-host bridge / `BrowserAction` surface instead of pretending the raw websocket is enough.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This correction slice does **not** include:
|
||||
|
||||
- broad feature work on the floating chat UI
|
||||
- multi-client service redesign
|
||||
- browser process lifecycle management
|
||||
- speculative protocol expansion
|
||||
- generic reconnection/backoff work
|
||||
- rewriting the entire compat/runtime stack without evidence
|
||||
- landing both bootstrap and bridge implementations in one branch
|
||||
|
||||
The purpose of this slice is to choose the correct integration surface first.
|
||||
|
||||
## Evidence Summary
|
||||
|
||||
### Evidence that the current raw-ws-direct assumption is weak
|
||||
|
||||
1. Real endpoint accepts connections but stays silent after the welcome/banner frame.
|
||||
2. Silence occurs even for malformed frames, which suggests the endpoint is not acting like an openly documented RPC surface for arbitrary external clients.
|
||||
3. The API documentation frames websocket use as a replacement for page-side JS invocation, not as a standalone public automation API.
|
||||
4. The documentation repeatedly depends on `requesturl`, callback function names, target pages, and browser areas (`show`, `hide`, `agent`).
|
||||
5. Historical frontend/product code uses `window.sgFunctionsUI(...)` and `window.BrowserAction(...)`, not raw external websocket business calls.
|
||||
6. Historical architecture docs emphasize `FunctionsUI`, `CommandRouter`, and browser-host bridge seams.
|
||||
|
||||
### Evidence that the current ws-native work is still useful
|
||||
|
||||
1. The ws-native auth replacement removed a real bug.
|
||||
2. The ws backend now correctly carries forward the last navigated request URL.
|
||||
3. `WsBrowserBackend` and `ws_protocol` remain valuable as deterministic protocol tooling for fake-server tests and any future bootstrap validation.
|
||||
|
||||
So the conclusion is **not** “delete ws-native work.”
|
||||
|
||||
The conclusion is:
|
||||
|
||||
- do not treat raw external websocket control as validated product architecture yet
|
||||
- use the ws-native code only behind a decision gate
|
||||
|
||||
## Design Decision
|
||||
|
||||
Adopt a **decision-gated integration strategy**.
|
||||
|
||||
### Decision Gate 1: Validate bootstrap viability first
|
||||
|
||||
Before any more production architecture changes, add a focused, deterministic validation harness that can exercise a candidate raw-websocket bootstrap sequence against a live endpoint.
|
||||
|
||||
The harness must support:
|
||||
|
||||
- ordered frame scripts
|
||||
- exact frame logging
|
||||
- exact timeout/silence observation
|
||||
- trying candidate setup sequences such as:
|
||||
- `sgSetAuthInfo`
|
||||
- `sgBrowserLogin`
|
||||
- `sgOpenAgent`
|
||||
- `sgBrowerserActiveTab`
|
||||
- then a minimal action such as `sgBrowerserOpenPage` or `sgBrowserExcuteJsCodeByArea`
|
||||
- trying the same action with different `requesturl` assumptions
|
||||
- distinguishing these outcomes:
|
||||
- numeric status returned
|
||||
- callback returned
|
||||
- welcome only, then silence
|
||||
- close/reset
|
||||
- protocol error
|
||||
|
||||
This harness is not product code. It is an evidence tool that prevents blind implementation.
|
||||
|
||||
### Decision Gate 2: Make bridge pivot the default fallback
|
||||
|
||||
If the validation harness cannot demonstrate a reproducible bootstrap sequence that yields real status/callback frames from the live browser endpoint, then raw websocket must be considered **non-validated for external control**.
|
||||
|
||||
At that point, the design must pivot to the bridge path:
|
||||
|
||||
- sgClaw browser control targets the real browser-host integration surface
|
||||
- use the bridge already evidenced in docs/code (`FunctionsUI`, browser host IPC, `BrowserAction`, `CommandRouter`)
|
||||
- keep raw websocket support, if retained at all, as a diagnostic or highly constrained adapter rather than the primary product path
|
||||
|
||||
## Architecture Options
|
||||
|
||||
## Option A: Bootstrap-validated raw websocket path
|
||||
|
||||
Choose this only if the live validation harness produces repeatable evidence.
|
||||
|
||||
### Resulting architecture
|
||||
|
||||
```text
|
||||
sg_claw_client
|
||||
-> sg_claw service
|
||||
-> bootstrap sequence executor
|
||||
-> WsBrowserBackend
|
||||
-> browserWsUrl
|
||||
-> sgBrowser
|
||||
```
|
||||
|
||||
### Required conditions
|
||||
|
||||
- a reproducible bootstrap sequence exists
|
||||
- the sequence yields status/callback traffic for real business actions
|
||||
- the sequence can be encoded as a narrow service-side precondition layer
|
||||
- the sequence does not require unowned browser UI/manual setup outside a documented contract
|
||||
|
||||
### Allowed production changes if Option A wins
|
||||
|
||||
- add explicit bootstrap calls before first browser action
|
||||
- persist validated session/context state needed by the real endpoint
|
||||
- tighten `request_url` / target-page handling around the proven contract
|
||||
|
||||
### Not allowed even if Option A wins
|
||||
|
||||
- guessing bootstrap steps without evidence
|
||||
- silently sprinkling many setup calls into random locations
|
||||
- broadening the compat/runtime API before the bootstrap contract is known
|
||||
|
||||
## Option B: Bridge-first integration path
|
||||
|
||||
Choose this if live validation does not prove a workable raw websocket bootstrap.
|
||||
|
||||
### Resulting architecture
|
||||
|
||||
```text
|
||||
sg_claw_client
|
||||
-> sg_claw service
|
||||
-> bridge adapter
|
||||
-> browser host / FunctionsUI / BrowserAction / CommandRouter
|
||||
-> sgBrowser page actions
|
||||
```
|
||||
|
||||
### Required conditions
|
||||
|
||||
- local docs/code show a stable supported bridge path
|
||||
- raw websocket remains non-validated or only page-context-scoped
|
||||
- the bridge surface can be wrapped behind the existing `BrowserBackend` abstraction or a sibling adapter without weakening pipe behavior
|
||||
|
||||
### Allowed production changes if Option B wins
|
||||
|
||||
- add a new browser backend implementation that targets the real bridge surface
|
||||
- redirect ws service/browser execution away from raw business frames
|
||||
- preserve ws-native code only for tests, probes, or intentionally constrained cases
|
||||
|
||||
### Not allowed even if Option B wins
|
||||
|
||||
- pretending the old raw-ws mainline still works “well enough”
|
||||
- leaving the service path ambiguously split between two competing primary surfaces
|
||||
|
||||
## Scope Guardrails for the Next Implementation Plan
|
||||
|
||||
The next implementation plan must obey these guardrails:
|
||||
|
||||
1. **One branch, one decision.** Do not implement both architecture options at once.
|
||||
2. **Evidence before code.** If bootstrap is unproven, the next coding task is probe/validation tooling, not another speculative service/runtime refactor.
|
||||
3. **Keep pipe untouched.** `src/lib.rs`, pipe handshake, and the pipe `BrowserPipeTool` path remain behaviorally unchanged.
|
||||
4. **Do not delete ws-native code prematurely.** It still has value for protocol tests and validation tooling.
|
||||
5. **Do not broaden success claims.** Removing `invalid hmac seed` did not make real browser control work.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Stage 1: Evidence tooling tests
|
||||
|
||||
Add deterministic tests for the live-probe/validation harness so it can:
|
||||
|
||||
- send an ordered frame script
|
||||
- record exact received frames
|
||||
- report silence/timeout precisely
|
||||
- expose transcript output suitable for comparing candidate bootstrap sequences
|
||||
|
||||
These tests use a fake websocket server, not sgBrowser.
|
||||
|
||||
### Stage 2: Live validation runs
|
||||
|
||||
Use the harness against the real endpoint with a fixed matrix of candidate sequences.
|
||||
|
||||
At minimum, compare:
|
||||
|
||||
1. no bootstrap -> minimal action
|
||||
2. `sgOpenAgent` -> minimal action
|
||||
3. `sgSetAuthInfo` -> minimal action
|
||||
4. `sgBrowserLogin` -> minimal action
|
||||
5. `sgBrowerserActiveTab` -> minimal action
|
||||
6. combined documented bootstrap candidates -> minimal action
|
||||
7. alternate `requesturl` values representing:
|
||||
- `about:blank`
|
||||
- target page URL
|
||||
- a currently open page URL if known
|
||||
|
||||
### Stage 3: Architecture-branch acceptance
|
||||
|
||||
If Option A wins:
|
||||
|
||||
- add one automated regression that proves the validated bootstrap sequence produces the first real status frame in a controlled integration test
|
||||
- then continue with the narrowest production implementation plan
|
||||
|
||||
If Option B wins:
|
||||
|
||||
- write a new bridge-integration implementation plan before changing production code
|
||||
- base all production tasks on the documented bridge surface
|
||||
|
||||
## Acceptance Criteria for This Design Correction
|
||||
|
||||
This design correction is successful only if future work follows these rules:
|
||||
|
||||
1. The repository has an explicit design document recording that raw ws-native direct control is **not currently validated**.
|
||||
2. The next engineering slice starts with validation or bridge selection, not another speculative runtime refactor.
|
||||
3. Any future claim that raw websocket is the supported production path must be backed by a reproducible live bootstrap transcript.
|
||||
4. If that evidence does not appear, the project pivots to the bridge path rather than continuing to guess.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- stops further speculative coding against an unproven surface
|
||||
- preserves useful ws-native work without over-committing to it
|
||||
- creates a clean decision point for the next implementation branch
|
||||
|
||||
### Trade-off
|
||||
|
||||
- this does not immediately unblock real browser control
|
||||
- it intentionally inserts an evidence phase before more production changes
|
||||
|
||||
That trade-off is acceptable because the current failure mode is architectural uncertainty, not a missing two-line fix.
|
||||
@@ -0,0 +1,105 @@
|
||||
# WS Browser Welcome Frame Compatibility Design
|
||||
|
||||
## Background
|
||||
|
||||
Manual smoke verification after the ws-native browser backend auth replacement showed that real `sgBrowser` sends a banner text frame immediately after the websocket connection is established:
|
||||
|
||||
- `Welcome! You are client #1`
|
||||
|
||||
The current ws-native path treats the first received text frame as a protocol status frame. In `src/browser/ws_backend.rs`, `WsBrowserBackend::invoke(...)` reads one text frame and immediately parses it as an integer status code. That works for the existing deterministic tests, but it fails against the real browser because the first frame is a human-readable welcome banner rather than `0` or another numeric status.
|
||||
|
||||
This means the auth replacement is working — the old `invalid hmac seed: session key must not be empty` error no longer appears — but real smoke still fails on protocol parsing.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the ws service path tolerate exactly one initial welcome/banner text frame from the real browser websocket, without weakening the general ws protocol semantics.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This change must not:
|
||||
|
||||
- Relax parsing of arbitrary non-protocol text frames
|
||||
- Change `WsBrowserBackend` into a browser-specific parser for banners
|
||||
- Affect the legacy pipe path
|
||||
- Add retry loops or broader reconnection logic
|
||||
- Change callback handling semantics
|
||||
|
||||
## Chosen approach
|
||||
|
||||
Handle the welcome banner only in `ServiceBrowserWsClient`.
|
||||
|
||||
### Why this layer
|
||||
|
||||
`ServiceBrowserWsClient` is already the real-browser adapter used only by the ws service path in `src/service/server.rs`. The welcome frame is a quirk of the real browser endpoint rather than a property of the shared ws protocol abstraction. Keeping the compatibility behavior in the service-side client preserves the stricter semantics of `WsBrowserBackend` for all other callers and test doubles.
|
||||
|
||||
## Behavioral rules
|
||||
|
||||
1. Only the first received text frame after establishing a browser websocket connection may be treated as a welcome/banner candidate.
|
||||
2. If that first text frame matches the real banner shape (currently observed as `Welcome! You are client #1`), the client discards it and continues waiting for the actual protocol frame.
|
||||
3. The welcome skip is one-time only per connection, not per request. Because `ServiceBrowserWsClient` holds a persistent socket, this state must survive multiple `invoke(...)` calls on the same underlying websocket.
|
||||
4. After the welcome skip:
|
||||
- status frames must still be numeric strings
|
||||
- callback frames must still match the existing JSON-array callback protocol
|
||||
- any other malformed frame remains a protocol error
|
||||
5. Timeout, close/reset, and connect-failure semantics remain unchanged.
|
||||
|
||||
## Matching strategy
|
||||
|
||||
Use a narrow string check in `ServiceBrowserWsClient` for a welcome/banner frame:
|
||||
|
||||
- starts with `Welcome! You are client #`
|
||||
|
||||
This is intentionally strict. We are adapting one known real-browser behavior, not introducing a generic “ignore garbage text” mode.
|
||||
|
||||
## Tests
|
||||
|
||||
### New red tests
|
||||
|
||||
Add focused unit tests under `src/service/server.rs` tests:
|
||||
|
||||
1. Positive case:
|
||||
- fake websocket server sends:
|
||||
1. `Welcome! You are client #1`
|
||||
2. `0`
|
||||
- then `WsBrowserBackend.invoke(Action::Navigate, ...)` succeeds
|
||||
|
||||
2. Negative case:
|
||||
- fake websocket server sends a different first text frame that does **not** match the known welcome prefix
|
||||
- assert the call still fails as a protocol error rather than silently skipping the frame
|
||||
|
||||
The positive test must fail before the implementation change and pass after it. The negative test guards the non-goal that we are not introducing a generic “ignore arbitrary text” mode.
|
||||
|
||||
### Regression coverage
|
||||
|
||||
Re-run:
|
||||
|
||||
- `cargo test service::server::tests -- --nocapture`
|
||||
- `cargo test --test browser_ws_backend_test -- --nocapture`
|
||||
- `cargo test --test service_task_flow_test -- --nocapture`
|
||||
|
||||
If those pass, re-run the earlier mixed ws+pipe sweep to confirm no unexpected regression escaped the targeted checks.
|
||||
|
||||
## Risks and controls
|
||||
|
||||
### Risk: swallowing a legitimate protocol error
|
||||
|
||||
Control:
|
||||
- only allow the one-time skip on the first received text frame
|
||||
- only skip frames matching the known welcome prefix
|
||||
|
||||
### Risk: broadening behavior beyond service ws path
|
||||
|
||||
Control:
|
||||
- keep the change entirely inside `ServiceBrowserWsClient`
|
||||
- do not modify `WsBrowserBackend` parsing rules
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The fix is complete only if all of the following are true:
|
||||
|
||||
1. The positive welcome-banner test fails before the change and passes after it.
|
||||
2. The negative malformed-first-frame test proves that non-matching first text frames still fail as protocol errors.
|
||||
3. Real ws service smoke no longer fails with `invalid browser status frame: Welcome! You are client #1` when using the configured real sgBrowser endpoint.
|
||||
4. Existing ws backend tests remain green.
|
||||
5. Existing service task-flow regression remains green.
|
||||
6. Pipe behavior remains unchanged, verified by the mixed ws+pipe regression suite.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Zhihu WS Submit Realignment Design
|
||||
|
||||
## Background
|
||||
|
||||
The current Zhihu submit path drifted away from the documented browser websocket contract.
|
||||
|
||||
The authoritative contract for this repository is `docs/_tmp_sgbrowser_ws_api_doc.txt`.
|
||||
|
||||
For this slice, the spec anchors to these documented invariants only:
|
||||
|
||||
- connect to `ws://127.0.0.1:12345`
|
||||
- send `{"type":"register","role":"web"}`
|
||||
- send browser actions as JSON arrays `[requesturl, action, ...args]`
|
||||
- let browser results come back through documented callback semantics such as `callBackJsToCpp(...)`
|
||||
- keep the current page URL as the request owner instead of inventing an external helper page
|
||||
|
||||
The current production path does not follow that shape for Zhihu routes.
|
||||
|
||||
Instead, the submit path selects `BrowserCallbackBackend`, which starts `LiveBrowserCallbackHost` and attempts to bootstrap a local helper page at `/sgclaw/browser-helper.html`. That helper-page bootstrap is not part of the user's confirmed production model, and live evidence already shows it is the wrong assumption for the Release browser.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Zhihu submit currently fails before real work begins because the service path depends on a helper-page callback host bootstrap that the Release browser does not use.
|
||||
|
||||
That drift shows up in three ways:
|
||||
|
||||
1. Zhihu submit routes select the callback-host backend instead of the direct websocket backend.
|
||||
2. The mainline request URL becomes the local helper page URL instead of the real browser page URL.
|
||||
3. The submit path waits for helper-page readiness rather than proceeding through the documented websocket callback model.
|
||||
|
||||
This causes the observable failure:
|
||||
|
||||
- `timeout while waiting for browser message`
|
||||
- no real Zhihu page open/action in the browser
|
||||
|
||||
## Goal
|
||||
|
||||
Realign the Zhihu submit path to the documented websocket callback model without changing the existing pipe/service contract.
|
||||
|
||||
Concretely, the target behavior is:
|
||||
|
||||
- Zhihu submit routes use the websocket browser backend directly
|
||||
- browser messages keep the real page URL as `requesturl`
|
||||
- browser actions continue to use documented websocket opcodes
|
||||
- callback-bearing results continue to use the documented callback payload model
|
||||
- the browser no longer depends on opening a local helper page before Zhihu work starts
|
||||
|
||||
## Non-goals
|
||||
|
||||
This slice does not include:
|
||||
|
||||
- changing `ClientMessage` or `ServiceMessage`
|
||||
- changing `run_submit_task_with_browser_backend(...)`
|
||||
- rewriting the Zhihu workflow itself
|
||||
- adding a new browser bridge abstraction
|
||||
- redesigning the pipe path
|
||||
- deleting callback-host code that is outside the Zhihu submit mainline
|
||||
- speculative protocol expansion beyond the documented websocket contract
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Choose **Option A**: withdraw Zhihu submit from the helper-page callback-host path and return it to the documented websocket callback model.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- Keep callback host but remove helper bootstrap: still preserves the wrong abstraction in the mainline.
|
||||
- Build a new orchestration layer: exceeds the requested scope.
|
||||
|
||||
## Mainline Architecture After Realignment
|
||||
|
||||
```text
|
||||
sg_claw_client
|
||||
-> sg_claw service / runtime submit path
|
||||
-> existing BrowserBackend seam
|
||||
-> WsBrowserBackend
|
||||
-> ws://127.0.0.1:12345
|
||||
-> documented browser opcodes and callback semantics
|
||||
```
|
||||
|
||||
For Zhihu submit routes, the callback-host helper page is no longer part of the mainline execution chain.
|
||||
|
||||
## Required Production Changes
|
||||
|
||||
### 1. Route selection
|
||||
|
||||
Update submit-route backend selection so these routes no longer instantiate `BrowserCallbackBackend`:
|
||||
|
||||
- `WorkflowRoute::ZhihuHotlistExportXlsx`
|
||||
- `WorkflowRoute::ZhihuHotlistScreen`
|
||||
- `WorkflowRoute::ZhihuArticleEntry`
|
||||
- `WorkflowRoute::ZhihuArticleDraft`
|
||||
- `WorkflowRoute::ZhihuArticlePublish`
|
||||
|
||||
The change applies in both:
|
||||
|
||||
- service submit path in `src/service/server.rs`
|
||||
- direct runtime submit path in `src/agent/mod.rs`
|
||||
|
||||
Direct runtime fallback behavior stays unchanged when no browser websocket URL is configured:
|
||||
|
||||
- if a real browser websocket URL is configured, use `WsBrowserBackend` for the listed Zhihu routes
|
||||
- if no browser websocket URL is configured, keep the existing pipe fallback instead of failing fast
|
||||
|
||||
### 2. Request URL ownership
|
||||
|
||||
Keep `requesturl` aligned with the real browser page instead of the helper page.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- initial request URL comes from the existing submit-path request context
|
||||
- after a successful navigate call, the websocket backend continues to update its request URL to the navigated target page
|
||||
- later `getText` and `eval` calls run against the real Zhihu page URL
|
||||
|
||||
This preserves the documented page-owned websocket model.
|
||||
|
||||
### 3. Callback semantics
|
||||
|
||||
Keep callback-bearing actions on the existing websocket protocol path, using the documented callback payload shape.
|
||||
|
||||
Required invariants:
|
||||
|
||||
- action frames remain `[requesturl, action, ...args]`
|
||||
- navigate uses the documented opcode `sgHideBrowserCallAfterLoaded`
|
||||
- `getText` and `eval` continue to emit `callBackJsToCpp(...)` payloads in the documented `sourceUrl@_@targetUrl@_@callback@_@actionUrl@_@responseTxt` form
|
||||
- callback decoding remains on the websocket path instead of moving through localhost helper-page HTTP endpoints
|
||||
|
||||
### 4. Callback-host removal from the Zhihu mainline
|
||||
|
||||
For this slice, callback-host code is removed from the Zhihu submit mainline, not redesigned.
|
||||
|
||||
Practical meaning:
|
||||
|
||||
- Zhihu submit must not start `LiveBrowserCallbackHost`
|
||||
- Zhihu submit must not emit `sgBrowerserOpenPage` for `/sgclaw/browser-helper.html`
|
||||
- Zhihu submit must not block on `/sgclaw/callback/ready`
|
||||
|
||||
Code outside the Zhihu submit mainline can remain unchanged unless tests require cleanup.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
This slice follows TDD and replaces the stale helper-page assumptions with direct websocket submit-path assertions.
|
||||
|
||||
### Red tests to add or rewrite
|
||||
|
||||
1. Rewrite the current submit regression that asserts helper-page bootstrap.
|
||||
- old behavior under test: Zhihu submit bootstraps callback host
|
||||
- new behavior under test: Zhihu submit does **not** bootstrap callback host and does **not** emit helper-page frames
|
||||
|
||||
2. Add or update a focused submit-path regression proving request ownership stays on the real page.
|
||||
- after navigate, subsequent Zhihu browser actions must use the real target page URL rather than `/sgclaw/browser-helper.html`
|
||||
|
||||
3. Remove or rewrite any newly added red test whose only purpose was to preserve callback-host-without-helper behavior.
|
||||
- that test belongs to the rejected Option B path, not the chosen Option A path
|
||||
|
||||
### Green verification
|
||||
|
||||
After the minimal code change, run focused verification in this order:
|
||||
|
||||
1. `agent_runtime_test` coverage for the submit path
|
||||
2. relevant Zhihu `compat_runtime_test` coverage
|
||||
3. submit/service websocket regressions impacted by route selection
|
||||
4. stronger real-browser validation after focused tests pass
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
The implementation plan for this spec must obey all of the following:
|
||||
|
||||
1. Do not modify the pipe contract.
|
||||
2. Do not add a new browser abstraction.
|
||||
3. Do not broaden the change beyond the Zhihu submit path and its directly affected websocket protocol tests.
|
||||
4. Do not keep the helper-page path as a second competing Zhihu mainline.
|
||||
5. If live validation still reveals a callback-payload mismatch, only adjust the websocket protocol encoding/decoding at the exact mismatch point.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The slice is complete when all of the following are true:
|
||||
|
||||
1. Zhihu submit routes no longer select the helper-page callback-host backend.
|
||||
2. No Zhihu submit regression expects or observes `/sgclaw/browser-helper.html` bootstrap.
|
||||
3. The websocket backend sends Zhihu follow-up actions with the real page URL as `requesturl`.
|
||||
4. Focused automated tests covering the changed submit path pass.
|
||||
5. Real-browser validation no longer fails at callback-host readiness timeout, emits no helper-page bootstrap frames, and emits at least one real-page follow-up browser action after navigate.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Service Chat Web Console Design
|
||||
|
||||
## Background
|
||||
|
||||
The current natural-language entrypoint is the terminal client in `src/bin/sg_claw_client.rs`.
|
||||
That client already talks to the existing service websocket, sends `ClientMessage`, and prints
|
||||
`ServiceMessage` responses.
|
||||
|
||||
The repository also contains a separate browser callback helper at
|
||||
`http://127.0.0.1:61058/sgclaw/browser-helper.html`. That page is part of the browser backend
|
||||
execution path and must remain untouched.
|
||||
|
||||
For this slice, the authoritative boundary is:
|
||||
|
||||
- the new page may talk to the existing service websocket only
|
||||
- the page must not talk to the browser websocket directly
|
||||
- the page must not reuse or replace `browser-helper.html`
|
||||
- the page must not change the service protocol or browser execution logic
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Running `cargo run --bin sg_claw_client` and typing into stdin works, but it is inconvenient for
|
||||
routine usage. The user wants a simple local HTML page with a websocket connection field, a natural-
|
||||
language input box, and a send button.
|
||||
|
||||
The risk is scope drift: if the new page reaches into the browser-helper flow or changes backend
|
||||
logic, it could damage the working Zhihu/browser path.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a standalone local HTML console that connects to the existing service websocket and submits
|
||||
natural-language tasks using the current `submit_task` message shape.
|
||||
|
||||
The page should be usable without changing `sg_claw`, `sg_claw_client`, `browser-helper.html`, or
|
||||
any existing service/browser runtime behavior.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This slice does not include:
|
||||
|
||||
- serving the page from the Rust service
|
||||
- changing `ClientMessage` or `ServiceMessage`
|
||||
- changing `src/service/server.rs`
|
||||
- changing `src/browser/callback_host.rs`
|
||||
- changing `src/browser/callback_backend.rs`
|
||||
- changing the helper-page bootstrap flow
|
||||
- adding authentication, persistence, or multi-session orchestration
|
||||
- replacing the terminal client
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Choose Option A: add one standalone HTML file that opens in a normal browser and talks to the
|
||||
existing service websocket at `ws://127.0.0.1:42321` by default.
|
||||
|
||||
Why this option:
|
||||
|
||||
- it is the narrowest possible change
|
||||
- it reuses the already-working service protocol
|
||||
- it does not alter the browser-helper path
|
||||
- it keeps all runtime ownership in the existing Rust service
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- extend `browser-helper.html` into a chat UI: wrong boundary; that page belongs to browser
|
||||
callback orchestration, not user task entry
|
||||
- add a new HTTP server inside `sg_claw`: unnecessary for the requested scope
|
||||
- replace the terminal client binary: not required; both clients can coexist
|
||||
|
||||
## File Placement
|
||||
|
||||
Create the page outside `frontend/runtime-host/`.
|
||||
|
||||
Chosen location:
|
||||
|
||||
- `frontend/service-console/sg_claw_service_console.html`
|
||||
|
||||
Reason:
|
||||
|
||||
- `frontend/runtime-host/` is reserved for SuperRPA runtime-host bundles
|
||||
- the new page is a standalone local tool, not a Chromium-hosted bundle
|
||||
- keeping it in its own directory makes the isolation explicit
|
||||
|
||||
## Page Architecture
|
||||
|
||||
The page is a single self-contained HTML file with inline CSS and inline JavaScript.
|
||||
No build step and no frontend framework are required.
|
||||
|
||||
The page has three UI regions:
|
||||
|
||||
1. Connection bar
|
||||
- websocket URL input
|
||||
- connect/disconnect button
|
||||
- current connection state label
|
||||
|
||||
2. Message stream
|
||||
- appends service logs in arrival order
|
||||
- distinguishes connection info, task logs, errors, and final completion
|
||||
- keeps the current session visible until the page is refreshed
|
||||
|
||||
3. Task composer
|
||||
- one textarea for natural-language input
|
||||
- one send button
|
||||
- send disabled while the websocket is disconnected
|
||||
- while a task is in flight, keep the composer enabled and let repeated submits surface the
|
||||
existing service-side `busy` response rather than adding a new frontend queue
|
||||
|
||||
## Protocol Contract
|
||||
|
||||
The page must reuse the existing service protocol exactly.
|
||||
|
||||
### Outbound message
|
||||
|
||||
When the user clicks send, the page sends:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "submit_task",
|
||||
"instruction": "<user input>",
|
||||
"conversation_id": "",
|
||||
"messages": [],
|
||||
"page_url": "",
|
||||
"page_title": ""
|
||||
}
|
||||
```
|
||||
|
||||
This matches the current terminal client shape in `src/bin/sg_claw_client.rs`.
|
||||
|
||||
### Inbound messages
|
||||
|
||||
The page displays these existing `ServiceMessage` variants:
|
||||
|
||||
- `status_changed` -> render as a compact connection/runtime status row
|
||||
- `log_entry` -> append as a chronological task log row
|
||||
- `task_complete` -> append as the terminal result row for that submission
|
||||
- `busy` -> append as a visible refusal/error row without automatic retry
|
||||
|
||||
No new message type is introduced.
|
||||
|
||||
## Interaction Flow
|
||||
|
||||
1. User opens the local HTML file with a normal browser, typically via `file://`.
|
||||
2. User connects to the service websocket.
|
||||
3. The page shows websocket connection status locally.
|
||||
4. User enters a natural-language instruction and clicks send.
|
||||
5. The page sends one `submit_task` payload over the service websocket.
|
||||
6. The service continues to execute tasks exactly as it already does.
|
||||
7. Incoming service messages are appended to the message stream.
|
||||
8. After `task_complete`, the websocket remains open so the user can send another task.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The page handles only UI-local failures:
|
||||
|
||||
- websocket connect failure -> show connection error and keep send disabled
|
||||
- websocket disconnect mid-session -> mark disconnected and require reconnect
|
||||
- empty instruction -> block send and show inline validation
|
||||
- `busy` response -> show as a visible service-side refusal without retry logic
|
||||
|
||||
The page does not add retries, protocol fallbacks, or browser-runtime recovery logic.
|
||||
|
||||
## Isolation From `browser-helper.html`
|
||||
|
||||
This is the critical constraint.
|
||||
|
||||
The new page must never:
|
||||
|
||||
- reference `/sgclaw/browser-helper.html`
|
||||
- reference `/sgclaw/callback/ready`
|
||||
- reference `/sgclaw/callback/events`
|
||||
- reference `/sgclaw/callback/commands/next`
|
||||
- reference `/sgclaw/callback/commands/ack`
|
||||
- connect to `ws://127.0.0.1:12345`
|
||||
|
||||
The only network target owned by the page is the service websocket, defaulting to
|
||||
`ws://127.0.0.1:42321`.
|
||||
|
||||
Because of that boundary, the page does not interfere with the helper-page bootstrap path.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
This slice stays minimal, so the automated guard is also minimal.
|
||||
|
||||
### Automated regression
|
||||
|
||||
Add one focused integration test in `tests/service_console_html_test.rs` that reads the standalone
|
||||
HTML source and asserts:
|
||||
|
||||
- the file exists at the agreed path and is resolved from `CARGO_MANIFEST_DIR` so the test is
|
||||
stable across working directories
|
||||
- it contains the service websocket default URL
|
||||
- it contains `submit_task` payload construction
|
||||
- it does not contain helper-page URLs or callback-host endpoints
|
||||
- it does not contain the browser websocket URL
|
||||
|
||||
This test is a scope guard, not a browser-E2E suite.
|
||||
|
||||
### Manual smoke verification
|
||||
|
||||
With the existing service binary running:
|
||||
|
||||
1. open the HTML file in a browser
|
||||
2. connect to the service websocket
|
||||
3. confirm local websocket open/close events and service `status_changed` messages both appear in the message stream
|
||||
4. submit a natural-language task
|
||||
5. confirm logs and completion render in the page
|
||||
6. confirm the helper-page path remains unchanged because the page never references it
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The slice is complete when all of the following are true:
|
||||
|
||||
1. `frontend/service-console/sg_claw_service_console.html` exists.
|
||||
2. The page connects to the existing service websocket without backend changes.
|
||||
3. The page sends the existing `submit_task` shape and receives existing `ServiceMessage` events.
|
||||
4. The page does not reference `browser-helper.html`, callback-host endpoints, or the browser
|
||||
websocket URL.
|
||||
5. Existing browser-helper logic remains untouched.
|
||||
6. The automated source guard passes.
|
||||
7. Manual smoke verification confirms a task can be submitted from the HTML page.
|
||||
@@ -0,0 +1,373 @@
|
||||
# Zhihu Hotlist Post-Export Auto-Open Design
|
||||
|
||||
## Background
|
||||
|
||||
The current Zhihu hotlist workflows already support two separate artifact outputs:
|
||||
|
||||
- `openxml_office` generates a local `.xlsx` file for hotlist export
|
||||
- `screen_html_export` generates a local `.html` dashboard for presentation
|
||||
|
||||
Today, the workflow stops after artifact generation and returns a summary string such as:
|
||||
|
||||
- `已导出知乎热榜 Excel <path>`
|
||||
- `已生成知乎热榜大屏 <path>`
|
||||
|
||||
That means the user still has to manually open the generated file.
|
||||
|
||||
The user wants one additional post-export action, but only one at a time:
|
||||
|
||||
1. for Excel-oriented tasks, automatically open the generated `.xlsx` with the system default spreadsheet application
|
||||
2. for dashboard-oriented tasks, automatically open the generated local dashboard HTML inside the running sgBrowser session
|
||||
|
||||
This is an exclusive choice, not a combined mode.
|
||||
|
||||
## Current Runtime Facts
|
||||
|
||||
The implementation must match the current browser/runtime boundary that already exists in the repo:
|
||||
|
||||
- the active service submit path in `src/service/server.rs` constructs `BrowserCallbackBackend`
|
||||
- `BrowserCallbackBackend::invoke(Action::Navigate, ...)` currently emits `sgBrowerserOpenPage`, which opens a new visible browser tab and keeps the helper page alive
|
||||
- `WsBrowserBackend::invoke(Action::Navigate, ...)` has different semantics and a different transport path from the callback-host service path
|
||||
- `MacPolicy::validate(...)` currently rejects empty or non-domain values, so a raw `file://...` navigation cannot pass through the normal domain validation path today
|
||||
- `screen_html_export` already returns `presentation.url`, which is the existing `file://` presentation URL contract for the generated dashboard
|
||||
|
||||
Those facts mean the design must not promise "replace the helper page" or "reuse identical tab behavior across all backends". The required success path for this slice is narrower: open the generated dashboard automatically in the current callback-host-backed sgBrowser service session without adding a new user-facing surface.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The existing workflow logic in `src/compat/workflow_executor.rs` already separates hotlist export from dashboard generation, but it treats both routes as artifact-only flows. The last mile is missing:
|
||||
|
||||
- the Excel route does not auto-open the generated file
|
||||
- the dashboard route does not consume the generated dashboard presentation URL and open it automatically in the browser runtime
|
||||
|
||||
The risk is scope drift. This change must not:
|
||||
|
||||
- turn Excel-open and dashboard-open into a combined workflow
|
||||
- add new help/help-like user-visible surfaces
|
||||
- move orchestration into `frontend/service-console/`
|
||||
- modify the websocket protocol
|
||||
- modify `browser-helper.html`
|
||||
- modify callback-host HTTP endpoints or their contracts
|
||||
- change the artifact-generation contract of `openxml_office` or `screen_html_export`
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the existing Zhihu hotlist post-export behavior so that:
|
||||
|
||||
- Excel tasks generate `.xlsx` and then auto-open it with the local system default spreadsheet application
|
||||
- dashboard tasks generate `.html` and then auto-open that generated dashboard inside sgBrowser
|
||||
|
||||
On the current callback-host service path, "inside sgBrowser" means opening the generated dashboard in a new visible browser tab while the helper page stays alive. The user does not need to open the file manually.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This slice does not include:
|
||||
|
||||
- opening Excel and dashboard in the same run
|
||||
- adding a new combined route that auto-opens both artifacts
|
||||
- adding any new help, helper, or user-visible assistance surface
|
||||
- modifying `frontend/service-console/sg_claw_service_console.html`
|
||||
- modifying `src/service/protocol.rs`
|
||||
- modifying `browser-helper.html`
|
||||
- modifying `/sgclaw/callback/*` contracts
|
||||
- turning the browser backend into a general-purpose local filesystem browser
|
||||
- changing the artifact-generation JSON contract of `openxml_office` or `screen_html_export`
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Keep the current two workflow routes, but add one route-specific post-export action to each:
|
||||
|
||||
- `ZhihuHotlistExportXlsx` -> generate `.xlsx`, then open it locally with the OS default app
|
||||
- `ZhihuHotlistScreen` -> generate `.html`, then open the generated dashboard presentation URL in the browser runtime
|
||||
|
||||
For the dashboard route, use the existing `presentation.url` returned by `screen_html_export` as the authoritative browser-open URL. Do not invent a separate normal-path URL conversion layer when the tool already returns the presentation contract.
|
||||
|
||||
The compat opener must emit one exact navigate request shape for this case.
|
||||
|
||||
- `action`: `Action::Navigate`
|
||||
- `expected_domain`: the exact literal `__sgclaw_local_dashboard__`
|
||||
- `params.url`: the exact `presentation.url` returned by `screen_html_export`
|
||||
- `params.sgclaw_local_dashboard_open.source`: the exact literal `compat.workflow_executor`
|
||||
- `params.sgclaw_local_dashboard_open.kind`: the exact literal `zhihu_hotlist_screen`
|
||||
- `params.sgclaw_local_dashboard_open.output_path`: the generated local dashboard artifact path
|
||||
- `params.sgclaw_local_dashboard_open.presentation_url`: the same `file://` URL stored in `params.url`
|
||||
|
||||
On the current callback-host-backed service path, only that exact request shape is approved for the local-dashboard special case. A plain `Action::Navigate` with an arbitrary `file://...` URL, or a request missing any one of the required marker fields above, must continue to be rejected.
|
||||
|
||||
Because normal `MacPolicy` domain validation cannot accept `file://...`, add a narrow local-dashboard presentation allowance in the browser backend/security boundary. That allowance must be limited to this one case:
|
||||
|
||||
- only for `Action::Navigate`
|
||||
- only for generated local dashboard presentation URLs
|
||||
- only for local HTML presentation, not arbitrary local paths or generic file browsing
|
||||
|
||||
Why this approach:
|
||||
|
||||
- it preserves the existing mutual exclusivity between Excel export and dashboard presentation
|
||||
- it keeps artifact generation in the existing tools
|
||||
- it keeps browser opening inside the existing browser backend boundary
|
||||
- it uses the existing `screen_html_export` presentation contract instead of duplicating it
|
||||
- it avoids pushing orchestration into the service console or protocol layer
|
||||
- it stays compatible with the current callback-host runtime, where visible navigation is new-tab based
|
||||
- it limits the guaranteed browser-open behavior in this slice to the callback-host-backed service path that the user is using today
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- add a combined "Excel + dashboard" route: explicitly rejected by user behavior
|
||||
- let `frontend/service-console/` decide when to open generated files: wrong layer; the console is only a submit/view surface
|
||||
- add help UI to expose output choices: explicitly unwanted by the user
|
||||
- change `browser-helper.html` so the helper page itself becomes the dashboard: this would break the current helper-page persistence model
|
||||
- promise a backend-agnostic "replace the current page" behavior: inaccurate because callback-host and websocket backends do not share identical navigate semantics
|
||||
- require the websocket backend to gain matching local-dashboard visible-open behavior in this slice: outside the narrow current-service-path goal
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
### `src/compat/workflow_executor.rs`
|
||||
|
||||
Continue to own:
|
||||
|
||||
- route detection for Zhihu hotlist workflows
|
||||
- artifact generation orchestration
|
||||
- post-export summary construction
|
||||
|
||||
New responsibilities in this slice:
|
||||
|
||||
- parse the successful artifact payloads after `openxml_office` and `screen_html_export`
|
||||
- call the route-specific post-export opener only after artifact creation succeeds
|
||||
- for the dashboard route, consume `presentation.url` from the `screen_html_export` result payload
|
||||
- keep generation success and post-export open success/failure distinct in the returned summary
|
||||
|
||||
### `src/compat/artifact_open.rs`
|
||||
|
||||
New helper module to keep side effects out of `workflow_executor.rs`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- open a generated local `.xlsx` with the system default application
|
||||
- open a generated local dashboard presentation URL through the existing `BrowserBackend`
|
||||
- construct the exact approved dashboard navigate request shape used by this slice
|
||||
- define the narrow local-dashboard presentation token/constants used by the compat layer and backend compatibility path
|
||||
- return narrow success/failure results so `workflow_executor.rs` can produce accurate summaries
|
||||
|
||||
This module must stay small and focused. It is not a general launcher framework.
|
||||
|
||||
### `src/browser/callback_backend.rs`
|
||||
|
||||
New narrow responsibility in this slice:
|
||||
|
||||
- at the `BrowserCallbackBackend::invoke(Action::Navigate, params, expected_domain)` entrypoint, recognize only the exact approved local-dashboard presentation request shape
|
||||
- preserve the current callback-host behavior of using `sgBrowerserOpenPage`, which opens a new visible tab and keeps the helper page alive
|
||||
- reject local-file navigate attempts that do not include the exact post-export marker payload from the compat layer
|
||||
|
||||
This slice must not change callback-host polling, helper bootstrap, or callback endpoint behavior.
|
||||
|
||||
### `src/browser/ws_backend.rs`
|
||||
|
||||
No required behavior change in this slice.
|
||||
|
||||
Notes:
|
||||
|
||||
- websocket transport semantics differ from the callback-host service path
|
||||
- this spec does not require websocket backend local-dashboard visible-open support
|
||||
- websocket-specific parity can be designed later as a separate slice if needed
|
||||
|
||||
### `src/security/mac_policy.rs`
|
||||
|
||||
New narrow responsibility in this slice:
|
||||
|
||||
- expose a small validation helper for the approved local-dashboard presentation case
|
||||
- validate the real local presentation URL and artifact path for that case rather than treating `file://` as a normal allowed domain
|
||||
- keep the normal domain-based validation path unchanged for ordinary remote navigation
|
||||
|
||||
The policy layer must not turn `file://` into a generally allowed "domain". This is an explicit special case for generated local dashboard presentation only.
|
||||
|
||||
### `src/compat/mod.rs`
|
||||
|
||||
Expose the new helper module.
|
||||
|
||||
## Route Semantics
|
||||
|
||||
### Excel export route
|
||||
|
||||
Trigger examples:
|
||||
|
||||
- `读取知乎热榜数据,并导出 excel 文件`
|
||||
- `导出知乎热榜 xlsx`
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. collect hotlist rows
|
||||
2. call `openxml_office`
|
||||
3. obtain `output_path`
|
||||
4. open the generated `.xlsx` using the local OS default spreadsheet application
|
||||
5. return a success summary reflecting both generation and open state
|
||||
|
||||
Summary rules:
|
||||
|
||||
- open succeeded -> `已导出并打开知乎热榜 Excel <path>`
|
||||
- open failed but file exists -> `已导出知乎热榜 Excel <path>,但自动打开失败:<reason>`
|
||||
|
||||
The workflow still counts artifact generation as successful even if the post-export open step fails.
|
||||
|
||||
### Dashboard route
|
||||
|
||||
Trigger examples:
|
||||
|
||||
- `读取知乎热榜数据并生成领导演示大屏`
|
||||
- `生成知乎热榜 dashboard`
|
||||
- `展示知乎热榜大屏`
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. collect hotlist rows
|
||||
2. call `screen_html_export`
|
||||
3. obtain `output_path`
|
||||
4. obtain `presentation.url` from the tool result payload
|
||||
5. invoke the browser opener through the existing `BrowserBackend`
|
||||
6. return a success summary reflecting both generation and browser-open state
|
||||
|
||||
Summary rules:
|
||||
|
||||
- browser open succeeded -> `已在浏览器中打开知乎热榜大屏 <path>`
|
||||
- browser open failed but file exists -> `已生成知乎热榜大屏 <path>,但浏览器自动打开失败:<reason>`
|
||||
|
||||
The workflow still counts artifact generation as successful even if the browser-open step fails.
|
||||
|
||||
## Browser Boundary
|
||||
|
||||
This slice must preserve the current browser/runtime boundary.
|
||||
|
||||
Allowed:
|
||||
|
||||
- use the existing `BrowserBackend`
|
||||
- use the existing `Action::Navigate`
|
||||
- use the existing `screen_html_export` `presentation.url`
|
||||
- add a narrow compatibility path so local generated dashboard presentation can pass backend validation
|
||||
|
||||
Not allowed:
|
||||
|
||||
- change `browser-helper.html`
|
||||
- introduce a new callback-host endpoint
|
||||
- move file-opening responsibility into the frontend service console
|
||||
- add a new browser-side bootstrap flow
|
||||
- require websocket protocol changes
|
||||
|
||||
Important semantic note:
|
||||
|
||||
- on the current service callback-host path, dashboard open is expected to use `sgBrowerserOpenPage`, so the generated dashboard appears in a new visible browser tab while the helper page remains available for later tasks
|
||||
- websocket-backed browser execution may continue to differ; this slice does not require matching visible-open semantics there
|
||||
|
||||
## Local Dashboard Presentation Allowance
|
||||
|
||||
The local dashboard browser-open path needs an explicit narrow validation rule because `file://...` cannot pass the normal domain allowlist.
|
||||
|
||||
Requirements for the narrow allowance:
|
||||
|
||||
- only approved for `Action::Navigate`
|
||||
- only approved for the exact compat marker payload described above
|
||||
- only approved for generated local dashboard presentation URLs
|
||||
- only approved when the validated local artifact path points to the generated dashboard HTML artifact returned by the same `screen_html_export` success payload
|
||||
- only approved for local HTML presentation, not arbitrary executables or unrelated local files
|
||||
- ordinary remote navigation must continue using the existing `MacPolicy::validate(...)` domain rules unchanged
|
||||
|
||||
This keeps the behavior small and auditable while still satisfying the user-visible dashboard auto-open requirement.
|
||||
|
||||
## Local File Opening Boundary
|
||||
|
||||
The Excel auto-open action is a local runtime side effect, not a browser action.
|
||||
|
||||
Requirements:
|
||||
|
||||
- use the system default application for `.xlsx`
|
||||
- support the current Windows environment first
|
||||
- keep the implementation minimal and focused on the generated artifact path
|
||||
|
||||
Not required in this slice:
|
||||
|
||||
- a cross-platform abstraction beyond the minimal shape needed for the current repo environment
|
||||
- opening arbitrary user-selected files
|
||||
- exposing local file opening to the service websocket protocol
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Excel route
|
||||
|
||||
If `.xlsx` generation fails:
|
||||
|
||||
- return the existing export failure
|
||||
|
||||
If `.xlsx` generation succeeds but auto-open fails:
|
||||
|
||||
- keep the artifact path in the summary
|
||||
- mark only the auto-open step as failed
|
||||
- do not delete the generated file
|
||||
|
||||
### Dashboard route
|
||||
|
||||
If `.html` generation fails:
|
||||
|
||||
- return the existing screen export failure
|
||||
|
||||
If `.html` generation succeeds but browser open fails:
|
||||
|
||||
- keep the artifact path in the summary
|
||||
- mark only the browser-open step as failed
|
||||
- do not delete the generated file
|
||||
|
||||
If the tool result is missing `presentation.url`:
|
||||
|
||||
- treat that as a protocol error in the post-export open step for this route
|
||||
- keep the generated artifact path in the summary if it is available
|
||||
- do not silently invent a different contract in the normal path
|
||||
|
||||
## Test Strategy
|
||||
|
||||
### Workflow tests
|
||||
|
||||
Update or add focused workflow coverage so that:
|
||||
|
||||
- Excel workflow still calls `openxml_office`
|
||||
- dashboard workflow still calls `screen_html_export`
|
||||
- the two routes remain mutually exclusive
|
||||
- dashboard workflow consumes the tool's existing `presentation.url`
|
||||
|
||||
### New Excel post-export test
|
||||
|
||||
Add a focused regression proving:
|
||||
|
||||
- an Excel-oriented hotlist request triggers export
|
||||
- the generated `.xlsx` path is passed into the local default-app opener
|
||||
- no browser dashboard navigate is triggered for that route
|
||||
|
||||
### New dashboard post-export test
|
||||
|
||||
Add a focused regression proving:
|
||||
|
||||
- a dashboard-oriented hotlist request triggers HTML generation
|
||||
- the generated tool payload `presentation.url` is used for browser open
|
||||
- the browser backend receives a local-dashboard navigate request through the approved compat path
|
||||
- no local spreadsheet opener is triggered for that route
|
||||
|
||||
### Backend/security compatibility tests
|
||||
|
||||
Add focused regressions proving:
|
||||
|
||||
- callback backend accepts the approved local-dashboard navigate case and still emits `sgBrowerserOpenPage`
|
||||
- the narrow local-dashboard allowance rejects non-local or malformed URLs
|
||||
- ordinary domain validation behavior remains unchanged for normal remote navigation
|
||||
|
||||
### Existing boundary tests remain unchanged
|
||||
|
||||
Do not change the service-console boundary guard. This slice is runtime behavior only.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The slice is complete when all of the following are true:
|
||||
|
||||
1. Excel hotlist export still generates a local `.xlsx` artifact.
|
||||
2. Excel hotlist export auto-opens that `.xlsx` with the system default spreadsheet application.
|
||||
3. Dashboard hotlist export still generates a local `.html` artifact.
|
||||
4. Dashboard hotlist export consumes the existing `screen_html_export` `presentation.url` and auto-opens it in the current callback-host-backed sgBrowser service session.
|
||||
5. On the current callback-host service path, the dashboard opens automatically in a visible browser tab without breaking the helper-page runtime.
|
||||
6. Excel-open and dashboard-open remain separate user-chosen flows, not a combined mode.
|
||||
7. No new help/help-like user-visible surface is added.
|
||||
8. The service console, websocket protocol, `browser-helper.html`, and callback-host endpoint surface remain untouched.
|
||||
@@ -0,0 +1,217 @@
|
||||
# 95598-repair-city-dispatch 操作分析
|
||||
|
||||
## 1. 场景概述
|
||||
|
||||
`95598-repair-city-dispatch` 对应“95598抢修-市指”场景,目标是监测抢修工单队列,并在必要时触发提醒、日志写入与自动派单等后续动作。根据 `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\95598-repair-city-dispatch\scene.json`、`D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-repair-city-dispatch\SKILL.md`、`D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-repair-city-dispatch\scripts\collect_repair_orders.js` 以及两份规则资产,当前最严谨的结论是:本场景的 packaged JS collector 已经实现输入驱动的 `monitor-snapshot` 归一化 / 比较逻辑,会按状态分桶 repair orders、解析 monitor/dispose logs、推导 `pending_ids` / `new_pending_ids`、给出 `success/partial/empty/blocked` 状态,并附带 desk 规则来源、配置基础页与已知问题元数据;但更强的业务监测、提醒与自动派单 workflow 证据仍主要存在于 desk 规则资产中,证据等级分别为 `code-confirmed`。
|
||||
|
||||
必须显式区分三层证据:
|
||||
|
||||
1. packaged runtime-snapshot-collector:`collect_repair_orders.js` 已直接实现 repair-order 分类、历史比较、状态判定与标准化快照输出,并显式携带 `workflow_rule_sources`、`config_base_page`、`config_base_role`、`packaged_collector_role` 与 `known_issues`,证据等级:`code-confirmed`。
|
||||
2. 业务监测逻辑:`D:\desk\智能体资料\大四区报告监测项\95598抢修-市指_业务检测配置.txt` 直接展示了队列采集、状态分类、监测日志比较、音频提醒与监测日志写入逻辑,证据等级:`code-confirmed`。
|
||||
3. 自动派单 / 提醒逻辑:`D:\desk\智能体资料\大四区报告监测项\95598抢修-市指_自动处理配置.txt` 直接展示了去重、班组匹配、自动派单请求、音频提醒、短信发送、外呼触发与处置日志写入逻辑,证据等级:`code-confirmed`。
|
||||
|
||||
但这些 `code-confirmed` 只表示“代码或规则资产中存在这些实现分支或动作定义”,不等于“运行时已验证成功”。本文不对运行时成功做任何拔高表述。
|
||||
|
||||
## 2. 证据来源
|
||||
|
||||
本分析统一只使用四个证据等级标签:`code-confirmed`、`contract-defined`、`implementation intent exists but not rigorous / buggy`、`no direct evidence / candidate only`。
|
||||
|
||||
1. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-repair-city-dispatch\scripts\collect_repair_orders.js`
|
||||
- 直接定义 `STATUS_GROUPS`、`LOCAL_SERVICE_ENDPOINTS`、`WORKFLOW_RULE_SOURCES`、`CONFIG_BASE_PAGE`、`KNOWN_ISSUES`,并实现 repair-order 分类、monitor/dispose log 解析比较、`new_pending_ids` 推导、`success/partial/empty/blocked` 状态判定,以及带 `evidence` / `known_issues` 的 `monitor-snapshot` 输出,证据等级:`code-confirmed`。
|
||||
2. `D:\desk\智能体资料\大四区报告监测项\95598抢修-市指_业务检测配置.txt`
|
||||
- 直接实现工单队列采集、按状态分桶、待处理列表比较、音频提醒、监测日志写入,且暴露待处理分类 bug,证据等级:`code-confirmed`。
|
||||
3. `D:\desk\智能体资料\大四区报告监测项\95598抢修-市指_自动处理配置.txt`
|
||||
- 直接实现处置日志去重、班组范围匹配、自动派单请求、自动派单成功/失败/异常/未匹配分支、音频日志、短信日志、外呼触发与 `setDisposeLog` 写入,证据等级:`code-confirmed`。
|
||||
4. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-repair-city-dispatch\SKILL.md`
|
||||
- 定义“优先使用 packaged collector、把监测快照与下游动作分离、允许 partial”的运行契约,证据等级:`contract-defined`。
|
||||
5. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-repair-city-dispatch\references\collection-flow.md`
|
||||
- 定义以页面配置为入口、结合规则资产理解语义、采集状态 `00/01/06/08`、对比 monitor/dispose logs 的一阶流程,证据等级:`contract-defined`。
|
||||
6. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-repair-city-dispatch\references\data-quality.md`
|
||||
- 定义状态分类、partial 规则、empty/failure 区分和下游副作用边界,证据等级:`contract-defined`。
|
||||
7. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\95598-repair-city-dispatch\scene.json`
|
||||
- 声明场景分类、输入 `time`、依赖和动作类型,证据等级:`code-confirmed`。
|
||||
8. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\95598-repair-city-dispatch\scene.draft.json`
|
||||
- 展示早期推断中对 `trigger-alert`、`configServices` 是否拆分的犹豫,属于候选整理结果,证据等级:`no direct evidence / candidate only`。
|
||||
|
||||
## 3. 实际入口与运行边界
|
||||
|
||||
实际入口在 `scene.json` 中已固定:场景页面入口为 `index.html`,技能工具名为 `95598-repair-city-dispatch.collect_repair_orders`,输出类型为 `monitor-snapshot`,输入为 `time`,这些都属于 `code-confirmed`。
|
||||
|
||||
其中 `assets/scene-snapshot/index.html` 只应被视为配置基础页(例如班组、联系人、范围维护),不应被当作规则 workflow 的主执行证据。
|
||||
|
||||
运行边界方面,需要做两个强制区分:
|
||||
|
||||
- packaged JS runtime collector 的实际边界:它已经能基于输入 `repair_orders`、`monitor_logs`、`dispose_logs` 做状态分类、历史比较、`new_pending_ids` 推导与 `success/partial/empty/blocked` 判定,并返回标准 `monitor-snapshot`;但它仍是输入驱动归一化 collector,不直接发起浏览器请求,也不直接承载完整业务 workflow,证据等级:`code-confirmed`。
|
||||
- rule-asset 行为边界:业务检测规则和自动处理规则分别展示了浏览器请求、日志比较、提醒副作用与自动派单副作用,证据等级:`code-confirmed`。
|
||||
|
||||
也就是说,本场景不能被单句描述成“统一 packaged collector 已完整实现实时队列监测与自动派单”。更严谨的说法是:packaged collector 已实现可测试的输入驱动快照归一化 / 比较逻辑;而较强的实时监测与自动处理链路证据仍来自 desk 规则资产,证据等级:`code-confirmed`。
|
||||
|
||||
同时,`SKILL.md` 与 reference 明确要求把“快照采集成功”与“音频、短信、外呼、自动派单等下游效果”分开表达;这属于运行契约约束,证据等级:`contract-defined`。
|
||||
|
||||
## 4. 代码已证实的实际操作流程
|
||||
|
||||
### 4.1 packaged runtime-snapshot-collector 已证实流程
|
||||
|
||||
`collect_repair_orders.js` 中现在能严格确认:
|
||||
|
||||
1. 调用 `collectRepairOrders(input)`,读取 `input.repair_orders`、`input.monitor_logs || input.monitor_log`、`input.dispose_logs || input.dispose_log`、`input.local_write_failures`、`input.blocked_reason` 等输入。
|
||||
2. 通过 `classifyRepairOrders(...)` 按 `STATUS_GROUPS.pending = ["00", "01"]`、`STATUS_GROUPS.audit = ["06"]`、`STATUS_GROUPS.processed = ["08"]` 对 repair orders 分桶,并记录未知状态。
|
||||
3. 从 pending orders 提取 `pending_ids`,再解析 monitor/dispose logs,识别 malformed payload,并据此推导 `new_pending_ids`。
|
||||
4. 按 `blocked > partial > empty > success` 的优先级计算 `status`,并把未知状态、日志缺失、日志解析失败、本地写失败等写入 `partial_reasons`。
|
||||
5. 返回 `type: "monitor-snapshot"`、`scene: "95598-repair-city-dispatch"`、`pending`、`audit`、`processed`、`pending_ids`、`new_pending_ids`、`status`、`partial_reasons`。
|
||||
6. 在返回对象中附带 `evidence.workflow_rule_sources`、`evidence.config_base_page`、`evidence.config_base_role`、`evidence.packaged_collector_role = "runtime-snapshot-collector"`,以及 `known_issues`。
|
||||
7. 模块额外导出 `STATUS_GROUPS`、`LOCAL_SERVICE_ENDPOINTS`、`WORKFLOW_RULE_SOURCES`、`CONFIG_BASE_PAGE`、`KNOWN_ISSUES`。
|
||||
|
||||
以上都属于 `code-confirmed`。
|
||||
|
||||
### 4.2 业务监测规则已证实流程
|
||||
|
||||
`95598抢修-市指_业务检测配置.txt` 中可直接确认:
|
||||
|
||||
1. 通过 `BrowserAction("sgBrowerserJsAjax2", ...)` 请求 `repairOrder/list`,查询条件包含 `statusName=00,01,06,08` 与当天时间窗,证据等级:`code-confirmed`。
|
||||
2. 将返回列表按状态分到 `list`、`shlist`、`ycjList`,并构造 `pending/audit/processed` 与 `pendingList`,证据等级:`code-confirmed`。
|
||||
3. 读取 `getMonitorLog`,并基于待处理列表对比决定是否播报音频提醒,证据等级:`code-confirmed`。
|
||||
4. 将监测结果写入 `setMonitorData` 与 `setMonitorLog`,证据等级:`code-confirmed`。
|
||||
5. 音频提醒结果会写入 `setAudioPlayLog` 成功/失败/异常三类状态,证据等级:`code-confirmed`。
|
||||
|
||||
但这里同时存在一个直接可见的 bug:待处理判断写成了 `item.status == "00" && item.status == "01"`,这在单个状态值上不可能同时成立,因此规则中的 `pending` 列表构造逻辑不严谨,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
### 4.3 自动处理规则已证实流程
|
||||
|
||||
`95598抢修-市指_自动处理配置.txt` 中可直接确认:
|
||||
|
||||
1. 先写一条“进入自动派单”的监测日志,再读取 `getDisposeLog` 做已派单去重,证据等级:`code-confirmed`。
|
||||
2. 对未派过单的待处理工单,读取 `getClassList`,按 `scope` 对故障地点 `gzdd` 做班组匹配,证据等级:`code-confirmed`。
|
||||
3. 匹配成功时,请求 `repairOrder/initProcess` 进行自动派单,证据等级:`code-confirmed`。
|
||||
4. 自动派单成功时,会触发成功音频播报、短信发送、外呼触发,并写 `setDisposeLog(state="成功")`,证据等级:`code-confirmed`。
|
||||
5. 自动派单失败时,会触发失败音频播报,并写 `setDisposeLog(state="失败")`,证据等级:`code-confirmed`。
|
||||
6. 自动派单异常时,会触发异常音频播报,并写 `setDisposeLog(state="异常")`,证据等级:`code-confirmed`。
|
||||
7. 未匹配到班组时,会触发未匹配音频播报,并写 `setDisposeLog(state="未匹配")`,证据等级:`code-confirmed`。
|
||||
|
||||
以上动作都只是“规则层实现分支存在”的直接证据,不代表运行时已经验证成功。
|
||||
|
||||
## 5. 标准化抽象流程
|
||||
|
||||
若为 command-center 做严格抽象,本场景更合理的标准化流程应写成:
|
||||
|
||||
1. 接收监测任务输入 `time`。
|
||||
2. 使用规则资产定义的浏览器请求采集 95598 抢修队列。
|
||||
3. 将源数据分为 `pending`、`audit`、`processed`,并保留规则层可见的待处理列表语义。
|
||||
4. 用 monitor log / dispose log 做比较上下文,得出“新增待处理”或待自动处理集合。
|
||||
5. 若进入标准配置归一层,再把这些结果映射为 `pending_ids`、`new_pending_ids` 等 canonical 字段。
|
||||
6. 先返回或保留监测快照语义。
|
||||
7. 再执行音频提醒、短信、外呼、自动派单、日志写入等下游动作。
|
||||
|
||||
其中第 1 步可由 packaged collector 的显式输入 `time` 支撑,第 3、4、5、6 步可由 packaged collector 的输入驱动归一化 / 比较逻辑支撑,证据等级:`code-confirmed`;第 2、7 步主要由规则资产直接支撑,证据等级:`code-confirmed`;“快照应先于下游副作用表达”这一边界来自 `SKILL.md` / references,证据等级:`contract-defined`。
|
||||
|
||||
如果进一步把这个抽象流程说成“已由统一 packaged collector 严格承载实时浏览器采集与自动派单副作用”,那就不严谨了,因为这些更强 workflow 证据仍在 desk 规则资产而不是 packaged collector 中,证据等级只能降为 `implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 6. 输入、上下文与依赖
|
||||
|
||||
### 输入
|
||||
|
||||
- `time` 是 scene 与 packaged script 共同声明的显式输入,证据等级:`code-confirmed`。
|
||||
- 当天时间窗拼接逻辑出现在业务监测规则中,即 `00:00:00` 到 `23:59:59`,证据等级:`code-confirmed`。
|
||||
- “当前队列窗口通常是当天”在 reference 中被明确说明,证据等级:`contract-defined`。
|
||||
|
||||
### 运行上下文
|
||||
|
||||
- 平台 session、org/user 上下文、浏览器可执行 `BrowserAction` 是规则资产和 reference 共同依赖的前提,证据等级分别为 `code-confirmed` 与 `contract-defined`。
|
||||
- 页面本身更偏配置页,而真正监测语义来自规则资产,这一点在 `collection-flow.md` 中被明确指出,证据等级:`contract-defined`。
|
||||
|
||||
### 依赖
|
||||
|
||||
- `scene.json` 中声明 `browser`、`local-service`、`repair-order-source`、`history-log`、`status-classification`,证据等级:`code-confirmed`。
|
||||
- 业务监测规则直接使用 `repairOrder/list`、`MonitorServices/getMonitorLog`、`setMonitorData`、`setMonitorLog`、`setAudioPlayLog`,证据等级:`code-confirmed`。
|
||||
- 自动处理规则直接使用 `getDisposeLog`、`getClassList`、`repairOrder/initProcess`、`setDisposeLog`、`setSendMessageLog` 与外呼触发 `mac.callOutLogin`,证据等级:`code-confirmed`。
|
||||
- `configServices` 是否需要单独提升为正式依赖,在 `scene.draft.json` 中仍是待确认项,证据等级:`no direct evidence / candidate only`。
|
||||
|
||||
## 7. 输出结构
|
||||
|
||||
当前输出结构需要分层描述。
|
||||
|
||||
### 7.1 packaged runtime collector 已直接定义的输出
|
||||
|
||||
`collect_repair_orders.js` 直接定义:
|
||||
|
||||
- `type: "monitor-snapshot"`
|
||||
- `scene: "95598-repair-city-dispatch"`
|
||||
- `time`
|
||||
- `pending`
|
||||
- `audit`
|
||||
- `processed`
|
||||
- `pending_ids`
|
||||
- `new_pending_ids`
|
||||
- `status`
|
||||
- `partial_reasons`
|
||||
- `evidence.workflow_rule_sources`
|
||||
- `evidence.config_base_page`
|
||||
- `evidence.config_base_role`
|
||||
- `evidence.packaged_collector_role`
|
||||
- `known_issues`
|
||||
|
||||
以上全部属于 `code-confirmed`。
|
||||
|
||||
### 7.2 规则资产已展示的实际快照字段语义
|
||||
|
||||
业务监测规则直接构造了:
|
||||
|
||||
- `time`
|
||||
- `type: "95598抢修-市指"`
|
||||
- `pending`
|
||||
- `pendingList`
|
||||
- `audit`
|
||||
- `processed`
|
||||
|
||||
这说明规则层实际快照对象与 packaged stub 的字段命名并不完全一致,尤其是 `pendingList` vs `pending_ids`、`type` vs `scene`,证据等级:`code-confirmed`。
|
||||
|
||||
### 7.3 `new_pending_ids` 的证据强度
|
||||
|
||||
`SKILL.md`、reference 和 `data-quality.md` 都把 `new_pending_ids` 当作期望输出的一部分,证据等级:`contract-defined`。但在已读规则资产里,能直接看到的是“对 monitor log / dispose log 做比较并决定是否提醒或进入自动派单”,而没有看到显式字段 `new_pending_ids` 被直接写出,因此“存在历史比较意图”是 `code-confirmed`,“`new_pending_ids` 已被当前实现严谨产出”只能标为 `implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 8. 下游动作证据表
|
||||
|
||||
| 下游动作 | 当前证据 | 证据等级 | 严谨结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 返回 `monitor-snapshot` runtime collector 输出 | `collect_repair_orders.js` 直接返回对象 | `code-confirmed` | packaged JS 直接证明标准 snapshot 字段、状态判定与 collector metadata 已存在。 |
|
||||
| 队列采集请求 | 业务监测规则调用 `repairOrder/list` | `code-confirmed` | 队列采集逻辑直接存在于规则资产中。 |
|
||||
| 音频提醒调用 | 业务监测规则和自动处理规则都调用 `mac.audioPlay(...)` | `code-confirmed` | 只能确认规则层存在音频提醒调用,不代表运行时已验证成功。 |
|
||||
| 短信发送调用 | 自动处理规则调用 `mac.sendMessages(request)` | `code-confirmed` | 只能确认规则层存在短信发送调用。 |
|
||||
| 电话 / 外呼触发 | 自动处理规则调用 `mac.callOutLogin(params)` | `code-confirmed` | 只能确认规则层存在外呼触发分支。 |
|
||||
| 自动派单请求调用 | 自动处理规则请求 `repairOrder/initProcess` | `code-confirmed` | 自动派单请求分支可直接定位。 |
|
||||
| `setDisposeLog` 成功写入 | 自动处理规则成功分支写 `state="成功"` | `code-confirmed` | 成功路径处置日志写入定义明确存在。 |
|
||||
| `setDisposeLog` 失败写入 | 自动处理规则失败分支写 `state="失败"` | `code-confirmed` | 失败路径处置日志写入定义明确存在。 |
|
||||
| `setDisposeLog` 异常写入 | 自动处理规则异常分支写 `state="异常"` | `code-confirmed` | 异常路径处置日志写入定义明确存在。 |
|
||||
| `setDisposeLog` 未匹配写入 | 自动处理规则未匹配分支写 `state="未匹配"` | `code-confirmed` | 未匹配路径处置日志写入定义明确存在。 |
|
||||
| `new_pending_ids` 严格产出 | 只在 skill/reference/data-quality 中被要求 | `implementation intent exists but not rigorous / buggy` | 有明确目标语义,但当前读到的规则资产未直接产出同名字段。 |
|
||||
| 把下游动作结果等同于采集成功 | skill/reference 明确禁止 | `contract-defined` | 契约要求把快照成功与副作用成功分离。 |
|
||||
|
||||
## 9. 当前代码疑点 / 不严谨点
|
||||
|
||||
1. 最明显的已知 bug 是业务监测规则中的待处理分类条件写成 `item.status == "00" && item.status == "01"`。这会导致 `pending` 分桶逻辑不可能按作者意图工作,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
2. packaged collector 与规则资产的输出命名仍不一致:collector 使用 `scene`、`pending_ids`、`new_pending_ids`,规则对象使用 `type`、`pendingList`,证据等级:`code-confirmed`。
|
||||
3. `SKILL.md` 把 `new_pending_ids` 作为输出重点,但当前直接证据更强的是“做日志比较并决定提醒/自动派单”,而不是“显式产出同名字段”,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
4. `scene.draft.json` 仍在犹豫 `trigger-alert` 是否拆成 audio-alert、message-alert、callout 三类动作,说明标准动作建模尚未完全收敛,证据等级:`no direct evidence / candidate only`。
|
||||
5. 虽然规则层存在音频、短信、外呼、自动派单与日志写入定义,但本文不能据此声称这些动作已完成运行时验证,任何这种拔高都不严谨。
|
||||
|
||||
## 10. 对 command-center 标准配置的修订建议
|
||||
|
||||
1. 对本场景应显式拆分两层实现证据:
|
||||
- `packaged_collector`: `collect_repair_orders.js` 的 runtime snapshot collector、状态判定、历史比较与 metadata(规则来源、配置基础页角色、已知问题),证据等级:`code-confirmed`;
|
||||
- `rule_asset_workflow`: 业务监测与自动处理规则资产中的真实流程分支,证据等级:`code-confirmed`。
|
||||
2. 在标准配置中把业务监测与自动处理拆成两个子流程:
|
||||
- `monitoring_flow` 对应 `95598抢修-市指_业务检测配置.txt`;
|
||||
- `auto_processing_flow` 对应 `95598抢修-市指_自动处理配置.txt`。
|
||||
这样可以避免把两份规则混成单一 collector。
|
||||
3. 输出 schema 建议区分:
|
||||
- `canonical_snapshot_fields`: `pending_ids` / `new_pending_ids` 等标准字段;
|
||||
- `observed_rule_fields`: `pendingList` / `type` 等规则层字段。
|
||||
当前两套命名并存,证据等级:`code-confirmed`。
|
||||
4. 对状态分类增加 `known_bug_note`,明确记录 `status == "00" && status == "01"` 的待处理分类 bug,防止后续文档误把 pending 计数写成稳定事实,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
5. 对下游动作增加 `effect_channels` 明细,至少拆出 `audio-reminder`、`sms-send`、`callout-trigger`、`auto-dispatch-request`、`dispose-log-write`,因为这些都已在规则资产中直接出现,证据等级:`code-confirmed`。
|
||||
|
||||
## 11. 最终严谨结论
|
||||
|
||||
关于 `95598-repair-city-dispatch`,当前最可靠的结论是:仓库已经同时存在一个可测试的 packaged JS runtime collector,以及两份更强的 desk 规则脚本实现(`D:\desk\智能体资料\大四区报告监测项\95598抢修-市指_业务检测配置.txt`、`D:\desk\智能体资料\大四区报告监测项\95598抢修-市指_自动处理配置.txt`),其中 packaged collector 已直接实现 repair-order 分类、monitor/dispose log 比较、`new_pending_ids` 推导与 `success/partial/empty/blocked` 状态判定;业务监测规则直接证实了队列采集、日志比较、音频提醒与监测日志写入,自动处理规则直接证实了去重、班组匹配、自动派单请求、短信发送、外呼触发以及 `setDisposeLog` 在成功 / 失败 / 异常 / 未匹配路径上的写入定义,证据等级:`code-confirmed`。
|
||||
|
||||
但同样必须严格说明:这些 `code-confirmed` 只证明“代码或规则层存在这些实现分支”,不证明运行时已验证成功。此外,desk 业务监测规则里还存在 `status == "00" && status == "01"` 的待处理分类 bug,因此 rule workflow 本身也不能被描述为严谨无误。对 command-center 而言,本场景最应该被建模为“packaged collector 已具备输入驱动快照归一化能力、desk rule-asset workflow 证据更强、且监测流与自动处理流必须分开表达”的 monitor scene。
|
||||
@@ -0,0 +1,155 @@
|
||||
# 95598-weekly-monitor-report 操作分析
|
||||
|
||||
## 1. 场景概述
|
||||
|
||||
`95598-weekly-monitor-report` 对应“95598、12398及配网设备监控情况周统计”场景,目标是汇总 95598、12398 与配网设备多来源周统计并生成统一周报。根据 `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\95598-weekly-monitor-report\scene.json`、`D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-weekly-monitor-report\SKILL.md` 与 `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-weekly-monitor-report\scripts\collect_weekly_metrics.js`,当前最硬直接证据是:脚本定义了六个 section template、空 artifact、`period`、`status: "ok"` 与 `partial_reasons: []`,证据等级:`code-confirmed`。
|
||||
|
||||
同时必须明确:当前 packaged script 对 artifact schema / section template 的定义,远强于对实时浏览器采集、多源周统计归并、双周期对齐或导出行为的证明。也就是说,本场景现在更接近“周报结构模板脚本”,而非“已被代码严格证明可跑通的 live browser collector”,证据等级:`code-confirmed`。
|
||||
|
||||
## 2. 证据来源
|
||||
|
||||
本分析统一只使用四个证据等级标签:`code-confirmed`、`contract-defined`、`implementation intent exists but not rigorous / buggy`、`no direct evidence / candidate only`。其中,脚本直接定义的 artifact schema / section template 归入 `code-confirmed`;未见脚本直接实现的双周期语义、采集逻辑与下游动作,不拔高于其对应较弱标签。
|
||||
|
||||
1. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-weekly-monitor-report\scripts\collect_weekly_metrics.js`
|
||||
- 直接定义六个 section template,并返回空 artifact,证据等级:`code-confirmed`。
|
||||
2. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-weekly-monitor-report\SKILL.md`
|
||||
- 描述应读取 current-period 与 cumulative-period、校验会话、收集多来源 source groups、归一 section 数据,并在输出中返回两个周期、included source groups、period alignment issues 等;这更像运行契约与实现方向,证据等级以 `contract-defined` 与 `implementation intent exists but not rigorous / buggy` 为主。
|
||||
3. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\95598-weekly-monitor-report\scene.json`
|
||||
- 定义场景输入 `period`、依赖 `browser` / `multi-source` / `period-alignment` / `local-report-service`,动作包括 `query` / `collect-report` / `aggregate-sections` / `align-periods`,证据等级:`code-confirmed`。
|
||||
4. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-weekly-monitor-report\references\collection-flow.md`
|
||||
- 明确入口页面提供两个日期范围:current-period 与 cumulative-period,并说明要先读两个范围,再收集 source groups、再按 section 归一,证据等级:`contract-defined`。
|
||||
5. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-weekly-monitor-report\references\data-quality.md`
|
||||
- 规定完整结果、partial 规则、常见弱点和 empty/failure 区分,证据等级:`contract-defined`。
|
||||
6. `D:\data\ideaSpace\rust\sgClaw\claw-new\docs\superpowers\specs\2026-04-08-command-center-virtual-employee-inventory.json`
|
||||
- 已将该场景整理为 `workflow`、`status_model`、`hidden_dependencies`、`open_questions` 等 command-center 视图;但其中部分是再抽象结果,不应反向拔高为实现证据,证据等级:`no direct evidence / candidate only`(仅限 inventory 不能单独证明 packaged script 已实现的部分)。
|
||||
|
||||
## 3. 实际入口与运行边界
|
||||
|
||||
实际入口已在 `scene.json` 固定:浏览器场景 `index.html`,技能工具名为 `95598-weekly-monitor-report.collect_weekly_metrics`,输出 artifact 为 `report-artifact`,这些都是 `code-confirmed`。
|
||||
|
||||
运行边界方面出现了本场景最明显的不严谨点:
|
||||
|
||||
- scene 与脚本都只保留一个 `period` 字段,证据等级:`code-confirmed`。
|
||||
- `SKILL.md`、`collection-flow.md` 与 inventory 整理结果都明确说明页面实际有 `current-period` 与 `cumulative-period` 两套输入,证据等级:`contract-defined`。
|
||||
- scene 还把 `period-alignment` 声明为依赖,并把 `align-periods` 声明为动作,但脚本没有任何相应执行逻辑,证据等级:`code-confirmed` 对元数据存在成立,而“已实现 period alignment”只能标为 `implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
因此,当前最严谨的边界判断是:上层元数据与参考文档都把本场景描述成“双周期、多来源、需周期对齐的 section 周报”,但 packaged script 实际只提供了一个空 artifact 模板壳,尚未证明 live collection 行为。
|
||||
|
||||
## 4. 代码已证实的实际操作流程
|
||||
|
||||
当前脚本中可直接证实的流程只有:
|
||||
|
||||
1. 调用 `collectWeeklyMetrics(input)`。
|
||||
2. 读取 `input.period || ""` 写入返回对象的 `period`。
|
||||
3. 构造空主表:`columns: []`、`rows: []`。
|
||||
4. 基于 `SECTION_TEMPLATES` 复制出 6 个 section,且每个 section 初始 `rows: []`。
|
||||
5. 返回 `type: "report-artifact"`、`report_name`、`status: "ok"` 与 `partial_reasons: []`。
|
||||
|
||||
这些都属于 `code-confirmed`。
|
||||
|
||||
至于“读取 current-period / cumulative-period 两个日期范围”“验证多系统会话”“按 source group 采集 95598 / 12398 / 配网设备指标”“执行 period alignment”“导出周报或写报告日志”等行为,只在 `SKILL.md` 与 reference 中被描述,没有在 packaged script 中以可执行逻辑出现,因此不能算“代码已证实的实际操作流程”。
|
||||
|
||||
## 5. 标准化抽象流程
|
||||
|
||||
若做 command-center 的标准化抽象,可将本场景整理为:
|
||||
|
||||
1. 接收周报任务请求。
|
||||
2. 解析 current-period 与 cumulative-period。
|
||||
3. 验证多系统访问与会话上下文。
|
||||
4. 按 source groups 收集周统计数据。
|
||||
5. 将结果归并到六个 section。
|
||||
6. 对 current-period 与 cumulative-period 做一致性校验或对齐。
|
||||
7. 生成 `report-artifact`。
|
||||
8. 视情况执行导出/日志等下游动作。
|
||||
|
||||
其中第 5 步“六个 section schema 已存在”以及第 7 步“返回 artifact 壳”是 `code-confirmed`。第 2、3、4、6、8 步主要来自 skill/reference/scene 的目标流程描述,证据等级为 `contract-defined`;若要说这些步骤已被 packaged script 落地,则只能降为 `implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 6. 输入、上下文与依赖
|
||||
|
||||
### 输入
|
||||
|
||||
- `period` 是 scene 与脚本共享的显式输入,证据等级:`code-confirmed`。
|
||||
- `currentPeriod` / `cumulativePeriod`(或 current-period / cumulative-period)是 `SKILL.md`、reference 与 inventory 隐含/显式要求的真实业务输入,证据等级:`contract-defined`。
|
||||
- 这意味着当前输入建模存在明显冲突:统一配置只暴露 `period`,但场景语义其实依赖双周期,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
### 运行上下文
|
||||
|
||||
- `session`、多源系统账号/缓存 token、浏览器可见页面、周期对齐上下文等在 scene/reference 中被描述,scene 元数据层面的声明是 `code-confirmed`,具体业务语义是 `contract-defined`。
|
||||
- “period-alignment-context” 被 inventory 当作 runtime_context 整理出来,可视为对 scene/reference 的再抽象;作为建议结构可以保留,但不宜拔高成脚本已实现能力。
|
||||
|
||||
### 依赖
|
||||
|
||||
- `browser`、`multi-source`、`period-alignment`、`local-report-service` 在 `scene.json` 中可直接定位,证据等级:`code-confirmed`。
|
||||
- `/a_js/YPTAPI.js`、`http://localhost:13313/ReportServices/*` 等具体依赖来自 reference,证据等级:`contract-defined`。
|
||||
|
||||
## 7. 输出结构
|
||||
|
||||
当前脚本直接证实的输出结构包括:
|
||||
|
||||
- `type: "report-artifact"`
|
||||
- `report_name: "95598-weekly-monitor-report"`
|
||||
- `period`
|
||||
- `columns: []`
|
||||
- `rows: []`
|
||||
- 6 个固定 section template
|
||||
- `status: "ok"`
|
||||
- `partial_reasons: []`
|
||||
|
||||
以上全部属于 `code-confirmed`。
|
||||
|
||||
六个已被脚本直接定义的 section 分别为:
|
||||
|
||||
1. `fault-repair`
|
||||
2. `frequent-outage`
|
||||
3. `full-aperture-work-orders`
|
||||
4. `key-opinion-control`
|
||||
5. `device-monitoring`
|
||||
6. `proactive-dispatch`
|
||||
|
||||
这些 section 中,前三个使用 `current_period`、`cumulative`、`year_over_year` 三类值列,后三个只使用 `value`,证据等级:`code-confirmed`。但这里也出现了建模歧义:
|
||||
|
||||
- 输出 artifact 顶层只保留一个 `period`。
|
||||
- section 内部却已经暗示了 `current_period` 与 `cumulative` 的双周期视角。
|
||||
- skill/reference 又在文字上强调 current-period 与 cumulative-period 两个输入。
|
||||
|
||||
因此,“双周期输入如何映射到 artifact 顶层 period 与 section 列结构”当前并不严谨,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 8. 下游动作证据表
|
||||
|
||||
| 下游动作 | 当前证据 | 证据等级 | 严谨结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 返回 section 化 `report-artifact` | `collect_weekly_metrics.js` 直接返回对象 | `code-confirmed` | 已有周报 artifact 模板壳,但仍为空数据。 |
|
||||
| 六类 section 模板存在 | 脚本直接定义 `SECTION_TEMPLATES` | `code-confirmed` | 只能确认输出分区 schema 存在,不能确认真实数据采集。 |
|
||||
| 双周期读取 | 只在 `SKILL.md` / `collection-flow.md` 中描述 | `contract-defined` | 契约明确需要 current-period 与 cumulative-period,但脚本未实现。 |
|
||||
| 多来源周统计采集 | 只在 skill/reference 中描述 | `contract-defined` | 有清晰目标流程,当前 packaged script 未直接证明。 |
|
||||
| period alignment | scene 动作/依赖 + skill/reference 说明 | `implementation intent exists but not rigorous / buggy` | 元数据和文档都表达了需要对齐,但脚本没有对齐逻辑,建模仍含糊。 |
|
||||
| 导出周报 | reference 提及 localhost report services | `contract-defined` | 只能确认存在下游服务约束,不能确认当前 skill 已执行导出。 |
|
||||
| 报告日志写入 | skill/reference 提及 report-log | `contract-defined` | 只有体系级概念证据,当前脚本无直接调用。 |
|
||||
| partial / blocked / empty 状态细分 | reference 有定义,脚本固定 `status: "ok"` | `implementation intent exists but not rigorous / buggy` | 状态模型意图明确,但 packaged script 尚未承载。 |
|
||||
|
||||
## 9. 当前代码疑点 / 不严谨点
|
||||
|
||||
1. `period` 与 `currentPeriod/cumulativePeriod` 的建模冲突最突出。scene 与 script 顶层只保留 `period`,但 skill/reference 明确要求双周期输入,前三个 section 的列结构也隐含双周期,这说明现有标准输入设计不严谨,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
2. `period-alignment` 既被声明为依赖,也被列为动作 `align-periods`,但脚本没有任何对齐实现;因此“周期对齐能力已实现”不能成立,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
3. 前三个 section 使用 `cumulative` 列名,而 skill/output 描述用的是 `cumulative period`;列名、输入名、顶层字段名之间没有形成统一建模,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
4. `status` 固定为 `"ok"`,与 reference 对 partial / empty / blocked 的细分要求不一致,证据等级:`code-confirmed` 对现状成立。
|
||||
5. 尽管 scene/skill 明确是多来源周统计,但脚本完全没有 source group 采集或映射逻辑,因此“周统计 collector 已落地”不能提升为当前代码事实,证据等级:`no direct evidence / candidate only`(对 live collection 执行层而言)。
|
||||
|
||||
## 10. 对 command-center 标准配置的修订建议
|
||||
|
||||
1. 本场景应把标准输入从单一 `period` 修订为显式双周期结构,例如 `currentPeriod` 与 `cumulativePeriod`。若仍需要统一路由入口,可额外保留上层 `period` 摘要,但不能替代执行层双周期字段,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
2. 对 `period-alignment` 建议在标准配置中拆成两部分:
|
||||
- `period_model`: 双周期输入结构;
|
||||
- `alignment_rule`: 这两组周期如何校验一致性。
|
||||
当前 scene 只表达了需要对齐,但未给出严格模型,因此这是必要修订。
|
||||
3. 在 artifact 配置中区分:
|
||||
- `implemented_section_templates`: 当前六个 section 已被脚本直接实现,证据等级:`code-confirmed`;
|
||||
- `implemented_collection_logic`: 当前未被 packaged script 直接证明,需显式标低。
|
||||
4. 对前三个 section 的列名建议统一成更一致的配置命名,如 `current_period` / `cumulative_period` / `year_over_year`,避免脚本列名、skill 文本、标准配置三套口径混用。
|
||||
5. 状态模型建议拆成“契约层”和“实现层”,防止 command-center 把 `partial` / `blocked` 误当成当前 collector 已具备的稳定判定能力。
|
||||
|
||||
## 11. 最终严谨结论
|
||||
|
||||
关于 `95598-weekly-monitor-report`,当前最可靠的结论是:仓库已经存在一个六分区周报 artifact 模板实现,明确给出了 section 名称、列 schema、顶层 `period` 字段以及基础状态字段,证据等级:`code-confirmed`。
|
||||
|
||||
但当前证据并不足以把它描述成“已严格实现双周期、多来源、含 period alignment 的真实浏览器周统计 collector”。相关双周期读取、source group 采集、period alignment、导出与日志行为,主要存在于 `SKILL.md`、`collection-flow.md`、`data-quality.md` 与 scene 元数据的目标描述中。尤其是 `period` vs `currentPeriod/cumulativePeriod` 以及 `period-alignment` 的建模仍明显含糊,说明本场景现在最适合被归类为“section schema 已定义,但 live browser collection 行为尚未被脚本严格证实”的 staged report scene。
|
||||
@@ -0,0 +1,203 @@
|
||||
# 指挥中心规格文档证据分级规则
|
||||
|
||||
## 目的
|
||||
|
||||
这份文档用于统一指挥中心相关规格文档中的证据表达方式,明确区分:
|
||||
|
||||
- 已被代码或规则资产直接证实的事实
|
||||
- 已被外部接口或文档契约明确约束的事实
|
||||
- 代码中表达了实现方向,但实现质量、完整性或正确性仍不充分的内容
|
||||
- 当前没有直接证据、只能作为候选判断的内容
|
||||
|
||||
目标不是让规格文档写得更保守,而是让“观察到的事实”“归纳后的结构”“目标态设计”之间的边界始终可追溯、可复核、可讨论。
|
||||
|
||||
## 为什么必须分级
|
||||
|
||||
如果不做证据分级,指挥中心文档很容易把三类内容混写在一起:
|
||||
|
||||
1. 代码里已经存在并可直接定位的行为
|
||||
2. 为了便于抽象而做出的归一化整理
|
||||
3. 未来希望达成、但当前未被运行时或资产严格证明的目标结构
|
||||
|
||||
混写的直接问题是:
|
||||
|
||||
- 读者会把“推断出的整理结果”误认为“当前已实现事实”
|
||||
- 后续实现或重构时,无法判断某一条到底是在复述现状,还是在提出目标
|
||||
- 多份规格文档之间会出现证据强弱不一致、措辞口径不一致的问题
|
||||
|
||||
因此,所有指挥中心规格文档都必须对关键判断显式标注证据等级。
|
||||
|
||||
## 证据标签
|
||||
|
||||
以下 4 个标签为唯一允许使用的标准标签,必须按原文书写,不得改写,不得替换为同义词。
|
||||
|
||||
### 1. `code-confirmed`
|
||||
|
||||
定义:该结论可由当前仓库中的代码、规则资产、静态配置或可直接定位的实现内容直接支持。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 某个字段、流程步骤、状态分类、规则动作在代码或规则资产中可直接定位
|
||||
- 某个输出结构、配置项、动作通道已被实现内容明确写出
|
||||
- 某条成功路径虽然未证明线上真实跑通,但“存在该逻辑分支”这一事实已被代码直接证实
|
||||
|
||||
使用边界:
|
||||
|
||||
- `code-confirmed` 只证明“代码/资产中存在该实现或定义”
|
||||
- 不自动等于“生产可用”“运行时已验证成功”“端到端已闭环”
|
||||
|
||||
### 2. `contract-defined`
|
||||
|
||||
定义:该结论不是直接来自仓库实现,而是由当前被认可的接口契约、协议文档、外部约束文档明确规定。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 浏览器侧/服务侧接口字段、消息格式、状态码语义由契约文档定义
|
||||
- 某一能力边界来自明确的外部 API 文档或经项目认可的集成约束
|
||||
|
||||
使用边界:
|
||||
|
||||
- `contract-defined` 证明“契约如此定义”
|
||||
- 不自动等于“本仓库已实现”
|
||||
- 如果代码实现与契约不一致,应分别描述,不得互相覆盖
|
||||
|
||||
### 3. `implementation intent exists but not rigorous / buggy`
|
||||
|
||||
定义:代码中已经出现实现意图、雏形或局部链路,但当前证据不足以把它写成稳定事实;或者已知实现不严谨、存在缺口、疑似有 bug、成功语义未被严格证明。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 能看到相关函数、分支、调用点、配置项或动作名,但缺少足够证据证明其稳定成立
|
||||
- 逻辑存在,但状态语义混乱、异常处理不足、前后约束不完整
|
||||
- 只能证明“作者想做这件事”,不能证明“这件事已经被可靠实现”
|
||||
|
||||
使用边界:
|
||||
|
||||
- 该标签用于承认“实现方向存在”
|
||||
- 同时明确指出“不能把它提升为已确认事实”
|
||||
- 这是指挥中心文档中承接“代码里有影子,但证据不够硬”的唯一合法标签
|
||||
|
||||
### 4. `no direct evidence / candidate only`
|
||||
|
||||
定义:当前没有找到代码、规则资产、契约文档或其他直接证据;该内容只能作为候选结构、候选能力、候选拆分或待确认项。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 为了统一配置结构而提出的候选字段
|
||||
- 为了后续架构演进而提出的候选能力名称
|
||||
- 仅由推测、命名习惯、经验归纳得到的判断
|
||||
|
||||
使用边界:
|
||||
|
||||
- 该标签明确表示“目前只是候选,不是事实”
|
||||
- 不能把它写成“已有但待接入”“已支持但未启用”之类更强说法,除非另有直接证据
|
||||
|
||||
## 推荐表述模板
|
||||
|
||||
### `code-confirmed`
|
||||
|
||||
可用表述:
|
||||
|
||||
- “根据当前代码/规则资产,可直接确认……,证据等级:`code-confirmed`。”
|
||||
- “文档中的……来自现有实现直接证据,证据等级:`code-confirmed`。”
|
||||
- “这里只能确认代码层存在该成功路径/动作定义,证据等级:`code-confirmed`;不代表运行时已验证。”
|
||||
|
||||
### `contract-defined`
|
||||
|
||||
可用表述:
|
||||
|
||||
- “根据当前接口契约,……被定义为……,证据等级:`contract-defined`。”
|
||||
- “该字段/消息结构来自认可的集成契约,证据等级:`contract-defined`。”
|
||||
- “这里描述的是契约约束,不等于仓库内实现已完成,证据等级:`contract-defined`。”
|
||||
|
||||
### `implementation intent exists but not rigorous / buggy`
|
||||
|
||||
可用表述:
|
||||
|
||||
- “当前实现中可以看到……的意图,但证据尚不足以将其写成稳定事实,证据等级:`implementation intent exists but not rigorous / buggy`。”
|
||||
- “代码存在相关链路,但实现不够严谨/疑似有缺口,因此仅标为 `implementation intent exists but not rigorous / buggy`。”
|
||||
- “目前最多只能确认作者试图支持……,不能确认其已被可靠实现,证据等级:`implementation intent exists but not rigorous / buggy`。”
|
||||
|
||||
### `no direct evidence / candidate only`
|
||||
|
||||
可用表述:
|
||||
|
||||
- “……目前没有直接证据,只能作为候选项,证据等级:`no direct evidence / candidate only`。”
|
||||
- “该拆分/命名属于归一化建议,不代表现状事实,证据等级:`no direct evidence / candidate only`。”
|
||||
- “除非后续补到代码或契约证据,否则这里只能保持为 `no direct evidence / candidate only`。”
|
||||
|
||||
## 禁止表述模式
|
||||
|
||||
以下表述在指挥中心规格文档中禁止使用,除非同时给出更低证据等级并明确限定范围。
|
||||
|
||||
### 1. 禁止把代码存在误写为运行时已验证
|
||||
|
||||
禁止示例:
|
||||
|
||||
- “系统已经稳定支持……”
|
||||
- “该链路已完成闭环……”
|
||||
- “运行时已证明可以成功……”
|
||||
|
||||
问题:这些表述把“代码里有逻辑”错误提升成“真实运行已被验证”。
|
||||
|
||||
### 2. 禁止把推断结构误写为既有事实
|
||||
|
||||
禁止示例:
|
||||
|
||||
- “当前配置结构就是……”
|
||||
- “系统已有统一能力模型……”
|
||||
- “所有任务已经按该 schema 实现……”
|
||||
|
||||
问题:如果只是为了整理而归纳出的标准结构,应标为候选或目标态,不能写成现状。
|
||||
|
||||
### 3. 禁止使用模糊强化词替代证据标签
|
||||
|
||||
禁止示例:
|
||||
|
||||
- “基本可以认为……”
|
||||
- “大概率就是……”
|
||||
- “看起来已经支持……”
|
||||
- “应该算是实现了……”
|
||||
|
||||
问题:模糊判断会绕开证据分级,导致读者无法判断结论强度。
|
||||
|
||||
### 4. 禁止自造同义标签或混用近义词
|
||||
|
||||
禁止示例:
|
||||
|
||||
- “代码已确认”
|
||||
- “契约已定义”
|
||||
- “半实现”
|
||||
- “待验证”
|
||||
- “候选”
|
||||
|
||||
问题:这些中文近义词会破坏跨文档一致性。必须使用本文规定的 4 个精确标签原文。
|
||||
|
||||
## 示例:`95598-repair-city-dispatch`
|
||||
|
||||
示例结论:
|
||||
|
||||
- 对 `95598-repair-city-dispatch` 而言,音频提醒、短信/消息提醒、外呼、处置日志等成功路径行为,如果能够在规则资产或实现内容中直接定位,应写为 `code-confirmed`。
|
||||
- 但这只能说明“代码或规则里存在这些成功路径定义”。
|
||||
- 不能据此直接写成“运行时已经稳定成功触发音频/短信/外呼/处置日志”。
|
||||
- 如果当前没有端到端运行验证证据,那么“运行时成功”只能写为 `implementation intent exists but not rigorous / buggy`,或者在证据更弱时写为 `no direct evidence / candidate only`;不能提升为 `code-confirmed`。
|
||||
|
||||
推荐写法:
|
||||
|
||||
“在 `95598-repair-city-dispatch` 中,音频提醒、短信/消息提醒、外呼、处置日志相关成功路径可在规则资产中直接定位,因此这些‘规则层已定义的成功路径行为’可标注为 `code-confirmed`。但目前没有同等强度证据证明这些动作在真实运行时已稳定成功,因此‘运行时成功已验证’这一结论不能标为 `code-confirmed`;在缺少严格运行证据时,应标为 `implementation intent exists but not rigorous / buggy`。”
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 所有指挥中心相关规格文档,必须使用本文定义的 4 个精确标签。
|
||||
- 不允许使用任何同义词、中文替代词、缩写或自定义等级名。
|
||||
- 一条关键结论如果没有证据等级,就视为表达不合格。
|
||||
- 当同一主题同时涉及“代码事实”和“目标结构”时,必须拆句分别标注,不能合并成一个模糊结论。
|
||||
|
||||
## 最短落地准则
|
||||
|
||||
写每一条关键判断前,先问两个问题:
|
||||
|
||||
1. 我是在复述当前已被直接证据支持的事实,还是在做归一化整理/目标设计?
|
||||
2. 我手上的证据,到底支撑的是代码存在、契约约束、实现意图,还是根本没有直接证据?
|
||||
|
||||
只有先回答这两个问题,指挥中心规格文档才能保持严格、可复核和可持续重写。
|
||||
@@ -0,0 +1,639 @@
|
||||
# 指挥中心虚拟员工标准配置清单建议结构
|
||||
|
||||
> 免责声明:本文件描述的是“未来可采用的规范化目标配置结构”,不是当前 staged runtime 已稳定实现的结构,也不是对现状的直接复述。文中所有“目标 schema 字段”都必须与当前证据分级文档一起阅读;凡缺乏静态资产直接支撑的字段,只能视为 normalization choice 或 open / candidate 字段,不能表述为当前已稳定存在。
|
||||
|
||||
## 目标
|
||||
|
||||
这份结构文档的用途,是把当前 evidence-graded 现状文档中的信息,逐步映射为后续可维护、可扩展、可复用的目标配置清单。
|
||||
|
||||
因此必须同时保持两条边界:
|
||||
|
||||
1. 当前已观察到的事实,来自 evidence-graded current-state docs。
|
||||
2. 这里提出的统一 schema,则是为后续 command-center 配置治理而做的 normalization proposal。
|
||||
|
||||
它们不能混写,更不能把 normalization proposal 误写成当前实现事实。
|
||||
|
||||
---
|
||||
|
||||
## 一、当前证据文档与目标配置的关系
|
||||
|
||||
当前已经存在三类文档角色:
|
||||
|
||||
1. `2026-04-08-command-center-virtual-employee-inventory-table.md`
|
||||
- 作用:给人读的 current-state 总览
|
||||
- 性质:当前观察结果,不是配置 schema
|
||||
2. `2026-04-08-command-center-virtual-employee-inventory.json`
|
||||
- 作用:给机器读的 current-state inventory
|
||||
- 性质:机器可消费的盘点结果,不是目标配置
|
||||
3. 各 scene 的 `*-operation-analysis.md`
|
||||
- 作用:记录每个场景的证据来源、强弱、已知问题和边界
|
||||
- 性质:最关键的证据支撑层
|
||||
|
||||
本文件提出的目标配置结构,是在这些 current-state 文档之上的“规范化目标层”。
|
||||
|
||||
### 映射原则
|
||||
|
||||
- operation-analysis 文档中的 `code-confirmed` 结论,可优先映射为目标 schema 中的“evidence-derived fields”。
|
||||
- `contract-defined` 结论,可映射为“declared / contract-backed fields”,但不能默认等于当前 runtime 已实现。
|
||||
- `implementation intent exists but not rigorous / buggy` 的内容,应进入目标 schema 的 `known_issues`、`implementation_gap`、`notes` 或 `open_questions`,而不是被包装成稳定主字段。
|
||||
- `no direct evidence / candidate only` 的内容,只能作为 normalization choice、candidate field 或未来扩展项保留。
|
||||
|
||||
简言之:evidence-graded current-state docs 告诉我们“现在能严谨说什么”,本文件只负责说明“未来若要统一配置,可怎样承接这些信息”。
|
||||
|
||||
---
|
||||
|
||||
## 二、推荐文件组织
|
||||
|
||||
```text
|
||||
command-center/
|
||||
employee.json
|
||||
capabilities.json
|
||||
tasks/
|
||||
fault-details-report.json
|
||||
jinchang-business-environment-weekly-report.json
|
||||
95598-weekly-monitor-report.json
|
||||
95598-repair-city-dispatch.json
|
||||
jiayuguan-meter-outage.json
|
||||
```
|
||||
|
||||
### 文件职责
|
||||
|
||||
- `employee.json`
|
||||
- 描述这个虚拟员工是谁、职责范围是什么、默认采用什么证据口径
|
||||
- `capabilities.json`
|
||||
- 维护归一化能力词表
|
||||
- 明确哪些能力来自现有证据,哪些只是规范化命名
|
||||
- `tasks/*.json`
|
||||
- 每个场景一份目标配置
|
||||
- 承接当前证据与未来标准字段的映射关系
|
||||
|
||||
### 为什么仍然推荐三层拆分
|
||||
|
||||
这类拆分仍然成立,但要加一条限定:
|
||||
|
||||
- 这是一种 target architecture proposal
|
||||
- 不是当前仓库已存在的稳定目录结构
|
||||
- 尤其 `capabilities.json` 代表“统一能力词表”的目标态,而不是当前 staged assets 已实现的统一能力注册表
|
||||
|
||||
因此,三层拆分本身属于 normalization choice,证据等级不应高于 `no direct evidence / candidate only`,除非未来真的落地成文件结构。
|
||||
|
||||
---
|
||||
|
||||
## 三、`employee.json` 目标结构
|
||||
|
||||
### 3.1 推荐示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "command-center-virtual-employee",
|
||||
"name": "指挥中心虚拟员工",
|
||||
"domain": "电力业务指挥中心",
|
||||
"positioning": "负责业务监测、统计报表、异常识别与后续提醒/处置支撑的虚拟运营员工",
|
||||
"mission": [
|
||||
"采集业务数据并生成结构化报表",
|
||||
"监测工单/事件并识别待处理对象",
|
||||
"比较历史记录识别新增待办",
|
||||
"为提醒、外呼、自动派单、自动处理等下游动作提供输入"
|
||||
],
|
||||
"task_ids": [
|
||||
"fault-details-report",
|
||||
"jinchang-business-environment-weekly-report",
|
||||
"95598-weekly-monitor-report",
|
||||
"95598-repair-city-dispatch",
|
||||
"jiayuguan-meter-outage"
|
||||
],
|
||||
"default_evidence_model": [
|
||||
"code-confirmed",
|
||||
"contract-defined",
|
||||
"implementation intent exists but not rigorous / buggy",
|
||||
"no direct evidence / candidate only"
|
||||
],
|
||||
"default_status_model": [
|
||||
"success",
|
||||
"partial",
|
||||
"empty",
|
||||
"blocked"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 字段分层说明
|
||||
|
||||
#### A. 可直接由当前证据承接的字段
|
||||
|
||||
- `name`
|
||||
- `domain`
|
||||
- `task_ids`(前提是仅映射当前已盘点的 5 个 scene)
|
||||
- `default_evidence_model`
|
||||
|
||||
这些字段之所以较容易承接,是因为 current-state inventory 已经稳定整理出对应对象和场景清单。
|
||||
|
||||
但仍要注意:这只是“可从当前文档整理得到”,不是说仓库里已经存在一个运行中的 `employee.json`。
|
||||
|
||||
#### B. normalization choices
|
||||
|
||||
- `id`
|
||||
- `positioning`
|
||||
- `mission`
|
||||
- `default_status_model`
|
||||
|
||||
这些字段主要是为了让目标配置更易治理、更可复用,属于规范化整理,不应表述为 staged runtime 现状。
|
||||
|
||||
#### C. open / candidate 字段
|
||||
|
||||
建议预留但暂不稳定化:
|
||||
|
||||
- `default_runtime_requirements`
|
||||
- `default_result_types`
|
||||
- `default_downstream_policy`
|
||||
- `org_scope`
|
||||
- `region_scope`
|
||||
|
||||
原因是:当前不同 scene 在“上下文依赖、输出类型、地区语义、下游策略”上并不一致,过早把这些做成员工级稳定字段会拔高现状。
|
||||
|
||||
---
|
||||
|
||||
## 四、`capabilities.json` 目标结构
|
||||
|
||||
### 4.1 推荐示例
|
||||
|
||||
```json
|
||||
{
|
||||
"catalog_version": 1,
|
||||
"evidence_method": "evidence-graded",
|
||||
"core": [
|
||||
{
|
||||
"id": "browser-collection",
|
||||
"name": "浏览器采集",
|
||||
"kind": "normalized-capability",
|
||||
"evidence_basis": "derived-from-multiple-scenes"
|
||||
},
|
||||
{
|
||||
"id": "report-generation",
|
||||
"name": "报表生成",
|
||||
"kind": "normalized-capability",
|
||||
"evidence_basis": "derived-from-report-scenes"
|
||||
},
|
||||
{
|
||||
"id": "monitor-snapshot",
|
||||
"name": "监测快照",
|
||||
"kind": "normalized-capability",
|
||||
"evidence_basis": "derived-from-monitor-scenes"
|
||||
}
|
||||
],
|
||||
"channels": [
|
||||
{
|
||||
"id": "audio-remind",
|
||||
"name": "音频提醒",
|
||||
"kind": "normalized-channel",
|
||||
"observed_in": [
|
||||
"95598-repair-city-dispatch",
|
||||
"jiayuguan-meter-outage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "message-remind",
|
||||
"name": "消息提醒",
|
||||
"kind": "normalized-channel",
|
||||
"observed_in": [
|
||||
"95598-repair-city-dispatch"
|
||||
],
|
||||
"notes": "在 jiayuguan-meter-outage 中只看到保留意图,不应等同视为稳定现状。"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "auto-dispatch",
|
||||
"name": "自动派单",
|
||||
"kind": "normalized-action"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 字段分层说明
|
||||
|
||||
#### A. 可由当前证据承接的字段
|
||||
|
||||
- `observed_in`
|
||||
- `notes`
|
||||
- `evidence_basis`
|
||||
|
||||
如果后续真的落地 `capabilities.json`,最应该优先保留的不是“能力名本身”,而是能力和 scene 之间的 evidence mapping。因为当前场景的能力证据强弱明显不同:
|
||||
|
||||
- 3 个报表 scene 多为 schema/template stub
|
||||
- 2 个监测 scene 更强 workflow 主要来自规则资产
|
||||
- `message-remind`、`callout`、`auto-dispatch` 等通道在不同 scene 中强度不一致
|
||||
|
||||
#### B. normalization choices
|
||||
|
||||
- `core`
|
||||
- `channels`
|
||||
- `actions`
|
||||
- `id`
|
||||
- `name`
|
||||
- `kind`
|
||||
|
||||
这些统一词表字段本身就是规范化选择。当前没有直接证据表明仓库中已经存在统一 capability registry。
|
||||
|
||||
#### C. open / candidate 字段
|
||||
|
||||
建议保持候选态:
|
||||
|
||||
- `required_contexts`
|
||||
- `result_semantics`
|
||||
- `stability_level`
|
||||
- `implemented_by`
|
||||
- `runtime_owner`
|
||||
|
||||
这些字段看起来很有用,但 staged assets 还不足以稳定支撑它们。
|
||||
|
||||
### 4.3 对能力词表的关键限制
|
||||
|
||||
- 不要把 `report-export`、`audio-remind`、`callout` 之类词条本身写成“已全局统一支持”。
|
||||
- 不要因为某个规则资产里出现了调用,就把它提升为所有 scene 的稳定 capability。
|
||||
- `email` 目前仍应保持 candidate,不应进入“已支持通道”集合。
|
||||
|
||||
---
|
||||
|
||||
## 五、`tasks/*.json` 目标结构
|
||||
|
||||
### 5.1 统一推荐骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "95598-repair-city-dispatch",
|
||||
"name": "95598抢修-市指",
|
||||
"category": "monitor",
|
||||
"current_state": {
|
||||
"primary_evidence_summary": "rule assets stronger than packaged JS stub",
|
||||
"source_refs": [],
|
||||
"known_issues": []
|
||||
},
|
||||
"binding": {
|
||||
"scene_id": "95598-repair-city-dispatch",
|
||||
"skill_package": "95598-repair-city-dispatch",
|
||||
"tool": "collect_repair_orders"
|
||||
},
|
||||
"trigger": {
|
||||
"observed": {},
|
||||
"normalized": {},
|
||||
"open_questions": []
|
||||
},
|
||||
"inputs": {
|
||||
"observed": {},
|
||||
"normalized": {},
|
||||
"open_questions": []
|
||||
},
|
||||
"systems": {
|
||||
"observed": {},
|
||||
"normalized": {},
|
||||
"open_questions": []
|
||||
},
|
||||
"workflow": {
|
||||
"observed": [],
|
||||
"normalized": [],
|
||||
"open_questions": []
|
||||
},
|
||||
"result": {
|
||||
"observed": {},
|
||||
"normalized": {},
|
||||
"open_questions": []
|
||||
},
|
||||
"downstream_effects": {
|
||||
"observed": [],
|
||||
"normalized": [],
|
||||
"open_questions": []
|
||||
},
|
||||
"required_capabilities": {
|
||||
"normalized": [],
|
||||
"open_questions": []
|
||||
},
|
||||
"status_model": {
|
||||
"declared": {},
|
||||
"implemented_notes": []
|
||||
},
|
||||
"evidence_grades": {},
|
||||
"open_questions": []
|
||||
}
|
||||
```
|
||||
|
||||
这个骨架的核心目标不是“把所有字段都填满”,而是强制区分:
|
||||
|
||||
- `observed`
|
||||
- `normalized`
|
||||
- `open_questions`
|
||||
|
||||
这样可避免把 future-facing target config 误写成 current-state。
|
||||
|
||||
---
|
||||
|
||||
## 六、报表类任务在目标 schema 中应如何表达
|
||||
|
||||
适用对象:
|
||||
|
||||
- `fault-details-report`
|
||||
- `jinchang-business-environment-weekly-report`
|
||||
- `95598-weekly-monitor-report`
|
||||
|
||||
### 6.1 当前证据对目标 schema 的约束
|
||||
|
||||
这 3 个任务当前最强直接证据主要是:
|
||||
|
||||
- 已有 `report-artifact` 结构壳
|
||||
- 已有 section/template 定义
|
||||
- 已有 `status` / `partial_reasons` 字段壳
|
||||
|
||||
但它们共同缺少同等强度的 live collection 证据。因此若采用该目标 schema,建议保留一个明确的 current-state 提示,例如:
|
||||
|
||||
```json
|
||||
"current_state": {
|
||||
"primary_evidence_summary": "packaged script mainly confirms artifact schema / section template; live collection remains contract-defined or weaker"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 报表类字段分层
|
||||
|
||||
#### A. evidence-derived fields
|
||||
|
||||
- `binding.scene_id`
|
||||
- `binding.skill_package`
|
||||
- `binding.tool`
|
||||
- `result.observed.artifact_type`
|
||||
- `result.observed.key_fields`
|
||||
- `systems.observed.browser_pages`
|
||||
- `source_refs`
|
||||
|
||||
#### B. normalization choices
|
||||
|
||||
- `trigger.normalized.natural_language_examples`
|
||||
- `inputs.normalized.runtime_context`
|
||||
- `workflow.normalized`
|
||||
- `required_capabilities.normalized`
|
||||
- `downstream_effects.normalized`
|
||||
|
||||
#### C. open / candidate fields
|
||||
|
||||
- `period_model`
|
||||
- `section_semantics`
|
||||
- `region_scope`
|
||||
- `alignment_rule`
|
||||
- `report_export_policy`
|
||||
|
||||
### 6.3 各报表任务的特别约束
|
||||
|
||||
#### `fault-details-report`
|
||||
|
||||
- 若采用该目标 schema,建议对外保留 `period`,但执行层最好允许展开为 `startTime/endTime`。
|
||||
- `summary-sheet` 建议标记为“template confirmed”,不要误写成“summary derivation implemented”。
|
||||
|
||||
#### `jinchang-business-environment-weekly-report`
|
||||
|
||||
- 若采用该目标 schema,建议把“4 个固定 section 模板已观察到”与“真实多源采集已实现”分开表达。
|
||||
- `region` 是否成为稳定字段,目前仍是 open item。
|
||||
|
||||
#### `95598-weekly-monitor-report`
|
||||
|
||||
- 若采用该目标 schema,建议预留 `currentPeriod` 与 `cumulativePeriod`,但必须注明这属于对当前建模冲突的修正提案。
|
||||
- `period alignment` 建议单列为 schema group 或 `alignment_rule`,而不是默认已经在 runtime 中稳定存在。
|
||||
|
||||
---
|
||||
|
||||
## 七、监测类任务在目标 schema 中应如何表达
|
||||
|
||||
适用对象:
|
||||
|
||||
- `95598-repair-city-dispatch`
|
||||
- `jiayuguan-meter-outage`
|
||||
|
||||
### 7.1 当前证据对目标 schema 的约束
|
||||
|
||||
这两个任务与报表类不同:
|
||||
|
||||
- packaged JS collector 已具备输入驱动的 `monitor-snapshot` 归一化 / 比较逻辑,并会附带规则来源、配置基础页角色、已知问题/身份模型说明
|
||||
- 更强 workflow 证据主要来自规则资产(当前按盘点口径以 `D:/desk/智能体资料/大四区报告监测项/*.txt` 规则脚本为主)
|
||||
- `assets/scene-snapshot/index.html` 仅属于配置基础层,不应计入 workflow 主执行证据
|
||||
|
||||
因此若采用该目标 schema,建议显式区分:
|
||||
|
||||
```json
|
||||
"current_state": {
|
||||
"packaged_stub_strength": "code-confirmed",
|
||||
"rule_asset_workflow_strength": "code-confirmed",
|
||||
"notes": "workflow evidence is stronger in rule assets than in packaged JS stub"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 监测类字段分层
|
||||
|
||||
#### A. evidence-derived fields
|
||||
|
||||
- `binding.*`
|
||||
- `inputs.observed.explicit`
|
||||
- `systems.observed.upstream_apis`
|
||||
- `systems.observed.local_services`
|
||||
- `workflow.observed`
|
||||
- `result.observed`
|
||||
- `downstream_effects.observed`
|
||||
- `current_state.known_issues`
|
||||
|
||||
#### B. normalization choices
|
||||
|
||||
- `workflow.normalized`
|
||||
- `required_capabilities.normalized`
|
||||
- `canonical_snapshot_fields`
|
||||
- `effect_channels`
|
||||
|
||||
#### C. open / candidate fields
|
||||
|
||||
- `identity_model`
|
||||
- `downstream_policy`
|
||||
- `alert_channel_split`
|
||||
- `auto_processing_policy`
|
||||
- `dependency_promotion_rules`
|
||||
|
||||
### 7.3 各监测任务的特别约束
|
||||
|
||||
#### `95598-repair-city-dispatch`
|
||||
|
||||
若采用该目标 schema,建议保留以下说明:
|
||||
|
||||
- workflow 强证据主要来自规则资产(当前盘点以 `D:/desk/智能体资料/大四区报告监测项/95598抢修-市指_业务检测配置.txt` 与 `D:/desk/智能体资料/大四区报告监测项/95598抢修-市指_自动处理配置.txt` 为主),而不是 packaged JS stub
|
||||
- `pending` 分类存在 `status == "00" && status == "01"` bug
|
||||
- `pending_ids/new_pending_ids` 更像 canonical target fields,而不是当前规则层已严格同名产出字段
|
||||
|
||||
建议把这个 bug 直接纳入:
|
||||
|
||||
```json
|
||||
"current_state": {
|
||||
"known_issues": [
|
||||
"pending classification bug: status == \"00\" && status == \"01\""
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `jiayuguan-meter-outage`
|
||||
|
||||
若采用该目标 schema,建议保留以下说明:
|
||||
|
||||
- workflow 强证据主要来自规则资产(当前盘点以 `D:/desk/智能体资料/大四区报告监测项/户表失电-嘉峪关_业务监测配置.txt` 与 `D:/desk/智能体资料/大四区报告监测项/户表失电-嘉峪关_自动处理配置.txt` 为主),而不是 packaged JS stub
|
||||
- marketing token 是自动处理链路的强依赖
|
||||
- monitor pending list 用 `consNo`,dispose dedupe 用 `eventId`,身份模型不一致
|
||||
|
||||
因此在该目标 schema 提案中,建议单列:
|
||||
|
||||
```json
|
||||
"identity_model": {
|
||||
"monitor_pending_identity": "consNo",
|
||||
"dispose_dedupe_identity": "eventId",
|
||||
"status": "implementation intent exists but not rigorous / buggy"
|
||||
}
|
||||
```
|
||||
|
||||
这类字段不应被伪装成“已经统一好的 snapshot identity model”。
|
||||
|
||||
---
|
||||
|
||||
## 八、推荐统一字段清单与证据边界
|
||||
|
||||
下面给出一个更严格的统一字段视图。
|
||||
|
||||
### 1. 元数据层
|
||||
|
||||
较适合作为稳定 target schema 的字段:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `category`
|
||||
- `binding.scene_id`
|
||||
- `binding.skill_package`
|
||||
- `binding.tool`
|
||||
|
||||
其中:
|
||||
|
||||
- `binding.*` 更偏 evidence-derived
|
||||
- `id/name/category` 更偏 normalization choice
|
||||
|
||||
### 2. 现状映射层
|
||||
|
||||
建议新增并长期保留:
|
||||
|
||||
- `current_state.primary_evidence_summary`
|
||||
- `current_state.source_refs`
|
||||
- `current_state.known_issues`
|
||||
- `current_state.notes`
|
||||
|
||||
这是本次重写后最重要的新增设计点之一。没有这层,target schema 很容易再次把“目标结构”和“现状证据”混在一起。
|
||||
|
||||
### 3. 触发层
|
||||
|
||||
- `trigger.observed`
|
||||
- `trigger.normalized`
|
||||
- `trigger.open_questions`
|
||||
|
||||
### 4. 输入层
|
||||
|
||||
- `inputs.observed`
|
||||
- `inputs.normalized`
|
||||
- `inputs.open_questions`
|
||||
|
||||
### 5. 系统层
|
||||
|
||||
- `systems.observed`
|
||||
- `systems.normalized`
|
||||
- `systems.open_questions`
|
||||
|
||||
### 6. 流程层
|
||||
|
||||
- `workflow.observed`
|
||||
- `workflow.normalized`
|
||||
- `workflow.open_questions`
|
||||
|
||||
### 7. 结果层
|
||||
|
||||
- `result.observed`
|
||||
- `result.normalized`
|
||||
- `result.open_questions`
|
||||
|
||||
### 8. 下游动作层
|
||||
|
||||
- `downstream_effects.observed`
|
||||
- `downstream_effects.normalized`
|
||||
- `downstream_effects.open_questions`
|
||||
|
||||
### 9. 能力层
|
||||
|
||||
- `required_capabilities.normalized`
|
||||
- `required_capabilities.open_questions`
|
||||
|
||||
### 10. 证据层
|
||||
|
||||
- `evidence_grades`
|
||||
- `source_refs`
|
||||
|
||||
### 11. 人工确认层
|
||||
|
||||
- `open_questions`
|
||||
- `known_issues`
|
||||
|
||||
---
|
||||
|
||||
## 九、为什么这次建议在 target schema 中显式保留“现状层”
|
||||
|
||||
旧版结构容易出现的问题是:
|
||||
|
||||
- 把 aggregate inventory 直接写成“标准配置已经长这样”
|
||||
- 把 `required_capabilities`、`downstream_effects` 这样的归一化字段误读成 runtime 现状
|
||||
- 把规则资产中的 workflow 直接等价成 packaged script 实现
|
||||
|
||||
因此这次建议最关键的修订不是多加几个字段,而是要求 target schema 同时携带:
|
||||
|
||||
1. `observed current state`
|
||||
2. `normalized target structure`
|
||||
3. `open / candidate items`
|
||||
|
||||
只有这样,后续继续扩展新 scene 时,文档才不会再次把三类内容混在一起。
|
||||
|
||||
---
|
||||
|
||||
## 十、建议的落地顺序
|
||||
|
||||
1. 先把 current-state inventory 保持为证据分级后的事实盘点。
|
||||
2. 再基于 inventory 生成目标态 `employee.json` / `capabilities.json` / `tasks/*.json` 草案。
|
||||
3. 落地草案时,强制为每个 major group 补齐:
|
||||
- `observed`
|
||||
- `normalized`
|
||||
- `open_questions`
|
||||
4. 先优先收敛已知关键不严谨点:
|
||||
- `fault-details-report` 的 `period` vs `startTime/endTime`
|
||||
- `95598-weekly-monitor-report` 的双周期 / period alignment
|
||||
- `95598-repair-city-dispatch` 的 pending classification bug
|
||||
- `jiayuguan-meter-outage` 的 `consNo` vs `eventId` 身份不一致
|
||||
5. 最后再考虑是否把能力词表与 target config 接入真实消费链路。
|
||||
|
||||
注意:在这些问题未收敛前,不应把目标配置字段写成“已经稳定”。
|
||||
|
||||
---
|
||||
|
||||
## 十一、推荐结论
|
||||
|
||||
如果目标是形成“指挥中心虚拟员工的标准配置清单”,那么未来仍然可以采用:
|
||||
|
||||
- `employee.json`
|
||||
- `capabilities.json`
|
||||
- `tasks/*.json`
|
||||
|
||||
这样的三层结构。
|
||||
|
||||
但和旧版不同的是,这套结构必须显式承认:
|
||||
|
||||
- 它是 target architecture proposal,不是现状复述
|
||||
- 每个 major schema group 都要区分 evidence-derived fields、normalization choices、open / candidate fields
|
||||
- evidence-graded current-state docs 才是现状依据
|
||||
- 报表类 3 个 scene 当前主要是 schema/template stub
|
||||
- `95598-repair-city-dispatch` 与 `jiayuguan-meter-outage` 的 workflow 强证据主要在规则资产
|
||||
- `95598-repair-city-dispatch` 存在 pending classification bug
|
||||
- `jiayuguan-meter-outage` 存在 `consNo` / `eventId` 身份不一致问题
|
||||
- 任何地方都不应宣称 runtime verification
|
||||
|
||||
只有在保持这些边界的前提下,这份“标准配置结构”才是严谨可持续的目标态提案,而不是再次把现状、推断和目标混写在一起。
|
||||
@@ -0,0 +1,121 @@
|
||||
# 指挥中心虚拟员工业务盘点清单(表格版)
|
||||
|
||||
> 说明:本文件是“当前状态总览”,不是目标配置 schema。自本次重写起,所有判断统一采用 `code-confirmed`、`contract-defined`、`implementation intent exists but not rigorous / buggy`、`no direct evidence / candidate only` 四级证据模型;结论仅基于已暂存/已落库资产的静态检查结果,不代表任何运行时验证。
|
||||
|
||||
## 盘点范围
|
||||
|
||||
本表覆盖当前已整理的 5 个 staged scene / skill:
|
||||
|
||||
- `fault-details-report`
|
||||
- `jinchang-business-environment-weekly-report`
|
||||
- `95598-weekly-monitor-report`
|
||||
- `95598-repair-city-dispatch`
|
||||
- `jiayuguan-meter-outage`
|
||||
|
||||
## 虚拟员工定位
|
||||
|
||||
以下“虚拟员工定位”是对当前 5 个 scene 的归一化汇总视角,不是当前仓库里已存在统一员工对象的直接事实;证据等级:`no direct evidence / candidate only`。在这个归一化视角下,可把它理解为“面向电力业务指挥中心的任务型虚拟运营员工”,其职责边界可概括为:
|
||||
|
||||
- 以报表模板或监测快照形式承载结构化结果
|
||||
- 对工单/事件队列做规则化监测与历史比较
|
||||
- 为提醒、日志、外呼、自动派单、自动处理等下游动作提供输入语义
|
||||
- 为未来统一配置清单提供归一化抽象基础
|
||||
|
||||
但必须强调:以上职责并不等于所有场景都已由统一 packaged runtime 严格实现,更不等于已完成运行时验证。
|
||||
|
||||
## 证据标签速记
|
||||
|
||||
| 标签 | 严格含义 |
|
||||
| --- | --- |
|
||||
| `code-confirmed` | 当前仓库代码、规则资产、静态配置中可直接定位到的事实 |
|
||||
| `contract-defined` | 由场景说明、参考流程、接口/文档契约明确规定的事实 |
|
||||
| `implementation intent exists but not rigorous / buggy` | 已看到实现方向或局部链路,但不够严谨、存在缺口或已知 bug |
|
||||
| `no direct evidence / candidate only` | 当前没有直接证据,只能作为候选抽象、候选结构或待确认项 |
|
||||
|
||||
## 业务盘点表
|
||||
|
||||
| 名称 | 场景 ID | 类别 | 当前任务目标 | 已观察系统 / 证据基础 | 证据分级摘要 | 严格说明 / 未解决问题 | 对应分析文档 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 故障明细 | `fault-details-report` | 报表 | 以“故障明细主表 + summary-sheet 分区”形式承载故障明细报表结果。 | `scene.json`、`SKILL.md`、`scripts/collect_fault_details.js`、`references/collection-flow.md`、`references/data-quality.md` | `code-confirmed`:已直接定义 `report-artifact` 外壳、主表列、`summary-sheet` 模板、`status`/`partial_reasons` 字段。`contract-defined`:页面时间读取、故障查询、字段归一、汇总派生、导出/日志语义。`implementation intent exists but not rigorous / buggy`:`period` 与 `startTime/endTime` 建模不严谨,状态细分只停留在契约层。 | 当前更像“报表 schema/template stub”,不能写成已严格实现实时浏览器采集器;不得表述为已运行验证。 | `D:/data/ideaSpace/rust/sgClaw/claw-new/docs/superpowers/specs/2026-04-08-fault-details-report-operation-analysis.md` |
|
||||
| 国网金昌供电公司营商环境周例会报告 | `jinchang-business-environment-weekly-report` | 报表 | 以四个固定 section 模板承载营商环境周报。 | `scene.json`、`SKILL.md`、`scripts/collect_business_environment_metrics.js`、`references/collection-flow.md`、`references/data-quality.md` | `code-confirmed`:四个 section template、空 artifact、`period`、基础状态字段已存在。`contract-defined`:多来源指标采集、周范围读取、section 聚合、导出/日志语义。`implementation intent exists but not rigorous / buggy`:`region` 仅在文案层出现,未进入稳定 schema。 | 这是“分区化周报模板”而不是已证实的 live collector;不能写成已稳定采集多个业务系统。 | `D:/data/ideaSpace/rust/sgClaw/claw-new/docs/superpowers/specs/2026-04-08-jinchang-business-environment-weekly-report-operation-analysis.md` |
|
||||
| 95598、12398及配网设备监控情况周统计 | `95598-weekly-monitor-report` | 报表 | 以六个固定 section 模板承载周统计结果。 | `scene.json`、`SKILL.md`、`scripts/collect_weekly_metrics.js`、`references/collection-flow.md`、`references/data-quality.md` | `code-confirmed`:六个 section template、空 artifact、顶层 `period`、基础状态字段已存在。`contract-defined`:双周期输入、period alignment、多来源周统计采集。`implementation intent exists but not rigorous / buggy`:`period` vs `currentPeriod/cumulativePeriod` 冲突明显,period alignment 只在元数据/文档层被要求。 | 三个报表 scene 都更接近“已打包的 schema/template stub”,不应写成已实现 live collector;本场景还存在双周期建模未闭合问题。 | `D:/data/ideaSpace/rust/sgClaw/claw-new/docs/superpowers/specs/2026-04-08-95598-weekly-monitor-report-operation-analysis.md` |
|
||||
| 95598抢修-市指 | `95598-repair-city-dispatch` | 监测 | 监测抢修工单队列,识别待处理/审核/已处理,并为提醒、日志、自动派单等链路提供输入。 | `scene.json`、`SKILL.md`、`scripts/collect_repair_orders.js`、`D:/desk/智能体资料/大四区报告监测项/95598抢修-市指_业务检测配置.txt`、`D:/desk/智能体资料/大四区报告监测项/95598抢修-市指_自动处理配置.txt` | `code-confirmed`:packaged JS 现已直接实现输入驱动的 `monitor-snapshot` collector,可做 repair-order 分类、monitor/dispose log 比较、`new_pending_ids` 推导、`success/partial/empty/blocked` 状态判定,并携带 `workflow_rule_sources`、`config_base_page/config_base_role`、`known_issues` 元数据;更强的队列采集、日志比较、音频提醒、短信、外呼、自动派单、处置日志写入证据直接存在于 desk 规则脚本。`contract-defined`:快照语义与下游副作用需分开表达。`implementation intent exists but not rigorous / buggy`:desk 规则内存在 `status == "00" && status == "01"` 的待处理分类 bug;规则层 `new_pending_ids` 仍更像归一化目标而非同名稳定字段。 | 本场景 desk workflow 证据仍强于 packaged collector,且当前实际定时执行证据以 desk 规则脚本为主;`assets/scene-snapshot/index.html` 仅是配置基础页。仍不能宣称任何运行时成功。 | `D:/data/ideaSpace/rust/sgClaw/claw-new/docs/superpowers/specs/2026-04-08-95598-repair-city-dispatch-operation-analysis.md` |
|
||||
| 户表失电-嘉峪关 | `jiayuguan-meter-outage` | 监测 | 监测户表失电事件,结合服务工单状态与历史日志识别待处理对象,并为自动处理链路提供输入。 | `scene.json`、`SKILL.md`、`scripts/collect_outage_events.js`、`D:/desk/智能体资料/大四区报告监测项/户表失电-嘉峪关_业务监测配置.txt`、`D:/desk/智能体资料/大四区报告监测项/户表失电-嘉峪关_自动处理配置.txt` | `code-confirmed`:packaged JS 现已直接实现输入驱动的 `monitor-snapshot` collector,可从 outage/service-order 数据计算 `pending/audit/processed`、比较 monitor/dispose logs、推导 `new_pending_ids`、输出 `success/partial/empty/blocked`,并携带 `workflow_rule_sources`、`config_base_page/config_base_role`、`identity_model` 元数据;更强的 outage collection、service-order enrichment、monitor/dispose log 比较、营销 token 依赖自动处理与派单分支直接存在于 desk 规则脚本。`contract-defined`:快照与下游自动处理需分开理解。`implementation intent exists but not rigorous / buggy`:监测 pending 列表用 `consNo`,处置去重用 `eventId`,身份模型不一致;短信通道只看到保留意图/注释代码。 | 本场景 desk workflow 证据也强于 packaged collector,且当前实际定时执行证据以 desk 规则脚本为主;`assets/scene-snapshot/index.html` 仅是配置基础页。必须保留身份不一致问题,不能把 `pending_ids/new_pending_ids` 写成已被严格统一定义。 | `D:/data/ideaSpace/rust/sgClaw/claw-new/docs/superpowers/specs/2026-04-08-jiayuguan-meter-outage-operation-analysis.md` |
|
||||
|
||||
## 当前状态汇总
|
||||
|
||||
### 1. 报表类场景的共同结论
|
||||
|
||||
- `fault-details-report`
|
||||
- `jinchang-business-environment-weekly-report`
|
||||
- `95598-weekly-monitor-report`
|
||||
|
||||
这 3 个 scene 当前最强直接证据都集中在“已打包脚本定义了 artifact schema / section template / 基础状态字段”。
|
||||
|
||||
因此,对这 3 个 scene 的严谨表述应是:
|
||||
|
||||
- `code-confirmed`:已存在结构模板、字段壳和分区定义
|
||||
- `contract-defined`:存在明确的目标采集流程与质量要求
|
||||
- `implementation intent exists but not rigorous / buggy`:运行时采集、周期对齐、状态细分、导出/日志等链路没有被 packaged JS 同等强度证实
|
||||
|
||||
换言之,它们当前主要是“结构化报表模板场景”,不应表述为“已验证的 live collector”。
|
||||
|
||||
### 2. 监测类场景的共同结论
|
||||
|
||||
- `95598-repair-city-dispatch`
|
||||
- `jiayuguan-meter-outage`
|
||||
|
||||
这 2 个 scene 的情况与报表类不同:
|
||||
|
||||
- packaged JS collector 已具备输入驱动的 `monitor-snapshot` 归一化 / 比较逻辑
|
||||
- 更强 workflow 证据主要存在于 desk 规则资产
|
||||
- 规则资产直接展示了采集、比较、提醒、日志、派单等流程分支
|
||||
|
||||
因此,对这 2 个 scene 的严谨表述应是:
|
||||
|
||||
- `code-confirmed`:规则资产中确有较强监测/自动处理链路定义
|
||||
- 但这仍只证明“规则层存在这些实现分支”
|
||||
- 不得进一步写成“运行时已稳定成功”
|
||||
|
||||
### 3. 当前全局未闭合问题
|
||||
|
||||
- `fault-details-report`:`period` 与 `startTime/endTime` 的关系未闭合
|
||||
- `jinchang-business-environment-weekly-report`:`region` 语义只在文案层出现,未形成稳定字段
|
||||
- `95598-weekly-monitor-report`:`period` 与 `currentPeriod/cumulativePeriod`、period alignment 之间的关系未闭合
|
||||
- `95598-repair-city-dispatch`:待处理分类规则存在 `status == "00" && status == "01"` bug
|
||||
- `jiayuguan-meter-outage`:monitor pending 使用 `consNo`,dispose dedupe 使用 `eventId`,身份模型不一致
|
||||
|
||||
## 按证据等级整理的能力视图
|
||||
|
||||
### `code-confirmed`
|
||||
|
||||
- 报表 artifact / monitor snapshot 的基础结构壳
|
||||
- 报表 scene 的固定 section/template 定义
|
||||
- 两个监测 scene 规则资产中的采集、比较、日志、提醒、派单分支存在性
|
||||
|
||||
### `contract-defined`
|
||||
|
||||
- 报表类 scene 的目标采集流程、导出语义、质量约束
|
||||
- 监测类 scene 的“快照成功”与“副作用成功”分离原则
|
||||
- 周报类双周期/多来源/对齐语义
|
||||
|
||||
### `implementation intent exists but not rigorous / buggy`
|
||||
|
||||
- 报表类 scene 中对 live collector、period alignment、状态细分的实现意图
|
||||
- `95598-repair-city-dispatch` 的 pending 分类 bug
|
||||
- `jiayuguan-meter-outage` 的身份键不一致
|
||||
- 若干下游通道存在定义或注释代码,但不足以提升为稳定现状
|
||||
|
||||
### `no direct evidence / candidate only`
|
||||
|
||||
- 统一 capability 名称本身
|
||||
- 未来标准配置里的字段拆分方案
|
||||
- `email` 等当前未见直接证据的候选通道
|
||||
|
||||
## 使用边界
|
||||
|
||||
本文件只用于帮助人快速理解“当前观察到的业务盘点状态”。如需:
|
||||
|
||||
- 看每个场景的证据出处与分级理由,读对应 operation-analysis 文档
|
||||
- 看机器可读盘点结构,读 `2026-04-08-command-center-virtual-employee-inventory.json`
|
||||
- 看未来目标配置结构提案,读 `2026-04-08-command-center-standard-config-structure.md`
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
# fault-details-report 操作分析
|
||||
|
||||
## 1. 场景概述
|
||||
|
||||
`fault-details-report` 对应“故障明细”场景,目标表述为查询故障明细并生成包含明细与汇总分区的结构化报表。根据 `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\fault-details-report\scene.json`、`D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\fault-details-report\SKILL.md` 与 `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\fault-details-report\scripts\collect_fault_details.js`,当前最强直接证据在于:已打包脚本明确了报表 artifact 的列结构、汇总 section 名称、空结果形态与 `status: "ok"` 默认值,证据等级:`code-confirmed`。
|
||||
|
||||
但同一批证据并没有展示真实浏览器页面抓取、请求触发、行级归一化或汇总派生的实际执行代码。也就是说,当前 packaged script 对 artifact schema / section template 的定义,明显强于对实时浏览器采集行为的证明,证据等级:`code-confirmed`。
|
||||
|
||||
## 2. 证据来源
|
||||
|
||||
本分析统一只使用四个证据等级标签:`code-confirmed`、`contract-defined`、`implementation intent exists but not rigorous / buggy`、`no direct evidence / candidate only`。其中,脚本直接定义的 artifact schema / section template 归入 `code-confirmed`;未见脚本直接实现的运行语义与下游动作,不拔高于其对应较弱标签。
|
||||
|
||||
1. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\fault-details-report\scripts\collect_fault_details.js`
|
||||
- 直接定义 `DETAIL_COLUMNS`、`SUMMARY_COLUMNS`、返回对象字段、空 `rows`、空 `sections[0].rows`、`status: "ok"`、`partial_reasons: []`,证据等级:`code-confirmed`。
|
||||
2. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\fault-details-report\SKILL.md`
|
||||
- 说明预期工作流为读取时间范围、收集原始故障明细、按规范列顺序归一、派生汇总 sheet、返回 artifact;这是技能说明与目标运行契约,能证明意图与期望输出,但不能单独证明脚本已实现全部步骤,整体证据等级以 `contract-defined` 与 `implementation intent exists but not rigorous / buggy` 并存描述更严谨。
|
||||
3. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\fault-details-report\scene.json`
|
||||
- 定义场景输入为 `period`、依赖为 `browser` / `report-history` / `local-report-service`、动作包括 `query` / `collect-report` / `build-summary-section`,属于场景元数据定义,证据等级:`code-confirmed`。
|
||||
4. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\fault-details-report\references\collection-flow.md`
|
||||
- 给出“读取开始结束时间、触发 repair-order query、收集明细、按 `excleIni[0].cols` 归一、派生 summary-sheet、再返回 artifact”的参考流程;它定义了预期采集语义,证据等级:`contract-defined`。
|
||||
5. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\fault-details-report\references\data-quality.md`
|
||||
- 给出必填列、可空列、summary 派生期望、partial 规则与 empty/failure 区分,属于质量约束参考,证据等级:`contract-defined`。
|
||||
6. `D:\data\ideaSpace\rust\sgClaw\claw-new\docs\superpowers\specs\2026-04-08-command-center-virtual-employee-inventory.json`
|
||||
- 已把该场景整理为 `workflow`、`result.key_fields`、`status_model` 与 `open_questions`,可作为当前 command-center 侧归纳结果,但其中部分内容是对 scene/skill/reference 的再整理,不应反向当作新实现证据;证据等级:`no direct evidence / candidate only`(仅限 inventory 不能单独证明 packaged script 已实现的部分)。
|
||||
|
||||
## 3. 实际入口与运行边界
|
||||
|
||||
实际入口已在 `scene.json` 中声明为浏览器场景 `index.html`,技能包工具名为 `fault-details-report.collect_fault_details`,artifact 类型为 `report-artifact`,这些都是当前仓库可直接定位的定义,证据等级:`code-confirmed`。
|
||||
|
||||
运行边界方面:
|
||||
|
||||
- 场景元数据只声明了 `inputs: ["period"]`,证据等级:`code-confirmed`。
|
||||
- 参考流程却明确要求从页面 datetime range control 读取 `start` / `end` 时间,证据等级:`contract-defined`。
|
||||
- 因而“外部统一输入叫 `period`,但页面真实业务输入更像 `startTime/endTime` 二元组”这一判断是当前最严谨的归纳,且 inventory 文件也把它列入 `open_questions`,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
还要强调:当前可直接运行的打包脚本并未包含浏览器操作、请求调用、页面解析或 localhost 导出调用代码,因此它的实际边界更接近“返回一个预定义空 artifact 的 schema stub”,而不是“已严格实现端到端浏览器采集器”,证据等级:`code-confirmed`。
|
||||
|
||||
## 4. 代码已证实的实际操作流程
|
||||
|
||||
当前代码真正能严格确认的流程只有以下最小闭环:
|
||||
|
||||
1. 调用 `collectFaultDetails(input)`。
|
||||
2. 读取 `input.period || ""` 填入返回对象的 `period` 字段。
|
||||
3. 将 `DETAIL_COLUMNS` 写入主表 `columns`。
|
||||
4. 将空数组写入主表 `rows`。
|
||||
5. 构造一个名为 `summary-sheet` 的 section,并写入 `SUMMARY_COLUMNS` 与空 `rows`。
|
||||
6. 返回 `type: "report-artifact"`、`report_name: "fault-details-report"`、`status: "ok"`、`partial_reasons: []`。
|
||||
|
||||
以上每一步都能在 `collect_fault_details.js` 中直接定位,证据等级:`code-confirmed`。
|
||||
|
||||
至于以下操作:读取页面时间、触发 repair-order 查询、抓取故障行、归一字段、按明细派生汇总、判断 partial/empty/blocked、调用导出服务或报告日志服务,目前在 packaged script 中没有对应实现代码,只在 skill/reference 文本里出现,证据等级最多是 `contract-defined` 或 `implementation intent exists but not rigorous / buggy`,不能写成当前代码已证实的实际流程。
|
||||
|
||||
## 5. 标准化抽象流程
|
||||
|
||||
若为 command-center 做严格抽象,当前更合理的标准化流程应写成:
|
||||
|
||||
1. 解析外部任务输入。
|
||||
2. 将业务时间范围映射到页面查询参数。
|
||||
3. 执行浏览器态查询并收集故障明细行。
|
||||
4. 按约定列顺序归一主表数据。
|
||||
5. 基于明细结果派生 `summary-sheet`。
|
||||
6. 生成 `report-artifact`。
|
||||
7. 如有需要再执行导出/日志等下游动作。
|
||||
|
||||
其中第 6 步“生成具有主表+summary-sheet 的 artifact 结构”可由脚本直接支撑,证据等级:`code-confirmed`。第 2、3、4、5、7 步主要来自场景说明与 reference 文档,不是当前脚本已实现事实,证据等级应分别按 `contract-defined` 或 `implementation intent exists but not rigorous / buggy` 标注。
|
||||
|
||||
## 6. 输入、上下文与依赖
|
||||
|
||||
### 输入
|
||||
|
||||
- `period` 被 scene 元数据与脚本入参直接使用,证据等级:`code-confirmed`。
|
||||
- “页面实际读取开始时间与结束时间”来自 `references/collection-flow.md` 和 `SKILL.md` 的 workflow 描述,证据等级:`contract-defined`。
|
||||
- 因此 `period` 与 `startTime/endTime` 的关系当前并不严谨:很可能 `period` 只是上层统一抽象,而底层真实 collector 需要双时间字段,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
### 运行上下文
|
||||
|
||||
- 浏览器页面可访问、页面日期控件存在、会话已登录,来自 scene/inventory/reference 的联合描述,证据等级以 `code-confirmed`(元数据存在)和 `contract-defined`(具体语义)共同成立。
|
||||
- `report-history`、`local-report-service` 被声明为依赖,但 reference 同时强调历史报告不是主数据源、localhost 服务是下游依赖,证据等级:`code-confirmed` 与 `contract-defined`。
|
||||
|
||||
### 依赖
|
||||
|
||||
- `browser`、`fault-detail-query-source`、`local-report-service` 等依赖名称或整理项可直接在 scene 或 inventory 中定位,证据等级:`code-confirmed`。
|
||||
- `/a_js/YPTAPI.js`、`http://localhost:13313/ReportServices/*`、`faultDetailsExportXLSXS` 等更具体依赖来自 reference,证据等级:`contract-defined`。
|
||||
|
||||
## 7. 输出结构
|
||||
|
||||
当前输出结构是本场景最硬的直接证据。`collect_fault_details.js` 已直接定义:
|
||||
|
||||
- `type: "report-artifact"`
|
||||
- `report_name: "fault-details-report"`
|
||||
- `period`
|
||||
- 主表 `columns` = `DETAIL_COLUMNS`
|
||||
- 主表 `rows` = `[]`
|
||||
- `sections[0].name = "summary-sheet"`
|
||||
- `sections[0].columns = SUMMARY_COLUMNS`
|
||||
- `sections[0].rows = []`
|
||||
- `status = "ok"`
|
||||
- `partial_reasons = []`
|
||||
|
||||
以上全部属于 `code-confirmed`。
|
||||
|
||||
但 `SKILL.md` 与 `data-quality.md` 还要求输出中体现 detail row count、summary row count、required column coverage、complete/partial status、missing columns、weak mappings、downstream failures 等诊断信息。除了 `status` 与 `partial_reasons` 字段壳子已经存在,其他诊断性内容并未在脚本中实现,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 8. 下游动作证据表
|
||||
|
||||
| 下游动作 | 当前证据 | 证据等级 | 严谨结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 生成 `report-artifact` 返回给上游 | `collect_fault_details.js` 直接返回对象 | `code-confirmed` | 已有稳定的 artifact 结构桩实现,但当前返回为空数据模板。 |
|
||||
| 明细列顺序标准化 | `DETAIL_COLUMNS` 明确定义 | `code-confirmed` | 只能确认列 schema 被定义,不能确认真实行数据已按此顺序完成映射。 |
|
||||
| `summary-sheet` 分区存在 | `sections` 中直接构造 `summary-sheet` | `code-confirmed` | 只能确认 section 模板存在,不能确认真实汇总派生逻辑已实现。 |
|
||||
| 页面采集故障明细行 | 只在 `SKILL.md` / `collection-flow.md` 中描述 | `contract-defined` | 存在明确目标流程,但当前 packaged script 未直接证明已实现。 |
|
||||
| 汇总派生 | 只在 `SKILL.md` / `collection-flow.md` / `data-quality.md` 中描述 | `contract-defined` | 有契约与质量要求,但没有脚本级派生代码证据。 |
|
||||
| 导出 Excel | scene 依赖与 reference 提到 localhost export service | `contract-defined` | 这是下游依赖定义,不等于本 skill 当前已实际执行导出。 |
|
||||
| 写报告日志 | scene 依赖 `report-history`,reference 提到 report-log | `contract-defined` | 只能确认体系中有该下游概念,当前脚本未直接实现日志写入。 |
|
||||
| partial / empty / blocked 状态细分 | skill/reference 有规则,脚本固定 `status: "ok"` | `implementation intent exists but not rigorous / buggy` | 状态模型意图存在,但 packaged script 目前未严格承载这些分支。 |
|
||||
|
||||
## 9. 当前代码疑点 / 不严谨点
|
||||
|
||||
1. `period` 与 `startTime/endTime` 的建模不一致。scene 与脚本只保留 `period`,reference 却明确要求读取开始/结束时间;这会让 command-center 难以判断标准输入究竟是一段字符串还是两个独立时间字段,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
2. 脚本把 `status` 固定为 `"ok"`,但 reference 与 `SKILL.md` 明确区分 success / partial / empty / blocked;当前实现无法承载这些语义,证据等级:`code-confirmed` 对现状成立,而“应支持细分状态”属于 `contract-defined`。
|
||||
3. `partial_reasons` 虽存在字段,但脚本没有任何填充逻辑,只能算 schema 占位,证据等级:`code-confirmed`。
|
||||
4. `DETAIL_COLUMNS` 与 `SUMMARY_COLUMNS` 已定义,但没有任何从页面数据到列值的映射代码;“字段归一化能力已落地”不能成立,证据等级最多为 `implementation intent exists but not rigorous / buggy`。
|
||||
5. 下游导出与日志在参考资料中存在,但当前 skill 脚本并未调用相关服务,因此“报表可直接生成 Excel”不能写成当前代码事实,证据等级:`no direct evidence / candidate only`(就 packaged script 内实际执行而言)。
|
||||
|
||||
## 10. 对 command-center 标准配置的修订建议
|
||||
|
||||
1. 将本场景输入从单一 `period` 修订为更严谨的双层表达:
|
||||
- 对外统一层可保留 `period` 便于路由;
|
||||
- 执行层建议显式展开 `startTime` / `endTime`。
|
||||
其中“需要展开”的结论来自 scene 与 reference 的冲突修正,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
2. 在标准配置里把“artifact schema 已明确、live collector 未证实”作为单独字段或备注保留,避免 command-center 误把 schema stub 当成已实现采集器,证据等级:`code-confirmed`。
|
||||
3. 将 `summary-sheet` 标记为 `section template confirmed`,而不是 `summary derivation implemented`。前者是 `code-confirmed`,后者当前没有同等强度证据。
|
||||
4. 状态模型建议分成两层:
|
||||
- `declared_status_model`: success / partial / empty / blocked,来源于 skill/reference,证据等级:`contract-defined`;
|
||||
- `implemented_status_behavior`: 当前仅看到固定 `ok` 成功壳,证据等级:`code-confirmed`。
|
||||
5. 对下游动作增加 `evidence_note`,明确 report-export / report-log 目前主要来自场景与参考定义,不是当前 packaged script 已证实行为。
|
||||
|
||||
## 11. 最终严谨结论
|
||||
|
||||
关于 `fault-details-report`,当前最可靠的结论是:仓库已经具备一个明确的报表 artifact 模板实现,能够稳定返回故障明细主表列定义、`summary-sheet` 汇总分区模板、空结果数组以及基础状态字段,证据等级:`code-confirmed`。
|
||||
|
||||
但如果把结论提升为“已经实现真实浏览器故障明细采集、列归一化、汇总派生、导出与日志闭环”,则证据并不充分。相关行为主要存在于 `SKILL.md`、`references/collection-flow.md`、`references/data-quality.md` 与 scene 元数据中,能够证明的是目标流程与契约要求,而不是当前 packaged script 已严格完成这些逻辑。因此,本场景目前应被描述为“artifact schema / section template 定义强,live browser collection 行为证据弱”的 staged report scene,而不能被描述为已严谨落地的实时采集器。
|
||||
@@ -0,0 +1,225 @@
|
||||
# jiayuguan-meter-outage 操作分析
|
||||
|
||||
## 1. 场景概述
|
||||
|
||||
`jiayuguan-meter-outage` 对应“户表失电-嘉峪关”场景,目标是采集户表失电事件、关联服务工单状态、对比历史监测 / 处置日志,并在必要时触发音频提醒或自动派单等后续动作。根据 `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\jiayuguan-meter-outage\scene.json`、`D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jiayuguan-meter-outage\SKILL.md`、`D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jiayuguan-meter-outage\scripts\collect_outage_events.js` 以及两份规则资产,当前最严谨的结论是:packaged JS collector 已经实现输入驱动的 `monitor-snapshot` 归一化 / 比较逻辑,会从 outage events 与 service orders 计算 `pending/audit/processed`、解析 monitor/dispose logs、推导 `pending_ids` / `new_pending_ids`、输出 `success/partial/empty/blocked` 状态,并附带 source endpoint 常量、localhost 端点、desk 规则来源、配置基础页标记与身份模型元数据;更强的业务工作流证据则主要存在于 desk 规则资产中,证据等级分别为 `code-confirmed`。
|
||||
|
||||
必须明确区分以下几层:
|
||||
|
||||
1. packaged runtime-snapshot-collector:`collect_outage_events.js` 已直接实现 outage/service-order 归一化、历史比较、身份模型暴露与标准快照输出,并显式携带 `workflow_rule_sources`、`config_base_page`、`config_base_role`、`packaged_collector_role` 与 `identity_model` 元数据,证据等级:`code-confirmed`。
|
||||
2. outage collection:业务监测规则直接请求 `outage/dhsd/dhsdList` 收集失电事件,证据等级:`code-confirmed`。
|
||||
3. service-order enrichment:业务监测规则再请求 `gdgl/active/service/order/list` 收集服务工单状态并补全 `audit` / `processed`,证据等级:`code-confirmed`。
|
||||
4. monitor-log comparison:业务监测规则通过 `getMonitorLog` 对比历史待处理列表并决定是否音频提醒,证据等级:`code-confirmed`。
|
||||
5. dispose-log dedupe:业务监测规则通过 `getDisposeLog` 做已派单去重并决定是否进入自动处理,证据等级:`code-confirmed`。
|
||||
6. marketing-token-dependent auto-processing and dispatch:自动处理规则显式读取营销系统 token,并基于营销系统查询结果、班组配置和自动派单接口推进派单,证据等级:`code-confirmed`。
|
||||
|
||||
但这些 `code-confirmed` 仍只证明“代码或规则资产中存在这些实现链路”,不代表运行时已验证成功。本文不声称任何运行时验证结论。
|
||||
|
||||
## 2. 证据来源
|
||||
|
||||
本分析统一只使用四个证据等级标签:`code-confirmed`、`contract-defined`、`implementation intent exists but not rigorous / buggy`、`no direct evidence / candidate only`。
|
||||
|
||||
1. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jiayuguan-meter-outage\scripts\collect_outage_events.js`
|
||||
- 直接定义 `SOURCE_GROUPS`、`LOCAL_SERVICE_ENDPOINTS`、`WORKFLOW_RULE_SOURCES`、`CONFIG_BASE_PAGE`、`IDENTITY_MODEL`,并实现 outage/service-order 分类、monitor/dispose log 解析比较、`new_pending_ids` 推导、`success/partial/empty/blocked` 状态判定,以及带 `evidence` / `identity_model` 的 `monitor-snapshot` 输出,证据等级:`code-confirmed`。
|
||||
2. `D:\desk\智能体资料\大四区报告监测项\户表失电-嘉峪关_业务监测配置.txt`
|
||||
- 直接实现失电事件采集、服务工单状态补充、monitor log 比较、dispose log 去重、音频提醒与监测日志写入,证据等级:`code-confirmed`。
|
||||
3. `D:\desk\智能体资料\大四区报告监测项\户表失电-嘉峪关_自动处理配置.txt`
|
||||
- 直接实现营销 token 读取、营销系统用户查询、工单编号获取、班组分配、自动派单请求、音频提醒、处置日志写入,以及备用短信函数定义,证据等级:`code-confirmed`。
|
||||
4. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jiayuguan-meter-outage\SKILL.md`
|
||||
- 定义“失电事件采集与工单状态采集要分开,再组合成一份快照;下游提醒与自动派单不应重定义采集成功”的运行契约,证据等级:`contract-defined`。
|
||||
5. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jiayuguan-meter-outage\references\collection-flow.md`
|
||||
- 定义以配置页为入口、组合 outage-event collection、service-order enrichment、历史比较和 auto-processing context 的流程,证据等级:`contract-defined`。
|
||||
6. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jiayuguan-meter-outage\references\data-quality.md`
|
||||
- 定义 pending / audit / processed 的来源语义、partial 规则与依赖告警,证据等级:`contract-defined`。
|
||||
7. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\jiayuguan-meter-outage\scene.json`
|
||||
- 声明场景分类、输入 `time`、依赖与动作,证据等级:`code-confirmed`。
|
||||
8. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\jiayuguan-meter-outage\scene.draft.json`
|
||||
- 暴露对 marketing token context 和 `trigger-alert` / `auto-processing` 是否进一步拆分的待定整理,证据等级:`no direct evidence / candidate only`。
|
||||
|
||||
## 3. 实际入口与运行边界
|
||||
|
||||
实际入口在 `scene.json` 中已固定:场景页面入口为 `index.html`,技能工具名为 `jiayuguan-meter-outage.collect_outage_events`,输出类型为 `monitor-snapshot`,输入为 `time`,这些都属于 `code-confirmed`。
|
||||
|
||||
其中 `assets/scene-snapshot/index.html` 只应被视为配置基础页(例如班组、联系人、范围维护),不应被当作规则 workflow 的主执行证据。
|
||||
|
||||
运行边界方面,需要特别强调 packaged collector 与 rule workflow 的分层:
|
||||
|
||||
- packaged JS runtime collector 的直接能力边界:它已经能基于输入 `outage_events`、`service_orders`、`monitor_logs`、`dispose_logs` 做 `pending/audit/processed` 归一化、历史比较、`new_pending_ids` 推导与 `success/partial/empty/blocked` 判定,并公开两个上游 source endpoint、一组 localhost endpoint、desk 规则来源、配置基础页角色与身份模型元数据;但它仍是输入驱动归一化 collector,不直接发起浏览器请求,也不直接承载完整业务 workflow,证据等级:`code-confirmed`。
|
||||
- 更强的业务流程边界,主要体现在 desk 规则资产:先采集户表失电事件,再请求服务工单列表补充状态,再做 monitor/dispose 日志比较,最后才决定提醒或自动处理,证据等级:`code-confirmed`。
|
||||
|
||||
因此,本场景不能被描述成“packaged collector 已完整实现嘉峪关户表失电实时工作流”。更严谨的说法是:packaged collector 已实现可测试的输入驱动快照归一化 / 比较逻辑;较强 workflow 证据主要在 desk 规则资产中,证据等级:`code-confirmed`。
|
||||
|
||||
此外,`collection-flow.md` 与 `SKILL.md` 都明确要求把 outage collection、service-order enrichment、历史比较与下游 auto-processing 分开理解;这是运行边界契约,证据等级:`contract-defined`。
|
||||
|
||||
## 4. 代码已证实的实际操作流程
|
||||
|
||||
### 4.1 packaged runtime-snapshot-collector 已证实流程
|
||||
|
||||
`collect_outage_events.js` 中现在能严格确认:
|
||||
|
||||
1. 调用 `collectOutageEvents(input)`,读取 `input.outage_events`、`input.service_orders`、`input.monitor_logs || input.monitor_log`、`input.dispose_logs || input.dispose_log`、`input.local_write_failures`、`input.blocked_reason` 等输入。
|
||||
2. 通过 `buildOutageContext(...)` 从 outage events 提取 `pending_ids`、`eventIds` 与 `eventIdsByConsNo`,并通过 `classifyServiceOrders(...)` 基于 `gdztmc` 计算 `audit` / `processed`。
|
||||
3. 解析 monitor/dispose logs,识别 malformed payload,并结合 `consNo` 与 `eventId` 的映射推导 `new_pending_ids`。
|
||||
4. 对未知工单状态、日志缺失、日志解析失败、缺失 event identity、identity crosswalk ambiguity、本地写失败等情况记录 `partial_reasons`。
|
||||
5. 按 `blocked > partial > empty > success` 的优先级计算 `status`,返回 `type: "monitor-snapshot"`、`scene: "jiayuguan-meter-outage"`、`pending`、`audit`、`processed`、`pending_ids`、`new_pending_ids`、`status`、`partial_reasons`。
|
||||
6. 在返回对象中附带 `evidence.workflow_rule_sources`、`evidence.config_base_page`、`evidence.config_base_role`、`evidence.packaged_collector_role = "runtime-snapshot-collector"`,以及 `identity_model`。
|
||||
7. 模块额外导出 `SOURCE_GROUPS`、`LOCAL_SERVICE_ENDPOINTS`、`WORKFLOW_RULE_SOURCES`、`CONFIG_BASE_PAGE`、`IDENTITY_MODEL`。
|
||||
|
||||
以上都属于 `code-confirmed`。
|
||||
|
||||
### 4.2 业务监测规则已证实流程
|
||||
|
||||
`户表失电-嘉峪关_业务监测配置.txt` 直接证实了以下分段流程:
|
||||
|
||||
1. outage collection:通过 `BrowserAction(... outage/dhsd/dhsdList ...)` 查询近两天到当天的失电事件,并把每条 `consNo` 放入 `idList`,证据等级:`code-confirmed`。
|
||||
2. service-order enrichment:随后通过 `BrowserAction(... gdgl/active/service/order/list ...)` 查询当天工单列表,并按 `gdztmc == "待审核"` / `gdztmc == "已归档"` 分别累计 `audit` 与 `processed`,证据等级:`code-confirmed`。
|
||||
3. monitor-log comparison:通过 `getMonitorLog` 读取历史 `pendingList`,对比当前 `idList`,如发现新增待处理则触发音频提醒,并把快照写入 `setMonitorData` / `setMonitorLog`,证据等级:`code-confirmed`。
|
||||
4. dispose-log dedupe:通过 `getDisposeLog` 读取历史处置日志,解析 `orderID` 后提取其中 `id`,再以 `eventId` 为键从当前失电事件中筛出未处置事件 `pendingList`,证据等级:`code-confirmed`。
|
||||
5. 若存在未处置事件,则把 `pendingList` 塞给 `_this.queueObj.pendingList` 并触发 `_this.autoTask()`;否则直接 `_this.processQueue()`,证据等级:`code-confirmed`。
|
||||
|
||||
### 4.3 自动处理规则已证实流程
|
||||
|
||||
`户表失电-嘉峪关_自动处理配置.txt` 直接证实:
|
||||
|
||||
1. 自动处理依赖营销系统 token:代码从 `localStorage["markYXObj"]` 中读取 `token` 与 `loginUserInfo`,证据等级:`code-confirmed`。
|
||||
2. 自动处理先按 `eqPsrName` 合并事件,再读取 `getClassList` 获取班组配置,证据等级:`code-confirmed`。
|
||||
3. 用营销系统接口 `queryEleCust` 按 `consNo` 查询用户营销归属,再据此确定 `ecssMgtOrgCode`,证据等级:`code-confirmed`。
|
||||
4. 之后还会调用 `gdgl/zdfw/tgforderzdfw/gdbh` 获取工单编号,再调用 `gdgl/active/service/order/saveAndSend` 发起自动派单,证据等级:`code-confirmed`。
|
||||
5. 自动派单成功 / 失败 / 异常分支都会触发不同音频提醒,并写 `setDisposeLog`,证据等级:`code-confirmed`。
|
||||
6. 短信函数 `msgFC` 在自动处理规则中被定义,但当前成功分支里的短信发送代码被整体注释掉,因此“短信通道已成为当前有效工作流”不能被写成稳定事实,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 5. 标准化抽象流程
|
||||
|
||||
若为 command-center 做严格抽象,本场景更合理的标准化流程应写成:
|
||||
|
||||
1. 接收监测任务输入 `time`。
|
||||
2. 单独采集 outage events。
|
||||
3. 单独采集 service-order states,并用其补充 `audit` / `processed`。
|
||||
4. 使用 monitor log 做待处理比较,判断提醒语义。
|
||||
5. 使用 dispose log 做已处置去重,筛出需要自动处理的事件集合。
|
||||
6. 先形成或保留监测快照语义。
|
||||
7. 若满足条件,再进入依赖营销 token 的自动处理 / 派单流程。
|
||||
8. 记录音频、日志与处置结果等下游动作。
|
||||
|
||||
其中第 1 步可由 packaged collector 的显式输入 `time` 支撑,第 2、3、4、5、6 步可由 packaged collector 的输入驱动归一化 / 比较逻辑支撑,证据等级:`code-confirmed`;第 7、8 步主要由规则资产直接支撑,证据等级:`code-confirmed`;“这些步骤应被分离理解、下游动作不应覆盖采集成功语义”的边界来自 `SKILL.md` / references,证据等级:`contract-defined`。
|
||||
|
||||
如果把上述流程进一步说成“已由 packaged collector 严格统一承载实时 outage 请求、service-order 查询与自动派单副作用”,则不严谨,因为这些更强 workflow 证据主要来自 desk 规则资产而不是 packaged collector,证据等级只能降为 `implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 6. 输入、上下文与依赖
|
||||
|
||||
### 输入
|
||||
|
||||
- `time` 是 scene 与 packaged script 共同声明的显式输入,证据等级:`code-confirmed`。
|
||||
- 业务监测规则对失电事件使用“近两天到今天”的 `offTime` 查询窗,对服务工单使用“当天”的 `createTime` 查询窗,证据等级:`code-confirmed`。
|
||||
- “当前 outage 和 service-order query windows 都属于实际输入的一部分”在 reference 中被明确说明,证据等级:`contract-defined`。
|
||||
|
||||
### 运行上下文
|
||||
|
||||
- 平台 session、org/user 上下文、浏览器 `BrowserAction` 能力在规则资产中直接使用,证据等级:`code-confirmed`。
|
||||
- marketing token context 在自动处理规则中是实际依赖,而不仅仅是文档说法,证据等级:`code-confirmed`。
|
||||
- reference 也把 marketing token context 明确列为 downstream enrichment / dispatch 依赖,证据等级:`contract-defined`。
|
||||
|
||||
### 依赖
|
||||
|
||||
- `scene.json` 声明 `browser`、`local-service`、`outage-source`、`service-order-source`、`history-log`,证据等级:`code-confirmed`。
|
||||
- 业务监测规则直接使用 `outage/dhsd/dhsdList`、`gdgl/active/service/order/list`、`getMonitorLog`、`setMonitorData`、`setMonitorLog`、`getDisposeLog`、`setAudioPlayLog`,证据等级:`code-confirmed`。
|
||||
- 自动处理规则直接使用营销系统 `queryEleCust`、工单编号接口 `gdgl/zdfw/tgforderzdfw/gdbh`、自动派单接口 `gdgl/active/service/order/saveAndSend`、`setDisposeLog` 与 `setAudioPlayLog`,证据等级:`code-confirmed`。
|
||||
- `scene.draft.json` 中 marketing token context 是否应提升为正式 dependency 仍是待确认项,因此在标准配置整理上属于 `no direct evidence / candidate only`。
|
||||
|
||||
## 7. 输出结构
|
||||
|
||||
当前输出结构需要分层描述。
|
||||
|
||||
### 7.1 packaged runtime collector 已直接定义的输出
|
||||
|
||||
`collect_outage_events.js` 直接定义:
|
||||
|
||||
- `type: "monitor-snapshot"`
|
||||
- `scene: "jiayuguan-meter-outage"`
|
||||
- `time`
|
||||
- `pending`
|
||||
- `audit`
|
||||
- `processed`
|
||||
- `pending_ids`
|
||||
- `new_pending_ids`
|
||||
- `status`
|
||||
- `partial_reasons`
|
||||
- `evidence.workflow_rule_sources`
|
||||
- `evidence.config_base_page`
|
||||
- `evidence.config_base_role`
|
||||
- `evidence.packaged_collector_role`
|
||||
- `identity_model`
|
||||
|
||||
以上全部属于 `code-confirmed`。
|
||||
|
||||
### 7.2 业务监测规则已展示的实际快照字段语义
|
||||
|
||||
业务监测规则直接构造了:
|
||||
|
||||
- `time`
|
||||
- `type: "户表失电-嘉峪关"`
|
||||
- `pending`
|
||||
- `pendingList`
|
||||
- `audit`
|
||||
- `processed`
|
||||
|
||||
这说明规则层快照对象与 packaged stub 的标准字段命名并不完全一致,尤其是 `pendingList` vs `pending_ids`、`type` vs `scene`,证据等级:`code-confirmed`。
|
||||
|
||||
### 7.3 `new_pending_ids` 的证据强度与身份不一致问题
|
||||
|
||||
`SKILL.md`、reference 与 `data-quality.md` 把 `new_pending_ids` 当成目标输出的一部分,证据等级:`contract-defined`。但当前规则资产里更强的直接事实是:
|
||||
|
||||
- monitor pending list 使用的是 `consNo`,即 `idList.push(item.consNo)`,证据等级:`code-confirmed`;
|
||||
- dispose dedupe 使用的是 `eventId`,即比较 `resList.indexOf(y.eventId)`,证据等级:`code-confirmed`。
|
||||
|
||||
这意味着当前实现存在明显身份不一致:监测 pending 列表是 `consNo` 视角,而处置去重是 `eventId` 视角。因而“`pending_ids` / `new_pending_ids` 已被当前实现严谨统一定义”不能成立,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 8. 下游动作证据表
|
||||
|
||||
| 下游动作 | 当前证据 | 证据等级 | 严谨结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 返回 `monitor-snapshot` runtime collector 输出 | `collect_outage_events.js` 直接返回对象 | `code-confirmed` | packaged JS 直接证明标准 snapshot 字段、状态判定、身份说明与 collector metadata 已存在。 |
|
||||
| 失电事件采集 | 业务监测规则调用 `outage/dhsd/dhsdList` | `code-confirmed` | outage collection 在规则资产中直接存在。 |
|
||||
| 服务工单状态补充 | 业务监测规则调用 `service/order/list` 并按 `gdztmc` 分桶 | `code-confirmed` | service-order enrichment 直接存在。 |
|
||||
| monitor-log 比较 | 业务监测规则调用 `getMonitorLog` 并对比 `consNo` 列表 | `code-confirmed` | 历史比较逻辑直接存在。 |
|
||||
| dispose-log 去重 | 业务监测规则调用 `getDisposeLog` 并按 `eventId` 过滤 | `code-confirmed` | 去重逻辑直接存在,但身份键与 monitor pending list 不一致。 |
|
||||
| 音频提醒调用 | 业务监测规则和自动处理规则都调用 `mac.audioPlay(...)` | `code-confirmed` | 只能确认规则层存在音频提醒调用。 |
|
||||
| 自动派单请求 | 自动处理规则调用 `service/order/saveAndSend` | `code-confirmed` | 自动派单请求分支可直接定位。 |
|
||||
| 依赖营销 token 的用户查询 | 自动处理规则调用营销系统 `queryEleCust`,请求头带 `auth_token` | `code-confirmed` | 自动处理对 marketing token 有明确硬依赖。 |
|
||||
| `setDisposeLog` 成功 / 失败 / 异常写入 | 自动处理规则各分支都写 `setDisposeLog` | `code-confirmed` | 处置日志写入分支存在。 |
|
||||
| 短信发送通道 | 自动处理规则定义 `msgFC`,但成功分支短信代码被注释 | `implementation intent exists but not rigorous / buggy` | 说明短信意图存在,但当前读取到的有效工作流未严格启用。 |
|
||||
| `pending_ids` / `new_pending_ids` 严格统一 | skill/reference 有目标要求,但规则层 `consNo` 与 `eventId` 混用 | `implementation intent exists but not rigorous / buggy` | 当前身份模型不统一,不能写成严谨既成事实。 |
|
||||
|
||||
## 9. 当前代码疑点 / 不严谨点
|
||||
|
||||
1. 最关键的不严谨点是身份不一致:monitor pending list 以 `consNo` 作为待处理标识,而 dispose dedupe 以 `eventId` 作为去重标识。这会让 `pending_ids`、`new_pending_ids` 与“已处置集合”的语义难以严格对齐,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
2. packaged collector 与规则资产输出命名仍不一致:collector 使用 `scene`、`pending_ids`、`new_pending_ids`,规则对象使用 `type`、`pendingList`,证据等级:`code-confirmed`。
|
||||
3. `SKILL.md` 明确要求把 outage collection 与 service-order enrichment 分离理解;当前规则确实这样做了,但 packaged stub 没有承载这层结构,因此如果 command-center 只读 packaged stub 会低估真实 workflow,证据等级:`code-confirmed`。
|
||||
4. 自动处理强依赖 marketing token,但 `scene.json` 现有正式 dependencies 没把它显式列出;`scene.draft.json` 已把这点作为待确认项,说明标准依赖建模尚未闭合,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
5. 自动处理规则中短信发送函数虽然存在,但主成功路径短信代码被注释,说明短信通道更像保留意图而非当前可靠工作流,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
6. 本文不能根据规则中存在自动派单和音频分支,就声称这些分支已经过运行时验证;任何这种表述都应避免。
|
||||
|
||||
## 10. 对 command-center 标准配置的修订建议
|
||||
|
||||
1. 对本场景应显式拆分两层证据:
|
||||
- `packaged_collector`: `collect_outage_events.js` 的 runtime snapshot collector、状态判定、历史比较与 metadata(规则来源、配置基础页角色、身份模型),证据等级:`code-confirmed`;
|
||||
- `rule_asset_workflow`: 规则资产中的 outage collection、service-order enrichment、历史比较与自动处理流程,证据等级:`code-confirmed`。
|
||||
2. 标准工作流建议强制拆成五段:
|
||||
- `outage_collection`
|
||||
- `service_order_enrichment`
|
||||
- `monitor_log_comparison`
|
||||
- `dispose_log_dedupe`
|
||||
- `marketing_token_dependent_auto_processing`
|
||||
这些拆分都能由现有规则资产直接支撑,证据等级:`code-confirmed`。
|
||||
3. 标准配置中应单独增加 `identity_model_note`,明确当前监测 pending list 基于 `consNo`,而 dispose dedupe 基于 `eventId`,两者尚未统一,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
4. 对 dependencies 建议把 `marketing-token-context` 提升为显式依赖项,因为自动处理规则确实直接读取并使用营销 token,证据等级:`code-confirmed`;但“如何在标准 scene schema 中表达”目前仍是配置整理问题,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
5. 对输出 schema 建议区分:
|
||||
- `canonical_snapshot_fields`: `pending_ids` / `new_pending_ids` 等标准字段;
|
||||
- `observed_rule_fields`: `pendingList` / `type` 等规则字段。
|
||||
并额外记录 `pending_identity = consNo`、`dispose_identity = eventId` 的差异,避免误建模。
|
||||
|
||||
## 11. 最终严谨结论
|
||||
|
||||
关于 `jiayuguan-meter-outage`,当前最可靠的结论是:仓库已经存在一个可测试的 packaged JS runtime collector,以及两份更强的 desk 规则脚本实现(`D:\desk\智能体资料\大四区报告监测项\户表失电-嘉峪关_业务监测配置.txt`、`D:\desk\智能体资料\大四区报告监测项\户表失电-嘉峪关_自动处理配置.txt`)。其中 packaged collector 已直接实现 outage/service-order 归一化、monitor/dispose log 比较、`new_pending_ids` 推导与 `success/partial/empty/blocked` 状态判定;业务监测规则直接证实了 outage collection、service-order enrichment、monitor-log comparison、dispose-log dedupe 与音频提醒 / 监测日志写入;自动处理规则则直接证实了依赖 marketing token 的用户归属查询、工单编号获取、自动派单请求以及音频 / 处置日志副作用分支,证据等级:`code-confirmed`。
|
||||
|
||||
但同样必须严格说明:更强 workflow 证据主要在 desk 规则资产中,而不是 packaged collector;因此不能把本场景描述成“packaged collector 已严谨实现全部实时业务流程”。此外,当前实现仍存在关键身份不一致问题:monitor pending list 使用 `consNo`,dispose dedupe 使用 `eventId`。这说明本场景虽然 workflow 证据较强,但 `pending_ids` / `new_pending_ids` 的统一身份模型仍不严谨,最适合被描述为“packaged collector 已具备输入驱动快照归一化能力、desk rule-asset workflow 较强、且身份键需要在 command-center 标准配置中显式澄清”的 monitor scene。
|
||||
@@ -0,0 +1,143 @@
|
||||
# jinchang-business-environment-weekly-report 操作分析
|
||||
|
||||
## 1. 场景概述
|
||||
|
||||
`jinchang-business-environment-weekly-report` 对应“国网金昌供电公司营商环境周例会报告”场景,目标是采集多来源指标并组装为分区结构化周报。根据 `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\jinchang-business-environment-weekly-report\scene.json`、`D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jinchang-business-environment-weekly-report\SKILL.md` 与 `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jinchang-business-environment-weekly-report\scripts\collect_business_environment_metrics.js`,当前已被代码直接证实的是:打包脚本定义了四个 section template、空主表、`period` 字段、`status: "ok"` 与 `partial_reasons: []`,证据等级:`code-confirmed`。
|
||||
|
||||
同时必须明确说明:当前 packaged script 更强地定义了 artifact schema / section template,而没有同等强度地定义真实浏览器采集、跨系统查询、period 对齐或导出执行逻辑。换言之,本场景当前更像“结构化周报模板脚本”,而不是“已被脚本严格实现的多源实时采集器”,证据等级:`code-confirmed`。
|
||||
|
||||
## 2. 证据来源
|
||||
|
||||
本分析统一只使用四个证据等级标签:`code-confirmed`、`contract-defined`、`implementation intent exists but not rigorous / buggy`、`no direct evidence / candidate only`。凡涉及脚本直接定义的 schema / section template,标为 `code-confirmed`;凡涉及将真实采集结果映射进这些结构的运行语义,如脚本未直接实现,则不高于 `contract-defined`。
|
||||
|
||||
1. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jinchang-business-environment-weekly-report\scripts\collect_business_environment_metrics.js`
|
||||
- 直接定义四个 section template:`abnormal-transformer-monitoring`、`power-outage-monitoring`、`work-order-acceptance`、`dispatch-summary`,并返回空 artifact,证据等级:`code-confirmed`。
|
||||
2. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jinchang-business-environment-weekly-report\SKILL.md`
|
||||
- 说明应读取周范围、校验会话、收集多个 metric group、映射到 report sections、必要时标记 partial,并在输出里返回 `region`、`period`、缺失 section、周期对齐问题等。它主要定义目标契约与运行意图,证据等级以 `contract-defined` 和 `implementation intent exists but not rigorous / buggy` 为主。
|
||||
3. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\jinchang-business-environment-weekly-report\scene.json`
|
||||
- 声明场景输入为 `period`,依赖包括 `browser`、`multi-source`、`local-report-service`,动作包括 `query` / `collect-report` / `aggregate-sections`,证据等级:`code-confirmed`。
|
||||
4. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jinchang-business-environment-weekly-report\references\collection-flow.md`
|
||||
- 描述周范围读取、跨系统会话校验、多指标组采集、section 装配与下游导出关系,证据等级:`contract-defined`。
|
||||
5. `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\jinchang-business-environment-weekly-report\references\data-quality.md`
|
||||
- 描述完整结果、partial 规则、弱点区域与 empty/failure 区分,证据等级:`contract-defined`。
|
||||
6. `D:\data\ideaSpace\rust\sgClaw\claw-new\docs\superpowers\specs\2026-04-08-command-center-virtual-employee-inventory.json`
|
||||
- 归纳出 workflow、key_fields、status_model 等 command-center 视图;它能帮助识别当前整理结果,但不应被当成比原始 scene/skill/script 更强的实现证据,证据等级:`no direct evidence / candidate only`(仅限 inventory 不能单独证明 packaged script 已实现的部分)。
|
||||
|
||||
## 3. 实际入口与运行边界
|
||||
|
||||
实际入口在 `scene.json` 中已固定:场景页面入口为 `index.html`,技能调用为 `jinchang-business-environment-weekly-report.collect_business_environment_metrics`,输出 artifact 类型为 `report-artifact`,这些都属于 `code-confirmed`。
|
||||
|
||||
运行边界方面,当前仓库能确认的内容是:
|
||||
|
||||
- 对外输入名为 `period`,证据等级:`code-confirmed`。
|
||||
- 需要浏览器页面、多源系统访问与本地报告服务,证据等级:`code-confirmed`。
|
||||
- 参考资料要求按周范围收集多个指标组并组装 section,证据等级:`contract-defined`。
|
||||
|
||||
但“真实 collector 已在 packaged script 中实现多源访问、登录态校验、周期一致性检查”这一说法并不成立。当前脚本只返回空 section 模板,因而其可直接证明的运行边界仍是 schema stub;多源采集与组装仅体现为明确实现意图,而非已严格落地逻辑,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 4. 代码已证实的实际操作流程
|
||||
|
||||
当前代码能严格确认的实际操作流程如下:
|
||||
|
||||
1. 调用 `collectBusinessEnvironmentMetrics(input)`。
|
||||
2. 读取 `input.period || ""` 写入 artifact 的 `period`。
|
||||
3. 构造空主表:`columns: []`、`rows: []`。
|
||||
4. 基于 `SECTION_TEMPLATES` 复制出 4 个 section,并确保每个 section 的 `rows: []`。
|
||||
5. 返回 `type: "report-artifact"`、`report_name`、`status: "ok"`、`partial_reasons: []`。
|
||||
|
||||
这些步骤均可在 `collect_business_environment_metrics.js` 中直接定位,证据等级:`code-confirmed`。
|
||||
|
||||
以下步骤虽然在 `SKILL.md` 与 reference 中多次出现,但并未被脚本直接实现:读取页面周范围、校验多源 token/session、采集变压器监测/停电监测/工单受理/调度总结等真实数据、检查 period alignment、生成最终文档或导出结果。这些内容不能写成“代码已证实的实际流程”,最多只能分别标记为 `contract-defined` 或 `implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 5. 标准化抽象流程
|
||||
|
||||
若做 command-center 的标准化抽象,本场景可整理为:
|
||||
|
||||
1. 接收周报任务输入。
|
||||
2. 解析页面周范围并绑定会话上下文。
|
||||
3. 访问多个业务来源,按指标组采集数据。
|
||||
4. 按四类 section 模板/列结构承载结果。
|
||||
5. 形成统一 `report-artifact`。
|
||||
6. 视情况执行导出/日志等下游动作。
|
||||
|
||||
其中第 4 步仅“四类 section 名称与列结构存在”是 `code-confirmed`;“真实采集结果已被映射进四类 section”仍只属于 `contract-defined` 的流程约定。第 2、3、6 步主要来自 skill/reference 的运行说明,证据等级应为 `contract-defined`。如果把这些步骤进一步写成“当前 packaged script 已可靠执行”,就会过度推断,证据等级只能降为 `implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 6. 输入、上下文与依赖
|
||||
|
||||
### 输入
|
||||
|
||||
- `period` 是 scene 与脚本已共同声明的业务输入,证据等级:`code-confirmed`。
|
||||
- `SKILL.md` 还要求输出中包含 `region`,但 scene 输入与 script 返回结构都未显式声明 `region` 字段,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
### 运行上下文
|
||||
|
||||
- `session`、多源系统可访问性、缓存 token 可用性等在 scene/reference 中被描述,scene 元数据层面的存在是 `code-confirmed`,更具体的业务语义是 `contract-defined`。
|
||||
- 页面历史报告区、执行日志区被 reference 提到,但被明确描述为下游历史/辅助区域,而非主数据源,证据等级:`contract-defined`。
|
||||
|
||||
### 依赖
|
||||
|
||||
- `browser`、`multi-source`、`local-report-service` 可直接在 scene 中定位,证据等级:`code-confirmed`。
|
||||
- `/a_js/YPTAPI.js`、`http://localhost:13313/ReportServices/*`、导出或 surface 服务来自 reference,证据等级:`contract-defined`。
|
||||
|
||||
## 7. 输出结构
|
||||
|
||||
当前脚本直接证实的输出结构包括:
|
||||
|
||||
- `type: "report-artifact"`
|
||||
- `report_name: "jinchang-business-environment-weekly-report"`
|
||||
- `period`
|
||||
- `columns: []`
|
||||
- `rows: []`
|
||||
- `sections` 包含 4 个固定模板
|
||||
- `status: "ok"`
|
||||
- `partial_reasons: []`
|
||||
|
||||
这些均属于 `code-confirmed`。
|
||||
|
||||
四个固定 section template 分别为:
|
||||
|
||||
1. `abnormal-transformer-monitoring`
|
||||
2. `power-outage-monitoring`
|
||||
3. `work-order-acceptance`
|
||||
4. `dispatch-summary`
|
||||
|
||||
它们的列结构也都在脚本中已明确定义,证据等级:`code-confirmed`。
|
||||
|
||||
但 `SKILL.md` 输出部分提到应返回 `region`、missing sections、period alignment issues、downstream export/logging failures。除 `period` 与空 `partial_reasons` 字段外,其余诊断信息都没有在脚本中被明确建模。尤其是 `region` 出现在输出文案中,却没有进入 artifact schema,这是一处场景特定的不一致点,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
|
||||
## 8. 下游动作证据表
|
||||
|
||||
| 下游动作 | 当前证据 | 证据等级 | 严谨结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 返回分区化 `report-artifact` | `collect_business_environment_metrics.js` 直接返回对象 | `code-confirmed` | 已有稳定 artifact 壳,但内容为空模板。 |
|
||||
| 四类 section 模板存在 | 脚本直接定义 `SECTION_TEMPLATES` | `code-confirmed` | 只能确认 section schema 已确定,不能确认 section 数据采集已实现。 |
|
||||
| 多源指标采集 | 只在 `SKILL.md` / `collection-flow.md` 中描述 | `contract-defined` | 契约上明确需要多源采集,但当前 packaged script 未直接证明。 |
|
||||
| 周期一致性判断 | `SKILL.md` / `data-quality.md` 提到 period alignment | `contract-defined` | 存在质量要求,但脚本没有 period alignment 逻辑。 |
|
||||
| 导出周报文档 | reference 提到 localhost export/surface services | `contract-defined` | 属于下游依赖定义,不等于当前 skill 已执行文档导出。 |
|
||||
| 报告日志写入 | `SKILL.md` / reference 提到 report-log | `contract-defined` | 只能确认有该下游概念,当前脚本没有调用证据。 |
|
||||
| `partial` 结果建模 | 脚本保留 `partial_reasons`,reference 定义 partial 语义 | `implementation intent exists but not rigorous / buggy` | 字段壳子存在,但没有真实 partial 分支。 |
|
||||
| `region` 输出 | 只在 `SKILL.md` 输出说明中出现 | `implementation intent exists but not rigorous / buggy` | 表达上存在地区语义,但未进入 scene 输入或 artifact schema。 |
|
||||
|
||||
## 9. 当前代码疑点 / 不严谨点
|
||||
|
||||
1. `region` 出现在 `SKILL.md` 的输出项中,但 scene.json 与脚本 schema 都没有显式 `region` 字段;这意味着“金昌”可能只是场景名称隐含语义,而非可追踪输出字段,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
2. 脚本固定返回空 `columns` 与空 `rows`,说明主表并不是核心结构,真正的核心是 4 个 section template;如果 command-center 仍把它当通用主表型报表,容易误建模,证据等级:`code-confirmed`。
|
||||
3. `status` 固定为 `"ok"`,与 skill/reference 所要求的 partial / empty / blocked 区分不一致,证据等级:`code-confirmed` 对现状成立,而目标状态模型仅为 `contract-defined`。
|
||||
4. 参考资料强调多源系统会话与 token 缓存,但脚本完全没有这些依赖的执行路径,因此“多源采集能力已落地”不能被提升为当前代码事实,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
5. 导出与报告历史区域在 reference 中存在,但未被脚本直接接入;若在 command-center 中直接把它配置为“可导出 Word/Excel”现状,将属于过度推断,证据等级:`no direct evidence / candidate only`(就 packaged script 执行层而言)。
|
||||
|
||||
## 10. 对 command-center 标准配置的修订建议
|
||||
|
||||
1. 本场景应把核心输出建模为 `section-based report artifact`,而不是普通二维表。原因是脚本对四个 section template 的定义明显强于对主表的定义,证据等级:`code-confirmed`。
|
||||
2. 在标准配置中补充 `region_semantics` 或 `fixed_region` 字段,明确“金昌”究竟只是场景命名,还是应成为可展示/可审计输出的一部分。目前这是未闭合问题,证据等级:`implementation intent exists but not rigorous / buggy`。
|
||||
3. 状态模型建议拆分:
|
||||
- 契约层声明 success / partial / empty / blocked,证据等级:`contract-defined`;
|
||||
- 实现层当前只有固定 `ok` artifact stub,证据等级:`code-confirmed`。
|
||||
4. 给配置增加 `collection_evidence` 备注,明确当前 packaged script 更偏向 section schema 模板,而不是 live browser collector,避免后续调度器把它误当已完成的实时采集技能。
|
||||
5. 对 `downstream_effects` 建议增加 `implemented: false / not-directly-proven` 之类标记,以区分“场景上需要导出”与“脚本里已执行导出”。
|
||||
|
||||
## 11. 最终严谨结论
|
||||
|
||||
关于 `jinchang-business-environment-weekly-report`,当前最可靠的现状判断是:仓库已经存在一个四分区结构化周报 artifact 模板,四个 section 的名称与列 schema 已由 packaged script 直接定义,证据等级:`code-confirmed`。
|
||||
|
||||
但“已经实现真实浏览器多源采集、周期一致性校验、section 数据组装、最终导出与日志闭环”这一更强表述没有被脚本直接证明。相关行为主要由 `SKILL.md`、`collection-flow.md`、`data-quality.md` 与 scene 元数据定义目标流程和质量要求,因此应把它理解为“有明确契约和实现意图,但当前 packaged script 主要还是 schema/section stub”。此外,`region` 在输出话术中出现、却未进入 artifact schema,是本场景当前最需要在 command-center 标准配置中澄清的不严谨点。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 异步 Browser Script 支持设计
|
||||
|
||||
## 问题
|
||||
|
||||
`collect_lineloss.js` 的 `buildBrowserEntrypointResult` 是 async 函数,但 `build_eval_js` 生成的执行代码是同步的,导致 Promise 被 JSON.stringify 序列化为 `{}`。
|
||||
|
||||
**日志表现**:
|
||||
```
|
||||
[execute_browser_script_impl] 返回成功, payload 长度: 4
|
||||
```
|
||||
返回 `{}(4字符)` 而不是实际的报表数据。
|
||||
|
||||
## 根本原因
|
||||
|
||||
`callback_backend.rs` 的 `build_eval_js` 函数:
|
||||
```javascript
|
||||
var v=(function(){return {script}})(); // 同步执行
|
||||
var t=(typeof v==='string')?v:JSON.stringify(v); // Promise -> "{}"
|
||||
```
|
||||
|
||||
当 script 返回 Promise 时,`JSON.stringify(Promise)` 返回 `{}`。
|
||||
|
||||
## 解决方案
|
||||
|
||||
修改 `build_eval_js` 支持 Promise:
|
||||
|
||||
1. 用 `await` 等待 script 执行结果
|
||||
2. 检测结果是否为 Promise,如果是则等待 resolve
|
||||
3. 保持对同步脚本的向后兼容
|
||||
|
||||
## 实现细节
|
||||
|
||||
修改 `src/browser/callback_backend.rs` 的 `build_eval_js` 函数:
|
||||
|
||||
```javascript
|
||||
(async function(){
|
||||
try {
|
||||
var v = await (function(){return {script}})();
|
||||
// 等待 Promise resolve
|
||||
if (v && typeof v.then === 'function') {
|
||||
v = await v;
|
||||
}
|
||||
var t = (typeof v === 'string') ? v : JSON.stringify(v);
|
||||
// ... 回调逻辑保持不变
|
||||
} catch(e) {}
|
||||
})()
|
||||
```
|
||||
|
||||
关键点:
|
||||
- 包装整个 IIFE 为 async
|
||||
- 用 `await` 等待 script 执行
|
||||
- 检测 Promise-like 对象 (`v.then === 'function'`)
|
||||
- 向后兼容:同步脚本直接返回值,async 脚本返回 Promise 后被 await
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `src/browser/callback_backend.rs`: 修改 `build_eval_js` 函数
|
||||
- 所有 `browser_script` 类型的 skill 自动支持 async
|
||||
|
||||
## 测试验证
|
||||
|
||||
1. 运行 `cargo test` 确保现有测试通过
|
||||
2. 端到端测试 `tq-lineloss-report.collect_lineloss` 返回实际数据而非 `{}`
|
||||
3. 验证同步脚本(如知乎热榜)仍然正常工作
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 不修改 `wrap_browser_script`(方案 C 的做法)
|
||||
- 不修改 skill 脚本本身
|
||||
47
docs/superpowers/specs/2026-04-13-async-eval-then-fix.md
Normal file
47
docs/superpowers/specs/2026-04-13-async-eval-then-fix.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 修复 build_eval_js 异步支持 + validatePageContext 诊断日志
|
||||
|
||||
## 问题描述
|
||||
|
||||
1. `collect_lineloss.js` 的 `buildBrowserEntrypointResult` 是 async 函数,返回 Promise
|
||||
2. 当前同步版 `build_eval_js` 中 `JSON.stringify(Promise)` = `"{}"`
|
||||
3. 之前的 async IIFE 方案导致 `page_context_unavailable`(原因待排查)
|
||||
|
||||
## 方案
|
||||
|
||||
### 修改1: build_eval_js 使用 .then() 分支
|
||||
|
||||
文件:`src/browser/callback_backend.rs` - `build_eval_js` 函数
|
||||
|
||||
逻辑:
|
||||
1. 外层 IIFE 保持同步(兼容 C++ 注入层)
|
||||
2. 将回调发送逻辑提取为 `_s` 函数
|
||||
3. 如果返回值是 Promise(有 `.then` 方法),用 `.then(_s)` 异步等待结果
|
||||
4. 否则直接同步调用 `_s(v)`
|
||||
|
||||
```javascript
|
||||
(function(){try{
|
||||
var v=(function(){return {script}})();
|
||||
function _s(v){
|
||||
var t=(typeof v==='string')?v:JSON.stringify(v);
|
||||
try{callBackJsToCpp(...);}catch(_){}
|
||||
var j=JSON.stringify({...});
|
||||
try{XHR...}catch(_){}
|
||||
try{sendBeacon...}catch(_){}
|
||||
}
|
||||
if(v&&typeof v.then==='function'){v.then(_s).catch(function(){});}
|
||||
else{_s(v);}
|
||||
}catch(e){}})()
|
||||
```
|
||||
|
||||
### 修改2: validatePageContext 添加诊断日志
|
||||
|
||||
文件:`D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\tq-lineloss-report\scripts\collect_lineloss.js`
|
||||
|
||||
在 `validatePageContext` 每个检查点添加 console.log,记录 host、expected_domain、mac 状态。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `cargo test` 通过
|
||||
2. 编译后拷贝 exe 到线上
|
||||
3. 执行 skill,确认不再返回 `{}`
|
||||
4. 如果出现 `page_context_unavailable`,查看浏览器控制台日志
|
||||
55
docs/superpowers/specs/2026-04-13-expected-domain-arg-fix.md
Normal file
55
docs/superpowers/specs/2026-04-13-expected-domain-arg-fix.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 修复 Browser Script Skill Tool expected_domain 参数丢失问题
|
||||
|
||||
## 问题描述
|
||||
|
||||
`tq-lineloss-report.collect_lineloss` skill 执行时返回 `status=blocked row=0 reasons=missing_expected_domain` 错误。
|
||||
|
||||
## 根本原因
|
||||
|
||||
`src/compat/browser_script_skill_tool.rs` 中 `execute_browser_script_impl` 函数:
|
||||
|
||||
```rust
|
||||
// 第 183 行:从 args 中移除 expected_domain
|
||||
let raw_expected_domain = match args.remove("expected_domain") {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => value,
|
||||
// ...
|
||||
};
|
||||
|
||||
// 第 200 行:规范化域名(去掉 scheme、port 等)
|
||||
let expected_domain = match normalize_domain_like(&raw_expected_domain) {
|
||||
Some(value) => value,
|
||||
// ...
|
||||
};
|
||||
|
||||
// 第 234 行:包装脚本时,args 中已经没有 expected_domain 了!
|
||||
let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone()));
|
||||
```
|
||||
|
||||
`args.remove()` 会从 HashMap 中删除键值对,后续 `wrap_browser_script()` 传入的 args 不包含 `expected_domain`,导致 JS 脚本中 `const args = {...}` 缺少该字段。
|
||||
|
||||
## 解决方案
|
||||
|
||||
在规范化域名后,将 `expected_domain` 重新插入 args。
|
||||
|
||||
### 修改位置
|
||||
|
||||
文件:`src/compat/browser_script_skill_tool.rs`
|
||||
行号:第 209 行后(`expected_domain` 赋值之后、`for required_arg` 循环之前)
|
||||
|
||||
### 修改内容
|
||||
|
||||
```rust
|
||||
// 第 209 行后添加:
|
||||
args.insert("expected_domain".to_string(), Value::String(expected_domain.clone()));
|
||||
```
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 只影响 `browser_script_skill_tool.rs`
|
||||
- 所有使用 `expected_domain` 的 browser_script skill 都会受益
|
||||
- 无破坏性变更
|
||||
|
||||
## 验证方法
|
||||
|
||||
1. 运行现有测试:`cargo test browser_script_skill_tool`
|
||||
2. 内网验证:执行 `tq-lineloss-report.collect_lineloss` skill
|
||||
48
docs/superpowers/specs/2026-04-13-lineloss-requesturl-fix.md
Normal file
48
docs/superpowers/specs/2026-04-13-lineloss-requesturl-fix.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 台区线损 Skill - requesturl 快速修复方案
|
||||
|
||||
## 问题背景
|
||||
|
||||
`sgHideBrowerserOpenPage` 命令需要 `requesturl` 参数(发起调用的页面 URL),但当前台区线损指令解析时返回 `about:blank`,导致浏览器不执行命令。
|
||||
|
||||
知乎热榜场景正常工作,因为 `derive_request_url_from_instruction` 返回了 `https://www.zhihu.com`。
|
||||
|
||||
## 设计方案
|
||||
|
||||
**方案:在 `derive_request_url_from_instruction` 中添加台区线损 URL 映射**
|
||||
|
||||
### 修改位置
|
||||
|
||||
`src/service/server.rs` - `derive_request_url_from_instruction` 函数
|
||||
|
||||
### 修改内容
|
||||
|
||||
```rust
|
||||
fn derive_request_url_from_instruction(instruction: &str) -> Option<String> {
|
||||
// 已有:知乎相关(保持不变)
|
||||
if crate::compat::workflow_executor::detect_route(instruction, None, None)
|
||||
.is_some_and(|route| { ... })
|
||||
{
|
||||
return Some("https://www.zhihu.com".to_string());
|
||||
}
|
||||
|
||||
// 新增:台区线损相关
|
||||
// TODO: 临时方案,后续应从 skill 配置或 deterministic_submit 解析结果中获取
|
||||
if instruction.contains("线损") || instruction.contains("lineloss") {
|
||||
return Some("http://20.76.57.61:18080".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
```
|
||||
|
||||
### 约束条件
|
||||
|
||||
- URL 为硬编码,后续需重构为通用方案
|
||||
- 仅匹配指令中包含"线损"或"lineloss"的场景
|
||||
|
||||
## 后续规划
|
||||
|
||||
将实现通用方案:
|
||||
- 从 `DeterministicExecutionPlan.expected_domain` 构造完整 URL
|
||||
- 或从 skill 配置文件中读取 target URL
|
||||
- 调整流程顺序,先解析 skill 再打开 helper page
|
||||
36
docs/superpowers/specs/2026-04-13-lineloss-target-url-fix.md
Normal file
36
docs/superpowers/specs/2026-04-13-lineloss-target-url-fix.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 台区线损 Skill - target_url 缺失修复方案
|
||||
|
||||
## 问题背景
|
||||
|
||||
`browser_script_skill_tool.rs` 调用 `Action::Eval` 时只传了 `script` 参数,没有传 `target_url`。`callback_backend.rs` 的 `target_url` 方法需要从 params 或 `current_target_url` 获取值,两者都没有时报错。
|
||||
|
||||
知乎热榜正常工作是因为先执行了 `Action::Navigate`,设置了 `current_target_url`。
|
||||
|
||||
## 设计方案
|
||||
|
||||
**方案:在 `browser_script_skill_tool.rs` 的 params 中添加 `target_url`**
|
||||
|
||||
### 修改位置
|
||||
|
||||
`src/compat/browser_script_skill_tool.rs` - `execute_browser_script_impl` 函数
|
||||
|
||||
### 修改内容
|
||||
|
||||
在调用 `browser_tool.invoke(Action::Eval, ...)` 时,从 `expected_domain` 构造完整 URL 并添加到 params:
|
||||
|
||||
```rust
|
||||
let target_url = format!("http://{}", expected_domain);
|
||||
let result = match browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": wrapped_script,
|
||||
"target_url": target_url,
|
||||
}),
|
||||
&expected_domain,
|
||||
) {
|
||||
```
|
||||
|
||||
### 约束条件
|
||||
|
||||
- 使用 `http://` 协议前缀
|
||||
- `expected_domain` 可能包含端口号(如 `20.76.57.61:18080`),直接拼接即可
|
||||
@@ -0,0 +1,84 @@
|
||||
# Remove mac Guard from validatePageContext
|
||||
|
||||
## Date
|
||||
|
||||
2026-04-13
|
||||
|
||||
## Problem
|
||||
|
||||
`tq-lineloss-report` skill execution reports `status=blocked rows=0 reasons=page_context_unavailable`.
|
||||
|
||||
Diagnostic instrumentation confirmed:
|
||||
|
||||
```
|
||||
href=http://20.76.57.61:18080/gsllys
|
||||
host=20.76.57.61
|
||||
port=18080
|
||||
title=台区线损大数据分析模块
|
||||
mac=false
|
||||
```
|
||||
|
||||
The script executes on the correct domain but `globalThis.mac` does not exist, triggering the `page_context_unavailable` guard.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`window.mac` is a Vue instance created by the **original scene page** (`index.html`), assigned via `window.mac = this` in `mounted()`. The original scene page acts as a controller that injects JS into the business page via `BrowserAction('sgBrowserExcuteJsCode', exactURL, jsCode)`.
|
||||
|
||||
In the skill execution model, there is no scene page. The script is injected directly via `sgBrowserExcuteJsCodeByDomain` onto a page matching the domain. No Vue instance is created, so `globalThis.mac` is always `undefined`. The `mac` check is architecturally invalid for the skill model.
|
||||
|
||||
Additionally, `sgBrowserExcuteJsCodeByDomain("20.76.57.61")` matches the parent frame page (`/gsllys`) rather than the business sub-page (`/gsllys/tqLinelossStatis/tqQualifyRateMonitor`). This is acceptable because the skill script makes direct HTTP requests with absolute URLs and does not depend on page-local state.
|
||||
|
||||
## Design
|
||||
|
||||
Remove the `globalThis.mac` existence check from `validatePageContext` in `collect_lineloss.js`. Retain the `host` matching check as a basic domain guard.
|
||||
|
||||
Also clean up the temporary diagnostic code (`diag` variable, `console.log` statements, enriched reason strings) added during debugging.
|
||||
|
||||
### Before
|
||||
|
||||
```javascript
|
||||
validatePageContext(args) {
|
||||
const host = normalizeText(globalThis.location?.hostname);
|
||||
const port = normalizeText(globalThis.location?.port);
|
||||
const href = normalizeText(globalThis.location?.href);
|
||||
const title = normalizeText(globalThis.document?.title);
|
||||
const expected = normalizeText(args.expected_domain);
|
||||
const hasMac = !!globalThis.mac;
|
||||
const diag = 'href=' + href + '|host=' + host + '|port=' + port + '|title=' + title + '|mac=' + hasMac;
|
||||
console.log('[validatePageContext] ' + diag);
|
||||
if (!host) {
|
||||
return { ok: false, reason: 'page_context_unavailable:host_empty|' + diag };
|
||||
}
|
||||
if (host !== expected) {
|
||||
return { ok: false, reason: 'page_context_mismatch:host=' + host + ',expected=' + expected + '|' + diag };
|
||||
}
|
||||
if (!hasMac) {
|
||||
return { ok: false, reason: 'page_context_unavailable:mac_missing|' + diag };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
```
|
||||
|
||||
### After
|
||||
|
||||
```javascript
|
||||
validatePageContext(args) {
|
||||
const host = normalizeText(globalThis.location?.hostname);
|
||||
const expected = normalizeText(args.expected_domain);
|
||||
if (!host) {
|
||||
return { ok: false, reason: 'page_context_unavailable' };
|
||||
}
|
||||
if (host !== expected) {
|
||||
return { ok: false, reason: 'page_context_mismatch' };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js` — `validatePageContext` function only
|
||||
|
||||
## No Recompilation Required
|
||||
|
||||
The JS file is read at runtime via `fs::read_to_string`. No Rust code changes.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Rust-Side Lineloss XLSX Export
|
||||
|
||||
## Problem
|
||||
|
||||
`collect_lineloss.js` runs on a remote page (`http://20.76.57.61:18080/gsllys`).
|
||||
The script successfully queries API data (12 rows), but cannot call
|
||||
`http://localhost:13313/.../faultDetailsExportXLSX` because the browser blocks
|
||||
cross-origin requests from a remote page to `localhost`.
|
||||
|
||||
The original scene architecture had a local scene page acting as a proxy,
|
||||
but skill mode has no local page -- so export is architecturally impossible
|
||||
from the browser side.
|
||||
|
||||
## Decision
|
||||
|
||||
Move XLSX generation to the Rust side. JS only collects data; Rust generates
|
||||
the `.xlsx` file locally after receiving the artifact.
|
||||
|
||||
Report log (`setReportLog`) is deferred to a later iteration.
|
||||
|
||||
## Design
|
||||
|
||||
### JS Changes (`collect_lineloss.js`)
|
||||
|
||||
1. Remove `exportWorkbook()` call and `writeReportLog()` call
|
||||
2. Return artifact with `rows` array and `column_defs` array
|
||||
3. Status is `ok` when rows > 0, `empty` when rows == 0, `error`/`blocked` unchanged
|
||||
|
||||
Artifact shape:
|
||||
```json
|
||||
{
|
||||
"type": "report-artifact",
|
||||
"report_name": "tq-lineloss-report",
|
||||
"status": "ok",
|
||||
"org": { "label": "...", "code": "..." },
|
||||
"period": { "mode": "month", "value": "2026-03" },
|
||||
"column_defs": [["ORG_NAME","供电单位"], ["YGDL","累计供电量"], ...],
|
||||
"rows": [
|
||||
{"ORG_NAME":"xxx", "YGDL":"12345.67", ...}
|
||||
],
|
||||
"counts": { "rows": 12 }
|
||||
}
|
||||
```
|
||||
|
||||
### Rust Changes
|
||||
|
||||
#### New file: `src/compat/lineloss_xlsx_export.rs`
|
||||
|
||||
Generates a standard `.xlsx` file using `zip` crate + OpenXML XML strings.
|
||||
Follows the pattern established in `openxml_office_tool.rs`.
|
||||
|
||||
Public API:
|
||||
```rust
|
||||
pub struct LinelossExportRequest {
|
||||
pub column_defs: Vec<(String, String)>, // (key, chinese_header)
|
||||
pub rows: Vec<Map<String, Value>>,
|
||||
pub sheet_name: String,
|
||||
pub output_path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn export_lineloss_xlsx(request: &LinelossExportRequest) -> anyhow::Result<PathBuf>;
|
||||
```
|
||||
|
||||
Internals:
|
||||
- Build header row from `column_defs[*].1` (chinese names)
|
||||
- Build data rows by looking up `column_defs[*].0` keys in each row map
|
||||
- Generate `worksheet_xml` with inline string cells
|
||||
- Package with standard OpenXML boilerplate (content_types, rels, workbook)
|
||||
- Write to `output_path`
|
||||
|
||||
#### Modified: `src/compat/deterministic_submit.rs`
|
||||
|
||||
In `execute_deterministic_submit_with_browser_backend` (and the non-backend variant):
|
||||
|
||||
```
|
||||
let output = execute_browser_script_skill_raw_output_with_browser_backend(...)?;
|
||||
let artifact = parse_lineloss_artifact(&output);
|
||||
|
||||
if artifact has rows > 0 && column_defs present:
|
||||
let export_path = workspace_root/out/tq-lineloss-{timestamp}.xlsx
|
||||
export_lineloss_xlsx(LinelossExportRequest { ... })?
|
||||
// attach export_path to outcome summary
|
||||
|
||||
Ok(summarize_lineloss_output_with_export(&output, export_path))
|
||||
```
|
||||
|
||||
#### Modified: `src/compat/mod.rs`
|
||||
|
||||
Add `pub mod lineloss_xlsx_export;`
|
||||
|
||||
### Output Path
|
||||
|
||||
`{workspace_root}/out/tq-lineloss-{org_label}-{period}-{timestamp_nanos}.xlsx`
|
||||
|
||||
### Error Handling
|
||||
|
||||
- XLSX generation failure: outcome status = `partial`, reason = `xlsx_export_failed`
|
||||
- Artifact parse failure: fall through to existing `summarize_lineloss_output`
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `collect_lineloss.js` | Modify: remove export/log calls, add rows+column_defs to artifact |
|
||||
| `src/compat/lineloss_xlsx_export.rs` | New: XLSX generation |
|
||||
| `src/compat/deterministic_submit.rs` | Modify: post-process artifact, call XLSX export |
|
||||
| `src/compat/mod.rs` | Modify: register new module |
|
||||
|
||||
## Requires Recompilation
|
||||
|
||||
Yes. Rust code changes require `cargo build`.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Helper Page Lifecycle Fix v2 — Same-Connection Close + Open
|
||||
|
||||
**Date:** 2026-04-14
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Two issues remain after v1:
|
||||
|
||||
1. **Process restart leaves orphaned helper pages**: When the sg_claw process restarts, the old helper page tab remains open in the browser. The new process opens another one.
|
||||
2. **Helper page is visible**: Uses `sgBrowerserOpenPage` (visible tab API) instead of `sgHideBrowerserOpenPage` (hidden domain API).
|
||||
|
||||
## Root Cause of v1 Failure
|
||||
|
||||
The v1 `close_helper_page` function created a **second** WebSocket connection to the browser during `Drop`. This likely conflicted with the existing bootstrap connection, causing the browser's WebSocket state to become confused.
|
||||
|
||||
## Solution
|
||||
|
||||
Send the close command on the **same** WebSocket connection used for bootstrap, before sending the open command:
|
||||
|
||||
1. Connect to browser WS
|
||||
2. Register as "web" role
|
||||
3. **Blindly send** `sgHideBrowerserClosePage(helper_url)` — closes any orphaned page from a previous process run
|
||||
4. Send `sgHideBrowerserOpenPage(helper_url)` — opens the new helper page
|
||||
5. Poll `/sgclaw/callback/ready` for page readiness
|
||||
|
||||
Both `use_hidden_domain = true` and the close+open logic are combined into a single change.
|
||||
|
||||
## Why This Works
|
||||
|
||||
- **Same connection**: Only one WebSocket connection to the browser. No conflict with existing connections.
|
||||
- **Best-effort close**: If no orphaned page exists (first run ever), the close command is silently ignored by the browser. This does not affect the subsequent open command.
|
||||
- **Fire-and-forget**: Both close and open commands use the same fire-and-forget semantics as the existing bootstrap command.
|
||||
|
||||
## API Reference
|
||||
|
||||
| API | Wire format | Effect |
|
||||
|-----|------------|--------|
|
||||
| `sgHideBrowerserOpenPage` (API #6) | `[requesturl, "sgHideBrowerserOpenPage", url]` | Opens in hidden domain |
|
||||
| `sgHideBrowerserClosePage` (API #68) | `[requesturl, "sgHideBrowerserClosePage", url]` | Closes hidden domain page |
|
||||
|
||||
## Affected Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/browser/callback_host.rs` | In `bootstrap_helper_page`: add close command before open command |
|
||||
| `src/service/server.rs` | Change `use_hidden_domain` from `false` to `true` |
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- `callback_backend.rs` — `SHOW_AREA`, `build_command` unchanged
|
||||
- `sgBrowserExcuteJsCodeByDomain` area parameter — stays `"show"`
|
||||
- Helper page HTML content — unchanged
|
||||
- `Drop for LiveBrowserCallbackHost` — remains simple (shutdown only, no close attempt)
|
||||
- `cached_host` in `mod.rs` — remains lifted to outer loop
|
||||
@@ -0,0 +1,99 @@
|
||||
# Helper Page Lifecycle Fix & Hidden Domain Support
|
||||
|
||||
**Date:** 2026-04-14
|
||||
**Status:** Approved
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Two bugs in the browser-helper.html page management:
|
||||
|
||||
1. **Duplicate helper pages**: Every WebSocket client reconnection triggers a new `serve_client()` call, which creates a new `LiveBrowserCallbackHost` and opens a new helper page via `sgBrowerserOpenPage`. The old helper page tab is never closed, causing accumulation of orphaned tabs.
|
||||
|
||||
2. **Helper page is visible**: The bootstrap uses `sgBrowerserOpenPage` (visible tab API) instead of `sgHideBrowerserOpenPage` (hidden domain API). The helper page should not be visible to the user.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Duplicate pages
|
||||
|
||||
Call chain:
|
||||
- `src/service/mod.rs:72` — outer `loop` accepts new WebSocket connections
|
||||
- `src/service/mod.rs:79` — each connection calls `serve_client()`
|
||||
- `src/service/server.rs:241` — `cached_host` declared as local variable, re-initialized to `None` each call
|
||||
- `src/service/server.rs:288` → `callback_host.rs:241` — `bootstrap_helper_page()` opens a new helper tab
|
||||
|
||||
`Drop for LiveBrowserCallbackHost` (`callback_host.rs:321-328`) only shuts down the HTTP server thread. It does not send a browser close command for the helper tab.
|
||||
|
||||
### Visible page
|
||||
|
||||
`callback_host.rs:28`: `HELPER_BOOTSTRAP_ACTION = "sgBrowerserOpenPage"` — this is the visible-domain open API (API #7). The hidden-domain equivalent is `sgHideBrowerserOpenPage` (API #6).
|
||||
|
||||
## Solution: Approach C — Incremental Fix
|
||||
|
||||
### Step 1: Fix lifecycle (immediate, deterministic fix)
|
||||
|
||||
#### 1a. Lift `cached_host` to outer loop
|
||||
|
||||
Move `cached_host: Option<Arc<LiveBrowserCallbackHost>>` from inside `serve_client()` to before the `loop` in `run_service()` (`mod.rs`). Change `serve_client()` signature to accept `&mut Option<Arc<LiveBrowserCallbackHost>>` instead of creating its own.
|
||||
|
||||
Effect: Multiple WebSocket reconnections share the same host. Helper page opens once per process lifetime.
|
||||
|
||||
#### 1b. Close helper page on Drop
|
||||
|
||||
Enhance `Drop for LiveBrowserCallbackHost`:
|
||||
- Add `browser_ws_url: String` field to `LiveBrowserCallbackHost` (stored at construction time)
|
||||
- Add `use_hidden_domain: bool` field (stored at construction time)
|
||||
- In `Drop::drop`, before shutting down the server thread:
|
||||
1. Connect to `browser_ws_url` with 100ms connection timeout
|
||||
2. Send register message
|
||||
3. Send close command: `[helper_url, close_api, helper_url]`
|
||||
- `close_api` = `"sgBrowserClosePage"` when `use_hidden_domain == false`
|
||||
- `close_api` = `"sgHideBrowerserClosePage"` when `use_hidden_domain == true`
|
||||
4. All steps are best-effort: failures are silently ignored
|
||||
5. Total timeout cap: 500ms
|
||||
|
||||
### Step 2: Hidden domain config switch (for testing/gradual rollout)
|
||||
|
||||
#### 2a. Parameter plumbing
|
||||
|
||||
- `LiveBrowserCallbackHost::start_with_browser_ws_url` gains parameter `use_hidden_domain: bool`
|
||||
- `bootstrap_helper_page` selects API based on this flag:
|
||||
- `true` → `"sgHideBrowerserOpenPage"`
|
||||
- `false` → `"sgBrowerserOpenPage"` (current behavior, default)
|
||||
- `LiveBrowserCallbackHost` stores the flag for Drop close-command selection
|
||||
|
||||
#### 2b. Caller changes
|
||||
|
||||
- `mod.rs` / `server.rs` pass `false` as default
|
||||
- To enable hidden domain, change the call site to pass `true`
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- `callback_backend.rs` `SHOW_AREA = "show"` — JS injection targets visible business pages, not the helper itself
|
||||
- `sgBrowserExcuteJsCodeByDomain` area parameter — stays `"show"` regardless of helper domain
|
||||
- Helper page HTML content — WebSocket connection and command polling JS remain the same
|
||||
- `collect_lineloss.js` — not affected
|
||||
|
||||
## Affected Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/browser/callback_host.rs` | New fields on `LiveBrowserCallbackHost`, `start_with_browser_ws_url` signature change, `Drop` enhancement, new `close_helper_page` helper fn |
|
||||
| `src/service/mod.rs` | `cached_host` lifted to outer loop, passed to `serve_client` |
|
||||
| `src/service/server.rs` | `serve_client` signature change to accept `&mut Option<Arc<LiveBrowserCallbackHost>>` |
|
||||
| Existing test files | Adapt `start_with_browser_ws_url` calls with new `use_hidden_domain` parameter |
|
||||
|
||||
## Testing
|
||||
|
||||
- Existing `callback_host` tests: adapt to new signature (add `false` parameter)
|
||||
- New unit test: `use_hidden_domain = true` → bootstrap sends `sgHideBrowerserOpenPage`
|
||||
- New unit test: `use_hidden_domain = false` → bootstrap sends `sgBrowerserOpenPage` (regression)
|
||||
- `cargo build` + `cargo test` full verification
|
||||
|
||||
## Browser API Reference
|
||||
|
||||
| API | Wire format | Effect |
|
||||
|-----|------------|--------|
|
||||
| `sgBrowerserOpenPage` (API #7) | `[requesturl, "sgBrowerserOpenPage", url]` | Opens visible tab |
|
||||
| `sgHideBrowerserOpenPage` (API #6) | `[requesturl, "sgHideBrowerserOpenPage", url]` | Opens in hidden domain |
|
||||
| `sgBrowserClosePage` (API #64) | `[requesturl, "sgBrowserClosePage", url]` | Closes visible tab |
|
||||
| `sgHideBrowerserClosePage` (API #68) | `[requesturl, "sgHideBrowerserClosePage", url]` | Closes hidden domain page |
|
||||
@@ -0,0 +1,284 @@
|
||||
# sgClaw Service Console Enhancement Design
|
||||
|
||||
## Background
|
||||
|
||||
The current `sg_claw_service_console.html` provides a basic UI for connecting to the sgClaw service WebSocket and submitting tasks. However, it requires manual connection on first load and has no way to configure the sgClaw settings (API key, model, base URL, skills directory) from the UI.
|
||||
|
||||
Users need to manually edit `sgclaw_config.json` before using the console, which is inconvenient for routine operations.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
1. Page requires manual "Connect" button click on first load
|
||||
2. No UI for configuring sgClaw runtime settings (model, API key, base URL, skills dir)
|
||||
3. Users must manually edit `sgclaw_config.json` file to change configuration
|
||||
|
||||
## Goal
|
||||
|
||||
Enhance the service console page with:
|
||||
|
||||
1. **Auto-connect on page load** - attempt WebSocket connection immediately
|
||||
2. **Settings panel** - edit sgClaw configuration fields through a friendly UI
|
||||
3. **Config save via WebSocket** - send configuration updates to the running sgClaw service, which writes them to `sgclaw_config.json`
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Auto-starting `sg_claw.exe` process (browser security limitation, deferred)
|
||||
- Changing existing `submit_task` protocol or execution flow
|
||||
- Modifying browser-helper.html or browser execution logic
|
||||
- Adding authentication or multi-user support
|
||||
- Configuration validation beyond basic field checks
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ sg_claw_service_console.html │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ Auto-connect on load │ │
|
||||
│ │ (ws://127.0.0.1:42321 default) │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ Settings Panel (Modal) │ │
|
||||
│ │ - API Key │ │
|
||||
│ │ - Base URL │ │
|
||||
│ │ - Model │ │
|
||||
│ │ - Skills Directory │ │
|
||||
│ │ - Direct Submit Skill (optional) │ │
|
||||
│ │ - Runtime Profile (dropdown) │ │
|
||||
│ │ - Browser Backend (dropdown) │ │
|
||||
│ │ [Save] [Cancel] │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ Existing: Connection + Composer │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
└──────────────┬──────────────────────────┘
|
||||
│ WebSocket
|
||||
│ submit_task / update_config
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ sg_claw.exe (service) │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ ClientMessage handler │ │
|
||||
│ │ - SubmitTask (existing) │ │
|
||||
│ │ - UpdateConfig (new) │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ Config writer │ │
|
||||
│ │ Writes to sgclaw_config.json │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Auto-connect flow:**
|
||||
- Page loads → JavaScript calls `connect()` automatically
|
||||
- If WS opens → show "已连接" chip, enable send button
|
||||
- If WS fails → show "未连接" chip, keep send disabled
|
||||
- Reconnect logic remains unchanged (existing heartbeat/reconnect)
|
||||
|
||||
2. **Config save flow:**
|
||||
- User clicks "设置" button → modal opens with current config values
|
||||
- User edits fields → clicks "保存"
|
||||
- Page sends `update_config` message via WS:
|
||||
```json
|
||||
{
|
||||
"type": "update_config",
|
||||
"config": {
|
||||
"apiKey": "...",
|
||||
"baseUrl": "...",
|
||||
"model": "...",
|
||||
"skillsDir": "...",
|
||||
"directSubmitSkill": "...",
|
||||
"runtimeProfile": "...",
|
||||
"browserBackend": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
- sgClaw service receives message → validates → writes to `sgclaw_config.json`
|
||||
- Service responds with success/error → page shows notification
|
||||
- Service reloads config in-memory (or requires restart - see below)
|
||||
|
||||
### Protocol Changes
|
||||
|
||||
#### New ClientMessage variant
|
||||
|
||||
Add to `src/service/protocol.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
Connect,
|
||||
Start,
|
||||
Stop,
|
||||
SubmitTask { ... },
|
||||
Ping,
|
||||
UpdateConfig { // NEW
|
||||
config: ConfigUpdatePayload,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigUpdatePayload {
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub skills_dir: Option<String>,
|
||||
pub direct_submit_skill: Option<String>,
|
||||
pub runtime_profile: Option<String>,
|
||||
pub browser_backend: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### New ServiceMessage variant (optional)
|
||||
|
||||
Add to `src/service/protocol.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServiceMessage {
|
||||
StatusChanged { state: String },
|
||||
LogEntry { level: String, message: String },
|
||||
TaskComplete { success: bool, summary: String },
|
||||
Busy { message: String },
|
||||
Pong,
|
||||
ConfigUpdated { success: bool, message: String }, // NEW
|
||||
}
|
||||
```
|
||||
|
||||
### Config Persistence
|
||||
|
||||
The service will:
|
||||
|
||||
1. Load current `sgclaw_config.json` from the config path (derived from process args)
|
||||
2. Merge incoming `ConfigUpdatePayload` fields (only non-null fields are updated)
|
||||
3. Write the merged config back to the same file
|
||||
4. Respond with success/error message
|
||||
5. **Hot reload**: The service should reload config in-memory without requiring restart
|
||||
|
||||
**Important:** If the config file path cannot be resolved (no `--config-path` arg), the service should respond with an error message indicating that config updates are not supported in env-var-only mode.
|
||||
|
||||
### UI Design
|
||||
|
||||
#### Settings Button
|
||||
|
||||
- Add a "设置" button in the sidebar, below the existing connect button
|
||||
- Styled as a ghost button with a gear icon (using unicode ⚙ or CSS-only icon)
|
||||
|
||||
#### Settings Modal
|
||||
|
||||
- Overlay modal with centered card
|
||||
- Form fields with labels in Chinese:
|
||||
- `API 密钥` (apiKey) - password input type with show/hide toggle
|
||||
- `模型服务地址` (baseUrl) - text input
|
||||
- `模型名称` (model) - text input
|
||||
- `Skills 目录路径` (skillsDir) - text input with path validation
|
||||
- `直接提交技能` (directSubmitSkill) - text input (optional, can be empty)
|
||||
- `运行模式` (runtimeProfile) - dropdown: `browser-attached` / `service-standalone`
|
||||
- `浏览器后端` (browserBackend) - dropdown: `super-rpa` / `pipe` / `none`
|
||||
- [保存] primary button, [取消] ghost button
|
||||
- Validation:
|
||||
- API Key and Model are required (show red error if empty on save)
|
||||
- Base URL must be a valid URL format
|
||||
- Skills Dir must be a valid path format
|
||||
- Other fields are optional
|
||||
|
||||
#### Connection State Auto-detection
|
||||
|
||||
- On page load, call `connect()` automatically
|
||||
- Connection state chip updates as before
|
||||
- Reconnect logic (existing) remains unchanged
|
||||
|
||||
### File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/service-console/sg_claw_service_console.html` | Add auto-connect on load, settings modal UI, save logic |
|
||||
| `src/service/protocol.rs` | Add `UpdateConfig` variant and `ConfigUpdatePayload` struct |
|
||||
| `src/service/protocol.rs` | Add `ConfigUpdated` service message variant |
|
||||
| `src/service/server.rs` | Handle `UpdateConfig` message, merge config, write file |
|
||||
| `src/agent/task_runner.rs` | Add `pub fn config_path(&self) -> Option<&Path>` getter to `AgentRuntimeContext` |
|
||||
| `src/config/settings.rs` | Add `save_to_path()` method for writing config to file |
|
||||
| `tests/service_console_html_test.rs` | Add assertions for settings modal and update_config message |
|
||||
|
||||
### Config Save Implementation
|
||||
|
||||
In `src/service/server.rs`, when handling `UpdateConfig`:
|
||||
|
||||
```rust
|
||||
ClientMessage::UpdateConfig { config } => {
|
||||
// 1. Load current config from config_path
|
||||
let config_path = runtime_context.config_path(); // needs to be exposed
|
||||
let current = SgClawSettings::load(config_path.as_deref())?;
|
||||
|
||||
// 2. Merge: only overwrite fields that are Some in the payload
|
||||
let mut merged = current.unwrap_or_default();
|
||||
if let Some(v) = config.api_key { merged.provider_api_key = v; }
|
||||
if let Some(v) = config.base_url { merged.provider_base_url = v; }
|
||||
if let Some(v) = config.model { merged.provider_model = v; }
|
||||
if let Some(v) = config.skills_dir { merged.skills_dir = Some(PathBuf::from(v)); }
|
||||
// ... etc for other fields
|
||||
|
||||
// 3. Write back to file
|
||||
merged.save_to_path(config_path.as_ref().ok_or("no config path")?)?;
|
||||
|
||||
// 4. Respond
|
||||
sink.send_service_message(ServiceMessage::ConfigUpdated {
|
||||
success: true,
|
||||
message: "配置已保存".to_string(),
|
||||
})?;
|
||||
}
|
||||
```
|
||||
|
||||
### Hot Reload Consideration
|
||||
|
||||
After saving config, the service should reload its in-memory settings. This requires:
|
||||
|
||||
1. Storing the loaded `SgClawSettings` in a reloadable container (e.g., `Arc<Mutex<SgClawSettings>>` or `Arc<RwLock<...>>`)
|
||||
2. Or, the service can respond with "配置已保存,请重启 sg_claw 以应用更改" (simpler, avoids hot reload complexity)
|
||||
|
||||
**Recommended:** Start with "requires restart" approach. Hot reload can be added later if needed.
|
||||
|
||||
### Error Handling
|
||||
|
||||
| Scenario | Response |
|
||||
|----------|----------|
|
||||
| WS not connected when saving | Show inline error: "请先连接服务" |
|
||||
| Config file not found | Service responds: "未找到配置文件,请通过 --config-path 指定" |
|
||||
| Invalid config values | Service validates and responds with specific error |
|
||||
| Write permission denied | Service responds: "无法写入配置文件,请检查文件权限" |
|
||||
| WS disconnected during save | Show error: "连接断开,保存失败,请重试" |
|
||||
|
||||
### Test Strategy
|
||||
|
||||
1. **Integration test** (`tests/service_console_html_test.rs`):
|
||||
- Assert page contains settings modal HTML
|
||||
- Assert page contains "设置" button
|
||||
- Assert page sends `update_config` message shape
|
||||
- Assert page auto-connects on load (contains `window.onload` or equivalent)
|
||||
|
||||
2. **Protocol test** (new or existing test file):
|
||||
- Assert `ClientMessage::UpdateConfig` serializes correctly
|
||||
- Assert `ServiceMessage::ConfigUpdated` deserializes correctly
|
||||
|
||||
3. **Config save test** (new test in `tests/compat_config_test.rs` or new file):
|
||||
- Create temp config file
|
||||
- Send UpdateConfig message
|
||||
- Verify file contents match expected merged config
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Page auto-connects to WS on load without manual button click
|
||||
2. Settings button visible in sidebar
|
||||
3. Settings modal opens with form fields for all configurable options
|
||||
4. Clicking "保存" sends `update_config` message via WS
|
||||
5. Service receives message and writes to `sgclaw_config.json`
|
||||
6. Service responds with success/error message
|
||||
7. Page displays save result notification
|
||||
8. Existing task submission flow unchanged
|
||||
9. Existing heartbeat/reconnect logic unchanged
|
||||
10. Automated tests pass
|
||||
@@ -0,0 +1,309 @@
|
||||
# Multi-Scene-Kind Generator Design
|
||||
|
||||
> **Status:** Draft
|
||||
> **Date:** 2026-04-16
|
||||
> **Author:** Qoder
|
||||
|
||||
## Problem Statement
|
||||
|
||||
`sg_scene_generate` 当前只支持 `report_collection` 类型的场景,强制要求场景目录的 `index.html` 包含 `sgclaw-scene-kind` 和 `sgclaw-tool-kind` meta 标签。
|
||||
|
||||
**现实情况**:
|
||||
- 400+ 第三方场景目录**没有** meta 标签
|
||||
- 场景类型不单一:既有**报表收集类**(查询数据导出 Excel),也有**监测类**(定时检查状态、监控告警)
|
||||
- 不可能要求所有第三方场景添加 meta 标签
|
||||
|
||||
**结果**:当前 `sg_scene_generate` 对真实场景目录完全无法使用。
|
||||
|
||||
## Goal
|
||||
|
||||
扩展 `sg_scene_generate` 支持多种场景类型,让用户在 Web UI 上**手动选择场景类型**,而不是依赖场景目录中的 meta 标签。
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不实现 LLM 自动识别场景类型(后续增强)
|
||||
- 不实现运行时自动推断场景类型
|
||||
- 不修改 `registry.rs` 的运行时校验逻辑(V1 仍只支持已注册的类型)
|
||||
- 不实现完整的监测类参数解析器(生成简化模板,用户手动编辑)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Web UI (HTML) │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ 场景路径输入 │ │ 场景类型下拉框│ │ 分析/生成按钮 │ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Node.js Server │
|
||||
│ /analyze → LLM 提取 scene-id, scene-name │
|
||||
│ /generate → 调用 cargo run,传递 --scene-kind 参数 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ sg_scene_generate (Rust CLI) │
|
||||
│ --scene-kind report_collection | monitoring │
|
||||
│ │
|
||||
│ analyzer.rs → 放宽 meta 校验,接受用户指定类型 │
|
||||
│ generator.rs → 根据类型选择不同模板 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. analyzer.rs — 放宽校验逻辑
|
||||
|
||||
**当前行为**:
|
||||
- 强制要求 `sgclaw-scene-kind` meta 标签 = `report_collection`
|
||||
- 强制要求 `sgclaw-tool-kind` meta 标签 = `browser_script`
|
||||
- 缺失则报错退出
|
||||
|
||||
**新行为**:
|
||||
- meta 标签**可选**
|
||||
- 如果缺失,使用用户通过 `--scene-kind` 参数指定的类型
|
||||
- 如果用户未指定,默认为 `report_collection`
|
||||
- `sgclaw-tool-kind` 默认为 `browser_script`(V1 只支持这一种)
|
||||
|
||||
**枚举扩展**:
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SceneKind {
|
||||
ReportCollection,
|
||||
Monitoring,
|
||||
}
|
||||
|
||||
impl SceneKind {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"report_collection" => Some(Self::ReportCollection),
|
||||
"monitoring" => Some(Self::Monitoring),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**函数签名变更**:
|
||||
```rust
|
||||
// 改动前
|
||||
pub fn analyze_scene_source(source_dir: &Path) -> Result<SceneSourceAnalysis, AnalyzeSceneError>;
|
||||
|
||||
// 改动后
|
||||
pub fn analyze_scene_source(
|
||||
source_dir: &Path,
|
||||
scene_kind_hint: Option<SceneKind>,
|
||||
) -> Result<SceneSourceAnalysis, AnalyzeSceneError>;
|
||||
```
|
||||
|
||||
### 2. generator.rs — 多模板支持
|
||||
|
||||
**函数签名变更**:
|
||||
```rust
|
||||
// 改动前
|
||||
fn scene_toml(request: &GenerateSceneRequest, tool_name: &str, expected_domain: &str, target_url: &str) -> String;
|
||||
|
||||
// 改动后
|
||||
fn scene_toml(request: &GenerateSceneRequest, analysis: &SceneSourceAnalysis, tool_name: &str) -> String;
|
||||
```
|
||||
|
||||
**模板路由**:
|
||||
```rust
|
||||
fn scene_toml(request: &GenerateSceneRequest, analysis: &SceneSourceAnalysis, tool_name: &str) -> String {
|
||||
match analysis.scene_kind {
|
||||
SceneKind::ReportCollection => scene_toml_report_collection(request, analysis, tool_name),
|
||||
SceneKind::Monitoring => scene_toml_monitoring(request, analysis, tool_name),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. sg_scene_generate.rs — 新增 CLI 参数
|
||||
|
||||
**新增参数**:
|
||||
```
|
||||
--scene-kind <report_collection|monitoring>
|
||||
场景类型,默认 report_collection
|
||||
- report_collection: 报表收集类(查询数据导出报表)
|
||||
- monitoring: 监测类(定时检查状态、监控告警)
|
||||
```
|
||||
|
||||
**用法示例**:
|
||||
```bash
|
||||
# 报表类(默认)
|
||||
cargo run --bin sg_scene_generate -- \
|
||||
--source-dir "D:/desk/场景/营销报表" \
|
||||
--scene-id marketing-report \
|
||||
--scene-name "营销报表" \
|
||||
--output-root "./out" \
|
||||
--lessons "docs/superpowers/references/tq-lineloss-lessons-learned.toml"
|
||||
|
||||
# 监测类
|
||||
cargo run --bin sg_scene_generate -- \
|
||||
--source-dir "D:/desk/场景/设备监测" \
|
||||
--scene-id device-monitor \
|
||||
--scene-name "设备监测" \
|
||||
--scene-kind monitoring \
|
||||
--output-root "./out" \
|
||||
--lessons "docs/superpowers/references/tq-lineloss-lessons-learned.toml"
|
||||
```
|
||||
|
||||
### 4. 监测类模板设计
|
||||
|
||||
监测类场景差异较大,生成**简化模板**,用户后续手动编辑:
|
||||
|
||||
```toml
|
||||
[scene]
|
||||
id = "<scene-id>"
|
||||
skill = "<scene-id>"
|
||||
tool = "monitor_<scene-id>"
|
||||
kind = "browser_script"
|
||||
version = "0.1.0"
|
||||
category = "monitoring"
|
||||
|
||||
[manifest]
|
||||
schema_version = "1"
|
||||
|
||||
[bootstrap]
|
||||
expected_domain = "<从 analyzer 提取或空>"
|
||||
target_url = "<从 analyzer 提取或空>"
|
||||
requires_target_page = true
|
||||
|
||||
[deterministic]
|
||||
suffix = "。。。"
|
||||
include_keywords = ["<scene-name>"]
|
||||
exclude_keywords = []
|
||||
|
||||
# 参数部分留空,用户手动编辑
|
||||
# [[params]]
|
||||
# name = "xxx"
|
||||
# resolver = "literal_passthrough"
|
||||
|
||||
[artifact]
|
||||
type = "monitoring-status"
|
||||
success_status = ["ok", "running"]
|
||||
failure_status = ["error", "timeout"]
|
||||
|
||||
# 后处理留空,用户手动编辑
|
||||
```
|
||||
|
||||
### 5. Web UI 改动
|
||||
|
||||
**新增控件**:
|
||||
```html
|
||||
<div class="form-group">
|
||||
<label>场景类型</label>
|
||||
<select id="sceneKind">
|
||||
<option value="report_collection" selected>报表收集类</option>
|
||||
<option value="monitoring">监测类</option>
|
||||
</select>
|
||||
<span class="hint">报表类:查询数据导出 Excel;监测类:定时检查状态</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**JavaScript 改动**:
|
||||
```javascript
|
||||
// generate() 函数增加 sceneKind 参数
|
||||
const sceneKind = document.getElementById('sceneKind').value;
|
||||
const response = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceDir,
|
||||
sceneId,
|
||||
sceneName,
|
||||
sceneKind, // 新增
|
||||
outputRoot,
|
||||
lessons
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Node.js Server 改动
|
||||
|
||||
**/generate 接口**:
|
||||
```javascript
|
||||
async function handleGenerate(req, res) {
|
||||
const { sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons } = body;
|
||||
// ...
|
||||
const args = [
|
||||
"run", "--bin", "sg_scene_generate", "--",
|
||||
"--source-dir", normalize(sourceDir),
|
||||
"--scene-id", sceneId,
|
||||
"--scene-name", sceneName,
|
||||
"--scene-kind", sceneKind || "report_collection", // 新增
|
||||
"--output-root", normalize(outputRoot),
|
||||
"--lessons", normalize(lessons),
|
||||
];
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
| 场景目录 | meta 标签 | 用户选择 | 最终类型 |
|
||||
|---------|----------|---------|---------|
|
||||
| 有 meta | `report_collection` | 未选择 | `report_collection` |
|
||||
| 有 meta | `report_collection` | `report_collection` | `report_collection` |
|
||||
| 有 meta | `report_collection` | `monitoring` | `monitoring`(用户优先) |
|
||||
| 无 meta | 无 | 未选择 | `report_collection`(默认) |
|
||||
| 无 meta | 无 | `monitoring` | `monitoring` |
|
||||
|
||||
**用户选择优先于 meta 标签**:因为用户比静态 meta 标签更了解场景的实际用途。
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| 文件 | 改动类型 | 改动量 |
|
||||
|------|---------|-------|
|
||||
| `src/generated_scene/analyzer.rs` | 修改 | ~30 行 |
|
||||
| `src/generated_scene/generator.rs` | 修改 | ~50 行 |
|
||||
| `src/bin/sg_scene_generate.rs` | 修改 | ~20 行 |
|
||||
| `frontend/scene-generator/sg_scene_generator.html` | 修改 | ~15 行 |
|
||||
| `frontend/scene-generator/server.js` | 修改 | ~5 行 |
|
||||
| `frontend/scene-generator/generator-runner.js` | 修改 | ~5 行 |
|
||||
| `tests/scene_generator_test.rs` | 修改 | ~30 行 |
|
||||
| `tests/fixtures/generated_scene/monitoring/index.html` | 新增 | ~20 行 |
|
||||
|
||||
**总计**:~175 行改动,8 个文件。
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 单元测试
|
||||
|
||||
1. `analyzer_accepts_missing_meta_with_hint` — 缺失 meta 标签时,使用 hint 参数
|
||||
2. `analyzer_uses_meta_when_present` — 有 meta 标签时,使用 meta 值
|
||||
3. `generator_emits_report_collection_template` — 报表类模板正确
|
||||
4. `generator_emits_monitoring_template` — 监测类模板正确
|
||||
|
||||
### 集成测试
|
||||
|
||||
1. 无 meta 标签的场景目录 + `--scene-kind report_collection` → 生成成功
|
||||
2. 无 meta 标签的场景目录 + `--scene-kind monitoring` → 生成成功
|
||||
3. Web UI 选择监测类 → 生成的 scene.toml 包含 `category = "monitoring"`
|
||||
|
||||
### 手动验证
|
||||
|
||||
1. 真实场景目录 `D:\desk\智能体资料\场景\营销2.0零度户报表数据生成` → 选择报表类 → 生成成功
|
||||
2. 真实监测类场景 → 选择监测类 → 生成成功
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 监测类模板过于简化 | 用户需要大量手动编辑 | 文档说明 + 后续迭代优化 |
|
||||
| 用户选错类型 | 生成错误模板 | UI 上提供清晰说明 |
|
||||
| registry.rs 不支持 monitoring | 生成的包无法注册 | V1 只生成,运行时支持后续迭代 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. 监测类场景的 `artifact.type` 应该是什么?当前设计为 `monitoring-status`,是否合适?
|
||||
2. 监测类是否需要新的 resolver 类型?
|
||||
3. 是否需要在前端 UI 显示更多类型说明?
|
||||
|
||||
## References
|
||||
|
||||
- `docs/superpowers/plans/2026-04-15-generated-scene-skill-platform-plan.md` — 原实现计划
|
||||
- `src/generated_scene/analyzer.rs` — 当前分析器
|
||||
- `src/generated_scene/generator.rs` — 当前生成器
|
||||
@@ -0,0 +1,417 @@
|
||||
# Scene Skill Generator — Design Document
|
||||
|
||||
> **Date:** 2026-04-16
|
||||
> **Status:** Draft — awaiting review
|
||||
> **Author:** Qoder
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
提供一个可视化界面,让用户选择场景目录后,自动通过大模型提取 scene-id 和 scene-name,配置输出路径和 lessons 文件,一键调用 `sg_scene_generate` 生成完整的 skill 包,并实时查看生成日志。
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ sg_scene_generator.html (浏览器) │
|
||||
│ ┌───────────────────┐ ┌───────────────────────────────┐ │
|
||||
│ │ 左侧:操作面板 │ │ 右侧:实时日志流 │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ 📂 选择场景目录 │───────│ [状态卡片 + 实时滚动日志] │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ 自动填充字段: │ │ 分析场景目录... │ │
|
||||
│ │ - scene-id │ │ 调用大模型提取场景信息... │ │
|
||||
│ │ - scene-name │ │ scene-id: tq-lineloss-report │ │
|
||||
│ │ │ │ scene-name: 台区线损报表 │ │
|
||||
│ │ 可编辑字段: │ │ 生成 skill 包... │ │
|
||||
│ │ - 输出根路径 │ │ 写入 SKILL.toml... │ │
|
||||
│ │ - lessons 路径 │ │ 写入 browser_script... │ │
|
||||
│ │ │ │ ✅ 生成完成 │ │
|
||||
│ │ [⚙ 设置] │ └───────────────────────────────┘ │
|
||||
│ │ [🚀 生成 Skill] │ │
|
||||
│ └───────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ 1. POST /analyze (选择目录后自动触发)
|
||||
│ → 发送目录路径 + 文件内容
|
||||
│ 2. SSE /generate (点击生成按钮后触发)
|
||||
│ → 推送实时进度
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ server.js (Node.js, 默认端口 3210) │
|
||||
│ │
|
||||
│ POST /analyze │
|
||||
│ 1. 读取 source-dir 下的关键文件 │
|
||||
│ - scene.toml (如果存在) │
|
||||
│ - *.js 脚本文件 │
|
||||
│ - SKILL.md / SKILL.toml (如果存在) │
|
||||
│ - 目录结构树 │
|
||||
│ 2. 构造 prompt,调用 LLM API │
|
||||
│ - baseUrl + apiKey + model 来自 sgclaw_config.json │
|
||||
│ 3. 返回 JSON: { sceneId, sceneName } │
|
||||
│ │
|
||||
│ POST /generate │
|
||||
│ 1. 接收 { sourceDir, sceneId, sceneName, outputRoot, lessons } │
|
||||
│ 2. spawn: cargo run --bin sg_scene_generate \ │
|
||||
│ --source-dir <sourceDir> \ │
|
||||
│ --scene-id <sceneId> \ │
|
||||
│ --scene-name <sceneName> \ │
|
||||
│ --output-root <outputRoot> \ │
|
||||
│ --lessons <lessons> │
|
||||
│ 3. 通过 SSE 实时推送 stdout/stderr │
|
||||
│ 4. 推送完成/失败事件 │
|
||||
│ │
|
||||
│ GET /health │
|
||||
│ → { status: "ok", pid: 12345 } │
|
||||
│ │
|
||||
│ GET / │
|
||||
│ → 服务 sg_scene_generator.html 静态文件 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ LLM API (OpenAI-compatible format)
|
||||
│ POST {baseUrl}/v1/chat/completions
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ LLM (DeepSeek) │
|
||||
│ │
|
||||
│ System: 你是一个场景 │
|
||||
│ 信息提取助手... │
|
||||
│ User: 以下是场景目录 │
|
||||
│ 内容... 请提取 │
|
||||
│ scene-id 和 │
|
||||
│ scene-name │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. File Map
|
||||
|
||||
### 新建文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend/scene-generator/sg_scene_generator.html` | 主页面,内联 CSS + JS,复用 service-console 设计风格 |
|
||||
| `frontend/scene-generator/server.js` | Node.js 轻量 HTTP 服务器(零外部依赖) |
|
||||
| `frontend/scene-generator/serve.sh` | 一键启动脚本(Windows 兼容) |
|
||||
| `frontend/scene-generator/serve.cmd` | Windows 一键启动脚本 |
|
||||
| `frontend/scene-generator/config-loader.js` | 读取并解析 `sgclaw_config.json` |
|
||||
| `frontend/scene-generator/llm-client.js` | 封装 LLM API 调用(OpenAI-compatible 格式) |
|
||||
| `frontend/scene-generator/generator-runner.js` | 封装 `sg_scene_generate` 子进程调用 + SSE 推送 |
|
||||
|
||||
### 引用文件(不修改)
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `src/bin/sg_scene_generate.rs` | 被 server.js 通过 `cargo run` 调用 |
|
||||
| `src/generated_scene/generator.rs` | 理解生成逻辑和输出结构 |
|
||||
| `sgclaw_config.json` | 读取 LLM 连接配置(apiKey, baseUrl, model) |
|
||||
| `docs/superpowers/references/tq-lineloss-lessons-learned.toml` | 默认 lessons 路径 |
|
||||
| `frontend/service-console/sg_claw_service_console.html` | UI 风格参考 |
|
||||
|
||||
---
|
||||
|
||||
## 4. UI Design
|
||||
|
||||
### 4.1 整体布局
|
||||
|
||||
复用 service-console 的双栏布局:
|
||||
|
||||
- **外层容器 (`.shell`)**:圆角玻璃拟态面板,与 service-console 共享 CSS 变量
|
||||
- **顶部 (`.hero`)**:标题 "场景 Skill 生成器" + 简短说明
|
||||
- **内容区 (`.content`)**:`grid` 双栏,左侧操作面板 + 右侧日志流
|
||||
|
||||
### 4.2 左侧操作面板
|
||||
|
||||
#### 场景目录选择区
|
||||
|
||||
```
|
||||
📂 场景目录
|
||||
[ 粘贴或输入路径 ____________________________ ] [ 浏览 📁 ]
|
||||
当前:D:\data\ideaSpace\rust\sgClaw\claw-new\examples\generated_scene_platform\scenarios\tq-lineloss-report
|
||||
```
|
||||
|
||||
使用文本输入框 + "浏览" 按钮。点击 "浏览" 时,前端调用 `POST /browse`,由 Node.js 弹出系统目录选择对话框(通过 `electron` 风格的 `open-dialog` 不可行 — 改为**用户在输入框中粘贴/输入路径**,服务端通过 `fs.stat` 校验路径合法性)。
|
||||
|
||||
为简化实现,采用更务实的方案:
|
||||
- 主输入框:用户粘贴或手动输入场景目录的**绝对路径**
|
||||
- 输入路径后按回车或点击 "分析" 按钮,触发 `/analyze` 请求
|
||||
- 服务端通过 `fs.statSync(sourceDir).isDirectory()` 校验路径
|
||||
|
||||
**可选增强**:如果 Node.js 安装了 `electron`,可通过 `dialog.showOpenDialog` 弹出系统选择框,但这会增加依赖。默认不采用。
|
||||
|
||||
#### 自动提取结果(只读展示,可手动修正)
|
||||
|
||||
```
|
||||
scene-id
|
||||
tq-lineloss-report
|
||||
|
||||
scene-name
|
||||
台区线损报表
|
||||
```
|
||||
|
||||
分析中显示 loading 状态,分析失败时可手动输入。
|
||||
|
||||
#### 设置按钮
|
||||
|
||||
点击弹出模态框,包含以下字段:
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 输出根路径 | `D:/data/ideaSpace/rust/sgClaw/claw-new/examples/generated_scene_platform` | skill 包输出根目录,实际输出到 `<output-root>/skills/<scene-id>/` |
|
||||
| Lessons 路径 | `D:/data/ideaSpace/rust/sgClaw/claw-new/docs/superpowers/references/tq-lineloss-lessons-learned.toml` | lessons TOML 文件路径 |
|
||||
| LLM 服务地址 | 来自 `sgclaw_config.json` 的 `baseUrl` | 可覆盖 |
|
||||
| LLM 模型 | 来自 `sgclaw_config.json` 的 `model` | 可覆盖 |
|
||||
| Node 服务端口 | `3210` | server.js 监听端口 |
|
||||
|
||||
#### 生成按钮
|
||||
|
||||
```
|
||||
[ 🚀 生成 Skill ] (disabled 直到选择了目录且提取完成)
|
||||
```
|
||||
|
||||
### 4.3 右侧日志流
|
||||
|
||||
与 service-console 一致的流式日志展示:
|
||||
|
||||
- **空状态**:显示提示 "选择场景目录开始生成"
|
||||
- **status 行**:关键阶段标记("开始分析", "提取完成", "开始生成", "生成成功")
|
||||
- **log 行**:cargo run 的 stdout 输出
|
||||
- **error 行**:stderr 输出或错误信息
|
||||
- **complete 行**:最终结果,包含生成的 skill 包路径
|
||||
|
||||
### 4.4 状态卡片
|
||||
|
||||
左侧面板顶部显示当前状态:
|
||||
|
||||
```
|
||||
[●] 就绪 / 分析中 / 生成中 / 完成 / 错误
|
||||
```
|
||||
|
||||
颜色编码:
|
||||
- 就绪:灰色
|
||||
- 分析中:橙色
|
||||
- 生成中:青色(accent)
|
||||
- 完成:绿色
|
||||
- 错误:红色
|
||||
|
||||
---
|
||||
|
||||
## 5. API Design
|
||||
|
||||
### 5.1 POST /analyze
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceDir": "D:/data/ideaSpace/rust/sgClaw/claw-new/examples/generated_scene_platform/scenarios/tq-lineloss-report"
|
||||
}
|
||||
```
|
||||
|
||||
服务端自行读取目录内容:
|
||||
- 校验路径是否存在且为目录
|
||||
- 读取 `scene.toml`(如果存在)
|
||||
- 读取 `*.js` 脚本文件
|
||||
- 读取 `SKILL.md` / `SKILL.toml`(如果存在)
|
||||
- 生成目录结构树
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sceneId": "tq-lineloss-report",
|
||||
"sceneName": "台区线损报表"
|
||||
}
|
||||
```
|
||||
|
||||
**LLM Prompt 设计:**
|
||||
|
||||
```
|
||||
System: 你是一个场景信息提取助手。根据场景目录的内容,提取 scene-id 和 scene-name。
|
||||
|
||||
scene-id 规则:
|
||||
- 使用英文短横线连接,如 tq-lineloss-report
|
||||
- 全小写,有业务含义
|
||||
|
||||
scene-name 规则:
|
||||
- 使用中文,简短描述性名称
|
||||
- 如 "台区线损报表"、"知乎热榜导出"
|
||||
|
||||
User: 以下是场景目录的内容:
|
||||
|
||||
=== scene.toml ===
|
||||
[scene content here]
|
||||
|
||||
=== 脚本文件 ===
|
||||
[script content here]
|
||||
|
||||
=== 目录结构 ===
|
||||
[file tree here]
|
||||
|
||||
请以 JSON 格式返回:{"sceneId": "...", "sceneName": "..."}
|
||||
```
|
||||
|
||||
### 5.2 POST /generate (SSE)
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceDir": "/path/to/scenario/dir",
|
||||
"sceneId": "tq-lineloss-report",
|
||||
"sceneName": "台区线损报表",
|
||||
"outputRoot": "/path/to/output/root",
|
||||
"lessons": "/path/to/lessons.toml"
|
||||
}
|
||||
```
|
||||
|
||||
**SSE 事件流:**
|
||||
|
||||
```
|
||||
event: status
|
||||
data: {"message": "开始生成 skill 包..."}
|
||||
|
||||
event: status
|
||||
data: {"message": "调用 sg_scene_generate..."}
|
||||
|
||||
event: log
|
||||
data: {"message": "generated scene package: ..."}
|
||||
|
||||
event: complete
|
||||
data: {"success": true, "skillRoot": "/path/to/skills/tq-lineloss-report"}
|
||||
|
||||
或
|
||||
|
||||
event: error
|
||||
data: {"message": "生成失败: ..."}
|
||||
```
|
||||
|
||||
### 5.3 GET /health
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"pid": 12345,
|
||||
"configLoaded": true,
|
||||
"configPath": "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Server Design (server.js)
|
||||
|
||||
### 6.1 模块结构
|
||||
|
||||
```
|
||||
server.js — HTTP 路由入口,SSE 连接管理
|
||||
config-loader.js — 读取 sgclaw_config.json,暴露 LLM 配置 + projectRoot
|
||||
llm-client.js — 调用 LLM API,返回 JSON 提取结果
|
||||
generator-runner.js — spawn 子进程,通过 SSE 推送输出
|
||||
```
|
||||
|
||||
### 6.1.1 projectRoot 配置
|
||||
|
||||
`cargo run --bin sg_scene_generate` 需要在项目根目录下执行。`projectRoot` 的确定优先级:
|
||||
|
||||
1. 环境变量 `SGCLAW_PROJECT_ROOT`(最高优先级)
|
||||
2. `sgclaw_config.json` 同级目录(常见情况:配置文件在项目根目录)
|
||||
3. 启动脚本所在目录
|
||||
|
||||
### 6.2 零依赖原则
|
||||
|
||||
仅使用 Node.js 内置模块:
|
||||
- `http` — HTTP 服务器
|
||||
- `fs` — 文件读取
|
||||
- `path` — 路径处理
|
||||
- `child_process` — 子进程调用
|
||||
- `events` — 事件发射
|
||||
|
||||
### 6.3 启动流程
|
||||
|
||||
```
|
||||
1. 读取 sgclaw_config.json (路径通过环境变量 SGCLAW_CONFIG_PATH 或默认 ../sgclaw_config.json)
|
||||
2. 验证必需字段: apiKey, baseUrl, model
|
||||
3. 启动 HTTP 服务器,监听 0.0.0.0:3210
|
||||
4. 打印启动信息,包含访问地址
|
||||
```
|
||||
|
||||
### 6.4 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| sgclaw_config.json 不存在 | 启动失败,提示用户设置环境变量 |
|
||||
| LLM API 调用失败 | 返回 502 + 错误信息,前端允许手动输入 |
|
||||
| cargo run 失败 | SSE 推送 error 事件,显示 stderr |
|
||||
| source-dir 不存在 | 返回 400 |
|
||||
| 端口被占用 | 启动失败,提示更换端口 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Security Considerations
|
||||
|
||||
1. **仅监听 localhost**:server.js 默认绑定 `127.0.0.1`,不暴露到外部网络
|
||||
2. **API Key 不暴露给前端**:LLM API 调用完全在 Node.js 服务端完成,前端不接触 API Key
|
||||
3. **路径校验**:`sourceDir` 和 `outputRoot` 需做基本路径合法性检查,防止路径遍历攻击
|
||||
4. **子进程超时**:`cargo run` 设置 5 分钟超时,防止挂起
|
||||
|
||||
---
|
||||
|
||||
## 8. Default Configuration
|
||||
|
||||
| 配置项 | 默认值 | 来源 |
|
||||
|--------|--------|------|
|
||||
| LLM apiKey | `sgclaw_config.json` 中的 `apiKey` | 启动时读取 |
|
||||
| LLM baseUrl | `sgclaw_config.json` 中的 `baseUrl` | 启动时读取 |
|
||||
| LLM model | `sgclaw_config.json` 中的 `model` | 启动时读取 |
|
||||
| 默认 lessons 路径 | `docs/superpowers/references/tq-lineloss-lessons-learned.toml` | 项目约定 |
|
||||
| 默认输出根路径 | `examples/generated_scene_platform` | 项目约定 |
|
||||
| Node 服务端口 | `3210` | 硬编码,可配置 |
|
||||
|
||||
---
|
||||
|
||||
## 9. User Flow
|
||||
|
||||
```
|
||||
1. 用户运行 bash serve.sh (或 node server.js)
|
||||
2. 浏览器打开 http://127.0.0.1:3210
|
||||
3. 页面加载,显示 "就绪" 状态
|
||||
4. 用户在 "场景目录" 输入框中粘贴或输入绝对路径
|
||||
5. 用户点击 "分析" 按钮(或输入框回车),触发 /analyze 请求
|
||||
6. server.js 读取目录内容,调用 LLM 提取 scene-id/name
|
||||
7. 页面自动填充 scene-id 和 scene-name 字段
|
||||
8. 用户确认/修改字段,点击 "设置" 检查输出路径和 lessons
|
||||
9. 用户点击 "生成 Skill"
|
||||
10. server.js 通过 SSE 推送实时进度
|
||||
11. 页面右侧日志流展示生成过程
|
||||
12. 生成完成,显示 skill 包路径
|
||||
13. 用户可前往输出目录查看生成的 skill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Windows Compatibility
|
||||
|
||||
由于目标平台是 Windows:
|
||||
|
||||
- `serve.sh` 同时提供 `serve.cmd` 替代方案
|
||||
- 路径分隔符统一使用 `/`(Node.js `path` 模块自动处理)
|
||||
- `cargo run` 命令在 Windows 上同样可用
|
||||
- 路径输入框支持 Windows 格式路径(如 `D:\data\ideaSpace\...`)
|
||||
- 服务端自动将 `\` 转换为 `/` 以兼容 Rust CLI 参数
|
||||
|
||||
---
|
||||
|
||||
## 11. Future Extensions (Not in Scope)
|
||||
|
||||
- 批量生成:一次选择多个场景目录
|
||||
- 生成后自动注册到 scene.toml manifest
|
||||
- 生成后自动运行 skill 测试
|
||||
- 历史记录:保存之前的生成记录
|
||||
- 生成参数模板:保存常用的输出路径/lessons 组合
|
||||
@@ -0,0 +1,573 @@
|
||||
# 增强 LLM 提取 Schema - 多模式业务逻辑自动化
|
||||
|
||||
> **Status:** Draft
|
||||
> **Date:** 2026-04-17
|
||||
> **Author:** Qoder
|
||||
|
||||
## Problem Statement
|
||||
|
||||
当前 `sg_scene_generate` 自动生成的 skill 脚本与 Claude 手写的 skill 存在显著差距:
|
||||
|
||||
### 差距清单
|
||||
|
||||
| 差距类型 | 描述 | 严重程度 |
|
||||
|----------|------|----------|
|
||||
| 多模式支持 | 无法识别 month/week 等多模式场景,只生成单一逻辑 | 🔴 高 |
|
||||
| 多 API 端点 | 定义多个 API 但只使用第一个 | 🔴 高 |
|
||||
| 请求格式检测 | 默认 JSON body,未检测 form-urlencoded | 🟡 中 |
|
||||
| 数据归一化 | 简单映射,无关键字段验证和空行过滤 | 🟡 中 |
|
||||
| 参数标准化 | 无参数标准化处理,直接透传 | 🟡 中 |
|
||||
| 分页参数 | 未提取和处理分页参数 (rows/page/sidx/sord) | 🟡 中 |
|
||||
|
||||
### 对比分析
|
||||
|
||||
| 功能维度 | tq-lineloss-report (手写) | marketing-zero-consumer-report (自动) |
|
||||
|----------|---------------------------|----------------------------------------|
|
||||
| 模式切换 | month/week 两套完整逻辑 | 无 |
|
||||
| API 端点 | queryMonthData + queryWeekData | 只用 API_ENDPOINTS[0] |
|
||||
| 列定义 | MONTH/WEEK_COLUMN_DEFS 双套 | 单一 COLUMN_DEFS |
|
||||
| 请求构建 | buildMonthRequest + buildWeekRequest | 单一 buildRequest |
|
||||
| 请求格式 | form-urlencoded | application/json |
|
||||
| 数据验证 | 关键字段验证 + filter(Boolean) | 无验证 |
|
||||
| 参数标准化 | normalized 对象 + 类型转换 | 无 |
|
||||
|
||||
## Goal
|
||||
|
||||
增强 LLM 提取 Schema,使其能够从 index.html 中自动识别:
|
||||
|
||||
1. **多模式业务逻辑** (month/week 等)
|
||||
2. **模式切换条件** (如何判断使用哪个模式)
|
||||
3. **每个模式的专属配置** (API、列定义、请求格式、验证规则)
|
||||
4. **数据归一化规则** (关键字段、过滤条件)
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不改变现有的两阶段架构 (LLM 提取 → Rust 渲染)
|
||||
- 不增加新的 CLI 参数
|
||||
- 不支持超过 2 种模式的复杂场景 (Phase 1)
|
||||
- 不处理认证/鉴权逻辑 (假设页面已登录)
|
||||
|
||||
## Architecture
|
||||
|
||||
### 增强后的 Schema 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"sceneId": "tq-lineloss-report",
|
||||
"sceneName": "台区线损报表",
|
||||
"sceneKind": "report_collection",
|
||||
|
||||
"modes": [
|
||||
{
|
||||
"name": "month",
|
||||
"label": "月度报表",
|
||||
"condition": {
|
||||
"field": "period_mode",
|
||||
"operator": "equals",
|
||||
"value": "month"
|
||||
},
|
||||
"apiEndpoint": {
|
||||
"name": "月度线损查询",
|
||||
"url": "http://20.76.57.61:18080/gsllys/fourVerEightHor/fourVerEightHorLinelossRateList",
|
||||
"method": "POST",
|
||||
"contentType": "application/x-www-form-urlencoded"
|
||||
},
|
||||
"columnDefs": [
|
||||
["ORG_NAME", "供电单位"],
|
||||
["YGDL", "累计供电量"],
|
||||
["YYDL", "累计售电量"],
|
||||
["YXSL", "线损完成率(%)"],
|
||||
["RAT_SCOPE", "线损率累计目标值"],
|
||||
["BLANK3", "目标完成率"],
|
||||
["BLANK2", "排行"]
|
||||
],
|
||||
"requestTemplate": {
|
||||
"orgno": "${args.org_code}",
|
||||
"yn_flag": 0,
|
||||
"_search": false,
|
||||
"nd": "${Date.now()}",
|
||||
"rows": 1000,
|
||||
"page": 1,
|
||||
"sidx": "TO_NUMBER(ORG_NO)",
|
||||
"sord": "asc"
|
||||
},
|
||||
"normalizeRules": {
|
||||
"type": "validate_all_columns",
|
||||
"filterNull": true
|
||||
},
|
||||
"responsePath": "content"
|
||||
},
|
||||
{
|
||||
"name": "week",
|
||||
"label": "周报表",
|
||||
"condition": {
|
||||
"field": "period_mode",
|
||||
"operator": "equals",
|
||||
"value": "week"
|
||||
},
|
||||
"apiEndpoint": {
|
||||
"name": "周线损查询",
|
||||
"url": "http://20.76.57.61:18080/gsllys/tqLinelossStatis/getYearMonWeekLinelossAnalysisList",
|
||||
"method": "POST",
|
||||
"contentType": "application/x-www-form-urlencoded"
|
||||
},
|
||||
"columnDefs": [
|
||||
["ORG_NAME", "供电单位"],
|
||||
["LINE_LOSS_RATE", "综合线损率(%)"],
|
||||
["PPQ", "供电量(Kwh)"],
|
||||
["UPQ", "售电量(Kwh)"],
|
||||
["LOSS_PQ", "损失电量(Kwh)"]
|
||||
],
|
||||
"requestTemplate": {
|
||||
"orgno": "${args.org_code}",
|
||||
"tjzq": "week",
|
||||
"level": "00",
|
||||
"_search": false,
|
||||
"nd": "${Date.now()}",
|
||||
"rows": 1000,
|
||||
"page": 1,
|
||||
"sidx": "TO_NUMBER(ORG_NO)",
|
||||
"sord": "asc"
|
||||
},
|
||||
"normalizeRules": {
|
||||
"type": "validate_required",
|
||||
"requiredFields": ["ORG_NAME", "LINE_LOSS_RATE"],
|
||||
"filterNull": true
|
||||
},
|
||||
"responsePath": "content"
|
||||
}
|
||||
],
|
||||
|
||||
"defaultMode": "month",
|
||||
"modeSwitchField": "period_mode",
|
||||
|
||||
"commonParams": {},
|
||||
"staticParams": {},
|
||||
|
||||
"expectedDomain": "20.76.57.61",
|
||||
"targetUrl": "http://20.76.57.61:18080/gsllys"
|
||||
}
|
||||
```
|
||||
|
||||
### Schema 字段说明
|
||||
|
||||
#### 顶层字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `modes` | Array | 模式配置数组 |
|
||||
| `defaultMode` | string | 默认模式名称 |
|
||||
| `modeSwitchField` | string | 用于切换模式的参数字段名 |
|
||||
|
||||
#### Mode 配置
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | string | 模式标识符 |
|
||||
| `label` | string | 模式显示名称 |
|
||||
| `condition` | object | 模式激活条件 |
|
||||
| `apiEndpoint` | object | 该模式的 API 配置 |
|
||||
| `columnDefs` | Array | 该模式的列定义 |
|
||||
| `requestTemplate` | object | 请求参数模板 |
|
||||
| `normalizeRules` | object | 数据归一化规则 |
|
||||
| `responsePath` | string | 响应数据路径 (如 `content`) |
|
||||
|
||||
#### Condition 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"field": "period_mode", // 检查的字段
|
||||
"operator": "equals", // 操作符: equals, not_equals, in, contains
|
||||
"value": "month" // 比较值
|
||||
}
|
||||
```
|
||||
|
||||
#### NormalizeRules 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "validate_required", // validate_all_columns | validate_required
|
||||
"requiredFields": ["ORG_NAME", "LINE_LOSS_RATE"], // 仅 validate_required 需要
|
||||
"filterNull": true // 是否过滤空值行
|
||||
}
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
用户请求 (args)
|
||||
↓
|
||||
validateArgs() → 参数验证
|
||||
↓
|
||||
detectMode(args[modeSwitchField]) → 检测当前模式
|
||||
↓
|
||||
selectModeConfig(mode) → 选择模式配置
|
||||
↓
|
||||
buildRequest(args, modeConfig) → 构建该模式的请求
|
||||
↓
|
||||
queryData(request, modeConfig.apiEndpoint) → HTTP 请求
|
||||
↓
|
||||
extractResponse(response, modeConfig.responsePath) → 提取数据
|
||||
↓
|
||||
normalizeRows(data, modeConfig.normalizeRules) → 数据归一化
|
||||
↓
|
||||
buildArtifact({ mode, columnDefs, rows, ... }) → 构建输出
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. 增强 LLM 提取 Prompt
|
||||
|
||||
修改 `frontend/scene-generator/llm-client.js` 中的 `DEEP_SYSTEM_PROMPT` 和 `buildDeepAnalyzePrompt`:
|
||||
|
||||
**关键 Prompt 增强:**
|
||||
|
||||
```
|
||||
分析 index.html 中的业务逻辑模式:
|
||||
|
||||
1. **模式识别**
|
||||
- 查找条件分支逻辑 (if/switch) 中基于 period_mode、reportType 等字段的分支
|
||||
- 识别不同分支对应的 API 端点、列定义、请求格式
|
||||
|
||||
2. **API 提取**
|
||||
- 提取 $.ajax/fetch 调用中的 URL、method、contentType
|
||||
- 识别请求参数构造方式 (JSON.stringify vs 对象展开)
|
||||
- 检测分页参数 (rows/page/sidx/sord)
|
||||
|
||||
3. **请求格式检测**
|
||||
- contentType: application/json → JSON body
|
||||
- contentType: application/x-www-form-urlencoded → 对象展开
|
||||
- 无显式 contentType → 检查 data 参数格式
|
||||
|
||||
4. **数据归一化**
|
||||
- 查找数据渲染/表格填充逻辑
|
||||
- 识别字段映射关系
|
||||
- 检测数据验证条件 (哪些字段不能为空)
|
||||
|
||||
5. **响应路径**
|
||||
- 识别数据在响应中的位置 (response.content / response.data / response)
|
||||
```
|
||||
|
||||
### 2. 增强 Rust Schema 结构
|
||||
|
||||
修改 `src/generated_scene/generator.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModeConditionJson {
|
||||
pub field: String,
|
||||
#[serde(default)]
|
||||
pub operator: String, // equals, not_equals, in, contains
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct NormalizeRulesJson {
|
||||
#[serde(rename = "type")]
|
||||
pub rules_type: String, // validate_all_columns, validate_required
|
||||
#[serde(default)]
|
||||
pub required_fields: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub filter_null: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ApiEndpointEnhancedJson {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModeConfigJson {
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub condition: ModeConditionJson,
|
||||
pub api_endpoint: ApiEndpointEnhancedJson,
|
||||
pub column_defs: Vec<(String, String)>,
|
||||
pub request_template: Option<serde_json::Value>,
|
||||
pub normalize_rules: Option<NormalizeRulesJson>,
|
||||
pub response_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SceneInfoEnhancedJson {
|
||||
#[serde(rename = "sceneId")]
|
||||
pub scene_id: String,
|
||||
#[serde(rename = "sceneName")]
|
||||
pub scene_name: String,
|
||||
|
||||
// 新增:多模式支持
|
||||
pub modes: Vec<ModeConfigJson>,
|
||||
pub default_mode: Option<String>,
|
||||
pub mode_switch_field: Option<String>,
|
||||
|
||||
// 向后兼容:单模式场景
|
||||
#[serde(rename = "apiEndpoints", default)]
|
||||
pub api_endpoints: Vec<ApiEndpointJson>,
|
||||
#[serde(rename = "columnDefs", default)]
|
||||
pub column_defs: Vec<(String, String)>,
|
||||
|
||||
// 其他字段保持不变
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 增强模板生成逻辑
|
||||
|
||||
```rust
|
||||
fn browser_script_with_modes(scene_id: &str, scene_info: &SceneInfoEnhancedJson) -> String {
|
||||
if scene_info.modes.is_empty() {
|
||||
// 向后兼容:使用现有单模式模板
|
||||
return browser_script_with_business_logic(scene_id, scene_info);
|
||||
}
|
||||
|
||||
// 多模式模板
|
||||
generate_multi_mode_script(scene_id, scene_info)
|
||||
}
|
||||
|
||||
fn generate_multi_mode_script(scene_id: &str, scene_info: &SceneInfoEnhancedJson) -> String {
|
||||
let modes_json = serde_json::to_string_pretty(&scene_info.modes).unwrap();
|
||||
let default_mode = scene_info.default_mode.as_deref().unwrap_or("month");
|
||||
|
||||
format!(r#"
|
||||
const REPORT_NAME = '{scene_id}';
|
||||
const MODES = {modes_json};
|
||||
const DEFAULT_MODE = '{default_mode}';
|
||||
const MODE_SWITCH_FIELD = '{mode_switch_field}';
|
||||
|
||||
function detectMode(args) {{
|
||||
const modeValue = args[MODE_SWITCH_FIELD] || DEFAULT_MODE;
|
||||
return MODES.find(m => m.condition.value === modeValue) || MODES[0];
|
||||
}}
|
||||
|
||||
function buildModeRequest(args, mode) {{
|
||||
const endpoint = mode.api_endpoint;
|
||||
const template = mode.request_template || {{}};
|
||||
|
||||
// 根据 contentType 构建请求
|
||||
const contentType = endpoint.content_type || 'application/json';
|
||||
const url = endpoint.url;
|
||||
const method = endpoint.method || 'POST';
|
||||
|
||||
// 解析模板,替换变量
|
||||
let body;
|
||||
if (contentType === 'application/x-www-form-urlencoded') {{
|
||||
body = {{ ...template }};
|
||||
// 替换模板变量
|
||||
for (const [key, value] of Object.entries(body)) {{
|
||||
if (typeof value === 'string' && value.startsWith('${{') && value.endsWith('}}')) {{
|
||||
const expr = value.slice(2, -1);
|
||||
body[key] = eval(expr);
|
||||
}}
|
||||
}}
|
||||
body.orgno = args.org_code;
|
||||
}} else {{
|
||||
body = JSON.stringify({{ ...template, ...args }});
|
||||
}}
|
||||
|
||||
return {{ url, method, headers: {{ 'Content-Type': contentType }}, body }};
|
||||
}}
|
||||
|
||||
function normalizeModeRows(data, mode) {{
|
||||
const rules = mode.normalize_rules || {{ type: 'validate_all_columns' }};
|
||||
const columns = mode.column_defs.map(([key]) => key);
|
||||
|
||||
if (!Array.isArray(data)) return [];
|
||||
|
||||
return data.map(row => {{
|
||||
const result = {{}};
|
||||
for (const key of columns) {{
|
||||
result[key] = row[key] ?? '';
|
||||
}}
|
||||
return result;
|
||||
}}).filter(row => {{
|
||||
if (!rules.filter_null) return true;
|
||||
|
||||
if (rules.type === 'validate_required') {{
|
||||
return rules.required_fields.every(f => row[f] !== '');
|
||||
}}
|
||||
return columns.every(k => row[k] !== '');
|
||||
}});
|
||||
}}
|
||||
|
||||
async function queryModeData(args, mode) {{
|
||||
const endpoint = mode.api_endpoint;
|
||||
const request = buildModeRequest(args, mode);
|
||||
|
||||
// jQuery 优先
|
||||
if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {{
|
||||
const contentType = endpoint.content_type || 'application/json';
|
||||
return new Promise((resolve, reject) => {{
|
||||
$.ajax({{
|
||||
url: request.url,
|
||||
type: request.method,
|
||||
data: request.body,
|
||||
contentType: contentType,
|
||||
dataType: 'json',
|
||||
success: resolve,
|
||||
error: (xhr, status, err) => reject(new Error(
|
||||
`API failed (${{xhr.status}}): ${{err}}`
|
||||
))
|
||||
}});
|
||||
}});
|
||||
}}
|
||||
|
||||
// fetch fallback
|
||||
if (typeof fetch === 'function') {{
|
||||
const response = await fetch(request.url, {{
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.method !== 'GET' ? request.body : undefined
|
||||
}});
|
||||
if (!response.ok) {{
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${{response.status}}: ${{text.substring(0, 200)}}`);
|
||||
}}
|
||||
return response.json();
|
||||
}}
|
||||
|
||||
throw new Error('No HTTP client available');
|
||||
}}
|
||||
|
||||
async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{
|
||||
// 1. 参数验证
|
||||
const validation = validateArgs(args);
|
||||
if (!validation.valid) {{
|
||||
return buildArtifact({{
|
||||
status: 'blocked',
|
||||
blockedReason: 'validation_failed',
|
||||
reasons: validation.errors,
|
||||
rows: [],
|
||||
args
|
||||
}});
|
||||
}}
|
||||
|
||||
// 2. 页面上下文验证
|
||||
const pageValidation = typeof deps.validatePageContext === 'function'
|
||||
? deps.validatePageContext(args)
|
||||
: {{ ok: true }};
|
||||
if (!pageValidation?.ok) {{
|
||||
return buildArtifact({{
|
||||
status: 'blocked',
|
||||
blockedReason: pageValidation?.reason || 'page_context_mismatch',
|
||||
reasons: [pageValidation?.reason || 'page_context_mismatch'],
|
||||
rows: [],
|
||||
args
|
||||
}});
|
||||
}}
|
||||
|
||||
// 3. 检测模式
|
||||
const mode = detectMode(args);
|
||||
|
||||
// 4. 数据获取
|
||||
const reasons = [];
|
||||
let rawData = null;
|
||||
try {{
|
||||
rawData = await queryModeData(args, mode);
|
||||
}} catch (error) {{
|
||||
return buildArtifact({{
|
||||
status: 'error',
|
||||
fatalError: error.message,
|
||||
reasons: ['api_query_failed:' + error.message],
|
||||
rows: [],
|
||||
args
|
||||
}});
|
||||
}}
|
||||
|
||||
// 5. 提取响应数据
|
||||
const responsePath = mode.response_path || '';
|
||||
let data = rawData;
|
||||
if (responsePath && rawData) {{
|
||||
data = rawData[responsePath] || rawData;
|
||||
}}
|
||||
|
||||
// 6. 数据归一化
|
||||
const rows = normalizeModeRows(data, mode);
|
||||
if (rows.length === 0 && Array.isArray(data) && data.length > 0) {{
|
||||
reasons.push('row_normalization_partial');
|
||||
}}
|
||||
|
||||
// 7. 构建 Artifact
|
||||
return buildArtifact({{
|
||||
reasons,
|
||||
rows,
|
||||
args,
|
||||
columnDefs: mode.column_defs,
|
||||
columns: mode.column_defs.map(([key]) => key)
|
||||
}});
|
||||
}}
|
||||
"#, scene_id = scene_id, modes_json = modes_json, default_mode = default_mode, mode_switch_field = scene_info.mode_switch_field.as_deref().unwrap_or("period_mode"))
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 单元测试
|
||||
|
||||
1. **Schema 解析测试**
|
||||
- 测试多模式 JSON 正确解析
|
||||
- 测试向后兼容(无 modes 字段时退化)
|
||||
|
||||
2. **模式检测测试**
|
||||
- 测试 `detectMode()` 根据参数正确选择模式
|
||||
|
||||
3. **请求构建测试**
|
||||
- 测试 form-urlencoded 格式正确
|
||||
- 测试 JSON 格式正确
|
||||
- 测试模板变量替换
|
||||
|
||||
4. **数据归一化测试**
|
||||
- 测试 validate_required 类型
|
||||
- 测试 validate_all_columns 类型
|
||||
- 测试空行过滤
|
||||
|
||||
### 集成测试
|
||||
|
||||
1. **端到端测试**
|
||||
- 使用 tq-lineloss-report 源场景
|
||||
- 验证生成的脚本与手写版本功能一致
|
||||
|
||||
2. **回归测试**
|
||||
- 验证单模式场景仍正常工作
|
||||
- 验证现有测试用例通过
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Schema 增强
|
||||
|
||||
1. 增强 Rust schema 结构
|
||||
2. 更新 LLM 提取 prompt
|
||||
3. 验证 schema 解析正确
|
||||
|
||||
### Phase 2: 模板实现
|
||||
|
||||
1. 实现多模式模板
|
||||
2. 实现请求格式检测
|
||||
3. 实现数据归一化规则
|
||||
|
||||
### Phase 3: 测试验证
|
||||
|
||||
1. 使用 tq-lineloss-report 源场景测试
|
||||
2. 对比生成代码与手写代码
|
||||
3. 修复差异
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| LLM 提取不准确 | 生成代码不可用 | 提供 few-shot 示例,增加验证步骤 |
|
||||
| 模式条件复杂 | 无法正确切换 | Phase 1 只支持 equals 操作符 |
|
||||
| 请求模板变量 | 表达能力有限 | 支持常用表达式,复杂场景用 lessons 补充 |
|
||||
| 向后兼容 | 现有场景受影响 | 无 modes 时使用旧模板 |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **多模式支持**:能够生成 month/week 双模式脚本
|
||||
2. **请求格式正确**:自动检测 form-urlencoded vs JSON
|
||||
3. **数据验证**:支持关键字段验证和空行过滤
|
||||
4. **向后兼容**:单模式场景不受影响
|
||||
5. **代码质量**:生成的代码与手写 tq-lineloss-report 功能对等
|
||||
@@ -0,0 +1,490 @@
|
||||
# LLM-Driven Skill Generation Design
|
||||
|
||||
> **Status:** Draft
|
||||
> **Date:** 2026-04-17
|
||||
> **Author:** Qoder
|
||||
|
||||
## Problem Statement
|
||||
|
||||
`sg_scene_generate` 当前只生成"骨架"技能包,缺乏实际业务逻辑:
|
||||
|
||||
### 当前产出 vs 实际需求
|
||||
|
||||
| 方面 | 当前产出 | 实际需求 (tq-lineloss-report) |
|
||||
|------|----------|------------------------------|
|
||||
| 脚本代码量 | 51 行 | 433 行 |
|
||||
| API 端点 | 无 | 有完整定义 |
|
||||
| 静态参数 | 无 | 有业务参数 |
|
||||
| 列定义 | 通用模板 | 业务特定 |
|
||||
| 可运行性 | 需手动填充 | 开箱即用 |
|
||||
|
||||
### 根本原因
|
||||
|
||||
1. **LLM 分析不读取 index.html** — 场景源码中的业务逻辑被忽略
|
||||
2. **只提取 scene-id/scene-name** — 缺少 API、参数、列定义等关键信息
|
||||
3. **Rust 模板过于简单** — 只生成骨架,无法渲染业务逻辑
|
||||
|
||||
## Goal
|
||||
|
||||
让 `sg_scene_generate` 自动生成**可直接运行**的完整技能包,包含:
|
||||
|
||||
- API 端点定义
|
||||
- 静态业务参数
|
||||
- 列定义(导出报表用)
|
||||
- 数据采集逻辑骨架
|
||||
- 参数验证逻辑
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不实现 100% 自动化 — 复杂业务逻辑仍需人工校验
|
||||
- 不支持所有 JavaScript 模式 — 仅覆盖常见场景
|
||||
- 不替换现有 Rust 模板系统 — 在其基础上增强
|
||||
- 不处理认证/授权逻辑 — 由运行时环境处理
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Web UI (HTML) │
|
||||
│ [选择场景目录] → [分析] → [预览提取结果] → [生成 Skill] │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Node.js Server (server.js) │
|
||||
│ /analyze → LLM 深度提取 (index.html + scripts) │
|
||||
│ /generate → 传递提取结果给 Rust CLI │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ llm-client.js │ │ generator- │ │ Rust CLI │
|
||||
│ (增强提取) │ │ runner.js │ │ (模板渲染) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## LLM Extraction Schema
|
||||
|
||||
### Input Sources
|
||||
|
||||
| 文件 | 提取内容 |
|
||||
|------|----------|
|
||||
| `index.html` | API 端点、静态参数、列定义、业务方法 |
|
||||
| `scripts/*.js` | 辅助函数、数据转换逻辑 |
|
||||
| 目录结构 | 文件组织方式 |
|
||||
|
||||
### Output Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"sceneId": "string - 场景标识",
|
||||
"sceneName": "string - 场景中文名",
|
||||
"sceneKind": "report_collection | monitoring",
|
||||
"sourceSystem": "string - 来源系统名",
|
||||
"expectedDomain": "string - 目标域名",
|
||||
"targetUrl": "string | null - 目标页面URL",
|
||||
"apiEndpoints": [
|
||||
{
|
||||
"name": "string - API 名称",
|
||||
"url": "string - 完整 URL",
|
||||
"method": "GET | POST",
|
||||
"description": "string - 用途说明"
|
||||
}
|
||||
],
|
||||
"staticParams": {
|
||||
"key": "value - 静态业务参数"
|
||||
},
|
||||
"columnDefs": [
|
||||
["fieldName", "中文列名"]
|
||||
],
|
||||
"entryMethod": "string - 入口方法名",
|
||||
"businessLogic": {
|
||||
"dataFetch": "string - 数据获取逻辑描述",
|
||||
"dataTransform": "string - 数据转换逻辑描述"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LLM Prompt Template
|
||||
|
||||
```
|
||||
你是一个场景代码分析专家。分析以下场景源码,提取关键业务信息。
|
||||
|
||||
## 分析目标
|
||||
|
||||
1. **API 端点**: 识别所有 HTTP 请求地址
|
||||
2. **静态参数**: 识别硬编码的业务参数
|
||||
3. **列定义**: 识别数据表格/导出的列配置
|
||||
4. **业务逻辑**: 理解数据获取和转换流程
|
||||
|
||||
## 源码内容
|
||||
|
||||
=== 目录结构 ===
|
||||
{directoryTree}
|
||||
|
||||
=== index.html ===
|
||||
{indexHtmlContent}
|
||||
|
||||
=== 脚本文件 ===
|
||||
{scriptsContent}
|
||||
|
||||
## 输出格式
|
||||
|
||||
请以 JSON 格式返回提取结果:
|
||||
{
|
||||
"sceneId": "...",
|
||||
"sceneName": "...",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### 当前流程
|
||||
|
||||
```
|
||||
用户选择目录
|
||||
↓
|
||||
Node.js 读取目录结构、脚本文件 (不读 index.html)
|
||||
↓
|
||||
LLM 只提取 scene-id, scene-name
|
||||
↓
|
||||
Rust 生成骨架脚本 (无业务逻辑)
|
||||
```
|
||||
|
||||
### 改造后流程
|
||||
|
||||
```
|
||||
用户选择目录
|
||||
↓
|
||||
Node.js 读取目录结构、index.html、脚本文件
|
||||
↓
|
||||
LLM 深度提取 API/参数/列定义/业务逻辑
|
||||
↓
|
||||
Web UI 展示提取结果供用户确认
|
||||
↓
|
||||
用户确认后,提取结果通过 CLI 参数传给 Rust
|
||||
↓
|
||||
Rust 根据提取结果渲染完整脚本
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Task 1: 增强 llm-client.js
|
||||
|
||||
**文件**: `frontend/scene-generator/llm-client.js`
|
||||
|
||||
**改动**:
|
||||
1. 新增 `buildDeepAnalyzePrompt()` 函数
|
||||
2. 增强 `SYSTEM_PROMPT` 包含深度提取指令
|
||||
3. 新增 `extractSceneInfo()` 函数处理复杂 JSON
|
||||
|
||||
**新增接口**:
|
||||
```javascript
|
||||
async function analyzeSceneDeep(sourceDir, dirContents, indexHtmlContent, config) {
|
||||
// 返回完整的 SceneInfo 对象
|
||||
}
|
||||
```
|
||||
|
||||
### Task 2: 增强 generator-runner.js
|
||||
|
||||
**文件**: `frontend/scene-generator/generator-runner.js`
|
||||
|
||||
**改动**:
|
||||
1. `readDirectory()` 增加读取 `index.html`
|
||||
2. 返回值增加 `indexHtml` 字段
|
||||
|
||||
```javascript
|
||||
function readDirectory(sourceDir) {
|
||||
// ... 现有逻辑 ...
|
||||
|
||||
const indexHtmlPath = p.join(sourceDir, "index.html");
|
||||
if (fs.existsSync(indexHtmlPath)) {
|
||||
result.indexHtml = fs.readFileSync(indexHtmlPath, "utf-8");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3: 增强 server.js
|
||||
|
||||
**文件**: `frontend/scene-generator/server.js`
|
||||
|
||||
**改动**:
|
||||
1. `/analyze` 调用深度分析
|
||||
2. `/generate` 传递提取结果给 Rust CLI
|
||||
|
||||
```javascript
|
||||
async function handleAnalyze(req, res) {
|
||||
// ...
|
||||
const indexHtml = dirContents.indexHtml;
|
||||
const result = await analyzeSceneDeep(sourceDir, dirContents, indexHtml, config);
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
async function handleGenerate(req, res) {
|
||||
const { sceneInfo, outputRoot, lessons } = body;
|
||||
// 将 sceneInfo 作为 JSON 参数传递给 Rust CLI
|
||||
}
|
||||
```
|
||||
|
||||
### Task 4: 增强 Rust CLI
|
||||
|
||||
**文件**: `src/bin/sg_scene_generate.rs`
|
||||
|
||||
**新增参数**:
|
||||
```bash
|
||||
--scene-info-json '<JSON>' # 完整的场景信息 JSON
|
||||
```
|
||||
|
||||
**解析逻辑**:
|
||||
```rust
|
||||
struct SceneInfoJson {
|
||||
scene_id: String,
|
||||
scene_name: String,
|
||||
scene_kind: String,
|
||||
source_system: Option<String>,
|
||||
expected_domain: Option<String>,
|
||||
target_url: Option<String>,
|
||||
api_endpoints: Option<Vec<ApiEndpoint>>,
|
||||
static_params: Option<HashMap<String, String>>,
|
||||
column_defs: Option<Vec<(String, String)>>,
|
||||
entry_method: Option<String>,
|
||||
business_logic: Option<BusinessLogic>,
|
||||
}
|
||||
```
|
||||
|
||||
### Task 5: 增强 generator.rs
|
||||
|
||||
**文件**: `src/generated_scene/generator.rs`
|
||||
|
||||
**新增结构**:
|
||||
```rust
|
||||
pub struct ApiEndpoint {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub struct BusinessLogic {
|
||||
pub data_fetch: Option<String>,
|
||||
pub data_transform: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SceneInfo {
|
||||
pub scene_id: String,
|
||||
pub scene_name: String,
|
||||
pub scene_kind: SceneKind,
|
||||
pub source_system: Option<String>,
|
||||
pub expected_domain: Option<String>,
|
||||
pub target_url: Option<String>,
|
||||
pub api_endpoints: Vec<ApiEndpoint>,
|
||||
pub static_params: HashMap<String, String>,
|
||||
pub column_defs: Vec<(String, String)>,
|
||||
pub entry_method: Option<String>,
|
||||
pub business_logic: Option<BusinessLogic>,
|
||||
}
|
||||
```
|
||||
|
||||
**模板渲染增强**:
|
||||
```rust
|
||||
fn browser_script_with_business_logic(scene_id: &str, info: &SceneInfo) -> String {
|
||||
// 根据 SceneInfo 生成完整脚本
|
||||
// 包含 API 端点定义、静态参数、列定义、数据获取逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### Task 6: Web UI 预览
|
||||
|
||||
**文件**: `frontend/scene-generator/sg_scene_generator.html`
|
||||
|
||||
**改动**:
|
||||
1. 分析完成后展示提取结果摘要
|
||||
2. 用户可编辑关键字段
|
||||
3. 确认后进入生成流程
|
||||
|
||||
**展示字段**:
|
||||
- 场景 ID / 名称
|
||||
- API 端点列表
|
||||
- 列定义预览
|
||||
- 静态参数摘要
|
||||
|
||||
## Generated Script Template
|
||||
|
||||
### 结构
|
||||
|
||||
```javascript
|
||||
// ===== 自动生成部分 =====
|
||||
|
||||
// 常量定义
|
||||
const REPORT_NAME = '{scene_id}';
|
||||
const API_BASE = '{api_base_url}';
|
||||
const EXPECTED_DOMAIN = '{expected_domain}';
|
||||
|
||||
// API 端点
|
||||
const API_ENDPOINTS = {
|
||||
{api_name}: '{api_path}',
|
||||
// ...
|
||||
};
|
||||
|
||||
// 静态参数
|
||||
const STATIC_PARAMS = {
|
||||
{key}: '{value}',
|
||||
// ...
|
||||
};
|
||||
|
||||
// 列定义
|
||||
const COLUMN_DEFS = [
|
||||
['{field}', '{label}'],
|
||||
// ...
|
||||
];
|
||||
const COLUMNS = COLUMN_DEFS.map(([k]) => k);
|
||||
|
||||
// ===== 标准框架 =====
|
||||
|
||||
function validateArgs(args) { /* 参数验证 */ }
|
||||
function buildRequest(args) { /* 构建请求 */ }
|
||||
function normalizeRows(rawRows) { /* 数据标准化 */ }
|
||||
function buildArtifact(opts) { /* 构建 Artifact */ }
|
||||
|
||||
async function buildBrowserEntrypointResult(args, deps = defaultDeps()) {
|
||||
// 1. 参数验证
|
||||
const validation = validateArgs(args);
|
||||
if (!validation.ok) {
|
||||
return buildArtifact({ status: 'blocked', reasons: validation.reasons });
|
||||
}
|
||||
|
||||
// 2. 页面上下文验证
|
||||
const pageValidation = deps.validatePageContext?.(args);
|
||||
if (!pageValidation?.ok) {
|
||||
return buildArtifact({ status: 'blocked', reasons: ['page_context_mismatch'] });
|
||||
}
|
||||
|
||||
// 3. 数据获取
|
||||
try {
|
||||
const request = buildRequest(args);
|
||||
const response = await deps.queryData(request);
|
||||
const rows = normalizeRows(response.rows || []);
|
||||
|
||||
return buildArtifact({
|
||||
status: rows.length > 0 ? 'ok' : 'empty',
|
||||
column_defs: COLUMN_DEFS,
|
||||
columns: COLUMNS,
|
||||
rows,
|
||||
});
|
||||
} catch (error) {
|
||||
return buildArtifact({ status: 'error', reasons: [error.message] });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 默认依赖实现 =====
|
||||
|
||||
function defaultDeps() {
|
||||
return {
|
||||
validatePageContext(args) {
|
||||
const host = globalThis.location?.hostname;
|
||||
return host === args.expected_domain
|
||||
? { ok: true }
|
||||
: { ok: false, reason: 'domain_mismatch' };
|
||||
},
|
||||
|
||||
async queryData(request) {
|
||||
// 根据 API_ENDPOINTS 调用实际接口
|
||||
// 此处为模板,可能需要根据具体场景调整
|
||||
if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {
|
||||
return new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: API_ENDPOINTS.primary,
|
||||
type: 'POST',
|
||||
data: request,
|
||||
success: resolve,
|
||||
error: (xhr, status, err) => reject(new Error(`API failed: ${err}`)),
|
||||
});
|
||||
});
|
||||
}
|
||||
throw new Error('No HTTP client available');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 模块导出 =====
|
||||
|
||||
if (typeof module !== 'undefined') {
|
||||
module.exports = {
|
||||
buildBrowserEntrypointResult,
|
||||
validateArgs,
|
||||
buildRequest,
|
||||
normalizeRows,
|
||||
COLUMN_DEFS,
|
||||
COLUMNS,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof args !== 'undefined') {
|
||||
return buildBrowserEntrypointResult(args);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
1. **LLM 提取测试**
|
||||
- 测试 fixture HTML 文件
|
||||
- 验证提取字段完整性
|
||||
- 验证 JSON 解析健壮性
|
||||
|
||||
2. **模板渲染测试**
|
||||
- 验证生成的脚本语法正确
|
||||
- 验证常量定义正确
|
||||
- 验证函数结构完整
|
||||
|
||||
### Integration Tests
|
||||
|
||||
1. **端到端测试**
|
||||
- 选择 fixture 场景目录
|
||||
- 分析 → 预览 → 生成
|
||||
- 验证生成的 skill 可加载
|
||||
|
||||
2. **真实场景测试**
|
||||
- 使用营销零度户场景
|
||||
- 对比 Claude 手动转换版本
|
||||
- 验证功能等价性
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: 基础设施 (Week 1)
|
||||
- Task 1-2: LLM 提取增强
|
||||
- Task 3: Server 改造
|
||||
|
||||
### Phase 2: Rust 模板 (Week 2)
|
||||
- Task 4: CLI 参数扩展
|
||||
- Task 5: Generator 模板增强
|
||||
|
||||
### Phase 3: 用户体验 (Week 3)
|
||||
- Task 6: Web UI 预览
|
||||
- 集成测试
|
||||
|
||||
### Phase 4: 验证优化 (Week 4)
|
||||
- 真实场景测试
|
||||
- 模板调优
|
||||
- 文档完善
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| LLM 提取不准确 | 生成的脚本无法运行 | 提供 Web UI 预览编辑,支持人工修正 |
|
||||
| 场景源码格式多样 | 提取失败率增加 | 覆盖常见模式,提供 fallback |
|
||||
| Token 消耗过大 | 成本增加 | 限制 index.html 读取长度,优先提取关键段落 |
|
||||
| 复杂业务逻辑无法自动生成 | 功能不完整 | 生成骨架 + TODO 注释,提示人工补充 |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **自动化率**: 80% 的场景可自动生成可运行脚本
|
||||
2. **准确率**: 提取的 API/参数/列定义准确率 > 90%
|
||||
3. **人工干预**: 平均每个场景人工编辑时间 < 5 分钟
|
||||
4. **对比 Claude**: 生成的脚本功能与 Claude 手动转换版本等价
|
||||
@@ -0,0 +1,333 @@
|
||||
# Progressive Browser Script Template Enhancement
|
||||
|
||||
> **Status:** Draft
|
||||
> **Date:** 2026-04-17
|
||||
> **Author:** Qoder
|
||||
|
||||
## Problem Statement
|
||||
|
||||
当前自动生成的 `browser_script_with_business_logic` 模板存在以下问题:
|
||||
|
||||
### 问题列表
|
||||
|
||||
| 问题 | 影响 | 严重程度 |
|
||||
|------|------|----------|
|
||||
| URL 构建错误 | `new URL(endpoint.url, window.location.origin)` 会错误地基于当前页面 URL | 高 |
|
||||
| 缺少 jQuery 支持 | 内网页面通常使用 jQuery `$.ajax`,fetch 可能遇到 CORS 问题 | 高 |
|
||||
| 状态判定简单 | 只有 ok/error 两种状态,缺少 blocked/partial/empty 细分 | 中 |
|
||||
| 缺少多端点支持 | 只有一个 `queryData` 方法,无法处理多 API 场景 | 中 |
|
||||
|
||||
### 对比 tq-lineloss-report
|
||||
|
||||
| 功能 | tq-lineloss (完整) | 当前模板 (骨架) |
|
||||
|------|-------------------|-----------------|
|
||||
| HTTP 客户端 | jQuery $.ajax + 错误处理 | 仅 fetch |
|
||||
| URL 处理 | 硬编码完整 URL | 错误的 URL 构建 |
|
||||
| 状态判定 | determineArtifactStatus 函数 | 简单三元表达式 |
|
||||
| API 端点 | 多个端点方法 | 单一 queryData |
|
||||
|
||||
## Goal
|
||||
|
||||
增强 `browser_script_with_business_logic` 模板,使其生成的脚本能够:
|
||||
1. 正确处理 API URL(修复 bug)
|
||||
2. 同时支持 jQuery 和 fetch HTTP 客户端
|
||||
3. 提供完整的状态判定逻辑(blocked/error/partial/empty/ok)
|
||||
4. 支持多 API 端点场景
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不改变 LLM 提取逻辑(本次仅增强 Rust 模板)
|
||||
- 不增加新的 CLI 参数
|
||||
- 不改变现有 API 契约
|
||||
- 不支持自动生成 tq-lineloss 的"月/周两套列定义"模式(需要 LLM 增强)
|
||||
|
||||
## Architecture
|
||||
|
||||
### 模板结构
|
||||
|
||||
```
|
||||
browser_script_with_business_logic()
|
||||
├── 常量定义
|
||||
│ ├── API_ENDPOINTS (from LLM)
|
||||
│ ├── STATIC_PARAMS (from LLM)
|
||||
│ └── COLUMN_DEFS (from LLM)
|
||||
├── 工具函数
|
||||
│ ├── normalizePayload()
|
||||
│ ├── pickFirstNonEmpty()
|
||||
│ └── isNonEmptyString()
|
||||
├── 参数验证
|
||||
│ └── validateArgs() - 增强版
|
||||
├── 请求构建
|
||||
│ ├── buildRequest() - 修复 URL
|
||||
│ └── buildRequestBody() - 新增
|
||||
├── HTTP 客户端
|
||||
│ ├── defaultDeps.queryData() - jQuery 优先
|
||||
│ └── fetchFallback() - 新增
|
||||
├── 数据处理
|
||||
│ └── normalizeRows()
|
||||
├── 状态判定
|
||||
│ └── determineArtifactStatus() - 新增
|
||||
├── Artifact 构建
|
||||
│ └── buildArtifact() - 增强版
|
||||
└── 入口函数
|
||||
└── buildBrowserEntrypointResult()
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
用户请求 (args)
|
||||
↓
|
||||
validateArgs() → 参数验证
|
||||
↓ (blocked if invalid)
|
||||
validatePageContext() → 页面上下文验证
|
||||
↓ (blocked if mismatch)
|
||||
buildRequest() → 构建请求参数
|
||||
↓
|
||||
queryData() → HTTP 请求 (jQuery/fetch)
|
||||
↓
|
||||
normalizeRows() → 数据归一化
|
||||
↓
|
||||
determineArtifactStatus() → 状态判定
|
||||
↓
|
||||
buildArtifact() → 构建 artifact
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. 修复 URL 构建
|
||||
|
||||
**当前代码 (有 bug)**:
|
||||
```javascript
|
||||
function buildRequest(args, endpoint) {
|
||||
const url = new URL(endpoint.url, window.location.origin); // 错误!
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**修复后**:
|
||||
```javascript
|
||||
function buildRequest(args, endpoint) {
|
||||
// 直接使用完整 URL,不基于 window.location.origin
|
||||
const url = endpoint.url;
|
||||
const method = endpoint.method || 'POST';
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const body = JSON.stringify({ ...STATIC_PARAMS, ...args });
|
||||
return { url, method, headers, body };
|
||||
}
|
||||
```
|
||||
|
||||
### 2. jQuery + fetch 双支持
|
||||
|
||||
```javascript
|
||||
const defaultDeps = {
|
||||
validatePageContext(args) {
|
||||
const host = (globalThis.location?.hostname || '').trim();
|
||||
const expected = (args.expected_domain || '').trim();
|
||||
if (!host) return { ok: false, reason: 'page_context_unavailable' };
|
||||
if (host !== expected) return { ok: false, reason: 'page_context_mismatch' };
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async queryData(args) {
|
||||
const endpoint = API_ENDPOINTS[0];
|
||||
if (!endpoint) throw new Error('No API endpoint configured');
|
||||
const request = buildRequest(args, endpoint);
|
||||
|
||||
// 优先使用 jQuery (内网页面通常有)
|
||||
if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {
|
||||
return new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: request.url,
|
||||
type: request.method,
|
||||
data: request.body,
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
success: resolve,
|
||||
error: (xhr, status, err) => reject(new Error(
|
||||
`API failed (${xhr.status}): ${err} | body=${(xhr.responseText || '').substring(0, 200)}`
|
||||
))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: fetch API
|
||||
if (typeof fetch === 'function') {
|
||||
const response = await fetch(request.url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.method !== 'GET' ? request.body : undefined
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}: ${text.substring(0, 200)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
throw new Error('No HTTP client available (need jQuery or fetch)');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 完整状态判定
|
||||
|
||||
```javascript
|
||||
function determineArtifactStatus({ blockedReason = '', fatalError = '', reasons = [], rows = [] }) {
|
||||
if (blockedReason) return 'blocked';
|
||||
if (fatalError) return 'error';
|
||||
if (reasons.length > 0) return 'partial';
|
||||
if (!rows.length) return 'empty';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
function buildArtifact({ status, blockedReason, fatalError, reasons, rows, args }) {
|
||||
return {
|
||||
type: 'report-artifact',
|
||||
report_name: REPORT_NAME,
|
||||
status: status || determineArtifactStatus({ blockedReason, fatalError, reasons, rows }),
|
||||
period: { mode: args.period_mode, mode_code: args.period_mode_code, value: args.period_value, payload: normalizePayload(args.period_payload) },
|
||||
org: { label: args.org_label, code: args.org_code },
|
||||
column_defs: COLUMN_DEFS,
|
||||
columns: COLUMNS,
|
||||
rows,
|
||||
counts: { detail_rows: rows.length },
|
||||
partial_reasons: reasons.filter(r => r && !r.startsWith('api_') && !r.startsWith('validation_')),
|
||||
reasons: Array.from(new Set(reasons.filter(Boolean)))
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 增强入口函数
|
||||
|
||||
```javascript
|
||||
async function buildBrowserEntrypointResult(args, deps = defaultDeps) {
|
||||
// 1. 参数验证
|
||||
const validation = validateArgs(args);
|
||||
if (!validation.valid) {
|
||||
return buildArtifact({
|
||||
status: 'blocked',
|
||||
blockedReason: 'validation_failed',
|
||||
reasons: validation.errors,
|
||||
rows: [],
|
||||
args
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 页面上下文验证
|
||||
const pageValidation = typeof deps.validatePageContext === 'function'
|
||||
? deps.validatePageContext(args)
|
||||
: { ok: true };
|
||||
if (!pageValidation?.ok) {
|
||||
return buildArtifact({
|
||||
status: 'blocked',
|
||||
blockedReason: pageValidation?.reason || 'page_context_mismatch',
|
||||
reasons: [pageValidation?.reason || 'page_context_mismatch'],
|
||||
rows: [],
|
||||
args
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 数据获取
|
||||
const reasons = [];
|
||||
let rawData = null;
|
||||
try {
|
||||
rawData = await (deps.queryData ? deps.queryData(args) : Promise.resolve([]));
|
||||
} catch (error) {
|
||||
return buildArtifact({
|
||||
status: 'error',
|
||||
fatalError: error.message,
|
||||
reasons: ['api_query_failed:' + error.message],
|
||||
rows: [],
|
||||
args
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 数据归一化
|
||||
const rows = normalizeRows(rawData);
|
||||
if (rows.length === 0 && Array.isArray(rawData) && rawData.length > 0) {
|
||||
reasons.push('row_normalization_partial');
|
||||
}
|
||||
|
||||
// 5. 构建 Artifact
|
||||
return buildArtifact({ reasons, rows, args });
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Rust 模板变更
|
||||
|
||||
修改 `src/generated_scene/generator.rs` 中的 `browser_script_with_business_logic` 函数,将上述 JavaScript 模板硬编码进去。
|
||||
|
||||
关键改动:
|
||||
- 替换 `buildRequest` 函数(修复 URL bug)
|
||||
- 替换 `defaultDeps` 对象(添加 jQuery 支持)
|
||||
- 添加 `determineArtifactStatus` 函数
|
||||
- 增强 `buildArtifact` 函数
|
||||
- 增强 `buildBrowserEntrypointResult` 函数
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
1. **URL 构建测试**
|
||||
- 验证完整 URL 被正确传递
|
||||
- 验证 GET/POST 方法正确处理
|
||||
|
||||
2. **状态判定测试**
|
||||
- 测试 blocked → blockedReason 存在
|
||||
- 测试 error → fatalError 存在
|
||||
- 测试 partial → reasons 非空
|
||||
- 测试 empty → rows 为空
|
||||
- 测试 ok → rows 非空
|
||||
|
||||
3. **HTTP 客户端测试**
|
||||
- Mock jQuery 环境,验证 $.ajax 被调用
|
||||
- Mock fetch 环境,验证 fetch 被调用
|
||||
- 验证错误处理
|
||||
|
||||
### Integration Tests
|
||||
|
||||
1. **端到端测试**
|
||||
- 使用 fixture 场景目录
|
||||
- 运行深度分析 → 生成 skill
|
||||
- 验证生成的脚本语法正确
|
||||
- 验证生成的脚本可被 Node.js 加载
|
||||
|
||||
2. **真实场景测试**
|
||||
- 使用 marketing-zero-consumer-report 源场景
|
||||
- 重新生成 skill
|
||||
- 验证脚本可运行(需要内网环境)
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: 修复 Bug
|
||||
- 修复 URL 构建问题
|
||||
- 保持其他逻辑不变
|
||||
- 验证现有场景不受影响
|
||||
|
||||
### Phase 2: 增强 HTTP 客户端
|
||||
- 添加 jQuery 支持
|
||||
- 保留 fetch 作为 fallback
|
||||
- 验证两种方式都能工作
|
||||
|
||||
### Phase 3: 完善状态判定
|
||||
- 添加 determineArtifactStatus
|
||||
- 增强 buildArtifact
|
||||
- 验证各种状态场景
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 模板过大 | 维护困难 | 分段组织,添加注释 |
|
||||
| jQuery 全局变量检查 | 可能误判 | 同时检查 $ 和 $.ajax |
|
||||
| 状态判定过于严格 | 部分场景不兼容 | 提供配置选项 |
|
||||
| 向后兼容 | 现有 skill 可能受影响 | 仅修改有 scene_info 的场景 |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **URL 修复**: `buildRequest` 不再依赖 `window.location.origin`
|
||||
2. **jQuery 支持**: 在有 jQuery 的页面优先使用 `$.ajax`
|
||||
3. **状态完整**: 支持 blocked/error/partial/empty/ok 五种状态
|
||||
4. **向后兼容**: 无 scene_info 时仍生成骨架模板
|
||||
5. **可运行性**: 生成的 marketing-zero-consumer-report 可在内网运行
|
||||
@@ -0,0 +1,192 @@
|
||||
# sgClaw 场景生成器质量提升设计
|
||||
|
||||
## 问题陈述
|
||||
|
||||
sgClaw 场景生成器能将 `D:\desk\智能体资料\场景\` 下的原始 HTML 场景转化为 skill 包,但自动生成质量仅为 Claude 手写的 tq-lineloss-report 的 ~40%。
|
||||
|
||||
### 四个核心问题
|
||||
|
||||
| # | 问题 | 根因 |
|
||||
|---|------|------|
|
||||
| 1 | Content-Type 硬编码为 `application/json` | `browser_script_with_business_logic()` 模板硬编码,`ApiEndpointJson` 无 content_type 字段 |
|
||||
| 2 | 请求体缺少业务必需字段 | 仅做 `{...STATIC_PARAMS, ...args}` 简单合并,无 requestTemplate 机制 |
|
||||
| 3 | 响应数据未正确提取 | 无 responsePath 提取步骤,`normalizeRows` 直接接收原始响应 |
|
||||
| 4 | 缺乏多模式路由能力 | 旧路径硬编码 `API_ENDPOINTS[0]`,无模式检测 |
|
||||
|
||||
### 根本架构缺陷
|
||||
|
||||
生成器有两条路径:
|
||||
- `browser_script_with_modes()` — 新路径,支持所有高级特性
|
||||
- `browser_script_with_business_logic()` — 旧路径,仅支持基础功能
|
||||
|
||||
LLM 判断为单模式的场景走旧路径,所有高级特性丢失。
|
||||
|
||||
### 原始场景分类
|
||||
|
||||
13 个场景按 API 调用模式分为三类:
|
||||
|
||||
- **模式 A:BrowserAction 跨页面注入**(5 场景)— 线损类、服务风险类、约时工单类
|
||||
- **模式 B:$http.sendByAxios 营销网关**(2 场景)— 营销2.0零度户、光伏
|
||||
- **模式 C:直接 AJAX**(6 场景)— 95598 类、监测类、大电量类
|
||||
|
||||
按复杂度分三个梯队:
|
||||
|
||||
| 梯队 | 场景数 | 当前质量 | 目标质量 | 特征 |
|
||||
|------|--------|---------|---------|------|
|
||||
| 第一梯队 | 5 | ~60% | ~90% | 单模式、直接 AJAX、无 BrowserAction |
|
||||
| 第二梯队 | 5 | ~40% | ~70% | 涉及 BrowserAction 或 form-urlencoded |
|
||||
| 第三梯队 | 3 | ~20% | ~40% | 链式多步 API 调用,仍需人工介入 |
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 核心策略
|
||||
|
||||
统一生成路径 + 增强 LLM 提取深度 + 修复已知问题
|
||||
|
||||
### 架构变更
|
||||
|
||||
```
|
||||
修改前:
|
||||
browser_script()
|
||||
├─ modes 非空 → browser_script_with_modes() [新路径]
|
||||
├─ api_endpoints 非空 → browser_script_with_business_logic() [旧路径,质量差]
|
||||
└─ 其他 → browser_script_skeleton() [骨架路径]
|
||||
|
||||
修改后:
|
||||
browser_script()
|
||||
├─ modes 非空 → browser_script_with_modes() [统一路径]
|
||||
├─ 单模式场景 → 自动包装为 modes=[default_mode] → browser_script_with_modes()
|
||||
└─ 无端点 → browser_script_skeleton() [骨架路径]
|
||||
```
|
||||
|
||||
## 实施阶段
|
||||
|
||||
### 阶段一:修基础(统一路径,修模板)
|
||||
|
||||
#### Task 1:统一生成路径
|
||||
|
||||
**目标**:废弃 `browser_script_with_business_logic()`,所有场景统一走 `browser_script_with_modes()`
|
||||
|
||||
**改动**:
|
||||
- 修改 `browser_script()` 路由逻辑:单模式场景自动包装为一个 mode
|
||||
- `browser_script_with_business_logic()` 标记为 `#[deprecated]`
|
||||
- `browser_script_skeleton()` 仅用于无端点、无列定义的场景
|
||||
|
||||
**文件**:`src/generated_scene/generator.rs`
|
||||
|
||||
#### Task 2:修复 jQuery processData 参数
|
||||
|
||||
**目标**:form-urlencoded 请求在 jQuery ajax 中正确序列化
|
||||
|
||||
**改动**:
|
||||
- `buildModeRequest` 函数中根据 `contentType` 区分处理:
|
||||
- `application/x-www-form-urlencoded`:body 传对象,jQuery 调用加 `processData: false`
|
||||
- `application/json`:body 传 `JSON.stringify()`,不加 processData
|
||||
- 在模板中为 jQuery ajax 调用增加 `processData` 条件判断
|
||||
|
||||
**文件**:`src/generated_scene/generator.rs`(`browser_script_with_modes` 模板)
|
||||
|
||||
#### Task 3:单模式场景自动包装为 mode 配置
|
||||
|
||||
**目标**:LLM 输出无 modes 但有 apiEndpoints 时,自动包装为单 mode
|
||||
|
||||
**改动**:
|
||||
- `analyzeSceneDeep()` 中增加自动包装逻辑
|
||||
- 当 `modes` 为空且 `apiEndpoints` 非空时,生成默认 mode:
|
||||
```js
|
||||
{
|
||||
name: "default",
|
||||
condition: { field: "period_mode", operator: "equals", value: "default" },
|
||||
apiEndpoint: apiEndpoints[0], // 使用第一个端点
|
||||
columnDefs: columnDefs,
|
||||
requestTemplate: staticParams,
|
||||
responsePath: "",
|
||||
normalizeRules: { type: "validate_all_columns", filterNull: true }
|
||||
}
|
||||
```
|
||||
- 设置 `defaultMode: "default"`, `modeSwitchField: "period_mode"`
|
||||
|
||||
**文件**:`frontend/scene-generator/llm-client.js`
|
||||
|
||||
### 阶段二:增强提取(让 LLM 提取更准)
|
||||
|
||||
#### Task 4:增强 LLM prompt 的强制约束
|
||||
|
||||
**目标**:让 LLM 必须输出 Content-Type、responsePath、requestTemplate 等关键字段
|
||||
|
||||
**改动**:
|
||||
- `DEEP_SYSTEM_PROMPT` 中增加强制字段约束说明
|
||||
- 增加"如果找不到就填默认值"的指导,避免 LLM 跳过这些字段
|
||||
- 增加 contentType 判断示例(`$.ajax` 中找 `contentType` 属性,`$http.sendByAxios` 中看封装)
|
||||
|
||||
**文件**:`frontend/scene-generator/llm-client.js`
|
||||
|
||||
#### Task 5:增加业务 JS 文件提取
|
||||
|
||||
**目标**:LLM 不仅读 index.html,还要读 js/ 目录下的业务逻辑文件
|
||||
|
||||
**改动**:
|
||||
- `frontend/scene-generator/server.js` 的切片逻辑:识别并提取 `js/mca.js`, `js/ami.js`, `js/sgApi.js`, `js/power.js` 等业务文件
|
||||
- `buildDeepAnalyzePrompt` 中增加业务 JS 文件的片段推送
|
||||
- 限制总 token 数不超过 `MAX_DEEP_PROMPT_CHARS`(60000)
|
||||
|
||||
**文件**:
|
||||
- `frontend/scene-generator/server.js`(新增业务 JS 文件识别和提取)
|
||||
- `frontend/scene-generator/llm-client.js`(prompt 中增加业务 JS 片段)
|
||||
|
||||
#### Task 6:提取后验证与二次追问
|
||||
|
||||
**目标**:LLM 返回后检查关键信息是否缺失,缺失则二次追问
|
||||
|
||||
**改动**:
|
||||
- 新增 `validateExtractedSceneInfo()` 函数
|
||||
- 检查项:至少一个 apiEndpoint 有 contentType、至少一个 mode 有 responsePath
|
||||
- 如果缺失,构造二次 prompt 要求补充
|
||||
- 最多追问 1 次,超时则使用默认值
|
||||
|
||||
**文件**:`frontend/scene-generator/llm-client.js`
|
||||
|
||||
### 阶段三:测试验证
|
||||
|
||||
#### Task 7:单元测试
|
||||
|
||||
**目标**:为多模式生成路径添加测试覆盖
|
||||
|
||||
**改动**:
|
||||
- 新增 `tests/scene_generator_modes_test.rs`
|
||||
- 测试用例:
|
||||
1. 单模式场景生成包含 `const MODES =` 的 JS
|
||||
2. 多模式场景生成包含 mode 路由逻辑
|
||||
3. 蛇形/驼峰序列化一致性
|
||||
4. form-urlencoded 请求体格式正确
|
||||
5. responsePath 提取步骤存在
|
||||
|
||||
**文件**:`tests/scene_generator_modes_test.rs`(新增)
|
||||
|
||||
#### Task 8:集成测试
|
||||
|
||||
**目标**:用真实场景验证生成质量
|
||||
|
||||
**步骤**:
|
||||
1. 拿 `用户日电量监测`(最简单的模式 C 场景)跑一次完整生成
|
||||
2. 拿 `台区线损大数据`(最复杂的模式 A 双模式场景)跑一次完整生成
|
||||
3. 对比生成结果与 tq-lineloss-report 的差异
|
||||
4. 记录差距清单
|
||||
|
||||
**产出**:集成测试报告文档
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 文件 | 改动类型 | 涉及 Task |
|
||||
|------|---------|-----------|
|
||||
| `src/generated_scene/generator.rs` | 修改 | Task 1, Task 2 |
|
||||
| `frontend/scene-generator/llm-client.js` | 修改 | Task 3, Task 4, Task 6 |
|
||||
| `frontend/scene-generator/server.js` | 修改 | Task 5 |
|
||||
| `tests/scene_generator_modes_test.rs` | 新增 | Task 7 |
|
||||
| 集成测试报告 | 新增 | Task 8 |
|
||||
|
||||
## 非目标
|
||||
|
||||
- 第三梯队场景(链式多步 API 调用)的完全自动化不在本方案范围内
|
||||
- BrowserAction 跨页面注入的自动识别和转换在第二梯队中部分支持,不追求 100% 准确
|
||||
- Web UI 变更不在本方案范围内(后续独立方案处理)
|
||||
BIN
docs/多核浏览器管道API接口文档.docx
Normal file
BIN
docs/多核浏览器管道API接口文档.docx
Normal file
Binary file not shown.
90
frontend/scene-generator/config-loader.js
Normal file
90
frontend/scene-generator/config-loader.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function resolveProjectRoot() {
|
||||
const envRoot = process.env.SGCLAW_PROJECT_ROOT;
|
||||
if (envRoot && fs.existsSync(envRoot)) {
|
||||
return path.resolve(envRoot);
|
||||
}
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
if (configPath && fs.existsSync(configPath)) {
|
||||
return path.dirname(configPath);
|
||||
}
|
||||
|
||||
return path.resolve(__dirname);
|
||||
}
|
||||
|
||||
function resolveConfigPath() {
|
||||
const envPath = process.env.SGCLAW_CONFIG_PATH;
|
||||
if (envPath && fs.existsSync(envPath)) {
|
||||
return path.resolve(envPath);
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
path.resolve(__dirname, "..", "..", "..", "sgclaw_config.json"), // Parent sgClaw directory
|
||||
path.resolve(__dirname, "..", "..", "sgclaw_config.json"), // claw-new/sgclaw_config.json
|
||||
path.resolve(__dirname, "..", "sgclaw_config.json"), // frontend/sgclaw_config.json
|
||||
path.resolve(__dirname, "sgclaw_config.json"), // scene-generator/sgclaw_config.json
|
||||
];
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = resolveConfigPath();
|
||||
if (!configPath) {
|
||||
throw new Error(
|
||||
"sgclaw_config.json not found. Set SGCLAW_CONFIG_PATH or place it in the project root."
|
||||
);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
const apiKey = config.apiKey || "";
|
||||
const baseUrl = config.baseUrl || "";
|
||||
const model = config.model || "";
|
||||
|
||||
if (!apiKey) throw new Error("sgclaw_config.json: 'apiKey' is required");
|
||||
if (!baseUrl) throw new Error("sgclaw_config.json: 'baseUrl' is required");
|
||||
if (!model) throw new Error("sgclaw_config.json: 'model' is required");
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: normalizeBaseUrl(baseUrl),
|
||||
model,
|
||||
projectRoot: resolveProjectRoot(),
|
||||
configPath,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
url = url.replace(/\/+$/, "");
|
||||
url = url.replace(/\/v1\/?$/, "");
|
||||
return url + "/v1";
|
||||
}
|
||||
|
||||
function getDefaults() {
|
||||
const config = loadConfig();
|
||||
const projectRoot = config.projectRoot;
|
||||
|
||||
return {
|
||||
outputRoot: path.join(projectRoot, "examples", "generated_scene_platform"),
|
||||
lessonsPath: path.join(
|
||||
projectRoot,
|
||||
"docs",
|
||||
"superpowers",
|
||||
"references",
|
||||
"tq-lineloss-lessons-learned.toml"
|
||||
),
|
||||
llmBaseUrl: config.baseUrl,
|
||||
llmModel: config.model,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { loadConfig, getDefaults, resolveProjectRoot, resolveConfigPath };
|
||||
197
frontend/scene-generator/generator-runner.js
Normal file
197
frontend/scene-generator/generator-runner.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const { spawn } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
function runGenerator(params, sseWriter, projectRoot) {
|
||||
const { sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons, sceneInfoJson } = params;
|
||||
|
||||
const normalize = (p) => p.replace(/\\/g, "/");
|
||||
|
||||
const args = [
|
||||
"run",
|
||||
"--bin",
|
||||
"sg_scene_generate",
|
||||
"--",
|
||||
"--source-dir",
|
||||
normalize(sourceDir),
|
||||
"--scene-id",
|
||||
sceneId,
|
||||
"--scene-name",
|
||||
sceneName,
|
||||
];
|
||||
|
||||
// 只有明确指定 sceneKind 时才添加参数(否则使用默认值 report_collection)
|
||||
if (sceneKind) {
|
||||
args.push("--scene-kind", sceneKind);
|
||||
}
|
||||
|
||||
// 如果提供了 targetUrl,添加参数
|
||||
if (targetUrl) {
|
||||
args.push("--target-url", targetUrl);
|
||||
}
|
||||
|
||||
args.push("--output-root", normalize(outputRoot));
|
||||
|
||||
if (lessons) {
|
||||
args.push("--lessons", normalize(lessons));
|
||||
}
|
||||
|
||||
// Pass scene info JSON if available
|
||||
if (sceneInfoJson) {
|
||||
args.push("--scene-info-json", sceneInfoJson);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
sseWriter.write(
|
||||
`event: status\ndata: ${JSON.stringify({
|
||||
message: "开始生成 skill 包...",
|
||||
})}\n\n`
|
||||
);
|
||||
sseWriter.write(
|
||||
`event: status\ndata: ${JSON.stringify({
|
||||
message: `执行: cargo ${args.join(" ")}`,
|
||||
})}\n\n`
|
||||
);
|
||||
|
||||
const child = spawn("cargo", args, {
|
||||
cwd: projectRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, RUST_BACKTRACE: "1" },
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
sseWriter.write(
|
||||
`event: error\ndata: ${JSON.stringify({
|
||||
message: "生成超时(5分钟)",
|
||||
})}\n\n`
|
||||
);
|
||||
resolve({ success: false, error: "timeout" });
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
sseWriter.write(
|
||||
`event: log\ndata: ${JSON.stringify({ message: text.trim() })}\n\n`
|
||||
);
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
stderr += text;
|
||||
sseWriter.write(
|
||||
`event: log\ndata: ${JSON.stringify({ message: text.trim() })}\n\n`
|
||||
);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (timedOut) return;
|
||||
|
||||
if (code === 0) {
|
||||
const match = stdout.match(/generated scene package:\s*(.+)/);
|
||||
const skillRoot = match ? match[1] : null;
|
||||
sseWriter.write(
|
||||
`event: status\ndata: ${JSON.stringify({
|
||||
message: "✅ 生成成功",
|
||||
})}\n\n`
|
||||
);
|
||||
sseWriter.write(
|
||||
`event: complete\ndata: ${JSON.stringify({
|
||||
success: true,
|
||||
skillRoot,
|
||||
})}\n\n`
|
||||
);
|
||||
resolve({ success: true, skillRoot });
|
||||
} else {
|
||||
sseWriter.write(
|
||||
`event: error\ndata: ${JSON.stringify({
|
||||
message: `生成失败 (exit code ${code})`,
|
||||
})}\n\n`
|
||||
);
|
||||
if (stderr.trim()) {
|
||||
sseWriter.write(
|
||||
`event: error\ndata: ${JSON.stringify({
|
||||
message: stderr.substring(0, 500),
|
||||
})}\n\n`
|
||||
);
|
||||
}
|
||||
resolve({ success: false, code, stderr });
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
sseWriter.write(
|
||||
`event: error\ndata: ${JSON.stringify({
|
||||
message: `无法启动 cargo: ${err.message}`,
|
||||
})}\n\n`
|
||||
);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readDirectory(sourceDir) {
|
||||
const fs = require("fs");
|
||||
const p = require("path");
|
||||
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
throw new Error(`Directory not found: ${sourceDir}`);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(sourceDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Not a directory: ${sourceDir}`);
|
||||
}
|
||||
|
||||
const result = {};
|
||||
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
||||
|
||||
const treeLines = [];
|
||||
for (const entry of entries) {
|
||||
treeLines.push(`├── ${entry.name}`);
|
||||
}
|
||||
result.tree = treeLines.join("\n");
|
||||
|
||||
const sceneTomlPath = p.join(sourceDir, "scene.toml");
|
||||
if (fs.existsSync(sceneTomlPath)) {
|
||||
result["scene.toml"] = fs.readFileSync(sceneTomlPath, "utf-8");
|
||||
}
|
||||
|
||||
const skillTomlPath = p.join(sourceDir, "SKILL.toml");
|
||||
if (fs.existsSync(skillTomlPath)) {
|
||||
result["SKILL.toml"] = fs.readFileSync(skillTomlPath, "utf-8");
|
||||
}
|
||||
|
||||
const skillMdPath = p.join(sourceDir, "SKILL.md");
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
result["SKILL.md"] = fs.readFileSync(skillMdPath, "utf-8");
|
||||
}
|
||||
|
||||
// Read index.html
|
||||
const indexHtmlPath = p.join(sourceDir, "index.html");
|
||||
if (fs.existsSync(indexHtmlPath)) {
|
||||
result.indexHtml = fs.readFileSync(indexHtmlPath, "utf-8");
|
||||
}
|
||||
|
||||
const scripts = {};
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith(".js")) {
|
||||
const scriptPath = p.join(sourceDir, entry.name);
|
||||
scripts[entry.name] = fs.readFileSync(scriptPath, "utf-8");
|
||||
}
|
||||
}
|
||||
if (Object.keys(scripts).length > 0) {
|
||||
result.scripts = scripts;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { runGenerator, readDirectory };
|
||||
348
frontend/scene-generator/llm-client.js
Normal file
348
frontend/scene-generator/llm-client.js
Normal file
@@ -0,0 +1,348 @@
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
|
||||
const SYSTEM_PROMPT = `你是一个场景信息提取助手。根据场景目录的内容,提取 scene-id 和 scene-name。
|
||||
|
||||
scene-id 规则:
|
||||
- 使用英文短横线连接,如 tq-lineloss-report
|
||||
- 全小写,有业务含义
|
||||
|
||||
scene-name 规则:
|
||||
- 使用中文,简短描述性名称
|
||||
- 如 "台区线损报表"、"知乎热榜导出"
|
||||
|
||||
请以 JSON 格式返回:{"sceneId": "...", "sceneName": "..."}`;
|
||||
|
||||
const DEEP_SYSTEM_PROMPT = `你是一个场景代码分析专家。分析场景源码,提取关键业务信息。
|
||||
|
||||
## 分析目标
|
||||
|
||||
1. **多模式识别** (关键):
|
||||
- 查找条件分支逻辑 (if/switch) 中基于 period_mode、reportType 等字段的分支
|
||||
- 识别不同分支对应的 API 端点、列定义、请求格式
|
||||
- 如果发现多模式,使用 modes 数组格式输出
|
||||
|
||||
2. **API 端点**: 识别所有 HTTP 请求地址 (URL, method, contentType, 用途)
|
||||
- 从 \$.ajax/fetch 调用中提取 contentType
|
||||
- 检测请求格式: application/json 或 application/x-www-form-urlencoded
|
||||
|
||||
3. **请求模板**: 识别请求参数结构
|
||||
- 提取硬编码的分页参数 (rows, page, sidx, sord)
|
||||
- 识别模板变量如 \${args.org_code}
|
||||
|
||||
4. **数据归一化**: 识别数据处理规则
|
||||
- 查找数据渲染/表格填充逻辑
|
||||
- 检测数据验证条件 (哪些字段不能为空)
|
||||
|
||||
5. **响应路径**: 识别数据在响应中的位置
|
||||
- 如 response.content 或 response.data
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 单模式场景 (无 modes 数组):
|
||||
{
|
||||
"sceneId": "string",
|
||||
"sceneName": "string",
|
||||
"sceneKind": "report_collection | monitoring",
|
||||
"expectedDomain": "string",
|
||||
"targetUrl": "string",
|
||||
"apiEndpoints": [{"name": "", "url": "", "method": "POST"}],
|
||||
"staticParams": {"key": "value"},
|
||||
"columnDefs": [["fieldName", "中文列名"]]
|
||||
}
|
||||
|
||||
### 多模式场景 (有 modes 数组):
|
||||
{
|
||||
"sceneId": "tq-lineloss-report",
|
||||
"sceneName": "台区线损报表",
|
||||
"sceneKind": "report_collection",
|
||||
"modes": [
|
||||
{
|
||||
"name": "month",
|
||||
"label": "月度报表",
|
||||
"condition": {"field": "period_mode", "operator": "equals", "value": "month"},
|
||||
"apiEndpoint": {
|
||||
"name": "月度线损查询",
|
||||
"url": "http://...",
|
||||
"method": "POST",
|
||||
"contentType": "application/x-www-form-urlencoded"
|
||||
},
|
||||
"columnDefs": [["ORG_NAME", "供电单位"], ...],
|
||||
"requestTemplate": {"orgno": "\${args.org_code}", "rows": 1000, "page": 1},
|
||||
"normalizeRules": {"type": "validate_all_columns", "filterNull": true},
|
||||
"responsePath": "content"
|
||||
},
|
||||
{
|
||||
"name": "week",
|
||||
"label": "周报表",
|
||||
"condition": {"field": "period_mode", "operator": "equals", "value": "week"},
|
||||
"apiEndpoint": {...},
|
||||
"columnDefs": [...],
|
||||
...
|
||||
}
|
||||
],
|
||||
"defaultMode": "month",
|
||||
"modeSwitchField": "period_mode"
|
||||
}
|
||||
|
||||
**重要**: 如果发现代码中有基于 period_mode 的 if/switch 分支,必须使用多模式格式输出!`;
|
||||
|
||||
function buildAnalyzePrompt(sourceDir, dirContents) {
|
||||
const parts = [];
|
||||
|
||||
parts.push(`=== 目录结构 ===`);
|
||||
parts.push(dirContents.tree || "(empty)");
|
||||
|
||||
if (dirContents["scene.toml"]) {
|
||||
parts.push(`\n=== scene.toml ===`);
|
||||
parts.push(dirContents["scene.toml"]);
|
||||
}
|
||||
|
||||
if (dirContents["SKILL.toml"]) {
|
||||
parts.push(`\n=== SKILL.toml ===`);
|
||||
parts.push(dirContents["SKILL.toml"]);
|
||||
}
|
||||
|
||||
if (dirContents["SKILL.md"]) {
|
||||
parts.push(`\n=== SKILL.md ===`);
|
||||
parts.push(dirContents["SKILL.md"]);
|
||||
}
|
||||
|
||||
if (dirContents.scripts && Object.keys(dirContents.scripts).length > 0) {
|
||||
parts.push(`\n=== 脚本文件 ===`);
|
||||
for (const [name, content] of Object.entries(dirContents.scripts)) {
|
||||
parts.push(`\n--- ${name} ---`);
|
||||
const contentStr = typeof content === 'string' ? content : String(content || '');
|
||||
parts.push(contentStr.substring(0, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
return `以下是场景目录 "${sourceDir}" 的内容:\n\n${parts.join("\n")}\n\n请以 JSON 格式返回:{"sceneId": "...", "sceneName": "..."}`;
|
||||
}
|
||||
|
||||
function buildDeepAnalyzePrompt(sourceDir, dirContents, indexHtmlContent) {
|
||||
const parts = [];
|
||||
|
||||
parts.push(`=== 目录结构 ===`);
|
||||
parts.push(dirContents.tree || "(empty)");
|
||||
|
||||
if (dirContents["scene.toml"]) {
|
||||
parts.push(`\n=== scene.toml ===`);
|
||||
parts.push(dirContents["scene.toml"]);
|
||||
}
|
||||
|
||||
if (dirContents["SKILL.toml"]) {
|
||||
parts.push(`\n=== SKILL.toml ===`);
|
||||
parts.push(dirContents["SKILL.toml"]);
|
||||
}
|
||||
|
||||
if (dirContents["SKILL.md"]) {
|
||||
parts.push(`\n=== SKILL.md ===`);
|
||||
parts.push(dirContents["SKILL.md"]);
|
||||
}
|
||||
|
||||
// Include index.html content (key addition)
|
||||
if (indexHtmlContent && typeof indexHtmlContent === 'string') {
|
||||
parts.push(`\n=== index.html ===`);
|
||||
// Limit to first 15000 chars to avoid token limits
|
||||
parts.push(indexHtmlContent.substring(0, 15000));
|
||||
}
|
||||
|
||||
if (dirContents.scripts && Object.keys(dirContents.scripts).length > 0) {
|
||||
parts.push(`\n=== 脚本文件 ===`);
|
||||
for (const [name, content] of Object.entries(dirContents.scripts)) {
|
||||
parts.push(`\n--- ${name} ---`);
|
||||
const contentStr = typeof content === 'string' ? content : String(content || '');
|
||||
parts.push(contentStr.substring(0, 3000));
|
||||
}
|
||||
}
|
||||
|
||||
return `以下是场景目录 "${sourceDir}" 的内容:\n\n${parts.join("\n")}\n\n请分析以上代码,提取完整的场景信息。`;
|
||||
}
|
||||
|
||||
function extractJsonFromResponse(text) {
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
||||
if (codeBlockMatch) return JSON.parse(codeBlockMatch[1]);
|
||||
|
||||
const jsonMatch = text.match(
|
||||
/\{[\s\S]*"sceneId"[\s\S]*"sceneName"[\s\S]*\}/
|
||||
);
|
||||
if (jsonMatch) return JSON.parse(jsonMatch[0]);
|
||||
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function extractSceneInfo(text) {
|
||||
// Try code block first
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
||||
if (codeBlockMatch) {
|
||||
try {
|
||||
return JSON.parse(codeBlockMatch[1]);
|
||||
} catch (e) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find JSON object with sceneId
|
||||
const jsonMatch = text.match(/\{[\s\S]*"sceneId"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
} catch (e) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: parse entire text
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error("Failed to extract valid SceneInfo JSON from LLM response");
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeScene(sourceDir, dirContents, { apiKey, baseUrl, model }) {
|
||||
const userPrompt = buildAnalyzePrompt(sourceDir, dirContents);
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: 256,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(baseUrl.replace(/\/v1\/?$/, "") + "/v1/chat/completions");
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Length": Buffer.byteLength(requestBody),
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`LLM API error ${res.statusCode}: ${data}`));
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.message?.content;
|
||||
if (!content) return reject(new Error("LLM returned empty response"));
|
||||
const result = extractJsonFromResponse(content);
|
||||
if (!result.sceneId || !result.sceneName) {
|
||||
return reject(
|
||||
new Error(`LLM response missing sceneId/sceneName: ${content}`)
|
||||
);
|
||||
}
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
reject(new Error(`Failed to parse LLM response: ${err.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.setTimeout(30000, () => {
|
||||
req.destroy(new Error("LLM API request timed out"));
|
||||
});
|
||||
|
||||
req.write(requestBody);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function analyzeSceneDeep(sourceDir, dirContents, indexHtmlContent, { apiKey, baseUrl, model }) {
|
||||
const userPrompt = buildDeepAnalyzePrompt(sourceDir, dirContents, indexHtmlContent);
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: DEEP_SYSTEM_PROMPT },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: 2048, // Increased for detailed response
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(baseUrl.replace(/\/v1\/?$/, "") + "/v1/chat/completions");
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Length": Buffer.byteLength(requestBody),
|
||||
},
|
||||
};
|
||||
|
||||
const httpModule = url.protocol === "https:" ? https : http;
|
||||
const req = httpModule.request(options, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`LLM API error ${res.statusCode}: ${data}`));
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.message?.content;
|
||||
if (!content) return reject(new Error("LLM returned empty response"));
|
||||
const result = extractSceneInfo(content);
|
||||
|
||||
// Validate required fields
|
||||
if (!result.sceneId || !result.sceneName) {
|
||||
return reject(new Error(`LLM response missing sceneId/sceneName: ${content}`));
|
||||
}
|
||||
|
||||
// Set defaults for optional fields
|
||||
result.sceneKind = result.sceneKind || "report_collection";
|
||||
result.apiEndpoints = result.apiEndpoints || [];
|
||||
result.staticParams = result.staticParams || {};
|
||||
result.columnDefs = result.columnDefs || [];
|
||||
result.businessLogic = result.businessLogic || {};
|
||||
result.modes = result.modes || [];
|
||||
result.defaultMode = result.defaultMode || (result.modes.length > 0 ? result.modes[0].name : null);
|
||||
result.modeSwitchField = result.modeSwitchField || "period_mode";
|
||||
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
reject(new Error(`Failed to parse LLM response: ${err.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.setTimeout(60000, () => {
|
||||
req.destroy(new Error("LLM API request timed out"));
|
||||
});
|
||||
|
||||
req.write(requestBody);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildAnalyzePrompt,
|
||||
extractJsonFromResponse,
|
||||
analyzeScene,
|
||||
// New exports
|
||||
buildDeepAnalyzePrompt,
|
||||
extractSceneInfo,
|
||||
analyzeSceneDeep,
|
||||
};
|
||||
19
frontend/scene-generator/serve.cmd
Normal file
19
frontend/scene-generator/serve.cmd
Normal file
@@ -0,0 +1,19 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set PORT=%1
|
||||
if "%PORT%"=="" set PORT=3210
|
||||
set SG_SCENE_GENERATOR_PORT=%PORT%
|
||||
|
||||
echo.
|
||||
echo +==================================================+
|
||||
echo ^| sgClaw ^· Scene Skill Generator ^|
|
||||
echo +==================================================+
|
||||
echo ^| ^|
|
||||
echo ^| 访问地址: http://127.0.0.1:%PORT%/ ^|
|
||||
echo ^| ^|
|
||||
echo ^| 按 Ctrl+C 停止服务 ^|
|
||||
echo +==================================================+
|
||||
echo.
|
||||
|
||||
cd /d "%~dp0"
|
||||
node server.js
|
||||
44
frontend/scene-generator/serve.sh
Executable file
44
frontend/scene-generator/serve.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# sgClaw Scene Skill Generator — HTTP 服务启动脚本
|
||||
#
|
||||
# 用法:
|
||||
# ./serve.sh # 默认 3210 端口
|
||||
# ./serve.sh 9090 # 指定端口
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
PORT="${1:-3210}"
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
get_ip() {
|
||||
ip -4 addr show 2>/dev/null \
|
||||
| grep -oP 'inet \K[\d.]+' \
|
||||
| grep -v '127.0.0.1' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
LOCAL_IP=$(get_ip)
|
||||
if [ -z "$LOCAL_IP" ]; then
|
||||
LOCAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
fi
|
||||
if [ -z "$LOCAL_IP" ]; then
|
||||
LOCAL_IP="<本机IP>"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " ╔══════════════════════════════════════════════════╗"
|
||||
echo " ║ sgClaw · Scene Skill Generator ║"
|
||||
echo " ╠══════════════════════════════════════════════════╣"
|
||||
echo " ║ ║"
|
||||
echo " ║ 本机访问: http://127.0.0.1:${PORT}/ ║"
|
||||
echo " ║ 局域网访问: http://${LOCAL_IP}:${PORT}/ ║"
|
||||
echo " ║ ║"
|
||||
echo " ║ 按 Ctrl+C 停止服务 ║"
|
||||
echo " ╚══════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
export SG_SCENE_GENERATOR_PORT="$PORT"
|
||||
node server.js
|
||||
417
frontend/scene-generator/server.js
Normal file
417
frontend/scene-generator/server.js
Normal file
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { loadConfig, getDefaults } = require("./config-loader");
|
||||
const { analyzeScene, analyzeSceneDeep } = require("./llm-client");
|
||||
const { runGenerator, readDirectory } = require("./generator-runner");
|
||||
|
||||
let config;
|
||||
let defaults;
|
||||
try {
|
||||
config = loadConfig();
|
||||
defaults = getDefaults();
|
||||
console.log(`[config] Loaded from: ${config.configPath}`);
|
||||
console.log(`[config] Project root: ${config.projectRoot}`);
|
||||
} catch (err) {
|
||||
console.error(`[error] Failed to load config: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const PORT = parseInt(process.env.SG_SCENE_GENERATOR_PORT, 10) || 3210;
|
||||
const HOST = "127.0.0.1";
|
||||
|
||||
const MIME_TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
};
|
||||
|
||||
function serveStatic(res, filePath) {
|
||||
const ext = path.extname(filePath);
|
||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
function initSSE(res) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
res.write(":\n");
|
||||
return res;
|
||||
}
|
||||
|
||||
function writeSSE(res, event, data) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function parseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => (body += chunk));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (err) {
|
||||
reject(new Error("Invalid JSON"));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAnalyze(req, res) {
|
||||
let body;
|
||||
try {
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceDir = (body.sourceDir || "").replace(/\\/g, "/");
|
||||
if (!sourceDir) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "sourceDir is required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let dirContents;
|
||||
try {
|
||||
dirContents = readDirectory(sourceDir);
|
||||
} catch (err) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await analyzeScene(sourceDir, dirContents, config);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: `LLM analysis failed: ${err.message}`,
|
||||
hint: "You can still enter scene-id and scene-name manually",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnalyzeDeep(req, res) {
|
||||
let body;
|
||||
try {
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceDir = (body.sourceDir || "").replace(/\\/g, "/");
|
||||
if (!sourceDir) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "sourceDir is required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let dirContents;
|
||||
try {
|
||||
dirContents = readDirectory(sourceDir);
|
||||
} catch (err) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const indexHtmlContent = dirContents.indexHtml || null;
|
||||
const result = await analyzeSceneDeep(sourceDir, dirContents, indexHtmlContent, config);
|
||||
|
||||
// Log extraction results for debugging
|
||||
console.log(`[analyze-deep] Extracted scene: ${result.sceneId} / ${result.sceneName}`);
|
||||
console.log(`[analyze-deep] API endpoints: ${result.apiEndpoints?.length || 0}`);
|
||||
console.log(`[analyze-deep] Column defs: ${result.columnDefs?.length || 0}`);
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
console.error(`[analyze-deep] Error: ${err.message}`);
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: `Deep analysis failed: ${err.message}`,
|
||||
hint: "You can still use basic analysis or enter data manually",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate(req, res) {
|
||||
let body;
|
||||
try {
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons, sceneInfoJson } = body;
|
||||
if (!sourceDir || !sceneId || !sceneName || !outputRoot) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error:
|
||||
"All fields required: sourceDir, sceneId, sceneName, outputRoot",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sseWriter = initSSE(res);
|
||||
|
||||
try {
|
||||
await runGenerator(
|
||||
{ sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons, sceneInfoJson },
|
||||
sseWriter,
|
||||
config.projectRoot
|
||||
);
|
||||
} catch (err) {
|
||||
writeSSE(sseWriter, "error", { message: `Server error: ${err.message}` });
|
||||
}
|
||||
|
||||
sseWriter.end();
|
||||
}
|
||||
|
||||
function handleHealth(req, res) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: "ok",
|
||||
pid: process.pid,
|
||||
configLoaded: true,
|
||||
configPath: config.configPath,
|
||||
projectRoot: config.projectRoot,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a native Windows folder selection dialog using PowerShell.
|
||||
* Returns the selected folder path or null if cancelled.
|
||||
*/
|
||||
function openFolderDialog(defaultPath) {
|
||||
return new Promise((resolve) => {
|
||||
const psScript = `
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$dialog.Description = "选择文件夹"
|
||||
$dialog.ShowNewFolderButton = true
|
||||
${defaultPath ? `$dialog.SelectedPath = '${defaultPath.replace(/'/g, "''")}'` : ""}
|
||||
if ($dialog.ShowDialog() -eq 'OK') {
|
||||
Write-Output $dialog.SelectedPath
|
||||
}
|
||||
`.trim();
|
||||
|
||||
const ps = spawn("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
psScript,
|
||||
], {
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
let error = "";
|
||||
|
||||
ps.stdout.on("data", (data) => {
|
||||
output += data.toString("utf8");
|
||||
});
|
||||
|
||||
ps.stderr.on("data", (data) => {
|
||||
error += data.toString("utf8");
|
||||
});
|
||||
|
||||
ps.on("close", (code) => {
|
||||
if (code === 0 && output.trim()) {
|
||||
// 移除可能的 BOM 标记
|
||||
let path = output.trim();
|
||||
if (path.charCodeAt(0) === 0xFEFF) {
|
||||
path = path.slice(1);
|
||||
}
|
||||
resolve(path);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
ps.on("error", () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSelectFolder(req, res) {
|
||||
let body = {};
|
||||
try {
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
// ignore parse error, use empty body
|
||||
}
|
||||
|
||||
const selectedPath = await openFolderDialog(body.defaultPath || "");
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ path: selectedPath }));
|
||||
}
|
||||
|
||||
async function handleSelectFile(req, res) {
|
||||
let body = {};
|
||||
try {
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
|
||||
const filter = body.filter || "所有文件 (*.*)|*.*";
|
||||
const psScript = `
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dialog = New-Object System.Windows.Forms.OpenFileDialog
|
||||
$dialog.Filter = '${filter}'
|
||||
$dialog.Title = "选择文件"
|
||||
${body.defaultPath ? `$dialog.InitialDirectory = '${body.defaultPath.replace(/'/g, "''")}'` : ""}
|
||||
if ($dialog.ShowDialog() -eq 'OK') {
|
||||
Write-Output $dialog.FileName
|
||||
}
|
||||
`.trim();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const ps = spawn("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
psScript,
|
||||
], {
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
|
||||
ps.stdout.on("data", (data) => {
|
||||
output += data.toString("utf8");
|
||||
});
|
||||
|
||||
ps.on("close", (code) => {
|
||||
let path = output.trim();
|
||||
if (path.charCodeAt(0) === 0xFEFF) {
|
||||
path = path.slice(1);
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ path: code === 0 && path ? path : null }));
|
||||
resolve();
|
||||
});
|
||||
|
||||
ps.on("error", () => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ path: null }));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (pathname === "/health" && req.method === "GET") {
|
||||
handleHealth(req, res);
|
||||
} else if (pathname === "/analyze" && req.method === "POST") {
|
||||
await handleAnalyze(req, res);
|
||||
} else if (pathname === "/analyze-deep" && req.method === "POST") {
|
||||
await handleAnalyzeDeep(req, res);
|
||||
} else if (pathname === "/generate" && req.method === "POST") {
|
||||
await handleGenerate(req, res);
|
||||
} else if (pathname === "/select-folder" && req.method === "POST") {
|
||||
await handleSelectFolder(req, res);
|
||||
} else if (pathname === "/select-file" && req.method === "POST") {
|
||||
await handleSelectFile(req, res);
|
||||
} else if (pathname === "/" || pathname === "/index.html") {
|
||||
serveStatic(res, path.join(__dirname, "sg_scene_generator.html"));
|
||||
} else {
|
||||
const filePath = path.resolve(__dirname, pathname);
|
||||
const resolvedDir = path.resolve(__dirname);
|
||||
if (!filePath.startsWith(resolvedDir + path.sep) && filePath !== resolvedDir) {
|
||||
res.writeHead(403, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Forbidden" }));
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
serveStatic(res, filePath);
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[error] ${req.method} ${pathname}: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
}
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log("");
|
||||
console.log(" ╔══════════════════════════════════════════════════╗");
|
||||
console.log(" ║ sgClaw · Scene Skill Generator ║");
|
||||
console.log(" ╠══════════════════════════════════════════════════╣");
|
||||
console.log(" ║ ║");
|
||||
console.log(` ║ 访问地址: http://${HOST}:${PORT}/ ║`);
|
||||
console.log(" ║ ║");
|
||||
console.log(" ║ 按 Ctrl+C 停止服务 ║");
|
||||
console.log(" ╚══════════════════════════════════════════════════╝");
|
||||
console.log("");
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
if (server.closing) return;
|
||||
server.closing = true;
|
||||
console.log("\n[info] Shutting down...");
|
||||
server.close(() => process.exit(0));
|
||||
// 强制退出超时
|
||||
setTimeout(() => process.exit(0), 2000);
|
||||
});
|
||||
690
frontend/scene-generator/sg_scene_generator.html
Normal file
690
frontend/scene-generator/sg_scene_generator.html
Normal file
@@ -0,0 +1,690 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>场景 Skill 生成器</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3efe4;
|
||||
--panel: rgba(255, 252, 247, 0.88);
|
||||
--panel-strong: #fffaf2;
|
||||
--text: #1f2329;
|
||||
--muted: #636b74;
|
||||
--line: rgba(31, 35, 41, 0.12);
|
||||
--accent: #0f766e;
|
||||
--accent-strong: #115e59;
|
||||
--warn: #b45309;
|
||||
--error: #b42318;
|
||||
--success: #166534;
|
||||
--shadow: 0 24px 60px rgba(34, 42, 53, 0.14);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 32%),
|
||||
radial-gradient(circle at right, rgba(180, 83, 9, 0.14), transparent 28%),
|
||||
linear-gradient(160deg, #f5f0e6 0%, #eef5f4 56%, #f7f3eb 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
.shell {
|
||||
width: min(1040px, 100%);
|
||||
margin: 0 auto;
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero {
|
||||
padding: 28px 28px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(135deg, rgba(255, 250, 242, 0.96), rgba(237, 246, 243, 0.92));
|
||||
}
|
||||
.hero h1 { margin: 0; font-size: clamp(1.8rem, 4vw, 2.6rem); line-height: 1.05; letter-spacing: 0.02em; }
|
||||
.hero p { margin: 10px 0 0; max-width: 60ch; color: var(--muted); line-height: 1.6; }
|
||||
.content { display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 0; }
|
||||
.sidebar, .stream-panel { padding: 24px; }
|
||||
.sidebar { border-right: 1px solid var(--line); background: rgba(255, 255, 255, 0.38); }
|
||||
.section-label { margin: 0 0 14px; font-size: 0.83rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; margin-bottom: 6px; font-size: 0.92rem; color: var(--muted); }
|
||||
.input-row { display: flex; gap: 8px; }
|
||||
.input-row input { flex: 1; }
|
||||
.input-row .browse-btn { width: auto; min-width: 60px; padding: 10px 14px; font-size: 0.85rem; }
|
||||
input, button {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
font: inherit;
|
||||
}
|
||||
input {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--text);
|
||||
padding: 12px 14px;
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
input:focus { border-color: rgba(15, 118, 110, 0.5); box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12); }
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--text);
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
select:focus { border-color: rgba(15, 118, 110, 0.5); box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12); }
|
||||
button { border: 0; padding: 12px 14px; font-weight: 700; cursor: pointer; transition: transform 140ms ease, opacity 140ms ease; }
|
||||
button:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
.primary-btn { background: linear-gradient(135deg, var(--accent), var(--accent-strong)); color: #f6fffd; box-shadow: 0 14px 28px rgba(15, 118, 110, 0.18); }
|
||||
.ghost-btn { background: rgba(255, 255, 255, 0.9); color: var(--text); border: 1px solid var(--line); }
|
||||
.status-card { display: grid; gap: 8px; padding: 14px; border-radius: 20px; background: var(--panel-strong); border: 1px solid var(--line); margin-bottom: 16px; }
|
||||
.state-chip { display: inline-flex; align-items: center; width: fit-content; padding: 5px 10px; border-radius: 999px; font-size: 0.85rem; font-weight: 700; background: rgba(99, 107, 116, 0.12); color: var(--muted); }
|
||||
.state-chip[data-state="ready"] { background: rgba(99, 107, 116, 0.12); color: var(--muted); }
|
||||
.state-chip[data-state="analyzing"] { background: rgba(180, 83, 9, 0.12); color: var(--warn); }
|
||||
.state-chip[data-state="generating"] { background: rgba(15, 118, 110, 0.12); color: var(--accent); }
|
||||
.state-chip[data-state="complete"] { background: rgba(22, 101, 52, 0.12); color: var(--success); }
|
||||
.state-chip[data-state="error"] { background: rgba(180, 35, 24, 0.12); color: var(--error); }
|
||||
.validation { min-height: 1.4em; margin: 8px 0 12px; color: var(--error); font-size: 0.92rem; }
|
||||
.stream-panel { display: grid; grid-template-rows: auto minmax(320px, 1fr); gap: 18px; }
|
||||
.stream-head { display: flex; justify-content: space-between; align-items: end; gap: 16px; }
|
||||
.stream-head h2 { margin: 0; font-size: 1.35rem; }
|
||||
.stream-head p { margin: 6px 0 0; color: var(--muted); font-size: 0.94rem; }
|
||||
.stream { display: grid; gap: 12px; align-content: start; min-height: 320px; max-height: 70vh; overflow: auto; padding: 4px; }
|
||||
.empty-state { padding: 22px; border-radius: 20px; background: rgba(255, 255, 255, 0.52); border: 1px dashed rgba(31, 35, 41, 0.16); color: var(--muted); line-height: 1.6; }
|
||||
.row { display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: start; padding: 14px 16px; border-radius: 18px; background: rgba(255, 255, 255, 0.76); border: 1px solid rgba(31, 35, 41, 0.08); animation: rise 180ms ease; }
|
||||
.row-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 76px; padding: 6px 10px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; background: rgba(99, 107, 116, 0.14); color: var(--muted); }
|
||||
.row.status .row-badge { background: rgba(15, 118, 110, 0.14); color: var(--accent-strong); }
|
||||
.row.log .row-badge { background: rgba(57, 91, 163, 0.14); color: #315aa2; }
|
||||
.row.complete .row-badge { background: rgba(22, 101, 52, 0.14); color: var(--success); }
|
||||
.row.error .row-badge { background: rgba(180, 35, 24, 0.14); color: var(--error); }
|
||||
.row-text { margin: 0; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.hint { font-size: 0.8rem; color: var(--muted); margin-top: 4px; }
|
||||
.divider { height: 1px; background: var(--line); margin: 12px 0; }
|
||||
@media (max-width: 900px) { body { padding: 16px; } .content { grid-template-columns: 1fr; } .sidebar { border-right: 0; border-bottom: 1px solid var(--line); } .stream { max-height: none; } }
|
||||
/* Preview panel styles */
|
||||
.preview-panel {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.preview-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.preview-header:hover {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.preview-content {
|
||||
padding: 16px;
|
||||
}
|
||||
.preview-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.preview-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.preview-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.preview-row {
|
||||
display: flex;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.preview-row .label {
|
||||
min-width: 80px;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.preview-row .value {
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.preview-list {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.preview-list-item {
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.preview-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.preview-code {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.btn-group button {
|
||||
flex: 1;
|
||||
}
|
||||
.secondary-btn {
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(15, 118, 110, 0.3);
|
||||
}
|
||||
.secondary-btn:hover:not(:disabled) {
|
||||
background: rgba(15, 118, 110, 0.15);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="hero">
|
||||
<h1>场景 Skill 生成器</h1>
|
||||
<p>选择场景目录,配置参数,一键生成 skill 包。</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="sidebar">
|
||||
<p class="section-label">Status</p>
|
||||
<div class="status-card">
|
||||
<span id="stateChip" class="state-chip" data-state="ready">就绪</span>
|
||||
<span id="statusText">请选择场景目录</span>
|
||||
</div>
|
||||
<p class="section-label">Source</p>
|
||||
<div class="field">
|
||||
<label>场景目录路径</label>
|
||||
<div class="input-row">
|
||||
<input id="sourceDir" placeholder="点击浏览选择目录..." readonly />
|
||||
<button id="browseSourceDir" class="ghost-btn browse-btn">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sceneId">scene-id</label>
|
||||
<input id="sceneId" placeholder="例如:tq-lineloss-report" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sceneName">scene-name</label>
|
||||
<input id="sceneName" placeholder="例如:台区线损报表" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sceneKind">场景类型</label>
|
||||
<select id="sceneKind">
|
||||
<option value="report_collection" selected>报表收集类</option>
|
||||
<option value="monitoring">监测类</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="targetUrl">目标 URL (可选)</label>
|
||||
<input id="targetUrl" placeholder="例如:http://20.76.57.61:18080/report" />
|
||||
<p class="hint">场景要访问的目标页面地址,留空则使用自动提取的域名</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-group" style="margin-top: 8px; margin-bottom: 16px;">
|
||||
<button id="analyzeBtn" class="secondary-btn" onclick="analyzeDeep()">深度分析</button>
|
||||
</div>
|
||||
|
||||
<!-- 提取结果预览 -->
|
||||
<div id="extractionPreview" class="preview-panel" style="display: none;">
|
||||
<div class="preview-header" onclick="togglePreview()">
|
||||
<h3>LLM 提取结果</h3>
|
||||
<span id="previewToggleIcon">▼</span>
|
||||
</div>
|
||||
<div id="previewContent" class="preview-content">
|
||||
<div class="preview-section">
|
||||
<h4>基本信息</h4>
|
||||
<div class="preview-row">
|
||||
<span class="label">场景 ID:</span>
|
||||
<span id="previewSceneId" class="value"></span>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="label">场景名称:</span>
|
||||
<span id="previewSceneName" class="value"></span>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="label">场景类型:</span>
|
||||
<span id="previewSceneKind" class="value"></span>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="label">目标域名:</span>
|
||||
<span id="previewExpectedDomain" class="value"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<h4>API 端点 (<span id="previewApiCount">0</span>)</h4>
|
||||
<div id="previewApiEndpoints" class="preview-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<h4>列定义 (<span id="previewColumnCount">0</span>)</h4>
|
||||
<div id="previewColumnDefs" class="preview-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<h4>静态参数</h4>
|
||||
<pre id="previewStaticParams" class="preview-code"></pre>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<h4>业务逻辑</h4>
|
||||
<div class="preview-row">
|
||||
<span class="label">数据获取:</span>
|
||||
<span id="previewDataFetch" class="value"></span>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="label">数据转换:</span>
|
||||
<span id="previewDataTransform" class="value"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<p class="section-label">Settings</p>
|
||||
<div class="field">
|
||||
<label>输出根路径</label>
|
||||
<div class="input-row">
|
||||
<input id="settingOutputRoot" placeholder="点击浏览选择目录..." readonly />
|
||||
<button id="browseOutputRoot" class="ghost-btn browse-btn">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="validationText" class="validation"></div>
|
||||
<button id="generateBtn" class="primary-btn" disabled>生成 Skill</button>
|
||||
</div>
|
||||
<div class="stream-panel">
|
||||
<div class="stream-head">
|
||||
<div>
|
||||
<p class="section-label">Generation Log</p>
|
||||
<h2>实时日志</h2>
|
||||
<p>显示生成过程的完整输出</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="messageStream" class="stream">
|
||||
<div class="empty-state" id="emptyState">选择场景目录并点击"生成 Skill"开始。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SERVER_URL = "http://127.0.0.1:3210";
|
||||
const els = {
|
||||
sourceDir: document.getElementById("sourceDir"),
|
||||
sceneId: document.getElementById("sceneId"),
|
||||
sceneName: document.getElementById("sceneName"),
|
||||
sceneKind: document.getElementById("sceneKind"),
|
||||
targetUrl: document.getElementById("targetUrl"),
|
||||
browseSourceDir: document.getElementById("browseSourceDir"),
|
||||
browseOutputRoot: document.getElementById("browseOutputRoot"),
|
||||
settingOutputRoot: document.getElementById("settingOutputRoot"),
|
||||
generateBtn: document.getElementById("generateBtn"),
|
||||
validationText: document.getElementById("validationText"),
|
||||
stateChip: document.getElementById("stateChip"),
|
||||
statusText: document.getElementById("statusText"),
|
||||
messageStream: document.getElementById("messageStream"),
|
||||
emptyState: document.getElementById("emptyState"),
|
||||
};
|
||||
let defaultsLoaded = false;
|
||||
let currentSceneInfo = null; // Stores deep extraction results
|
||||
let previewExpanded = false;
|
||||
|
||||
function setState(state, text) {
|
||||
els.stateChip.textContent = text;
|
||||
els.stateChip.dataset.state = state;
|
||||
els.statusText.textContent = text;
|
||||
}
|
||||
|
||||
function setValidation(msg) { els.validationText.textContent = msg; }
|
||||
|
||||
function updateGenerateBtn() {
|
||||
const ready = els.sourceDir.value.trim() && els.sceneId.value.trim() && els.sceneName.value.trim() && defaultsLoaded;
|
||||
els.generateBtn.disabled = !ready;
|
||||
}
|
||||
|
||||
function appendRow(kind, text) {
|
||||
if (els.emptyState) { els.emptyState.remove(); els.emptyState = null; }
|
||||
const row = document.createElement("div");
|
||||
row.className = "row " + kind;
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "row-badge";
|
||||
badge.textContent = kind;
|
||||
const content = document.createElement("p");
|
||||
content.className = "row-text";
|
||||
content.textContent = text;
|
||||
row.appendChild(badge);
|
||||
row.appendChild(content);
|
||||
els.messageStream.appendChild(row);
|
||||
els.messageStream.scrollTop = els.messageStream.scrollHeight;
|
||||
}
|
||||
|
||||
async function selectFolder(defaultPath) {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/select-folder`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ defaultPath }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.path;
|
||||
} catch (err) {
|
||||
console.error("Failed to select folder:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(defaultPath, filter) {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/select-file`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ defaultPath, filter }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.path;
|
||||
} catch (err) {
|
||||
console.error("Failed to select file:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDefaults() {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/health`);
|
||||
if (!res.ok) throw new Error("health check failed");
|
||||
defaultsLoaded = true;
|
||||
const health = await res.json();
|
||||
if (health.projectRoot) {
|
||||
const root = health.projectRoot.replace(/\\/g, "/");
|
||||
els.settingOutputRoot.value = root + "/examples/generated_scene_platform";
|
||||
}
|
||||
updateGenerateBtn();
|
||||
} catch (err) {
|
||||
console.error("Failed to load defaults:", err);
|
||||
setState("error", "无法连接服务器");
|
||||
appendRow("error", `服务器连接失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeSourceDir(sourceDir) {
|
||||
if (!sourceDir) return;
|
||||
setState("analyzing", "正在分析场景目录...");
|
||||
appendRow("status", "开始分析场景目录...");
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/analyze`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceDir }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Analyze failed");
|
||||
if (data.sceneId) {
|
||||
els.sceneId.value = data.sceneId;
|
||||
}
|
||||
if (data.sceneName) {
|
||||
els.sceneName.value = data.sceneName;
|
||||
}
|
||||
appendRow("status", `分析完成: ${data.sceneId || ""} ${data.sceneName || ""}`.trim());
|
||||
} catch (err) {
|
||||
appendRow("error", `分析失败: ${err.message}`);
|
||||
} finally {
|
||||
setState("ready", "就绪");
|
||||
updateGenerateBtn();
|
||||
}
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
const content = document.getElementById("previewContent");
|
||||
const icon = document.getElementById("previewToggleIcon");
|
||||
previewExpanded = !previewExpanded;
|
||||
content.style.display = previewExpanded ? "block" : "none";
|
||||
icon.textContent = previewExpanded ? "▲" : "▼";
|
||||
}
|
||||
|
||||
function showExtractionPreview(data) {
|
||||
const panel = document.getElementById("extractionPreview");
|
||||
panel.style.display = "block";
|
||||
previewExpanded = true;
|
||||
document.getElementById("previewContent").style.display = "block";
|
||||
document.getElementById("previewToggleIcon").textContent = "▲";
|
||||
|
||||
// Basic info
|
||||
document.getElementById("previewSceneId").textContent = data.sceneId || "-";
|
||||
document.getElementById("previewSceneName").textContent = data.sceneName || "-";
|
||||
document.getElementById("previewSceneKind").textContent = data.sceneKind || "-";
|
||||
document.getElementById("previewExpectedDomain").textContent = data.expectedDomain || "-";
|
||||
|
||||
// API endpoints
|
||||
const apiList = document.getElementById("previewApiEndpoints");
|
||||
const apiCount = document.getElementById("previewApiCount");
|
||||
if (data.apiEndpoints && data.apiEndpoints.length > 0) {
|
||||
apiCount.textContent = data.apiEndpoints.length;
|
||||
apiList.innerHTML = data.apiEndpoints.map(ep => {
|
||||
const name = escapeHtml(ep.name || "unknown");
|
||||
const url = escapeHtml(ep.url || "");
|
||||
const method = escapeHtml(ep.method || "GET");
|
||||
return `<div class="preview-list-item"><strong>${name}</strong>: ${url} <span style="color: var(--muted);">[${method}]</span></div>`;
|
||||
}).join("");
|
||||
} else {
|
||||
apiCount.textContent = "0";
|
||||
apiList.innerHTML = '<div class="preview-list-item" style="color: var(--muted);">无</div>';
|
||||
}
|
||||
|
||||
// Column definitions
|
||||
const colList = document.getElementById("previewColumnDefs");
|
||||
const colCount = document.getElementById("previewColumnCount");
|
||||
if (data.columnDefs && data.columnDefs.length > 0) {
|
||||
colCount.textContent = data.columnDefs.length;
|
||||
colList.innerHTML = data.columnDefs.map(col => {
|
||||
const field = escapeHtml(Array.isArray(col) ? col[0] : (col.field || ""));
|
||||
const label = escapeHtml(Array.isArray(col) ? col[1] : (col.label || ""));
|
||||
return `<div class="preview-list-item"><code>${field}</code> → ${label}</div>`;
|
||||
}).join("");
|
||||
} else {
|
||||
colCount.textContent = "0";
|
||||
colList.innerHTML = '<div class="preview-list-item" style="color: var(--muted);">无</div>';
|
||||
}
|
||||
|
||||
// Static params
|
||||
const staticParams = document.getElementById("previewStaticParams");
|
||||
if (data.staticParams) {
|
||||
staticParams.textContent = JSON.stringify(data.staticParams, null, 2);
|
||||
} else {
|
||||
staticParams.textContent = "{}";
|
||||
}
|
||||
|
||||
// Business logic
|
||||
const biz = data.businessLogic || {};
|
||||
document.getElementById("previewDataFetch").textContent = biz.dataFetch || "-";
|
||||
document.getElementById("previewDataTransform").textContent = biz.dataTransform || "-";
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function analyzeDeep() {
|
||||
const sourceDir = els.sourceDir.value.trim().replace(/\\/g, "/");
|
||||
if (!sourceDir) {
|
||||
setValidation("请先选择场景目录");
|
||||
return;
|
||||
}
|
||||
setValidation("");
|
||||
setState("analyzing", "正在深度分析...");
|
||||
appendRow("status", "开始深度分析场景...");
|
||||
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/analyze-deep`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceDir }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Deep analysis failed");
|
||||
|
||||
// Store the scene info for generation
|
||||
currentSceneInfo = data;
|
||||
|
||||
// Fill in form fields if not already set
|
||||
if (data.sceneId && !els.sceneId.value.trim()) {
|
||||
els.sceneId.value = data.sceneId;
|
||||
}
|
||||
if (data.sceneName && !els.sceneName.value.trim()) {
|
||||
els.sceneName.value = data.sceneName;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
showExtractionPreview(data);
|
||||
appendRow("status", `深度分析完成: 找到 ${data.apiEndpoints?.length || 0} 个 API 端点, ${data.columnDefs?.length || 0} 个列定义`);
|
||||
} catch (err) {
|
||||
appendRow("error", `深度分析失败: ${err.message}`);
|
||||
} finally {
|
||||
setState("ready", "就绪");
|
||||
updateGenerateBtn();
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const sourceDir = els.sourceDir.value.trim().replace(/\\/g, "/");
|
||||
const sceneId = els.sceneId.value.trim();
|
||||
const sceneName = els.sceneName.value.trim();
|
||||
const sceneKind = els.sceneKind.value;
|
||||
const targetUrl = els.targetUrl.value.trim();
|
||||
const outputRoot = els.settingOutputRoot.value.trim().replace(/\\/g, "/");
|
||||
if (!sourceDir || !sceneId || !sceneName || !outputRoot) { setValidation("场景目录、scene-id、scene-name、输出根路径为必填"); return; }
|
||||
setValidation("");
|
||||
setState("generating", "正在生成 skill 包...");
|
||||
els.generateBtn.disabled = true;
|
||||
appendRow("status", "开始生成 skill 包...");
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
sourceDir,
|
||||
sceneId,
|
||||
sceneName,
|
||||
sceneKind,
|
||||
targetUrl: targetUrl || null,
|
||||
outputRoot,
|
||||
};
|
||||
// Include deep extraction results if available
|
||||
if (currentSceneInfo) {
|
||||
requestBody.sceneInfoJson = JSON.stringify(currentSceneInfo);
|
||||
}
|
||||
const res = await fetch(`${SERVER_URL}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Generation failed"); }
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let lastEvent = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) { lastEvent = line.slice(6).trim(); }
|
||||
else if (line.startsWith("data:") && line.trim()) {
|
||||
const dataStr = line.slice(5).trim();
|
||||
if (!dataStr) continue;
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
switch (lastEvent) {
|
||||
case "status": appendRow("status", data.message); break;
|
||||
case "log": appendRow("log", data.message); break;
|
||||
case "complete":
|
||||
if (data.success) { setState("complete", "生成完成"); appendRow("complete", `生成完成: ${data.skillRoot || ""}`); }
|
||||
else { setState("error", "生成失败"); appendRow("error", data.message || "生成失败"); }
|
||||
break;
|
||||
case "error": setState("error", "生成失败"); appendRow("error", data.message); break;
|
||||
default: appendRow("log", JSON.stringify(data));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setState("error", "生成失败");
|
||||
appendRow("error", err.message);
|
||||
} finally {
|
||||
els.generateBtn.disabled = false;
|
||||
updateGenerateBtn();
|
||||
}
|
||||
}
|
||||
|
||||
// Browse buttons
|
||||
els.browseSourceDir.addEventListener("click", async () => {
|
||||
const path = await selectFolder(els.sourceDir.value || null);
|
||||
if (path) {
|
||||
els.sourceDir.value = path;
|
||||
const parts = path.replace(/\\/g, "/").split("/");
|
||||
const folderName = parts[parts.length - 1];
|
||||
if (folderName && !els.sceneId.value) {
|
||||
els.sceneId.value = folderName;
|
||||
}
|
||||
updateGenerateBtn();
|
||||
await analyzeSourceDir(path.replace(/\\/g, "/"));
|
||||
}
|
||||
});
|
||||
|
||||
els.browseOutputRoot.addEventListener("click", async () => {
|
||||
const path = await selectFolder(els.settingOutputRoot.value || null);
|
||||
if (path) {
|
||||
els.settingOutputRoot.value = path;
|
||||
updateGenerateBtn();
|
||||
}
|
||||
});
|
||||
|
||||
els.generateBtn.addEventListener("click", generate);
|
||||
els.sceneId.addEventListener("input", updateGenerateBtn);
|
||||
els.sceneName.addEventListener("input", updateGenerateBtn);
|
||||
|
||||
loadDefaults();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
835
frontend/service-console/sg_claw_service_console.html
Normal file
835
frontend/service-console/sg_claw_service_console.html
Normal file
@@ -0,0 +1,835 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>sgClaw Service Console</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3efe4;
|
||||
--panel: rgba(255, 252, 247, 0.88);
|
||||
--panel-strong: #fffaf2;
|
||||
--text: #1f2329;
|
||||
--muted: #636b74;
|
||||
--line: rgba(31, 35, 41, 0.12);
|
||||
--accent: #0f766e;
|
||||
--accent-strong: #115e59;
|
||||
--warn: #b45309;
|
||||
--error: #b42318;
|
||||
--success: #166534;
|
||||
--shadow: 0 24px 60px rgba(34, 42, 53, 0.14);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 32%),
|
||||
radial-gradient(circle at right, rgba(180, 83, 9, 0.14), transparent 28%),
|
||||
linear-gradient(160deg, #f5f0e6 0%, #eef5f4 56%, #f7f3eb 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1040px, 100%);
|
||||
margin: 0 auto;
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 28px 28px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(135deg, rgba(255, 250, 242, 0.96), rgba(237, 246, 243, 0.92));
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 10px 0 0;
|
||||
max-width: 60ch;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 320px) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.stream-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.38);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--text);
|
||||
padding: 14px 16px;
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: rgba(15, 118, 110, 0.5);
|
||||
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
padding: 14px 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease, opacity 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #f6fffd;
|
||||
box-shadow: 0 14px 28px rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
background: rgba(99, 107, 116, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state-chip[data-state="connected"] {
|
||||
background: rgba(22, 101, 52, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.state-chip[data-state="connecting"] {
|
||||
background: rgba(180, 83, 9, 0.12);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.validation {
|
||||
min-height: 1.4em;
|
||||
margin: 10px 0 14px;
|
||||
color: var(--error);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.stream-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(320px, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stream-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stream-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.stream-head p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.stream {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
min-height: 320px;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 22px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.52);
|
||||
border: 1px dashed rgba(31, 35, 41, 0.16);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(31, 35, 41, 0.08);
|
||||
animation: rise 180ms ease;
|
||||
}
|
||||
|
||||
.row-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 76px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: rgba(99, 107, 116, 0.14);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row.status .row-badge {
|
||||
background: rgba(15, 118, 110, 0.14);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.row.log .row-badge {
|
||||
background: rgba(57, 91, 163, 0.14);
|
||||
color: #315aa2;
|
||||
}
|
||||
|
||||
.row.complete .row-badge {
|
||||
background: rgba(22, 101, 52, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.row.error .row-badge {
|
||||
background: rgba(180, 35, 24, 0.14);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.row-text {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Settings modal elements */
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: rgba(15, 118, 110, 0.5);
|
||||
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.stream {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell" id="app">
|
||||
<div class="hero">
|
||||
<h1>sgClaw Service Console</h1>
|
||||
<p>直接连接现有 service websocket,提交自然语言任务,并持续查看 service 返回的状态、日志和完成结果。</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="sidebar">
|
||||
<p class="section-label">Connection</p>
|
||||
<div class="status-card">
|
||||
<span id="connectionState" class="state-chip" data-state="disconnected">未连接</span>
|
||||
<span>默认地址使用现有 service websocket。</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wsUrl">WebSocket 地址</label>
|
||||
<input id="wsUrl" value="ws://127.0.0.1:42321" />
|
||||
</div>
|
||||
<button id="connectBtn" class="ghost-btn">连接</button>
|
||||
<button id="settingsBtn" class="ghost-btn" style="margin-top: 8px;">⚙ 设置</button>
|
||||
|
||||
<p class="section-label" style="margin-top: 26px;">Composer</p>
|
||||
<div class="field">
|
||||
<label for="instructionInput">任务内容</label>
|
||||
<textarea id="instructionInput" placeholder="例如:打开百度"></textarea>
|
||||
</div>
|
||||
<div id="validationText" class="validation"></div>
|
||||
<button id="sendBtn" class="primary-btn" disabled>发送任务</button>
|
||||
</div>
|
||||
|
||||
<div class="stream-panel">
|
||||
<div class="stream-head">
|
||||
<div>
|
||||
<p class="section-label">Service Stream</p>
|
||||
<h2>消息流</h2>
|
||||
<p>只展示本地连接状态与现有 service message。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="messageStream" class="stream">
|
||||
<div class="empty-state" id="emptyState">尚无消息。先连接 service websocket,再发送一条自然语言任务。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settingsModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
|
||||
<div style="background: var(--panel); border-radius: 20px; padding: 28px; width: min(520px, 90%); max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);">
|
||||
<h3 style="margin: 0 0 20px; font-size: 1.2rem;">sgClaw 配置</h3>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingApiKey">API 密钥 *</label>
|
||||
<input id="settingApiKey" type="password" placeholder="输入模型 API 密钥" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingBaseUrl">模型服务地址 *</label>
|
||||
<input id="settingBaseUrl" type="url" placeholder="例如:https://api.deepseek.com" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingModel">模型名称 *</label>
|
||||
<input id="settingModel" type="text" placeholder="例如:deepseek-chat" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingSkillsDir">Skills 目录路径</label>
|
||||
<input id="settingSkillsDir" type="text" placeholder="例如:D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingDirectSubmitSkill">直接提交技能</label>
|
||||
<input id="settingDirectSubmitSkill" type="text" placeholder="例如:tq-lineloss-report.collect_lineloss" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingRuntimeProfile">运行模式</label>
|
||||
<select id="settingRuntimeProfile" style="width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; background: rgba(255, 255, 255, 0.92); color: var(--text); font: inherit;">
|
||||
<option value="browser-attached">browser-attached</option>
|
||||
<option value="browser-heavy">browser-heavy</option>
|
||||
<option value="general-assistant">general-assistant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="settingBrowserBackend">浏览器后端</label>
|
||||
<select id="settingBrowserBackend" style="width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; background: rgba(255, 255, 255, 0.92); color: var(--text); font: inherit;">
|
||||
<option value="super-rpa">super-rpa</option>
|
||||
<option value="agent-browser">agent-browser</option>
|
||||
<option value="rust-native">rust-native</option>
|
||||
<option value="computer-use">computer-use</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="settingsValidation" style="color: var(--error); font-size: 0.92rem; min-height: 1.4em; margin: 10px 0;"></div>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-top: 16px;">
|
||||
<button id="settingsSaveBtn" class="primary-btn" style="flex: 1;">保存</button>
|
||||
<button id="settingsCancelBtn" class="ghost-btn" style="flex: 1;">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const defaultWsUrl = "ws://127.0.0.1:42321";
|
||||
const elements = {
|
||||
wsUrl: document.getElementById("wsUrl"),
|
||||
connectBtn: document.getElementById("connectBtn"),
|
||||
connectionState: document.getElementById("connectionState"),
|
||||
messageStream: document.getElementById("messageStream"),
|
||||
instructionInput: document.getElementById("instructionInput"),
|
||||
validationText: document.getElementById("validationText"),
|
||||
sendBtn: document.getElementById("sendBtn"),
|
||||
emptyState: document.getElementById("emptyState")
|
||||
};
|
||||
|
||||
let socket = null;
|
||||
let reconnectTimer = null;
|
||||
let connectTimeoutTimer = null;
|
||||
let heartbeatTimer = null;
|
||||
let shouldReconnect = false;
|
||||
let lastHeartbeatAt = 0;
|
||||
const reconnectDelayMs = 1500;
|
||||
const reconnectCloseCode = 4000;
|
||||
const reconnectCloseReason = "manual_disconnect";
|
||||
const heartbeatIntervalMs = 15000;
|
||||
const heartbeatTimeoutMs = 30000;
|
||||
const connectTimeoutMs = 5000;
|
||||
|
||||
function appendRow(kind, text) {
|
||||
if (elements.emptyState) {
|
||||
elements.emptyState.remove();
|
||||
elements.emptyState = null;
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "row " + kind;
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "row-badge";
|
||||
badge.textContent = kind;
|
||||
|
||||
const content = document.createElement("p");
|
||||
content.className = "row-text";
|
||||
content.textContent = text;
|
||||
|
||||
row.appendChild(badge);
|
||||
row.appendChild(content);
|
||||
elements.messageStream.appendChild(row);
|
||||
elements.messageStream.scrollTop = elements.messageStream.scrollHeight;
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearConnectTimeoutTimer() {
|
||||
if (connectTimeoutTimer) {
|
||||
clearTimeout(connectTimeoutTimer);
|
||||
connectTimeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
lastHeartbeatAt = Date.now();
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastHeartbeatAt > heartbeatTimeoutMs) {
|
||||
appendRow("error", "heartbeat missed, forcing reconnect");
|
||||
const activeSocket = socket;
|
||||
socket = null;
|
||||
stopHeartbeat();
|
||||
clearConnectTimeoutTimer();
|
||||
activeSocket.close();
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
socket.send(JSON.stringify({ type: "ping" }));
|
||||
}, heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
clearReconnectTimer();
|
||||
clearConnectTimeoutTimer();
|
||||
if (!shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
appendRow("status", "service websocket disconnected, retrying");
|
||||
reconnectTimer = setTimeout(() => connectOrDisconnectService(true), reconnectDelayMs);
|
||||
updateUiState();
|
||||
}
|
||||
|
||||
function setValidation(message) {
|
||||
elements.validationText.textContent = message;
|
||||
}
|
||||
|
||||
function updateUiState() {
|
||||
const readyState = socket ? socket.readyState : WebSocket.CLOSED;
|
||||
const connected = readyState === WebSocket.OPEN;
|
||||
const connecting = readyState === WebSocket.CONNECTING || Boolean(reconnectTimer);
|
||||
let stateText = "未连接";
|
||||
let stateValue = "disconnected";
|
||||
|
||||
if (connected) {
|
||||
stateText = "已连接";
|
||||
stateValue = "connected";
|
||||
} else if (connecting) {
|
||||
stateText = "连接中";
|
||||
stateValue = "connecting";
|
||||
}
|
||||
|
||||
elements.connectBtn.textContent = connected || connecting ? "断开" : "连接";
|
||||
elements.sendBtn.disabled = !connected;
|
||||
elements.connectionState.textContent = stateText;
|
||||
elements.connectionState.dataset.state = stateValue;
|
||||
}
|
||||
|
||||
function connectOrDisconnectService(forceConnect = false) {
|
||||
if (!forceConnect && socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
shouldReconnect = false;
|
||||
clearReconnectTimer();
|
||||
clearConnectTimeoutTimer();
|
||||
stopHeartbeat();
|
||||
socket.close(reconnectCloseCode, reconnectCloseReason);
|
||||
return;
|
||||
}
|
||||
|
||||
clearReconnectTimer();
|
||||
clearConnectTimeoutTimer();
|
||||
const url = elements.wsUrl.value.trim() || defaultWsUrl;
|
||||
elements.wsUrl.value = url;
|
||||
shouldReconnect = true;
|
||||
const nextSocket = new WebSocket(url);
|
||||
socket = nextSocket;
|
||||
updateUiState();
|
||||
|
||||
connectTimeoutTimer = setTimeout(() => {
|
||||
if (socket !== nextSocket || nextSocket.readyState !== WebSocket.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
appendRow("error", "service websocket connect timed out");
|
||||
socket = null;
|
||||
nextSocket.close();
|
||||
scheduleReconnect();
|
||||
}, connectTimeoutMs);
|
||||
|
||||
nextSocket.addEventListener("open", () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
clearConnectTimeoutTimer();
|
||||
lastHeartbeatAt = Date.now();
|
||||
startHeartbeat();
|
||||
appendRow("status", "service websocket connected");
|
||||
updateUiState();
|
||||
});
|
||||
|
||||
nextSocket.addEventListener("close", (event) => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
socket = null;
|
||||
clearConnectTimeoutTimer();
|
||||
stopHeartbeat();
|
||||
const manualClose = event.code === reconnectCloseCode || event.reason === reconnectCloseReason;
|
||||
if (manualClose) {
|
||||
shouldReconnect = false;
|
||||
appendRow("status", "service websocket disconnected");
|
||||
updateUiState();
|
||||
return;
|
||||
}
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
nextSocket.addEventListener("error", () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
appendRow("error", "service websocket error");
|
||||
});
|
||||
|
||||
nextSocket.addEventListener("message", handleMessage);
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
lastHeartbeatAt = Date.now();
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch (_error) {
|
||||
appendRow("error", "invalid service message: " + event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "status_changed":
|
||||
appendRow("status", message.state);
|
||||
break;
|
||||
case "log_entry":
|
||||
appendRow("log", message.message);
|
||||
break;
|
||||
case "task_complete":
|
||||
appendRow(message.success ? "complete" : "error", message.summary);
|
||||
break;
|
||||
case "busy":
|
||||
appendRow("error", message.message);
|
||||
break;
|
||||
case "pong":
|
||||
break;
|
||||
case "config_updated":
|
||||
handleConfigResponse(message);
|
||||
break;
|
||||
default:
|
||||
appendRow("error", "unknown service message: " + event.data);
|
||||
}
|
||||
}
|
||||
|
||||
function sendTask() {
|
||||
const instruction = elements.instructionInput.value.trim();
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (!instruction) {
|
||||
setValidation("请输入任务内容。");
|
||||
return;
|
||||
}
|
||||
|
||||
setValidation("");
|
||||
socket.send(JSON.stringify({
|
||||
type: "submit_task",
|
||||
instruction,
|
||||
conversation_id: "",
|
||||
messages: [],
|
||||
page_url: "",
|
||||
page_title: ""
|
||||
}));
|
||||
}
|
||||
|
||||
elements.connectBtn.addEventListener("click", connectOrDisconnectService);
|
||||
elements.sendBtn.addEventListener("click", sendTask);
|
||||
elements.instructionInput.addEventListener("input", () => {
|
||||
if (elements.instructionInput.value.trim()) {
|
||||
setValidation("");
|
||||
}
|
||||
});
|
||||
|
||||
updateUiState();
|
||||
|
||||
// Auto-connect on page load
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
connectOrDisconnectService(true);
|
||||
});
|
||||
|
||||
// Settings modal state
|
||||
const settingsElements = {
|
||||
modal: document.getElementById("settingsModal"),
|
||||
apiKey: document.getElementById("settingApiKey"),
|
||||
baseUrl: document.getElementById("settingBaseUrl"),
|
||||
model: document.getElementById("settingModel"),
|
||||
skillsDir: document.getElementById("settingSkillsDir"),
|
||||
directSubmitSkill: document.getElementById("settingDirectSubmitSkill"),
|
||||
runtimeProfile: document.getElementById("settingRuntimeProfile"),
|
||||
browserBackend: document.getElementById("settingBrowserBackend"),
|
||||
validation: document.getElementById("settingsValidation"),
|
||||
saveBtn: document.getElementById("settingsSaveBtn"),
|
||||
cancelBtn: document.getElementById("settingsCancelBtn"),
|
||||
};
|
||||
let settingsOpenBtn = null;
|
||||
|
||||
function openSettingsModal() {
|
||||
settingsElements.apiKey.value = "";
|
||||
settingsElements.baseUrl.value = "";
|
||||
settingsElements.model.value = "";
|
||||
settingsElements.skillsDir.value = "";
|
||||
settingsElements.directSubmitSkill.value = "";
|
||||
settingsElements.runtimeProfile.value = "browser-attached";
|
||||
settingsElements.browserBackend.value = "super-rpa";
|
||||
settingsElements.validation.textContent = "";
|
||||
settingsElements.modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
settingsElements.modal.style.display = "none";
|
||||
}
|
||||
|
||||
function validateSettings() {
|
||||
const apiKey = settingsElements.apiKey.value.trim();
|
||||
const baseUrl = settingsElements.baseUrl.value.trim();
|
||||
const model = settingsElements.model.value.trim();
|
||||
|
||||
if (!apiKey) {
|
||||
return "API 密钥不能为空";
|
||||
}
|
||||
if (!model) {
|
||||
return "模型名称不能为空";
|
||||
}
|
||||
if (!baseUrl) {
|
||||
return "模型服务地址不能为空";
|
||||
}
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
} catch {
|
||||
return "模型服务地址格式无效,请输入有效的 URL";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const error = validateSettings();
|
||||
if (error) {
|
||||
settingsElements.validation.textContent = error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
settingsElements.validation.textContent = "请先连接服务";
|
||||
return;
|
||||
}
|
||||
|
||||
settingsElements.validation.textContent = "";
|
||||
settingsElements.saveBtn.disabled = true;
|
||||
settingsElements.saveBtn.textContent = "保存中...";
|
||||
|
||||
const config = {
|
||||
apiKey: settingsElements.apiKey.value.trim(),
|
||||
baseUrl: settingsElements.baseUrl.value.trim(),
|
||||
model: settingsElements.model.value.trim(),
|
||||
};
|
||||
|
||||
const skillsDir = settingsElements.skillsDir.value.trim();
|
||||
if (skillsDir) config.skillsDir = skillsDir;
|
||||
|
||||
const directSubmitSkill = settingsElements.directSubmitSkill.value.trim();
|
||||
if (directSubmitSkill) config.directSubmitSkill = directSubmitSkill;
|
||||
|
||||
config.runtimeProfile = settingsElements.runtimeProfile.value;
|
||||
config.browserBackend = settingsElements.browserBackend.value;
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: "update_config",
|
||||
config,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleConfigResponse(message) {
|
||||
settingsElements.saveBtn.disabled = false;
|
||||
settingsElements.saveBtn.textContent = "保存";
|
||||
|
||||
if (message.success) {
|
||||
settingsElements.validation.textContent = message.message;
|
||||
settingsElements.validation.style.color = "var(--success)";
|
||||
setTimeout(closeSettingsModal, 2000);
|
||||
} else {
|
||||
settingsElements.validation.textContent = message.message;
|
||||
settingsElements.validation.style.color = "var(--error)";
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners for settings
|
||||
settingsOpenBtn = document.getElementById("settingsBtn");
|
||||
settingsOpenBtn.addEventListener("click", openSettingsModal);
|
||||
settingsElements.cancelBtn.addEventListener("click", closeSettingsModal);
|
||||
settingsElements.saveBtn.addEventListener("click", saveSettings);
|
||||
|
||||
settingsElements.modal.addEventListener("click", (e) => {
|
||||
if (e.target === settingsElements.modal) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,6 +6,8 @@
|
||||
"oa.example.com",
|
||||
"erp.example.com",
|
||||
"hr.example.com",
|
||||
"sgcc.example.invalid",
|
||||
"95598.example.invalid",
|
||||
"baidu.com",
|
||||
"www.baidu.com",
|
||||
"zhihu.com",
|
||||
|
||||
415
src/agent/mod.rs
415
src/agent/mod.rs
@@ -1,142 +1,78 @@
|
||||
pub mod planner;
|
||||
pub mod runtime;
|
||||
pub mod task_runner;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::browser::ws_backend::WsBrowserBackend;
|
||||
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport};
|
||||
use crate::pipe::{BrowserMessage, BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentRuntimeContext {
|
||||
config_path: Option<PathBuf>,
|
||||
workspace_root: PathBuf,
|
||||
pub use task_runner::{
|
||||
run_submit_task, run_submit_task_with_browser_backend, AgentEventSink, AgentRuntimeContext,
|
||||
SubmitTaskRequest,
|
||||
};
|
||||
|
||||
fn normalize_optional_submit_field(value: String) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
impl AgentRuntimeContext {
|
||||
pub fn new(config_path: Option<PathBuf>, workspace_root: PathBuf) -> Self {
|
||||
Self {
|
||||
config_path,
|
||||
workspace_root,
|
||||
}
|
||||
fn browser_backend_for_submit<T: Transport + 'static>(
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: &SubmitTaskRequest,
|
||||
) -> Result<Arc<dyn BrowserBackend>, PipeError> {
|
||||
if let Some(browser_ws_url) = configured_browser_ws_url(context) {
|
||||
let settings = context.load_sgclaw_settings()?.unwrap_or(
|
||||
SgClawSettings::from_legacy_deepseek_fields(
|
||||
"test-key".to_string(),
|
||||
"https://example.invalid".to_string(),
|
||||
"test-model".to_string(),
|
||||
None,
|
||||
)
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?,
|
||||
);
|
||||
let bootstrap_target = crate::service::server::resolve_submit_bootstrap_target(
|
||||
request,
|
||||
context.workspace_root(),
|
||||
&settings,
|
||||
);
|
||||
return Ok(Arc::new(
|
||||
WsBrowserBackend::new(
|
||||
Arc::new(crate::service::browser_ws_client::ServiceWsClient::connect(
|
||||
&browser_ws_url,
|
||||
)?),
|
||||
browser_tool.mac_policy().clone(),
|
||||
bootstrap_target.request_url,
|
||||
)
|
||||
.with_response_timeout(browser_tool.response_timeout()),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn from_process_args<I, S>(args: I) -> Result<Self, PipeError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
let mut config_path = None;
|
||||
let mut args = args.into_iter().map(Into::into);
|
||||
let _ = args.next();
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == OsString::from("--config-path") {
|
||||
let Some(value) = args.next() else {
|
||||
return Err(PipeError::Protocol(
|
||||
"missing value for --config-path".to_string(),
|
||||
));
|
||||
};
|
||||
config_path = Some(PathBuf::from(value));
|
||||
continue;
|
||||
}
|
||||
|
||||
let arg_string = arg.to_string_lossy();
|
||||
if let Some(value) = arg_string.strip_prefix("--config-path=") {
|
||||
config_path = Some(PathBuf::from(value));
|
||||
}
|
||||
}
|
||||
|
||||
let workspace_root = config_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent().map(|parent| parent.to_path_buf()))
|
||||
.unwrap_or_else(default_workspace_root);
|
||||
|
||||
Ok(Self::new(config_path, workspace_root))
|
||||
}
|
||||
|
||||
fn load_sgclaw_settings(&self) -> Result<Option<SgClawSettings>, PipeError> {
|
||||
SgClawSettings::load(self.config_path.as_deref())
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))
|
||||
}
|
||||
|
||||
fn settings_source_label(&self) -> String {
|
||||
match &self.config_path {
|
||||
Some(path) if path.exists() => path.display().to_string(),
|
||||
_ => "environment".to_string(),
|
||||
}
|
||||
}
|
||||
Ok(Arc::new(PipeBrowserBackend::from_inner(browser_tool.clone())))
|
||||
}
|
||||
|
||||
impl Default for AgentRuntimeContext {
|
||||
fn default() -> Self {
|
||||
Self::new(None, default_workspace_root())
|
||||
}
|
||||
fn configured_browser_ws_url(context: &AgentRuntimeContext) -> Option<String> {
|
||||
std::env::var("SGCLAW_BROWSER_WS_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
context
|
||||
.load_sgclaw_settings()
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|settings| settings.browser_ws_url)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn default_workspace_root() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn send_mode_log<T: Transport>(transport: &T, mode: &str) -> Result<(), PipeError> {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "mode".to_string(),
|
||||
message: mode.to_string(),
|
||||
fn send_status_changed<T: Transport>(transport: &T, state: &str) -> Result<(), PipeError> {
|
||||
transport.send(&crate::pipe::AgentMessage::StatusChanged {
|
||||
state: state.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_llm_configuration_summary() -> String {
|
||||
"未配置大语言模型。请先在 sgclaw_config.json 或环境变量中配置 apiKey、baseUrl 与 model。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn runtime_version_log_message() -> String {
|
||||
format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
crate::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_plan<T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
plan: &planner::TaskPlan,
|
||||
) -> Result<String, PipeError> {
|
||||
for step in &plan.steps {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: step.log_message.clone(),
|
||||
})?;
|
||||
|
||||
let result = browser_tool.invoke(
|
||||
step.action.clone(),
|
||||
step.params.clone(),
|
||||
&step.expected_domain,
|
||||
)?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"browser action failed: {}",
|
||||
result.data
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plan.summary.clone())
|
||||
}
|
||||
|
||||
pub fn execute_task<T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
instruction: &str,
|
||||
) -> Result<String, PipeError> {
|
||||
let plan = planner::plan_instruction(instruction)
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
execute_plan(transport, browser_tool, &plan)
|
||||
}
|
||||
|
||||
pub fn handle_browser_message<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
@@ -157,6 +93,9 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
message: BrowserMessage,
|
||||
) -> Result<(), PipeError> {
|
||||
match message {
|
||||
BrowserMessage::Connect => send_status_changed(transport, "connected"),
|
||||
BrowserMessage::Start => send_status_changed(transport, "started"),
|
||||
BrowserMessage::Stop => send_status_changed(transport, "stopped"),
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction,
|
||||
conversation_id,
|
||||
@@ -164,188 +103,25 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
page_url,
|
||||
page_title,
|
||||
} => {
|
||||
let raw_instruction = instruction;
|
||||
let trimmed_instruction = raw_instruction.trim().to_string();
|
||||
if trimmed_instruction.is_empty() {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: "请输入任务内容。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let task_context = CompatTaskContext {
|
||||
conversation_id: (!conversation_id.trim().is_empty())
|
||||
.then_some(conversation_id.clone()),
|
||||
let request = SubmitTaskRequest {
|
||||
instruction,
|
||||
conversation_id: normalize_optional_submit_field(conversation_id),
|
||||
messages,
|
||||
page_url: (!page_url.trim().is_empty()).then_some(page_url),
|
||||
page_title: (!page_title.trim().is_empty()).then_some(page_title),
|
||||
page_url: normalize_optional_submit_field(page_url),
|
||||
page_title: normalize_optional_submit_field(page_title),
|
||||
};
|
||||
let mut instruction = trimmed_instruction;
|
||||
let mut deterministic_plan = None;
|
||||
match crate::compat::deterministic_submit::decide_deterministic_submit(
|
||||
&raw_instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
crate::compat::deterministic_submit::DeterministicSubmitDecision::NotDeterministic => {}
|
||||
crate::compat::deterministic_submit::DeterministicSubmitDecision::Prompt { summary } => {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
crate::compat::deterministic_submit::DeterministicSubmitDecision::Execute(plan) => {
|
||||
instruction = plan.instruction.clone();
|
||||
deterministic_plan = Some(plan);
|
||||
}
|
||||
if configured_browser_ws_url(context).is_some() {
|
||||
let browser_backend = browser_backend_for_submit(browser_tool, context, &request)?;
|
||||
run_submit_task_with_browser_backend(
|
||||
transport,
|
||||
transport,
|
||||
browser_backend,
|
||||
context,
|
||||
request,
|
||||
)
|
||||
} else {
|
||||
run_submit_task(transport, transport, browser_tool, context, request)
|
||||
}
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: runtime_version_log_message(),
|
||||
});
|
||||
if !task_context.messages.is_empty() {
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"continuing conversation with {} prior turns",
|
||||
task_context.messages.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dir =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"DeepSeek config loaded from {} model={} base_url={}",
|
||||
context.settings_source_label(),
|
||||
settings.provider_model,
|
||||
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 let Some(plan) = deterministic_plan.as_ref() {
|
||||
let _ = send_mode_log(transport, "direct_skill_primary");
|
||||
let completion = match crate::compat::deterministic_submit::execute_deterministic_submit(
|
||||
browser_tool.clone(),
|
||||
plan,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(outcome) => AgentMessage::TaskComplete {
|
||||
success: outcome.success,
|
||||
summary: outcome.summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
};
|
||||
return transport.send(&completion);
|
||||
}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings
|
||||
.direct_submit_skill
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
let _ = send_mode_log(transport, "direct_skill_primary");
|
||||
let completion = match crate::compat::direct_skill_runtime::execute_direct_submit_skill(
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(outcome) => AgentMessage::TaskComplete {
|
||||
success: outcome.success,
|
||||
summary: outcome.summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
};
|
||||
return transport.send(&completion);
|
||||
}
|
||||
let _ = send_mode_log(transport, "compat_llm_primary");
|
||||
match crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: missing_llm_configuration_summary(),
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "error".to_string(),
|
||||
message: format!("failed to load DeepSeek config: {err}"),
|
||||
});
|
||||
AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
transport.send(&completion)
|
||||
}
|
||||
BrowserMessage::Init { .. } => {
|
||||
eprintln!("ignoring duplicate init after handshake");
|
||||
@@ -357,3 +133,36 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_optional_submit_field;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn normalize_optional_submit_field_trims_and_drops_blank_values() {
|
||||
assert_eq!(normalize_optional_submit_field(" \n\t ".to_string()), None);
|
||||
assert_eq!(
|
||||
normalize_optional_submit_field(" https://example.com/page ".to_string()),
|
||||
Some("https://example.com/page".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_module_cleanup_removes_legacy_runtime_and_planner_sources() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let agent_module = fs::read_to_string(manifest_dir.join("src/agent/mod.rs")).unwrap();
|
||||
let top_lines = agent_module
|
||||
.lines()
|
||||
.take(10)
|
||||
.map(str::trim)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(!manifest_dir.join("src/agent/runtime.rs").exists());
|
||||
assert!(!manifest_dir.join("src/agent/planner.rs").exists());
|
||||
assert!(!top_lines.iter().any(|line| *line == "pub mod runtime;"));
|
||||
assert!(!top_lines.iter().any(|line| *line == "pub mod planner;"));
|
||||
assert!(top_lines.iter().any(|line| *line == "pub mod task_runner;"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Value};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::config::PlannerMode;
|
||||
use crate::pipe::Action;
|
||||
|
||||
/// Legacy deterministic planner kept for dev-only verification and fixture coverage.
|
||||
/// Production browser submit flow no longer routes into this planner.
|
||||
pub const LEGACY_DEV_ONLY: bool = true;
|
||||
|
||||
const BAIDU_URL: &str = "https://www.baidu.com";
|
||||
const BAIDU_DOMAIN: &str = "www.baidu.com";
|
||||
const BAIDU_INPUT_SELECTOR: &str = "#kw";
|
||||
const BAIDU_SEARCH_BUTTON_SELECTOR: &str = "#su";
|
||||
const ZHIHU_HOME_URL: &str = "https://www.zhihu.com";
|
||||
const ZHIHU_SEARCH_URL: &str = "https://www.zhihu.com/search";
|
||||
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PlannedStep {
|
||||
pub action: Action,
|
||||
pub params: Value,
|
||||
pub expected_domain: String,
|
||||
pub log_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TaskPlan {
|
||||
pub summary: String,
|
||||
pub steps: Vec<PlannedStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExecutionPreview {
|
||||
pub summary: String,
|
||||
pub steps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
pub enum PlannerError {
|
||||
#[error("unsupported instruction: {0}")]
|
||||
UnsupportedInstruction(String),
|
||||
#[error("missing search query in instruction")]
|
||||
MissingQuery,
|
||||
}
|
||||
|
||||
pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
|
||||
let trimmed = instruction.trim();
|
||||
if matches_exact(trimmed, &["打开百度"]) {
|
||||
return Ok(plan_homepage("已打开百度首页", BAIDU_URL, BAIDU_DOMAIN));
|
||||
}
|
||||
|
||||
if let Some(query) = extract_query(trimmed, &["打开百度搜索", "打开百度并搜索"])? {
|
||||
return Ok(plan_baidu_search(query));
|
||||
}
|
||||
|
||||
if matches_exact(trimmed, &["打开知乎"]) {
|
||||
return Ok(plan_homepage(
|
||||
"已打开知乎首页",
|
||||
ZHIHU_HOME_URL,
|
||||
ZHIHU_DOMAIN,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(query) = extract_query(trimmed, &["打开知乎搜索", "打开知乎并搜索"])? {
|
||||
return Ok(plan_zhihu_search(query));
|
||||
}
|
||||
|
||||
Err(PlannerError::UnsupportedInstruction(trimmed.to_string()))
|
||||
}
|
||||
|
||||
pub fn build_execution_preview(
|
||||
mode: PlannerMode,
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> Option<ExecutionPreview> {
|
||||
if matches!(mode, PlannerMode::LegacyDeterministic) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trimmed = instruction.trim();
|
||||
if crate::runtime::is_zhihu_hotlist_task(trimmed, page_url, page_title) {
|
||||
return Some(build_zhihu_hotlist_preview(trimmed));
|
||||
}
|
||||
|
||||
if let Ok(plan) = plan_instruction(trimmed) {
|
||||
return Some(ExecutionPreview {
|
||||
summary: format!("先规划再执行:{}", plan.summary),
|
||||
steps: plan
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|step| step.log_message)
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
Some(ExecutionPreview {
|
||||
summary: "先规划再执行当前任务".to_string(),
|
||||
steps: vec![
|
||||
"inspect current browser context".to_string(),
|
||||
"choose the required sgclaw runtime tools".to_string(),
|
||||
"execute and return the concrete result".to_string(),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_query<'a>(
|
||||
instruction: &'a str,
|
||||
prefixes: &[&str],
|
||||
) -> Result<Option<&'a str>, PlannerError> {
|
||||
let Some(query) = prefixes
|
||||
.iter()
|
||||
.find_map(|prefix| instruction.strip_prefix(prefix))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Err(PlannerError::MissingQuery);
|
||||
}
|
||||
|
||||
Ok(Some(query))
|
||||
}
|
||||
|
||||
fn matches_exact(instruction: &str, candidates: &[&str]) -> bool {
|
||||
candidates.iter().any(|candidate| instruction == *candidate)
|
||||
}
|
||||
|
||||
fn plan_homepage(summary: &str, url: &str, domain: &str) -> TaskPlan {
|
||||
TaskPlan {
|
||||
summary: summary.to_string(),
|
||||
steps: vec![PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": url }),
|
||||
expected_domain: domain.to_string(),
|
||||
log_message: format!("navigate {url}"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_baidu_search(query: &str) -> TaskPlan {
|
||||
TaskPlan {
|
||||
summary: format!("已在百度搜索{query}"),
|
||||
steps: vec![
|
||||
PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": BAIDU_URL }),
|
||||
expected_domain: BAIDU_DOMAIN.to_string(),
|
||||
log_message: "navigate https://www.baidu.com".to_string(),
|
||||
},
|
||||
PlannedStep {
|
||||
action: Action::Type,
|
||||
params: json!({
|
||||
"selector": BAIDU_INPUT_SELECTOR,
|
||||
"text": query,
|
||||
"clear_first": true
|
||||
}),
|
||||
expected_domain: BAIDU_DOMAIN.to_string(),
|
||||
log_message: format!("type {query} into {BAIDU_INPUT_SELECTOR}"),
|
||||
},
|
||||
PlannedStep {
|
||||
action: Action::Click,
|
||||
params: json!({ "selector": BAIDU_SEARCH_BUTTON_SELECTOR }),
|
||||
expected_domain: BAIDU_DOMAIN.to_string(),
|
||||
log_message: format!("click {BAIDU_SEARCH_BUTTON_SELECTOR}"),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_zhihu_search(query: &str) -> TaskPlan {
|
||||
let url = Url::parse_with_params(ZHIHU_SEARCH_URL, &[("type", "content"), ("q", query)])
|
||||
.expect("valid Zhihu search URL");
|
||||
let url: String = url.into();
|
||||
|
||||
TaskPlan {
|
||||
summary: format!("已在知乎搜索{query}"),
|
||||
steps: vec![PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": url }),
|
||||
expected_domain: ZHIHU_DOMAIN.to_string(),
|
||||
log_message: format!("navigate {url}"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_zhihu_hotlist_preview(instruction: &str) -> ExecutionPreview {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
if normalized.contains("dashboard")
|
||||
|| instruction.contains("大屏")
|
||||
|| instruction.contains("新标签页")
|
||||
{
|
||||
return ExecutionPreview {
|
||||
summary: "先规划再执行知乎热榜大屏生成".to_string(),
|
||||
steps: vec![
|
||||
"navigate https://www.zhihu.com/hot".to_string(),
|
||||
"getText main".to_string(),
|
||||
"call screen_html_export".to_string(),
|
||||
"return generated local .html path".to_string(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
ExecutionPreview {
|
||||
summary: "先规划再执行知乎热榜 Excel 导出".to_string(),
|
||||
steps: vec![
|
||||
"navigate https://www.zhihu.com/hot".to_string(),
|
||||
"getText main".to_string(),
|
||||
"call openxml_office".to_string(),
|
||||
"return generated local .xlsx path".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
|
||||
use crate::pipe::{Action, AgentMessage, BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
/// Legacy browser-only runtime kept for dev-only validation and narrow regression coverage.
|
||||
/// Production browser submit flow uses `compat::runtime` plus `runtime::engine`.
|
||||
pub const LEGACY_DEV_ONLY: bool = true;
|
||||
|
||||
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct BrowserActionCall {
|
||||
action: Action,
|
||||
expected_domain: String,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
pub fn execute_task_with_provider<P: LlmProvider, T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
provider: &P,
|
||||
instruction: &str,
|
||||
) -> Result<String, PipeError> {
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: "You are sgClaw. Use browser_action to complete the browser task.".to_string(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: instruction.to_string(),
|
||||
},
|
||||
];
|
||||
let tools = vec![browser_action_tool_definition()];
|
||||
let calls = provider
|
||||
.chat(&messages, &tools)
|
||||
.map_err(map_llm_error_to_pipe_error)?;
|
||||
|
||||
for call in calls {
|
||||
let browser_call =
|
||||
parse_browser_action_call(call).map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"{} {}",
|
||||
browser_call.action.as_str(),
|
||||
browser_call.expected_domain
|
||||
),
|
||||
})?;
|
||||
|
||||
let result = browser_tool.invoke(
|
||||
browser_call.action,
|
||||
browser_call.params,
|
||||
&browser_call.expected_domain,
|
||||
)?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"browser action failed: {}",
|
||||
result.data
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("已通过 Agent 执行任务: {instruction}"))
|
||||
}
|
||||
|
||||
pub fn browser_action_tool_definition() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: BROWSER_ACTION_TOOL_NAME.to_string(),
|
||||
description: "Execute browser actions in SuperRPA".to_string(),
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"required": ["action", "expected_domain"],
|
||||
"properties": {
|
||||
"action": { "type": "string", "enum": ["click", "type", "navigate", "getText"] },
|
||||
"expected_domain": { "type": "string" },
|
||||
"selector": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"clear_first": { "type": "boolean" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_browser_action_call(call: ToolFunctionCall) -> Result<BrowserActionCall, RuntimeError> {
|
||||
if call.name != BROWSER_ACTION_TOOL_NAME {
|
||||
return Err(RuntimeError::UnsupportedTool(call.name));
|
||||
}
|
||||
|
||||
let mut args = match call.arguments {
|
||||
Value::Object(args) => args,
|
||||
other => {
|
||||
return Err(RuntimeError::InvalidArguments(format!(
|
||||
"expected object arguments, got {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let action_name = take_required_string(&mut args, "action")?;
|
||||
let expected_domain = take_required_string(&mut args, "expected_domain")?;
|
||||
let action = parse_action(&action_name)?;
|
||||
let params = Value::Object(action_params_from_args(args));
|
||||
|
||||
Ok(BrowserActionCall {
|
||||
action,
|
||||
expected_domain,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_llm_error_to_pipe_error(err: LlmError) -> PipeError {
|
||||
PipeError::Protocol(err.to_string())
|
||||
}
|
||||
|
||||
fn parse_action(action_name: &str) -> Result<Action, RuntimeError> {
|
||||
match action_name {
|
||||
"click" => Ok(Action::Click),
|
||||
"type" => Ok(Action::Type),
|
||||
"navigate" => Ok(Action::Navigate),
|
||||
"getText" => Ok(Action::GetText),
|
||||
other => Err(RuntimeError::UnsupportedAction(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_required_string(
|
||||
args: &mut Map<String, Value>,
|
||||
key: &'static str,
|
||||
) -> Result<String, RuntimeError> {
|
||||
match args.remove(key) {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => Ok(value),
|
||||
Some(other) => Err(RuntimeError::InvalidArguments(format!(
|
||||
"{key} must be a non-empty string, got {other}"
|
||||
))),
|
||||
None => Err(RuntimeError::MissingField(key)),
|
||||
}
|
||||
}
|
||||
|
||||
fn action_params_from_args(args: Map<String, Value>) -> Map<String, Value> {
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum RuntimeError {
|
||||
#[error("unsupported tool: {0}")]
|
||||
UnsupportedTool(String),
|
||||
#[error("unsupported action: {0}")]
|
||||
UnsupportedAction(String),
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("invalid tool arguments: {0}")]
|
||||
InvalidArguments(String),
|
||||
}
|
||||
576
src/agent/task_runner.rs
Normal file
576
src/agent/task_runner.rs
Normal file
@@ -0,0 +1,576 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{
|
||||
AgentMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport,
|
||||
};
|
||||
use crate::runtime::RuntimeEngine;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentRuntimeContext {
|
||||
config_path: Option<PathBuf>,
|
||||
workspace_root: PathBuf,
|
||||
}
|
||||
|
||||
impl AgentRuntimeContext {
|
||||
pub fn new(config_path: Option<PathBuf>, workspace_root: PathBuf) -> Self {
|
||||
Self {
|
||||
config_path,
|
||||
workspace_root,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_process_args<I, S>(args: I) -> Result<Self, PipeError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
let mut config_path = None;
|
||||
let mut args = args.into_iter().map(Into::into);
|
||||
let _ = args.next();
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == OsString::from("--config-path") {
|
||||
let Some(value) = args.next() else {
|
||||
return Err(PipeError::Protocol(
|
||||
"missing value for --config-path".to_string(),
|
||||
));
|
||||
};
|
||||
config_path = Some(resolve_process_path(PathBuf::from(value)));
|
||||
continue;
|
||||
}
|
||||
|
||||
let arg_string = arg.to_string_lossy();
|
||||
if let Some(value) = arg_string.strip_prefix("--config-path=") {
|
||||
config_path = Some(resolve_process_path(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
let workspace_root = config_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent().map(|parent| parent.to_path_buf()))
|
||||
.unwrap_or_else(default_workspace_root);
|
||||
|
||||
Ok(Self::new(config_path, workspace_root))
|
||||
}
|
||||
|
||||
pub(crate) fn load_sgclaw_settings(&self) -> Result<Option<SgClawSettings>, PipeError> {
|
||||
SgClawSettings::load(self.config_path.as_deref())
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))
|
||||
}
|
||||
|
||||
pub fn config_path(&self) -> Option<&Path> {
|
||||
self.config_path.as_deref()
|
||||
}
|
||||
|
||||
pub fn workspace_root(&self) -> &Path {
|
||||
&self.workspace_root
|
||||
}
|
||||
|
||||
fn settings_source_label(&self) -> String {
|
||||
match &self.config_path {
|
||||
Some(path) if path.exists() => path.display().to_string(),
|
||||
_ => "environment".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AgentRuntimeContext {
|
||||
fn default() -> Self {
|
||||
Self::new(None, default_workspace_root())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_workspace_root() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn resolve_process_path(path: PathBuf) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
default_workspace_root().join(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_process_args_resolves_relative_config_path_against_current_dir() {
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
let context = AgentRuntimeContext::from_process_args([
|
||||
OsString::from("sg_claw"),
|
||||
OsString::from("--config-path"),
|
||||
OsString::from("../tmp/sgclaw_config.json"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
context.config_path,
|
||||
Some(current_dir.join("../tmp/sgclaw_config.json"))
|
||||
);
|
||||
assert_eq!(context.workspace_root, current_dir.join("../tmp"));
|
||||
assert!(context.workspace_root.is_absolute());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SubmitTaskRequest {
|
||||
pub instruction: String,
|
||||
pub conversation_id: Option<String>,
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
pub page_url: Option<String>,
|
||||
pub page_title: Option<String>,
|
||||
}
|
||||
|
||||
pub trait AgentEventSink: Send + Sync {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError>;
|
||||
}
|
||||
|
||||
impl<T: Transport + ?Sized> AgentEventSink for T {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError> {
|
||||
Transport::send(self, message)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_submit_instruction(
|
||||
instruction: String,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> Result<(String, Option<crate::compat::deterministic_submit::DeterministicExecutionPlan>), AgentMessage> {
|
||||
let raw_instruction = instruction;
|
||||
let trimmed_instruction = raw_instruction.trim().to_string();
|
||||
if trimmed_instruction.is_empty() {
|
||||
return Err(AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: "请输入任务内容。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
match crate::compat::deterministic_submit::decide_deterministic_submit(
|
||||
&raw_instruction,
|
||||
page_url,
|
||||
page_title,
|
||||
) {
|
||||
crate::compat::deterministic_submit::DeterministicSubmitDecision::NotDeterministic => {
|
||||
Ok((trimmed_instruction, None))
|
||||
}
|
||||
crate::compat::deterministic_submit::DeterministicSubmitDecision::Prompt { summary } => {
|
||||
Err(AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
crate::compat::deterministic_submit::DeterministicSubmitDecision::Execute(plan) => {
|
||||
Ok((plan.instruction.clone(), Some(plan)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_submit_task<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
sink: &dyn AgentEventSink,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: SubmitTaskRequest,
|
||||
) -> Result<(), PipeError> {
|
||||
let SubmitTaskRequest {
|
||||
instruction,
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
} = request;
|
||||
|
||||
let task_context = CompatTaskContext {
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
};
|
||||
let (instruction, deterministic_plan) = match resolve_submit_instruction(
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(completion) => return sink.send(&completion),
|
||||
};
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: runtime_version_log_message(),
|
||||
});
|
||||
if !task_context.messages.is_empty() {
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"continuing conversation with {} prior turns",
|
||||
task_context.messages.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dir =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"DeepSeek config loaded from {} model={} base_url={}",
|
||||
context.settings_source_label(),
|
||||
settings.provider_model,
|
||||
settings.provider_base_url
|
||||
),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("skills dir resolved to {}", resolved_skills_dir.display()),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"runtime profile={:?} skills_prompt_mode={:?}",
|
||||
settings.runtime_profile, settings.skills_prompt_mode
|
||||
),
|
||||
});
|
||||
if let Some(plan) = deterministic_plan.as_ref() {
|
||||
let _ = send_mode_log(sink, "direct_skill_primary");
|
||||
let completion =
|
||||
match crate::compat::deterministic_submit::execute_deterministic_submit(
|
||||
browser_tool.clone(),
|
||||
plan,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(outcome) => AgentMessage::TaskComplete {
|
||||
success: outcome.success,
|
||||
summary: outcome.summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
};
|
||||
return sink.send(&completion);
|
||||
}
|
||||
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
|
||||
&& crate::compat::orchestration::should_use_primary_orchestration(
|
||||
&instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
)
|
||||
{
|
||||
let _ = send_mode_log(sink, "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 sink.send(&AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings
|
||||
.direct_submit_skill
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
match crate::compat::direct_skill_runtime::execute_direct_submit_skill(
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(outcome) => {
|
||||
let _ = send_mode_log(sink, "direct_skill_primary");
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: outcome.success,
|
||||
summary: outcome.summary,
|
||||
});
|
||||
}
|
||||
Err(PipeError::Protocol(message))
|
||||
if message.contains("must use skill.tool format") =>
|
||||
{
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: message,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send_mode_log(sink, "compat_llm_primary");
|
||||
match crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: missing_llm_configuration_summary(),
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "error".to_string(),
|
||||
message: format!("failed to load DeepSeek config: {err}"),
|
||||
});
|
||||
AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sink.send(&completion)
|
||||
}
|
||||
|
||||
pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
|
||||
_transport: &T,
|
||||
sink: &dyn AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: SubmitTaskRequest,
|
||||
) -> Result<(), PipeError> {
|
||||
let SubmitTaskRequest {
|
||||
instruction,
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
} = request;
|
||||
|
||||
let task_context = CompatTaskContext {
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
};
|
||||
let (instruction, deterministic_plan) = match resolve_submit_instruction(
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(completion) => return sink.send(&completion),
|
||||
};
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: runtime_version_log_message(),
|
||||
});
|
||||
if !task_context.messages.is_empty() {
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"continuing conversation with {} prior turns",
|
||||
task_context.messages.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dir =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"DeepSeek config loaded from {} model={} base_url={}",
|
||||
context.settings_source_label(),
|
||||
settings.provider_model,
|
||||
settings.provider_base_url
|
||||
),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("skills dir resolved to {}", resolved_skills_dir.display()),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"runtime profile={:?} skills_prompt_mode={:?}",
|
||||
settings.runtime_profile, settings.skills_prompt_mode
|
||||
),
|
||||
});
|
||||
if let Some(plan) = deterministic_plan.as_ref() {
|
||||
let _ = send_mode_log(sink, "direct_skill_primary");
|
||||
let completion = match crate::compat::deterministic_submit::execute_deterministic_submit_with_browser_backend(
|
||||
browser_backend.clone(),
|
||||
plan,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(outcome) => AgentMessage::TaskComplete {
|
||||
success: outcome.success,
|
||||
summary: outcome.summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
};
|
||||
return sink.send(&completion);
|
||||
}
|
||||
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
|
||||
&& crate::compat::orchestration::should_use_primary_orchestration(
|
||||
&instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
)
|
||||
{
|
||||
let _ = send_mode_log(sink, "zeroclaw_process_message_primary");
|
||||
match crate::compat::orchestration::execute_task_with_browser_backend(
|
||||
sink,
|
||||
browser_backend.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings
|
||||
.direct_submit_skill
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
match crate::compat::direct_skill_runtime::execute_direct_submit_skill_with_browser_backend(
|
||||
browser_backend.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(outcome) => {
|
||||
let _ = send_mode_log(sink, "direct_skill_primary");
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: outcome.success,
|
||||
summary: outcome.summary,
|
||||
});
|
||||
}
|
||||
Err(PipeError::Protocol(message))
|
||||
if message.contains("must use skill.tool format") =>
|
||||
{
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: message,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send_mode_log(sink, "compat_llm_primary");
|
||||
match crate::compat::runtime::execute_task_with_browser_backend(
|
||||
sink,
|
||||
browser_backend,
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: missing_llm_configuration_summary(),
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "error".to_string(),
|
||||
message: format!("failed to load DeepSeek config: {err}"),
|
||||
});
|
||||
AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sink.send(&completion)
|
||||
}
|
||||
|
||||
fn send_mode_log(sink: &dyn AgentEventSink, mode: &str) -> Result<(), PipeError> {
|
||||
sink.send(&AgentMessage::LogEntry {
|
||||
level: "mode".to_string(),
|
||||
message: mode.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_llm_configuration_summary() -> String {
|
||||
"未配置大语言模型。请先在 sgclaw_config.json 或环境变量中配置 apiKey、baseUrl 与 model。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn runtime_version_log_message() -> String {
|
||||
format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
crate::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
}
|
||||
10
src/bin/sg_claw.rs
Normal file
10
src/bin/sg_claw.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
if let Err(err) = sgclaw::service::run() {
|
||||
eprintln!("sg_claw failed: {err}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
105
src/bin/sg_claw_client.rs
Normal file
105
src/bin/sg_claw_client.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
use sgclaw::service::{ClientMessage, ServiceMessage};
|
||||
use tungstenite::{connect, Message};
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
match run() {
|
||||
Ok(()) => std::process::ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("sg_claw_client failed: {err}");
|
||||
std::process::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_request(input: &str) -> (ClientMessage, bool) {
|
||||
match input.trim() {
|
||||
"/connect" => (ClientMessage::Connect, true),
|
||||
"/start" => (ClientMessage::Start, true),
|
||||
"/stop" => (ClientMessage::Stop, true),
|
||||
instruction => (
|
||||
ClientMessage::SubmitTask {
|
||||
instruction: instruction.to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: String::new(),
|
||||
page_title: String::new(),
|
||||
},
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let service_url = std::env::var("SG_CLAW_SERVICE_WS_URL")
|
||||
.unwrap_or_else(|_| "ws://127.0.0.1:42321".to_string());
|
||||
let (mut socket, _) = connect(service_url.as_str()).map_err(|err| err.to_string())?;
|
||||
|
||||
let stdin = io::stdin();
|
||||
|
||||
loop {
|
||||
eprint!("> ");
|
||||
let mut input = String::new();
|
||||
let bytes_read = stdin
|
||||
.lock()
|
||||
.read_line(&mut input)
|
||||
.map_err(|err| err.to_string())?;
|
||||
if bytes_read == 0 {
|
||||
break; // EOF — graceful exit
|
||||
}
|
||||
if input.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (request, exit_on_status) = parse_request(&input);
|
||||
|
||||
let payload = serde_json::to_string(&request).map_err(|err| err.to_string())?;
|
||||
socket
|
||||
.send(Message::Text(payload.into()))
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// Inner loop: consume service messages until the task finishes.
|
||||
loop {
|
||||
match socket.read().map_err(|err| err.to_string())? {
|
||||
Message::Text(text) => {
|
||||
let message: ServiceMessage =
|
||||
serde_json::from_str(&text).map_err(|err| err.to_string())?;
|
||||
match message {
|
||||
ServiceMessage::StatusChanged { state } => {
|
||||
println!("status: {state}");
|
||||
if exit_on_status {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ServiceMessage::LogEntry { level: _, message } => {
|
||||
println!("{message}");
|
||||
}
|
||||
ServiceMessage::TaskComplete { success: _, summary } => {
|
||||
println!("{summary}");
|
||||
break;
|
||||
}
|
||||
ServiceMessage::Busy { message } => {
|
||||
eprintln!("busy: {message}");
|
||||
break;
|
||||
}
|
||||
ServiceMessage::Pong => {}
|
||||
ServiceMessage::ConfigUpdated { success, message } => {
|
||||
if success {
|
||||
println!("config updated: {message}");
|
||||
} else {
|
||||
eprintln!("config update failed: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => {
|
||||
return Err("service disconnected".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
107
src/bin/sg_scene_generate.rs
Normal file
107
src/bin/sg_scene_generate.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sgclaw::generated_scene::analyzer::SceneKind;
|
||||
use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest, SceneInfoJson};
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = run() {
|
||||
eprintln!("sg_scene_generate: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args = parse_args(env::args().skip(1))?;
|
||||
let scene_info: Option<SceneInfoJson> = args.scene_info_json
|
||||
.map(|json| serde_json::from_str(&json))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Invalid scene-info-json: {}", e))?;
|
||||
let skill_root = generate_scene_package(GenerateSceneRequest {
|
||||
source_dir: args.source_dir,
|
||||
scene_id: args.scene_id,
|
||||
scene_name: args.scene_name,
|
||||
scene_kind: args.scene_kind,
|
||||
target_url: args.target_url,
|
||||
output_root: args.output_root,
|
||||
lessons_path: args.lessons_path,
|
||||
scene_info_json: scene_info,
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
println!("generated scene package: {}", skill_root.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct CliArgs {
|
||||
source_dir: PathBuf,
|
||||
scene_id: String,
|
||||
scene_name: String,
|
||||
scene_kind: Option<SceneKind>,
|
||||
target_url: Option<String>,
|
||||
output_root: PathBuf,
|
||||
lessons_path: Option<PathBuf>,
|
||||
scene_info_json: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
|
||||
let mut source_dir = None;
|
||||
let mut scene_id = None;
|
||||
let mut scene_name = None;
|
||||
let mut scene_kind = None;
|
||||
let mut target_url = None;
|
||||
let mut output_root = None;
|
||||
let mut lessons_path = None;
|
||||
let mut scene_info_json = None;
|
||||
let mut pending_flag: Option<String> = None;
|
||||
|
||||
for arg in args {
|
||||
if let Some(flag) = pending_flag.take() {
|
||||
match flag.as_str() {
|
||||
"--source-dir" => source_dir = Some(PathBuf::from(arg)),
|
||||
"--scene-id" => scene_id = Some(arg),
|
||||
"--scene-name" => scene_name = Some(arg),
|
||||
"--scene-kind" => {
|
||||
scene_kind = Some(
|
||||
SceneKind::from_str(&arg)
|
||||
.ok_or_else(|| format!("invalid scene kind: {}", arg))?,
|
||||
);
|
||||
}
|
||||
"--target-url" => target_url = Some(arg),
|
||||
"--output-root" => output_root = Some(PathBuf::from(arg)),
|
||||
"--lessons" => lessons_path = Some(PathBuf::from(arg)),
|
||||
"--scene-info-json" => scene_info_json = Some(arg),
|
||||
_ => return Err(format!("unsupported argument {flag}")),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match arg.as_str() {
|
||||
"--source-dir" | "--scene-id" | "--scene-name" | "--scene-kind" | "--target-url"
|
||||
| "--output-root" | "--lessons" | "--scene-info-json" => {
|
||||
pending_flag = Some(arg);
|
||||
}
|
||||
"--help" | "-h" => return Err(usage()),
|
||||
_ => return Err(format!("unsupported argument {arg}\n{}", usage())),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(flag) = pending_flag {
|
||||
return Err(format!("missing value for {flag}"));
|
||||
}
|
||||
|
||||
Ok(CliArgs {
|
||||
source_dir: source_dir.ok_or_else(usage)?,
|
||||
scene_id: scene_id.ok_or_else(usage)?,
|
||||
scene_name: scene_name.ok_or_else(usage)?,
|
||||
scene_kind,
|
||||
target_url,
|
||||
output_root: output_root.ok_or_else(usage)?,
|
||||
lessons_path,
|
||||
scene_info_json,
|
||||
})
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: sg_scene_generate --source-dir <scenario-dir> --scene-id <scene-id> --scene-name <display-name> [--scene-kind <report_collection|monitoring>] [--target-url <url>] --output-root <skill-staging-root> [--lessons <lessons-toml>] [--scene-info-json '<json>']".to_string()
|
||||
}
|
||||
70
src/bin/sgbrowser_ws_probe.rs
Normal file
70
src/bin/sgbrowser_ws_probe.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use std::env;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use sgclaw::{parse_probe_args, run_probe_script, ProbeOutcome};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("sgbrowser_ws_probe failed: {err}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
let config = match parse_probe_args(&args) {
|
||||
Ok(config) => config,
|
||||
Err(err) => return Err(err.to_string()),
|
||||
};
|
||||
let results = match run_probe_script(
|
||||
&config.ws_url,
|
||||
Duration::from_millis(config.timeout_ms),
|
||||
config.steps,
|
||||
) {
|
||||
Ok(results) => results,
|
||||
Err(err) => return Err(err.to_string()),
|
||||
};
|
||||
|
||||
for (index, result) in results.iter().enumerate() {
|
||||
println!("STEP {} {}", index + 1, result.label);
|
||||
println!("SEND: {}", result.sent);
|
||||
match &result.outcome {
|
||||
ProbeOutcome::Received(frames) => {
|
||||
if frames.is_empty() {
|
||||
println!("RECV: <none>");
|
||||
} else {
|
||||
for frame in frames {
|
||||
println!("RECV: {}", frame);
|
||||
}
|
||||
}
|
||||
println!("OUTCOME: received");
|
||||
}
|
||||
ProbeOutcome::NoReplyExpected => {
|
||||
println!("RECV: <none>");
|
||||
println!("OUTCOME: no-reply-expected");
|
||||
}
|
||||
ProbeOutcome::TimedOut => {
|
||||
println!("RECV: <none>");
|
||||
println!("OUTCOME: timeout");
|
||||
}
|
||||
ProbeOutcome::Closed => {
|
||||
println!("RECV: <none>");
|
||||
println!("OUTCOME: closed");
|
||||
}
|
||||
ProbeOutcome::ConnectFailed(message) => {
|
||||
println!("RECV: <none>");
|
||||
println!("OUTCOME: connect-failed");
|
||||
println!("DETAIL: {}", message);
|
||||
}
|
||||
}
|
||||
if index + 1 < results.len() {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
47
src/browser/backend.rs
Normal file
47
src/browser/backend.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError};
|
||||
|
||||
pub trait BrowserBackend: Send + Sync {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError>;
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata;
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn supports_live_input(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: BrowserBackend + ?Sized> BrowserBackend for Arc<T> {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.as_ref().invoke(action, params, expected_domain)
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.as_ref().surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.as_ref().supports_eval()
|
||||
}
|
||||
|
||||
fn supports_live_input(&self) -> bool {
|
||||
self.as_ref().supports_live_input()
|
||||
}
|
||||
}
|
||||
66
src/browser/bridge_backend.rs
Normal file
66
src/browser/bridge_backend.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::browser::backend::BrowserBackend;
|
||||
use crate::browser::bridge_contract::{BridgeBrowserActionReply, BridgeBrowserActionRequest};
|
||||
use crate::browser::bridge_transport::BridgeActionTransport;
|
||||
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
pub struct BridgeBrowserBackend {
|
||||
transport: Arc<dyn BridgeActionTransport>,
|
||||
mac_policy: MacPolicy,
|
||||
next_seq: AtomicU64,
|
||||
}
|
||||
|
||||
impl BridgeBrowserBackend {
|
||||
pub fn new(transport: Arc<dyn BridgeActionTransport>, mac_policy: MacPolicy) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
mac_policy,
|
||||
next_seq: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for BridgeBrowserBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.mac_policy.validate(&action, expected_domain)?;
|
||||
|
||||
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let reply = self.transport.execute(BridgeBrowserActionRequest::new(
|
||||
action.as_str(),
|
||||
params,
|
||||
expected_domain,
|
||||
))?;
|
||||
|
||||
match reply {
|
||||
BridgeBrowserActionReply::Success(success) => Ok(CommandOutput {
|
||||
seq,
|
||||
success: true,
|
||||
data: success.data,
|
||||
aom_snapshot: success.aom_snapshot,
|
||||
timing: success.timing,
|
||||
}),
|
||||
BridgeBrowserActionReply::Error(error) => Err(PipeError::Protocol(format!(
|
||||
"bridge action failed: {}",
|
||||
error.message
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.mac_policy.privileged_surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.mac_policy.supports_pipe_action(&Action::Eval)
|
||||
}
|
||||
}
|
||||
63
src/browser/bridge_contract.rs
Normal file
63
src/browser/bridge_contract.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::pipe::Timing;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeLifecycleCall {
|
||||
Connect,
|
||||
Start,
|
||||
Stop,
|
||||
SubmitTask,
|
||||
}
|
||||
|
||||
impl BridgeLifecycleCall {
|
||||
pub fn bridge_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Connect => "sgclawConnect",
|
||||
Self::Start => "sgclawStart",
|
||||
Self::Stop => "sgclawStop",
|
||||
Self::SubmitTask => "sgclawSubmitTask",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BridgeBrowserActionRequest {
|
||||
pub action: String,
|
||||
pub params: Value,
|
||||
pub expected_domain: String,
|
||||
}
|
||||
|
||||
impl BridgeBrowserActionRequest {
|
||||
pub fn new(
|
||||
action: impl Into<String>,
|
||||
params: Value,
|
||||
expected_domain: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
action: action.into(),
|
||||
params,
|
||||
expected_domain: expected_domain.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BridgeBrowserActionReply {
|
||||
Success(BridgeBrowserActionSuccess),
|
||||
Error(BridgeBrowserActionError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BridgeBrowserActionSuccess {
|
||||
pub data: Value,
|
||||
pub aom_snapshot: Vec<Value>,
|
||||
pub timing: Timing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BridgeBrowserActionError {
|
||||
pub message: String,
|
||||
pub details: Value,
|
||||
}
|
||||
9
src/browser/bridge_transport.rs
Normal file
9
src/browser/bridge_transport.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use crate::browser::bridge_contract::{BridgeBrowserActionReply, BridgeBrowserActionRequest};
|
||||
use crate::pipe::PipeError;
|
||||
|
||||
pub trait BridgeActionTransport: Send + Sync {
|
||||
fn execute(
|
||||
&self,
|
||||
request: BridgeBrowserActionRequest,
|
||||
) -> Result<BridgeBrowserActionReply, PipeError>;
|
||||
}
|
||||
974
src/browser/callback_backend.rs
Normal file
974
src/browser/callback_backend.rs
Normal file
@@ -0,0 +1,974 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::browser::backend::BrowserBackend;
|
||||
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
const CLICK_PROBE_CALLBACK_NAME: &str = "sgclawOnClickProbe";
|
||||
const TYPE_PROBE_CALLBACK_NAME: &str = "sgclawOnTypeProbe";
|
||||
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
|
||||
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
|
||||
const SHOW_AREA: &str = "show";
|
||||
const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
|
||||
const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
|
||||
|
||||
pub trait BrowserCallbackHost: Send + Sync {
|
||||
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BrowserCallbackRequest {
|
||||
pub seq: u64,
|
||||
pub request_url: String,
|
||||
pub expected_domain: String,
|
||||
pub action: String,
|
||||
pub command: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BrowserCallbackResponse {
|
||||
Success(BrowserCallbackSuccess),
|
||||
Error(BrowserCallbackError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BrowserCallbackSuccess {
|
||||
pub success: bool,
|
||||
pub data: Value,
|
||||
pub aom_snapshot: Vec<Value>,
|
||||
pub timing: Timing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BrowserCallbackError {
|
||||
pub message: String,
|
||||
pub details: Value,
|
||||
}
|
||||
|
||||
pub struct BrowserCallbackBackend {
|
||||
host: Arc<dyn BrowserCallbackHost>,
|
||||
mac_policy: MacPolicy,
|
||||
helper_page_url: String,
|
||||
current_target_url: Mutex<Option<String>>,
|
||||
next_seq: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CallbackInputMode {
|
||||
Click,
|
||||
Type,
|
||||
}
|
||||
|
||||
impl BrowserCallbackBackend {
|
||||
pub fn new(
|
||||
host: Arc<dyn BrowserCallbackHost>,
|
||||
mac_policy: MacPolicy,
|
||||
helper_page_url: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
host,
|
||||
mac_policy,
|
||||
helper_page_url: helper_page_url.into(),
|
||||
current_target_url: Mutex::new(None),
|
||||
next_seq: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command(&self, action: &Action, params: &Value) -> Result<Value, PipeError> {
|
||||
match action {
|
||||
Action::Navigate => {
|
||||
let target_url = required_string(params, "url")?;
|
||||
// Use sgBrowerserOpenPage to open the target URL in a **new**
|
||||
// visible browser tab. This keeps the helper page alive so its
|
||||
// WebSocket connection, command polling, and callback functions
|
||||
// remain functional for subsequent GetText / Eval commands.
|
||||
//
|
||||
// sgBrowserCallAfterLoaded would navigate the helper page tab
|
||||
// itself to the target URL, destroying all helper-page JS
|
||||
// context and making further communication impossible.
|
||||
//
|
||||
// sgBrowerserOpenPage does not fire a JS callback; the callback
|
||||
// host will treat the navigate action as fire-and-forget and
|
||||
// return success once the command has been forwarded.
|
||||
Ok(json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowerserOpenPage",
|
||||
target_url,
|
||||
]))
|
||||
}
|
||||
Action::Click => self.build_input_command(action, params, CallbackInputMode::Click),
|
||||
Action::Type => self.build_input_command(action, params, CallbackInputMode::Type),
|
||||
Action::GetText => {
|
||||
let target_url = self.target_url(action, params)?;
|
||||
let domain = extract_domain(&target_url)?;
|
||||
let selector = required_string(params, "selector")?;
|
||||
let js_code = build_get_text_js(&self.helper_page_url, &selector);
|
||||
// Use sgBrowserExcuteJsCodeByDomain (API #25) which matches
|
||||
// pages by domain rather than exact URL. This is far more
|
||||
// robust than sgBrowserExcuteJsCodeByArea because the actual
|
||||
// page URL may differ from what we navigated to (redirects,
|
||||
// query parameters, etc.).
|
||||
Ok(json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowserExcuteJsCodeByDomain",
|
||||
domain,
|
||||
js_code,
|
||||
SHOW_AREA,
|
||||
]))
|
||||
}
|
||||
Action::Eval => {
|
||||
let target_url = self.target_url(action, params)?;
|
||||
let domain = extract_domain(&target_url)?;
|
||||
let script = required_string(params, "script")?;
|
||||
let js_code = build_eval_js(&self.helper_page_url, &script);
|
||||
Ok(json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowserExcuteJsCodeByDomain",
|
||||
domain,
|
||||
js_code,
|
||||
SHOW_AREA,
|
||||
]))
|
||||
}
|
||||
_ => Err(PipeError::Protocol(format!(
|
||||
"unsupported callback-host browser action: {}",
|
||||
action.as_str()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input_command(
|
||||
&self,
|
||||
action: &Action,
|
||||
params: &Value,
|
||||
mode: CallbackInputMode,
|
||||
) -> Result<Value, PipeError> {
|
||||
let target_url = self.target_url(action, params)?;
|
||||
let domain = extract_domain(&target_url)?;
|
||||
let selector = optional_string(params, "selector");
|
||||
let probe_script = optional_string(params, "probe_script");
|
||||
let text = matches!(mode, CallbackInputMode::Type)
|
||||
.then(|| required_string(params, "text"))
|
||||
.transpose()?;
|
||||
let js_code = build_input_probe_js(
|
||||
mode,
|
||||
&self.helper_page_url,
|
||||
selector.as_deref(),
|
||||
probe_script.as_deref(),
|
||||
text.as_deref(),
|
||||
)?;
|
||||
Ok(json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowserExcuteJsCodeByDomain",
|
||||
domain,
|
||||
js_code,
|
||||
SHOW_AREA,
|
||||
]))
|
||||
}
|
||||
|
||||
fn target_url(&self, action: &Action, params: &Value) -> Result<String, PipeError> {
|
||||
if let Some(target_url) = params
|
||||
.get("target_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
{
|
||||
return Ok(target_url);
|
||||
}
|
||||
|
||||
self.current_target_url
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("callback backend target url lock poisoned".to_string()))?
|
||||
.clone()
|
||||
.ok_or_else(|| PipeError::Protocol(format!("target_url is required for {}", action.as_str())))
|
||||
}
|
||||
|
||||
fn execute_simulated_click(
|
||||
&self,
|
||||
seq: u64,
|
||||
expected_domain: &str,
|
||||
success: &BrowserCallbackSuccess,
|
||||
) -> Result<BrowserCallbackSuccess, PipeError> {
|
||||
let probe = success
|
||||
.data
|
||||
.get("probe")
|
||||
.ok_or_else(|| PipeError::Protocol("callback click probe payload missing".to_string()))?;
|
||||
let x = probe
|
||||
.get("x")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| PipeError::Protocol("callback click probe missing x".to_string()))?;
|
||||
let y = probe
|
||||
.get("y")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| PipeError::Protocol("callback click probe missing y".to_string()))?;
|
||||
let timing = success.timing.clone();
|
||||
match self.host.execute(BrowserCallbackRequest {
|
||||
seq,
|
||||
request_url: self.helper_page_url.clone(),
|
||||
expected_domain: expected_domain.to_string(),
|
||||
action: Action::Click.as_str().to_string(),
|
||||
command: json!([
|
||||
self.helper_page_url,
|
||||
"sgBroewserSimulateMouse",
|
||||
x,
|
||||
y,
|
||||
"left",
|
||||
"",
|
||||
""
|
||||
]),
|
||||
}) {
|
||||
Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
|
||||
"callback host browser action failed: {} ({})",
|
||||
error.message, error.details
|
||||
))),
|
||||
Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
|
||||
Ok(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({
|
||||
"clicked": true,
|
||||
"probe": { "x": x, "y": y },
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing,
|
||||
})
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_simulated_type(
|
||||
&self,
|
||||
seq: u64,
|
||||
expected_domain: &str,
|
||||
params: &Value,
|
||||
success: &BrowserCallbackSuccess,
|
||||
) -> Result<BrowserCallbackSuccess, PipeError> {
|
||||
let probe = success
|
||||
.data
|
||||
.get("probe")
|
||||
.ok_or_else(|| PipeError::Protocol("callback type probe payload missing".to_string()))?;
|
||||
let x = probe
|
||||
.get("x")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| PipeError::Protocol("callback type probe missing x".to_string()))?;
|
||||
let y = probe
|
||||
.get("y")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| PipeError::Protocol("callback type probe missing y".to_string()))?;
|
||||
let text = params
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| PipeError::Protocol("text is required".to_string()))?;
|
||||
let timing = success.timing.clone();
|
||||
match self.host.execute(BrowserCallbackRequest {
|
||||
seq,
|
||||
request_url: self.helper_page_url.clone(),
|
||||
expected_domain: expected_domain.to_string(),
|
||||
action: Action::Type.as_str().to_string(),
|
||||
command: json!([
|
||||
self.helper_page_url,
|
||||
"sgBroewserSimulateKeyborad",
|
||||
x,
|
||||
y,
|
||||
text
|
||||
]),
|
||||
}) {
|
||||
Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
|
||||
"callback host browser action failed: {} ({})",
|
||||
error.message, error.details
|
||||
))),
|
||||
Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
|
||||
Ok(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({
|
||||
"typed": true,
|
||||
"probe": { "x": x, "y": y, "text": text },
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing,
|
||||
})
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for BrowserCallbackBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
if let Some(local_dashboard) = approved_local_dashboard_request(&action, ¶ms, expected_domain)
|
||||
{
|
||||
self.mac_policy
|
||||
.validate_local_dashboard_presentation(
|
||||
&action,
|
||||
expected_domain,
|
||||
&local_dashboard.presentation_url,
|
||||
&local_dashboard.output_path,
|
||||
)
|
||||
.map_err(PipeError::Security)?;
|
||||
} else {
|
||||
self.mac_policy
|
||||
.validate(&action, expected_domain)
|
||||
.map_err(PipeError::Security)?;
|
||||
}
|
||||
|
||||
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let reply = self.host.execute(BrowserCallbackRequest {
|
||||
seq,
|
||||
request_url: self.helper_page_url.clone(),
|
||||
expected_domain: expected_domain.to_string(),
|
||||
action: action.as_str().to_string(),
|
||||
command: self.build_command(&action, ¶ms)?,
|
||||
})?;
|
||||
|
||||
match reply {
|
||||
BrowserCallbackResponse::Success(success) => {
|
||||
let success = match action {
|
||||
Action::Click => self.execute_simulated_click(seq, expected_domain, &success)?,
|
||||
Action::Type => {
|
||||
self.execute_simulated_type(seq, expected_domain, ¶ms, &success)?
|
||||
}
|
||||
_ => success,
|
||||
};
|
||||
if matches!(action, Action::Navigate) {
|
||||
if let Some(url) = params
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
*self.current_target_url.lock().map_err(|_| {
|
||||
PipeError::Protocol("callback backend target url lock poisoned".to_string())
|
||||
})? = Some(url.to_string());
|
||||
}
|
||||
}
|
||||
Ok(CommandOutput {
|
||||
seq,
|
||||
success: success.success,
|
||||
data: success.data,
|
||||
aom_snapshot: success.aom_snapshot,
|
||||
timing: success.timing,
|
||||
})
|
||||
}
|
||||
BrowserCallbackResponse::Error(error) => Err(PipeError::Protocol(format!(
|
||||
"callback host browser action failed: {} ({})",
|
||||
error.message, error.details
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.mac_policy.privileged_surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.mac_policy.supports_pipe_action(&Action::Eval)
|
||||
}
|
||||
|
||||
fn supports_live_input(&self) -> bool {
|
||||
self.mac_policy.supports_pipe_action(&Action::Click)
|
||||
&& self.mac_policy.supports_pipe_action(&Action::Type)
|
||||
}
|
||||
}
|
||||
|
||||
fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.ok_or_else(|| PipeError::Protocol(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn optional_string(params: &Value, key: &str) -> Option<String> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn build_get_text_js(source_url: &str, selector: &str) -> String {
|
||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||
let escaped_selector = escape_js_single_quoted(selector);
|
||||
let callback = GET_TEXT_CALLBACK_NAME;
|
||||
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||
|
||||
// Three delivery paths for getting the result back to the callback host:
|
||||
//
|
||||
// 1. callBackJsToCpp (API #40) — browser-native IPC that routes the
|
||||
// callback function to the helper page.
|
||||
// 2. XMLHttpRequest POST to callback host — localhost (127.0.0.1) is
|
||||
// exempt from mixed-content restrictions in Chromium.
|
||||
// 3. navigator.sendBeacon fallback — same localhost exemption.
|
||||
//
|
||||
// The XHR / sendBeacon paths POST the event DIRECTLY in the format the
|
||||
// callback host expects (callback="sgclawOnGetText", payload={text:...})
|
||||
// so normalize_callback_result can process it via Path A.
|
||||
format!(
|
||||
"(function(){{try{{\
|
||||
var el=document.querySelector('{escaped_selector}');\
|
||||
var t=el?((el.innerText||el.textContent||'').trim()):'';\
|
||||
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+t)}}catch(_){{}}\
|
||||
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{text:t}}}});\
|
||||
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||
}}catch(e){{}}}})()"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_eval_js(source_url: &str, script: &str) -> String {
|
||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||
let callback = EVAL_CALLBACK_NAME;
|
||||
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||
|
||||
format!(
|
||||
"(function(){{try{{\
|
||||
var v=(function(){{return {script}}})();\
|
||||
function _s(v){{\
|
||||
var t=(typeof v==='string')?v:JSON.stringify(v);\
|
||||
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\
|
||||
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\
|
||||
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||
}}\
|
||||
if(v&&typeof v.then==='function'){{v.then(_s).catch(function(){{}});}}else{{_s(v);}}\
|
||||
}}catch(e){{}}}})()"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_input_probe_js(
|
||||
mode: CallbackInputMode,
|
||||
source_url: &str,
|
||||
selector: Option<&str>,
|
||||
probe_script: Option<&str>,
|
||||
text: Option<&str>,
|
||||
) -> Result<String, PipeError> {
|
||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||
let callback = match mode {
|
||||
CallbackInputMode::Click => CLICK_PROBE_CALLBACK_NAME,
|
||||
CallbackInputMode::Type => TYPE_PROBE_CALLBACK_NAME,
|
||||
};
|
||||
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||
let payload_expression = match mode {
|
||||
CallbackInputMode::Click => "JSON.stringify({x:x,y:y})".to_string(),
|
||||
CallbackInputMode::Type => {
|
||||
let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
|
||||
format!("JSON.stringify({{x:x,y:y,text:'{escaped_text}'}})")
|
||||
}
|
||||
};
|
||||
let payload_object = match mode {
|
||||
CallbackInputMode::Click => "{x:x,y:y}".to_string(),
|
||||
CallbackInputMode::Type => {
|
||||
let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
|
||||
format!("{{x:x,y:y,text:'{escaped_text}'}}")
|
||||
}
|
||||
};
|
||||
let element_lookup = if let Some(script) = probe_script {
|
||||
format!("(function(){{{script}}})()")
|
||||
} else if let Some(selector) = selector {
|
||||
let escaped_selector = escape_js_single_quoted(selector);
|
||||
format!("document.querySelector('{escaped_selector}')")
|
||||
} else {
|
||||
return Err(PipeError::Protocol(
|
||||
"selector or probe_script is required".to_string(),
|
||||
));
|
||||
};
|
||||
let missing_hint = selector
|
||||
.map(|value| format!("selector not found: {}", escape_js_single_quoted(value)))
|
||||
.unwrap_or_else(|| "input probe target not found".to_string());
|
||||
|
||||
Ok(format!(
|
||||
"(function(){{try{{\
|
||||
var el={element_lookup};\
|
||||
if(!el){{throw new Error('{missing_hint}');}}\
|
||||
var rect=(typeof el.getBoundingClientRect==='function')?el.getBoundingClientRect():null;\
|
||||
var x=rect?(rect.left+(rect.width/2)):0;\
|
||||
var y=rect?(rect.top+(rect.height/2)):0;\
|
||||
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+String({payload_expression}))}}catch(_){{}}\
|
||||
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{payload_object}}});\
|
||||
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||
}}catch(e){{}}}})()"
|
||||
))
|
||||
}
|
||||
|
||||
/// Derive the callback host events endpoint URL from the helper page URL.
|
||||
/// e.g. "http://127.0.0.1:62819/sgclaw/browser-helper.html"
|
||||
/// → "http://127.0.0.1:62819/sgclaw/callback/events"
|
||||
fn events_endpoint_url(helper_page_url: &str) -> String {
|
||||
let origin = helper_page_url
|
||||
.find("://")
|
||||
.and_then(|scheme_end| {
|
||||
helper_page_url[scheme_end + 3..]
|
||||
.find('/')
|
||||
.map(|path_start| &helper_page_url[..scheme_end + 3 + path_start])
|
||||
})
|
||||
.unwrap_or(helper_page_url);
|
||||
format!("{origin}/sgclaw/callback/events")
|
||||
}
|
||||
|
||||
/// Extract the domain from a URL.
|
||||
/// e.g. "https://www.zhihu.com/hot" → "www.zhihu.com"
|
||||
fn extract_domain(url: &str) -> Result<String, PipeError> {
|
||||
let after_scheme = url
|
||||
.find("://")
|
||||
.map(|i| &url[i + 3..])
|
||||
.unwrap_or(url);
|
||||
let domain = after_scheme
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or(after_scheme)
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or(after_scheme);
|
||||
if domain.is_empty() {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"failed to extract domain from URL: {url}"
|
||||
)));
|
||||
}
|
||||
Ok(domain.to_string())
|
||||
}
|
||||
|
||||
fn escape_js_single_quoted(raw: &str) -> String {
|
||||
raw.replace('\\', "\\\\")
|
||||
.replace('\'', "\\'")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\0', "\\0")
|
||||
.replace('\u{2028}', "\\u2028")
|
||||
.replace('\u{2029}', "\\u2029")
|
||||
}
|
||||
|
||||
struct LocalDashboardRequest {
|
||||
presentation_url: String,
|
||||
output_path: String,
|
||||
}
|
||||
|
||||
fn approved_local_dashboard_request(
|
||||
action: &Action,
|
||||
params: &Value,
|
||||
expected_domain: &str,
|
||||
) -> Option<LocalDashboardRequest> {
|
||||
if action != &Action::Navigate || expected_domain != LOCAL_DASHBOARD_EXPECTED_DOMAIN {
|
||||
return None;
|
||||
}
|
||||
|
||||
let presentation_url = params.get("url")?.as_str()?.trim();
|
||||
let marker = params.get("sgclaw_local_dashboard_open")?.as_object()?;
|
||||
let source = marker.get("source")?.as_str()?.trim();
|
||||
let kind = marker.get("kind")?.as_str()?.trim();
|
||||
let output_path = marker.get("output_path")?.as_str()?.trim();
|
||||
let marker_presentation_url = marker.get("presentation_url")?.as_str()?.trim();
|
||||
|
||||
if source != LOCAL_DASHBOARD_SOURCE
|
||||
|| kind != LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN
|
||||
|| output_path.is_empty()
|
||||
|| presentation_url.is_empty()
|
||||
|| marker_presentation_url != presentation_url
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(LocalDashboardRequest {
|
||||
presentation_url: presentation_url.to_string(),
|
||||
output_path: output_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct FakeCallbackHost {
|
||||
requests: Mutex<Vec<BrowserCallbackRequest>>,
|
||||
replies: Mutex<VecDeque<Result<BrowserCallbackResponse, PipeError>>>,
|
||||
}
|
||||
|
||||
impl FakeCallbackHost {
|
||||
fn new(replies: Vec<Result<BrowserCallbackResponse, PipeError>>) -> Self {
|
||||
Self {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
replies: Mutex::new(VecDeque::from(replies)),
|
||||
}
|
||||
}
|
||||
|
||||
fn requests(&self) -> Vec<BrowserCallbackRequest> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserCallbackHost for FakeCallbackHost {
|
||||
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
self.replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Err(PipeError::Timeout))
|
||||
}
|
||||
}
|
||||
|
||||
fn success_reply(data: Value) -> Result<BrowserCallbackResponse, PipeError> {
|
||||
Ok(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data,
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_click_treats_simulated_mouse_follow_up_as_fire_and_forget() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
|
||||
json!({ "probe": { "x": 320.5, "y": 240.25 } }),
|
||||
)]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "button"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateMouse",
|
||||
320.5,
|
||||
240.25,
|
||||
"left",
|
||||
"",
|
||||
""
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_click_survives_simulated_mouse_timeout() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||
success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
|
||||
Err(PipeError::Timeout),
|
||||
]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "button"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.expect("simulated mouse timeout should be treated as fire-and-forget success");
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_click_uses_domain_probe_then_simulated_mouse_input() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||
success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
|
||||
success_reply(json!({ "clicked": true })),
|
||||
]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "button"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].action, "click");
|
||||
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
|
||||
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
|
||||
let script = requests[0].command[3].as_str().unwrap();
|
||||
assert!(script.contains("document.querySelector('button')"));
|
||||
assert!(script.contains("sgclawOnClick"));
|
||||
assert_eq!(requests[1].action, "click");
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateMouse",
|
||||
320.5,
|
||||
240.25,
|
||||
"left",
|
||||
"",
|
||||
""
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_type_treats_simulated_keyboard_follow_up_as_fire_and_forget() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
|
||||
json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } }),
|
||||
)]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Type,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "div[contenteditable='true']",
|
||||
"text": "正文"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateKeyborad",
|
||||
160.0,
|
||||
90.0,
|
||||
"正文"
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_type_uses_custom_probe_script_when_provided() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||
success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
|
||||
success_reply(json!({ "typed": true })),
|
||||
]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Type,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"probe_script": "return document.body;",
|
||||
"text": "正文"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let script = requests[0].command[3].as_str().unwrap();
|
||||
assert!(script.contains("return document.body;"));
|
||||
assert!(!script.contains("selector not found: div[contenteditable='true']"));
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateKeyborad",
|
||||
160.0,
|
||||
90.0,
|
||||
"正文"
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_type_uses_domain_probe_then_simulated_keyboard_input() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||
success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
|
||||
success_reply(json!({ "typed": true })),
|
||||
]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Type,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "div[contenteditable='true']",
|
||||
"text": "正文"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].action, "type");
|
||||
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
|
||||
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
|
||||
let script = requests[0].command[3].as_str().unwrap();
|
||||
assert!(script.contains("document.querySelector('div[contenteditable=\\'true\\']')"));
|
||||
assert!(script.contains("sgclawOnType"));
|
||||
assert!(!script.contains("el.value="));
|
||||
assert_eq!(requests[1].action, "type");
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateKeyborad",
|
||||
160.0,
|
||||
90.0,
|
||||
"正文"
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_accepts_approved_local_dashboard_navigate_request() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(json!({
|
||||
"navigated": true
|
||||
}))]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({
|
||||
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": "compat.workflow_executor",
|
||||
"kind": "zhihu_hotlist_screen",
|
||||
"output_path": "C:/tmp/zhihu-hotlist-screen.html",
|
||||
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
}
|
||||
}),
|
||||
"__sgclaw_local_dashboard__",
|
||||
)
|
||||
.expect("approved local dashboard request should be accepted");
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBrowerserOpenPage",
|
||||
"file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let err = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({
|
||||
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": "compat.workflow_executor",
|
||||
"kind": "zhihu_hotlist_screen",
|
||||
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
}
|
||||
}),
|
||||
"__sgclaw_local_dashboard__",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(host.requests().is_empty());
|
||||
assert!(err.to_string().contains("domain is not allowed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_js_single_quoted_escapes_newlines_and_control_chars() {
|
||||
let raw = "第一行\n第二行\r\n第三行";
|
||||
let escaped = escape_js_single_quoted(raw);
|
||||
assert!(!escaped.contains('\n'), "literal newline must be escaped");
|
||||
assert!(!escaped.contains('\r'), "literal carriage return must be escaped");
|
||||
assert!(escaped.contains("\\n"), "should contain escaped newline");
|
||||
assert!(escaped.contains("\\r"), "should contain escaped carriage return");
|
||||
assert_eq!(escaped, "第一行\\n第二行\\r\\n第三行");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_probe_script_with_multiline_text_is_valid_js() {
|
||||
let text_with_newlines = "标题\n\n正文第一段\n正文第二段";
|
||||
let js = build_input_probe_js(
|
||||
CallbackInputMode::Type,
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
Some("div[contenteditable='true']"),
|
||||
None,
|
||||
Some(text_with_newlines),
|
||||
)
|
||||
.unwrap();
|
||||
// The generated JS must NOT contain literal newlines inside single-quoted strings.
|
||||
// Split on single quotes and check inner segments.
|
||||
assert!(
|
||||
!js.contains("标题\n"),
|
||||
"literal newline must not appear in the JS probe script"
|
||||
);
|
||||
assert!(js.contains("标题\\n"));
|
||||
assert!(js.contains("sgclawOnTypeProbe"));
|
||||
}
|
||||
}
|
||||
1494
src/browser/callback_host.rs
Normal file
1494
src/browser/callback_host.rs
Normal file
File diff suppressed because it is too large
Load Diff
19
src/browser/mod.rs
Normal file
19
src/browser/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
pub mod bridge_backend;
|
||||
pub mod bridge_contract;
|
||||
pub mod bridge_transport;
|
||||
pub mod callback_backend;
|
||||
mod backend;
|
||||
pub(crate) mod callback_host;
|
||||
mod pipe_backend;
|
||||
pub mod ws_backend;
|
||||
pub mod ws_probe;
|
||||
pub mod ws_protocol;
|
||||
|
||||
pub use backend::BrowserBackend;
|
||||
pub use bridge_backend::BridgeBrowserBackend;
|
||||
pub use callback_backend::{
|
||||
BrowserCallbackBackend, BrowserCallbackError, BrowserCallbackHost,
|
||||
BrowserCallbackRequest, BrowserCallbackResponse, BrowserCallbackSuccess,
|
||||
};
|
||||
pub use pipe_backend::PipeBrowserBackend;
|
||||
pub use ws_backend::WsBrowserBackend;
|
||||
55
src/browser/pipe_backend.rs
Normal file
55
src/browser/pipe_backend.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::{Action, BrowserPipeTool, CommandOutput, ExecutionSurfaceMetadata, PipeError, Transport};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
pub struct PipeBrowserBackend<T: Transport> {
|
||||
inner: BrowserPipeTool<T>,
|
||||
}
|
||||
|
||||
impl<T: Transport> PipeBrowserBackend<T> {
|
||||
pub fn new(transport: Arc<T>, mac_policy: MacPolicy, session_key: Vec<u8>) -> Self {
|
||||
Self {
|
||||
inner: BrowserPipeTool::new(transport, mac_policy, session_key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_inner(inner: BrowserPipeTool<T>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub fn with_response_timeout(mut self, response_timeout: std::time::Duration) -> Self {
|
||||
self.inner = self.inner.with_response_timeout(response_timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Transport> Clone for PipeBrowserBackend<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: self.inner.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Transport> BrowserBackend for PipeBrowserBackend<T> {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.inner.invoke(action, params, expected_domain)
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.inner.surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.inner.supports_eval()
|
||||
}
|
||||
}
|
||||
158
src/browser/ws_backend.rs
Normal file
158
src/browser/ws_backend.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::browser::{ws_protocol, BrowserBackend};
|
||||
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
pub trait WsClient: Send + Sync {
|
||||
fn send_text(&self, payload: &str) -> Result<(), PipeError>;
|
||||
fn recv_text_timeout(&self, timeout: Duration) -> Result<String, PipeError>;
|
||||
}
|
||||
|
||||
pub struct WsBrowserBackend<C: WsClient> {
|
||||
client: Arc<C>,
|
||||
mac_policy: MacPolicy,
|
||||
request_url: Mutex<String>,
|
||||
next_seq: AtomicU64,
|
||||
response_timeout: Duration,
|
||||
in_flight: Mutex<()>,
|
||||
}
|
||||
|
||||
impl<C: WsClient> WsBrowserBackend<C> {
|
||||
pub fn new(client: Arc<C>, mac_policy: MacPolicy, request_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
mac_policy,
|
||||
request_url: Mutex::new(request_url.into()),
|
||||
next_seq: AtomicU64::new(1),
|
||||
response_timeout: Duration::from_secs(30),
|
||||
in_flight: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_response_timeout(mut self, response_timeout: Duration) -> Self {
|
||||
self.response_timeout = response_timeout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: WsClient> BrowserBackend for WsBrowserBackend<C> {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
let _guard = self
|
||||
.in_flight
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("browser ws request lock poisoned".to_string()))?;
|
||||
|
||||
self.mac_policy.validate(&action, expected_domain)?;
|
||||
|
||||
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let request_id = seq.to_string();
|
||||
let request_url = self
|
||||
.request_url
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("browser ws request url lock poisoned".to_string()))?
|
||||
.clone();
|
||||
let encoded = ws_protocol::encode_v1_action(
|
||||
&action,
|
||||
¶ms,
|
||||
&request_url,
|
||||
Some(request_id.as_str()),
|
||||
)?;
|
||||
|
||||
self.client.send_text(&encoded.payload)?;
|
||||
|
||||
let status = Some(recv_status_frame(&*self.client, self.response_timeout)?);
|
||||
if let Some(status) = status {
|
||||
let status_code = parse_status_code(&status)?;
|
||||
if status_code != 0 {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"browser returned non-zero status: {status_code}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if action == Action::Navigate {
|
||||
if let Some(url) = params.get("url").and_then(Value::as_str) {
|
||||
let mut request_url = self.request_url.lock().map_err(|_| {
|
||||
PipeError::Protocol("browser ws request url lock poisoned".to_string())
|
||||
})?;
|
||||
*request_url = url.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(callback) = encoded.callback {
|
||||
loop {
|
||||
let frame = self.client.recv_text_timeout(self.response_timeout)?;
|
||||
let decoded = ws_protocol::decode_callback_frame(&frame)?;
|
||||
if decoded.callback_name == callback.callback_name {
|
||||
return Ok(CommandOutput {
|
||||
seq,
|
||||
success: true,
|
||||
data: json!({ "text": decoded.response_text }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 0,
|
||||
exec_ms: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CommandOutput {
|
||||
seq,
|
||||
success: true,
|
||||
data: json!({}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 0,
|
||||
exec_ms: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.mac_policy.privileged_surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.mac_policy.supports_pipe_action(&Action::Eval)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_status_code(raw: &str) -> Result<i64, PipeError> {
|
||||
raw.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| PipeError::Protocol(format!("invalid browser status frame: {raw}")))
|
||||
}
|
||||
|
||||
fn recv_status_frame(client: &dyn WsClient, timeout: Duration) -> Result<String, PipeError> {
|
||||
loop {
|
||||
let frame = client.recv_text_timeout(timeout)?;
|
||||
if is_ignorable_status_prelude(&frame) {
|
||||
continue;
|
||||
}
|
||||
return Ok(frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ignorable_status_prelude(frame: &str) -> bool {
|
||||
let trimmed = frame.trim();
|
||||
if trimmed.starts_with("Welcome!") || trimmed.starts_with("Welcome ") {
|
||||
return true;
|
||||
}
|
||||
|
||||
serde_json::from_str::<Value>(trimmed)
|
||||
.ok()
|
||||
.and_then(|value| value.get("type").and_then(Value::as_str).map(str::to_string))
|
||||
.is_some_and(|kind| kind == "welcome")
|
||||
}
|
||||
307
src/browser/ws_probe.rs
Normal file
307
src/browser/ws_probe.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
use thiserror::Error;
|
||||
use tungstenite::stream::MaybeTlsStream;
|
||||
use tungstenite::{connect, Message, WebSocket};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbeStep {
|
||||
pub label: String,
|
||||
pub payload: String,
|
||||
pub expect_reply: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProbeOutcome {
|
||||
Received(Vec<String>),
|
||||
NoReplyExpected,
|
||||
TimedOut,
|
||||
Closed,
|
||||
ConnectFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbeStepResult {
|
||||
pub label: String,
|
||||
pub sent: String,
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbeCliConfig {
|
||||
pub ws_url: String,
|
||||
pub timeout_ms: u64,
|
||||
pub steps: Vec<ProbeStep>,
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 1500;
|
||||
const DEFAULT_REGISTER_STEP_LABEL: &str = "register";
|
||||
const DEFAULT_REGISTER_STEP_PAYLOAD: &str = r#"{"type":"register","role":"web"}"#;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProbeError {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("probe timeout while waiting for websocket frame")]
|
||||
Timeout,
|
||||
#[error("probe websocket closed")]
|
||||
Closed,
|
||||
#[error("probe protocol error: {0}")]
|
||||
Protocol(String),
|
||||
#[error("probe argument error: {0}")]
|
||||
Args(String),
|
||||
}
|
||||
|
||||
pub fn parse_probe_args(args: &[String]) -> Result<ProbeCliConfig, ProbeError> {
|
||||
let mut ws_url = None;
|
||||
let mut timeout_ms = None;
|
||||
let mut steps = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < args.len() {
|
||||
match args[index].as_str() {
|
||||
"--ws-url" => {
|
||||
index += 1;
|
||||
let value = args
|
||||
.get(index)
|
||||
.ok_or_else(|| ProbeError::Args("missing value for --ws-url".to_string()))?;
|
||||
ws_url = Some(value.clone());
|
||||
}
|
||||
"--timeout-ms" => {
|
||||
index += 1;
|
||||
let value = args.get(index).ok_or_else(|| {
|
||||
ProbeError::Args("missing value for --timeout-ms".to_string())
|
||||
})?;
|
||||
let parsed = value.parse::<u64>().map_err(|_| {
|
||||
ProbeError::Args(format!("invalid --timeout-ms value: {value}"))
|
||||
})?;
|
||||
timeout_ms = Some(parsed);
|
||||
}
|
||||
"--step" => {
|
||||
index += 1;
|
||||
let value = args
|
||||
.get(index)
|
||||
.ok_or_else(|| ProbeError::Args("missing value for --step".to_string()))?;
|
||||
let (label, payload) = value.split_once("::").ok_or_else(|| {
|
||||
ProbeError::Args(format!(
|
||||
"invalid --step value (expected <label>::<payload>): {value}"
|
||||
))
|
||||
})?;
|
||||
if label.is_empty() {
|
||||
return Err(ProbeError::Args("step label must not be empty".to_string()));
|
||||
}
|
||||
if payload.is_empty() {
|
||||
return Err(ProbeError::Args("step payload must not be empty".to_string()));
|
||||
}
|
||||
steps.push(ProbeStep {
|
||||
label: label.to_string(),
|
||||
payload: payload.to_string(),
|
||||
expect_reply: true,
|
||||
});
|
||||
}
|
||||
flag => {
|
||||
return Err(ProbeError::Args(format!("unknown argument: {flag}")));
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
let ws_url = ws_url.ok_or_else(|| ProbeError::Args("missing required --ws-url".to_string()))?;
|
||||
validate_ws_url(&ws_url)?;
|
||||
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
|
||||
if steps.is_empty() {
|
||||
steps.push(ProbeStep {
|
||||
label: DEFAULT_REGISTER_STEP_LABEL.to_string(),
|
||||
payload: DEFAULT_REGISTER_STEP_PAYLOAD.to_string(),
|
||||
expect_reply: true,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ProbeCliConfig {
|
||||
ws_url,
|
||||
timeout_ms,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_ws_url(ws_url: &str) -> Result<(), ProbeError> {
|
||||
if ws_url.starts_with("ws://") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ProbeError::Args(format!(
|
||||
"unsupported --ws-url scheme (only ws:// is supported for this probe): {ws_url}"
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn run_probe_script(
|
||||
ws_url: &str,
|
||||
timeout: Duration,
|
||||
steps: Vec<ProbeStep>,
|
||||
) -> Result<Vec<ProbeStepResult>, ProbeError> {
|
||||
let mut socket = match connect(ws_url) {
|
||||
Ok((socket, _)) => socket,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
return Ok(steps
|
||||
.into_iter()
|
||||
.map(|step| ProbeStepResult {
|
||||
label: step.label,
|
||||
sent: step.payload,
|
||||
outcome: ProbeOutcome::ConnectFailed(message.clone()),
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
};
|
||||
|
||||
configure_socket_timeout(&mut socket, timeout)?;
|
||||
|
||||
let mut results = Vec::with_capacity(steps.len());
|
||||
for step in steps {
|
||||
let ProbeStep {
|
||||
label,
|
||||
payload,
|
||||
expect_reply,
|
||||
} = step;
|
||||
|
||||
let send_outcome = match socket.send(Message::Text(payload.clone().into())) {
|
||||
Ok(()) => None,
|
||||
Err(err) => Some(map_websocket_error(err, "browser websocket send")),
|
||||
};
|
||||
|
||||
let outcome = match send_outcome {
|
||||
Some(ProbeError::Timeout) => ProbeOutcome::TimedOut,
|
||||
Some(ProbeError::Closed) => ProbeOutcome::Closed,
|
||||
Some(err) => return Err(err),
|
||||
None if expect_reply => match read_probe_frames(&mut socket) {
|
||||
Ok(frames) => ProbeOutcome::Received(frames),
|
||||
Err(ProbeError::Timeout) => ProbeOutcome::TimedOut,
|
||||
Err(ProbeError::Closed) => ProbeOutcome::Closed,
|
||||
Err(err) => return Err(err),
|
||||
},
|
||||
None => ProbeOutcome::NoReplyExpected,
|
||||
};
|
||||
|
||||
results.push(ProbeStepResult {
|
||||
label,
|
||||
sent: payload,
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn configure_socket_timeout(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
timeout: Duration,
|
||||
) -> Result<(), ProbeError> {
|
||||
match websocket.get_mut() {
|
||||
MaybeTlsStream::Plain(stream) => {
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_probe_frames(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
) -> Result<Vec<String>, ProbeError> {
|
||||
let first_frame = read_probe_frame(websocket)?;
|
||||
let mut frames = vec![first_frame];
|
||||
|
||||
let Some(original_timeout) = get_plain_read_timeout(websocket)? else {
|
||||
return Ok(frames);
|
||||
};
|
||||
|
||||
set_plain_read_timeout(websocket, Some(Duration::from_millis(1)))?;
|
||||
|
||||
loop {
|
||||
match read_probe_frame(websocket) {
|
||||
Ok(frame) => frames.push(frame),
|
||||
Err(ProbeError::Timeout) | Err(ProbeError::Closed) => break,
|
||||
Err(err) => {
|
||||
set_plain_read_timeout(websocket, original_timeout)?;
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_plain_read_timeout(websocket, original_timeout)?;
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn get_plain_read_timeout(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
) -> Result<Option<Option<Duration>>, ProbeError> {
|
||||
match websocket.get_mut() {
|
||||
MaybeTlsStream::Plain(stream) => Ok(Some(stream.read_timeout()?)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_plain_read_timeout(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<(), ProbeError> {
|
||||
match websocket.get_mut() {
|
||||
MaybeTlsStream::Plain(stream) => {
|
||||
stream.set_read_timeout(timeout)?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_probe_frame(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
) -> Result<String, ProbeError> {
|
||||
loop {
|
||||
match websocket.read() {
|
||||
Ok(Message::Text(text)) => return Ok(text.to_string()),
|
||||
Ok(Message::Close(_)) => return Err(ProbeError::Closed),
|
||||
Ok(Message::Ping(payload)) => {
|
||||
websocket
|
||||
.send(Message::Pong(payload))
|
||||
.map_err(|err| map_websocket_error(err, "browser websocket pong"))?;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(map_websocket_error(err, "browser websocket read")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_websocket_error(err: tungstenite::Error, operation: &str) -> ProbeError {
|
||||
match err {
|
||||
tungstenite::Error::ConnectionClosed
|
||||
| tungstenite::Error::AlreadyClosed
|
||||
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::ResetWithoutClosingHandshake)
|
||||
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::SendAfterClosing) => {
|
||||
ProbeError::Closed
|
||||
}
|
||||
tungstenite::Error::Io(io_err)
|
||||
if matches!(
|
||||
io_err.kind(),
|
||||
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
|
||||
) =>
|
||||
{
|
||||
ProbeError::Timeout
|
||||
}
|
||||
tungstenite::Error::Io(io_err)
|
||||
if matches!(
|
||||
io_err.kind(),
|
||||
std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::UnexpectedEof
|
||||
) =>
|
||||
{
|
||||
ProbeError::Closed
|
||||
}
|
||||
tungstenite::Error::Io(io_err) => ProbeError::Io(io_err),
|
||||
other => ProbeError::Protocol(format!("{operation} failed: {other}")),
|
||||
}
|
||||
}
|
||||
306
src/browser/ws_protocol.rs
Normal file
306
src/browser/ws_protocol.rs
Normal file
@@ -0,0 +1,306 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::pipe::{Action, PipeError};
|
||||
|
||||
const CALLBACK_DELIMITER: &str = "@_@";
|
||||
const CALLBACK_PREFIX: &str = "sgclaw_cb_";
|
||||
const JS_AREA_HIDE: &str = "hide";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CallbackCorrelation {
|
||||
pub request_id: String,
|
||||
pub callback_name: String,
|
||||
pub source_url: String,
|
||||
pub target_url: String,
|
||||
pub action_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EncodedWsRequest {
|
||||
pub payload: String,
|
||||
pub callback: Option<CallbackCorrelation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecodedCallback {
|
||||
pub source_url: String,
|
||||
pub target_url: String,
|
||||
pub callback_name: String,
|
||||
pub action_url: String,
|
||||
pub response_text: String,
|
||||
}
|
||||
|
||||
pub fn encode_v1_action(
|
||||
action: &Action,
|
||||
params: &Value,
|
||||
request_url: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
match action {
|
||||
Action::Navigate => encode_navigate(params, request_url, request_id),
|
||||
Action::Click => encode_click(params, request_url),
|
||||
Action::Type => encode_type(params, request_url),
|
||||
Action::GetText => encode_get_text(params, request_url, request_id),
|
||||
Action::Eval => encode_eval(params, request_url, request_id),
|
||||
_ => Err(PipeError::Protocol(format!(
|
||||
"unsupported browser ws action: {}",
|
||||
action.as_str()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_callback_frame(frame: &str) -> Result<DecodedCallback, PipeError> {
|
||||
let payload: Value = serde_json::from_str(frame)?;
|
||||
let array = payload.as_array().ok_or_else(|| {
|
||||
PipeError::Protocol("callback frame must be a JSON array".to_string())
|
||||
})?;
|
||||
if array.len() != 3 {
|
||||
return Err(PipeError::Protocol(
|
||||
"callback frame must contain [requesturl, function, payload]".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let function_name = array[1].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("callback frame function name must be a string".to_string())
|
||||
})?;
|
||||
if function_name != "callBackJsToCpp" {
|
||||
return Err(PipeError::Protocol(
|
||||
"callback frame must target callBackJsToCpp".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let param = array[2].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("callback payload must be a string".to_string())
|
||||
})?;
|
||||
let mut parts = param.splitn(5, CALLBACK_DELIMITER);
|
||||
let source_url = parts.next().unwrap_or_default();
|
||||
let target_url = parts.next().unwrap_or_default();
|
||||
let callback_name = parts.next().unwrap_or_default();
|
||||
let action_url = parts.next().unwrap_or_default();
|
||||
let response_text = parts.next().unwrap_or_default();
|
||||
|
||||
if source_url.is_empty()
|
||||
|| target_url.is_empty()
|
||||
|| callback_name.is_empty()
|
||||
|| action_url.is_empty()
|
||||
|| response_text.is_empty() && !param.ends_with(CALLBACK_DELIMITER)
|
||||
{
|
||||
return Err(PipeError::Protocol(
|
||||
"malformed callback payload".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(DecodedCallback {
|
||||
source_url: source_url.to_string(),
|
||||
target_url: target_url.to_string(),
|
||||
callback_name: callback_name.to_string(),
|
||||
action_url: action_url.to_string(),
|
||||
response_text: response_text.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_navigate(
|
||||
params: &Value,
|
||||
request_url: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
let url = required_string(params, "url")?;
|
||||
let callback = callback_metadata(
|
||||
request_id,
|
||||
request_url,
|
||||
&url,
|
||||
"sgHideBrowserCallAfterLoaded",
|
||||
)?;
|
||||
let callback_call = format!(
|
||||
"callBackJsToCpp(\"{request_url}@_@{url}@_@{callback_name}@_@sgHideBrowserCallAfterLoaded@_@\")",
|
||||
callback_name = callback.callback_name,
|
||||
);
|
||||
Ok(EncodedWsRequest {
|
||||
payload: serde_json::to_string(&json!([
|
||||
request_url,
|
||||
"sgHideBrowserCallAfterLoaded",
|
||||
url,
|
||||
callback_call,
|
||||
]))?,
|
||||
callback: Some(callback),
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_click(params: &Value, request_url: &str) -> Result<EncodedWsRequest, PipeError> {
|
||||
let target_url = target_url(params, request_url)?;
|
||||
let selector = required_string(params, "selector")?;
|
||||
let script = format!(
|
||||
"(function(){{const el=document.querySelector({selector:?});if(!el){{throw new Error(\"selector not found: {selector}\");}}el.click();}})();"
|
||||
);
|
||||
encode_js_in_area(request_url, &target_url, &script, None)
|
||||
}
|
||||
|
||||
fn encode_type(params: &Value, request_url: &str) -> Result<EncodedWsRequest, PipeError> {
|
||||
let target_url = target_url(params, request_url)?;
|
||||
let selector = required_string(params, "selector")?;
|
||||
let text = required_string(params, "text")?;
|
||||
let script = format!(
|
||||
"(function(){{const el=document.querySelector({selector:?});if(!el){{throw new Error(\"selector not found: {selector}\");}}el.value={text:?};el.dispatchEvent(new Event(\"input\",{{bubbles:true}}));el.dispatchEvent(new Event(\"change\",{{bubbles:true}}));}})();"
|
||||
);
|
||||
encode_js_in_area(request_url, &target_url, &script, None)
|
||||
}
|
||||
|
||||
fn encode_get_text(
|
||||
params: &Value,
|
||||
request_url: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
let target_url = target_url(params, request_url)?;
|
||||
let selector = required_string(params, "selector")?;
|
||||
let callback = callback_metadata(
|
||||
request_id,
|
||||
request_url,
|
||||
&target_url,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
)?;
|
||||
let script = format!(
|
||||
"(function(){{const el=document.querySelector({selector:?});if(!el){{throw new Error(\"selector not found: {selector}\");}}const text=el.innerText ?? el.textContent ?? \"\";callBackJsToCpp(\"{request_url}@_@{target_url}@_@{callback_name}@_@sgBrowserExcuteJsCodeByArea@_@\"+String(text));}})();",
|
||||
callback_name = callback.callback_name
|
||||
);
|
||||
encode_js_in_area(request_url, &target_url, &script, Some(callback))
|
||||
}
|
||||
|
||||
fn encode_eval(
|
||||
params: &Value,
|
||||
request_url: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
let target_url = target_url(params, request_url)?;
|
||||
let source_script = required_string(params, "script")?;
|
||||
let callback = callback_metadata(
|
||||
request_id,
|
||||
request_url,
|
||||
&target_url,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
)?;
|
||||
let script = format!(
|
||||
"(function(){{const result=(function(){{{source_script}}})();callBackJsToCpp(\"{request_url}@_@{target_url}@_@{callback_name}@_@sgBrowserExcuteJsCodeByArea@_@\"+String(result));}})();",
|
||||
callback_name = callback.callback_name
|
||||
);
|
||||
encode_js_in_area(request_url, &target_url, &script, Some(callback))
|
||||
}
|
||||
|
||||
fn encode_js_in_area(
|
||||
request_url: &str,
|
||||
target_url: &str,
|
||||
script: &str,
|
||||
callback: Option<CallbackCorrelation>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
Ok(EncodedWsRequest {
|
||||
payload: serde_json::to_string(&json!([
|
||||
request_url,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
target_url,
|
||||
script,
|
||||
JS_AREA_HIDE,
|
||||
]))?,
|
||||
callback,
|
||||
})
|
||||
}
|
||||
|
||||
fn callback_metadata(
|
||||
request_id: Option<&str>,
|
||||
request_url: &str,
|
||||
target_url: &str,
|
||||
action_url: &str,
|
||||
) -> Result<CallbackCorrelation, PipeError> {
|
||||
let request_id = request_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| PipeError::Protocol("request_id is required".to_string()))?;
|
||||
Ok(CallbackCorrelation {
|
||||
request_id: request_id.to_string(),
|
||||
callback_name: format!("{CALLBACK_PREFIX}{request_id}"),
|
||||
source_url: request_url.to_string(),
|
||||
target_url: target_url.to_string(),
|
||||
action_url: action_url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn target_url(params: &Value, request_url: &str) -> Result<String, PipeError> {
|
||||
Ok(optional_string(params, "target_url")
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| request_url.to_string()))
|
||||
}
|
||||
|
||||
fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
|
||||
optional_string(params, key)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| PipeError::Protocol(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn optional_string(params: &Value, key: &str) -> Option<String> {
|
||||
params.get(key)?.as_str().map(ToString::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode_callback_frame, encode_v1_action};
|
||||
use crate::pipe::Action;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
fn get_text_callback_uses_documented_browser_opcode() {
|
||||
let request = encode_v1_action(
|
||||
&Action::GetText,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#content"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req42"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
assert_eq!(payload[1], json!("sgBrowserExcuteJsCodeByArea"));
|
||||
assert_eq!(payload[4], json!("hide"));
|
||||
assert_eq!(
|
||||
request.callback.unwrap().action_url,
|
||||
"sgBrowserExcuteJsCodeByArea"
|
||||
);
|
||||
assert!(payload[3].as_str().unwrap().contains(
|
||||
"callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@\"+String(text))"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_callback_uses_documented_browser_opcode() {
|
||||
let request = encode_v1_action(
|
||||
&Action::Eval,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"script": "2 + 2"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req-eval"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
assert_eq!(payload[1], json!("sgBrowserExcuteJsCodeByArea"));
|
||||
assert_eq!(
|
||||
request.callback.unwrap().action_url,
|
||||
"sgBrowserExcuteJsCodeByArea"
|
||||
);
|
||||
assert!(payload[3].as_str().unwrap().contains(
|
||||
"callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req-eval@_@sgBrowserExcuteJsCodeByArea@_@\"+String(result))"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_documented_callback_payload() {
|
||||
let callback = decode_callback_frame(
|
||||
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@天气"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
|
||||
assert_eq!(callback.response_text, "天气");
|
||||
}
|
||||
}
|
||||
267
src/compat/artifact_open.rs
Normal file
267
src/compat/artifact_open.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::{Action, CommandOutput};
|
||||
|
||||
pub const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
pub const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
|
||||
pub const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
|
||||
const DISABLE_POST_EXPORT_OPEN_ENV: &str = "SGCLAW_DISABLE_POST_EXPORT_OPEN";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PostExportOpen {
|
||||
Opened,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub fn open_exported_xlsx(output_path: &Path) -> PostExportOpen {
|
||||
open_exported_xlsx_with(output_path, launch_with_default_xlsx_app)
|
||||
}
|
||||
|
||||
fn open_exported_xlsx_with<F>(output_path: &Path, opener: F) -> PostExportOpen
|
||||
where
|
||||
F: FnOnce(&Path) -> Result<(), String>,
|
||||
{
|
||||
if !output_path.exists() {
|
||||
return PostExportOpen::Failed(format!(
|
||||
"导出的 Excel 文件不存在:{}",
|
||||
output_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
match opener(output_path) {
|
||||
Ok(()) => PostExportOpen::Opened,
|
||||
Err(reason) => PostExportOpen::Failed(reason),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_local_dashboard(
|
||||
browser_backend: &dyn BrowserBackend,
|
||||
output_path: &Path,
|
||||
presentation_url: &str,
|
||||
) -> PostExportOpen {
|
||||
if !output_path.exists() {
|
||||
return PostExportOpen::Failed(format!(
|
||||
"生成的大屏文件不存在:{}",
|
||||
output_path.display()
|
||||
));
|
||||
}
|
||||
if presentation_url.trim().is_empty() {
|
||||
return PostExportOpen::Failed("screen_html_export did not return presentation.url".to_string());
|
||||
}
|
||||
|
||||
let params = json!({
|
||||
"url": presentation_url,
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": LOCAL_DASHBOARD_SOURCE,
|
||||
"kind": LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN,
|
||||
"output_path": output_path.to_string_lossy(),
|
||||
"presentation_url": presentation_url,
|
||||
}
|
||||
});
|
||||
|
||||
match browser_backend.invoke(Action::Navigate, params, LOCAL_DASHBOARD_EXPECTED_DOMAIN) {
|
||||
Ok(output) if output.success => PostExportOpen::Opened,
|
||||
Ok(output) => PostExportOpen::Failed(command_output_reason(&output)),
|
||||
Err(err) => PostExportOpen::Failed(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", "start", "", &output_path.display().to_string()])
|
||||
.output()
|
||||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
Err(format!(
|
||||
"启动 Excel 默认程序失败:exit status {}",
|
||||
output.status
|
||||
))
|
||||
} else {
|
||||
Err(format!("启动 Excel 默认程序失败:{stderr}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = Command::new("open")
|
||||
.arg(output_path)
|
||||
.status()
|
||||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("启动 Excel 默认程序失败:exit status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = Command::new("xdg-open")
|
||||
.arg(output_path)
|
||||
.status()
|
||||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("启动 Excel 默认程序失败:exit status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn command_output_reason(output: &CommandOutput) -> String {
|
||||
output
|
||||
.data
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| output.data.get("message").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| output.data.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::pipe::{ExecutionSurfaceMetadata, PipeError, Timing};
|
||||
|
||||
fn temp_file_path(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-artifact-open-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
std::fs::create_dir_all(&root).expect("temp root should exist");
|
||||
root.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_passes_generated_path_to_launcher() {
|
||||
let output_path = temp_file_path("zhihu-hotlist.xlsx");
|
||||
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
|
||||
let seen = Mutex::new(None::<PathBuf>);
|
||||
|
||||
let result = open_exported_xlsx_with(&output_path, |path| {
|
||||
*seen.lock().unwrap() = Some(path.to_path_buf());
|
||||
Ok(())
|
||||
});
|
||||
|
||||
assert!(matches!(result, PostExportOpen::Opened));
|
||||
assert_eq!(seen.lock().unwrap().clone().unwrap(), output_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_reports_launcher_failure() {
|
||||
let output_path = temp_file_path("zhihu-hotlist.xlsx");
|
||||
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
|
||||
|
||||
let result = open_exported_xlsx_with(&output_path, |_path| Err("launcher failed".to_string()));
|
||||
|
||||
assert!(matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed")));
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeBrowserBackend {
|
||||
responses: Mutex<VecDeque<Result<CommandOutput, PipeError>>>,
|
||||
invocations: Mutex<Vec<(Action, Value, String)>>,
|
||||
}
|
||||
|
||||
impl FakeBrowserBackend {
|
||||
fn new(responses: Vec<Result<CommandOutput, PipeError>>) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(VecDeque::from(responses)),
|
||||
invocations: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for FakeBrowserBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.invocations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((action, params, expected_domain.to_string()));
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Err(PipeError::Timeout))
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_local_dashboard_uses_exact_approved_marker_payload() {
|
||||
let output_path = temp_file_path("zhihu-hotlist-screen.html");
|
||||
std::fs::write(&output_path, "<html></html>").expect("dashboard fixture should be writable");
|
||||
let presentation_url = format!("file:///{}", output_path.display().to_string().replace('\\', "/"));
|
||||
let backend = FakeBrowserBackend::new(vec![Ok(CommandOutput {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
})]);
|
||||
|
||||
let result = open_local_dashboard(&backend, &output_path, &presentation_url);
|
||||
let invocations = backend.invocations.lock().unwrap().clone();
|
||||
|
||||
assert!(matches!(result, PostExportOpen::Opened));
|
||||
assert_eq!(invocations.len(), 1);
|
||||
assert_eq!(invocations[0].0, Action::Navigate);
|
||||
assert_eq!(invocations[0].2, LOCAL_DASHBOARD_EXPECTED_DOMAIN.to_string());
|
||||
assert_eq!(invocations[0].1["url"], json!(presentation_url));
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["source"],
|
||||
json!(LOCAL_DASHBOARD_SOURCE)
|
||||
);
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["kind"],
|
||||
json!(LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN)
|
||||
);
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["output_path"],
|
||||
json!(output_path.to_string_lossy().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["presentation_url"],
|
||||
invocations[0].1["url"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
@@ -8,23 +9,24 @@ use serde_json::{json, Value};
|
||||
use zeroclaw::skills::{Skill, SkillTool};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
use crate::pipe::{Action, BrowserPipeTool, Transport};
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::Action;
|
||||
|
||||
pub struct BrowserScriptSkillTool<T: Transport> {
|
||||
pub struct BrowserScriptSkillTool {
|
||||
tool_name: String,
|
||||
tool_description: String,
|
||||
skill_root: PathBuf,
|
||||
script_path: PathBuf,
|
||||
args: HashMap<String, String>,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
}
|
||||
|
||||
impl<T: Transport> BrowserScriptSkillTool<T> {
|
||||
impl BrowserScriptSkillTool {
|
||||
pub fn new(
|
||||
skill_name: &str,
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
|
||||
|
||||
@@ -70,7 +72,7 @@ impl<T: Transport> BrowserScriptSkillTool<T> {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
|
||||
impl Tool for BrowserScriptSkillTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
@@ -92,101 +94,20 @@ impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
|
||||
args: self.args.clone(),
|
||||
};
|
||||
|
||||
execute_browser_script_tool(&tool, &self.skill_root, self.browser_tool.clone(), args).await
|
||||
execute_browser_script_tool(&tool, &self.skill_root, self.browser_tool.as_ref(), args).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_browser_script_tool<T: Transport + 'static>(
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
if tool.kind != "browser_script" {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"browser script tool kind must be browser_script, got {}",
|
||||
tool.kind
|
||||
)));
|
||||
}
|
||||
|
||||
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
|
||||
let mut args = match args {
|
||||
Value::Object(args) => args,
|
||||
other => return Ok(failed_tool_result(format!("expected object arguments, got {other}"))),
|
||||
};
|
||||
|
||||
let raw_expected_domain = match args.remove("expected_domain") {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => value,
|
||||
Some(other) => {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"expected_domain must be a non-empty string, got {other}"
|
||||
)))
|
||||
}
|
||||
None => {
|
||||
return Ok(failed_tool_result(
|
||||
"missing required field expected_domain".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let expected_domain = match normalize_domain_like(&raw_expected_domain) {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
for required_arg in tool.args.keys() {
|
||||
if !args.contains_key(required_arg) {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"missing required field {required_arg}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let script_body = match fs::read_to_string(&script_path) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"failed to read browser script {}: {err}",
|
||||
script_path.display()
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone()));
|
||||
let result = match browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({ "script": wrapped_script }),
|
||||
&expected_domain,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(err) => return Ok(failed_tool_result(err.to_string())),
|
||||
};
|
||||
|
||||
if !result.success {
|
||||
return Ok(failed_tool_result(format_browser_script_error(&result.data)));
|
||||
}
|
||||
|
||||
let payload = result
|
||||
.data
|
||||
.get("text")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| result.data.clone());
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: stringify_tool_payload(&payload)?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_browser_script_skill_tools<T: Transport + 'static>(
|
||||
pub fn build_browser_script_skill_tools(
|
||||
skills: &[Skill],
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
) -> Result<Vec<Box<dyn Tool>>, anyhow::Error> {
|
||||
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
|
||||
if !browser_tool.supports_eval() {
|
||||
return Ok(tools);
|
||||
}
|
||||
|
||||
for skill in skills {
|
||||
let Some(location) = skill.location.as_ref() else {
|
||||
continue;
|
||||
@@ -211,6 +132,153 @@ pub fn build_browser_script_skill_tools<T: Transport + 'static>(
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
pub async fn execute_browser_script_tool(
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
if tool.kind != "browser_script" {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"browser script tool kind must be browser_script, got {}",
|
||||
tool.kind
|
||||
)));
|
||||
}
|
||||
|
||||
execute_browser_script_impl(tool, skill_root, browser_tool, args)
|
||||
}
|
||||
|
||||
fn execute_browser_script_impl(
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
eprintln!("[execute_browser_script_impl] 开始执行");
|
||||
eprintln!("[execute_browser_script_impl] tool.name: {}", tool.name);
|
||||
eprintln!("[execute_browser_script_impl] tool.command: {}", tool.command);
|
||||
eprintln!("[execute_browser_script_impl] skill_root: {:?}", skill_root);
|
||||
eprintln!("[execute_browser_script_impl] args: {:?}", args);
|
||||
|
||||
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
|
||||
eprintln!("[execute_browser_script_impl] script_path: {:?}", script_path);
|
||||
|
||||
// 检查脚本文件是否存在
|
||||
if !script_path.exists() {
|
||||
eprintln!("[execute_browser_script_impl] 脚本文件不存在!");
|
||||
} else {
|
||||
eprintln!("[execute_browser_script_impl] 脚本文件存在");
|
||||
}
|
||||
|
||||
let mut args = match args {
|
||||
Value::Object(args) => args,
|
||||
other => {
|
||||
eprintln!("[execute_browser_script_impl] args 不是 Object: {:?}", other);
|
||||
return Ok(failed_tool_result(format!(
|
||||
"expected object arguments, got {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let raw_expected_domain = match args.remove("expected_domain") {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => value,
|
||||
Some(other) => {
|
||||
eprintln!("[execute_browser_script_impl] expected_domain 格式错误: {:?}", other);
|
||||
return Ok(failed_tool_result(format!(
|
||||
"expected_domain must be a non-empty string, got {other}"
|
||||
)))
|
||||
}
|
||||
None => {
|
||||
eprintln!("[execute_browser_script_impl] 缺少 expected_domain");
|
||||
return Ok(failed_tool_result(
|
||||
"missing required field expected_domain".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
eprintln!("[execute_browser_script_impl] raw_expected_domain: {}", raw_expected_domain);
|
||||
|
||||
let expected_domain = match normalize_domain_like(&raw_expected_domain) {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
eprintln!("[execute_browser_script_impl] expected_domain 解析失败");
|
||||
return Ok(failed_tool_result(format!(
|
||||
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
eprintln!("[execute_browser_script_impl] expected_domain: {}", expected_domain);
|
||||
args.insert("expected_domain".to_string(), Value::String(expected_domain.clone()));
|
||||
|
||||
for required_arg in tool.args.keys() {
|
||||
if !args.contains_key(required_arg) {
|
||||
eprintln!("[execute_browser_script_impl] 缺少必需参数: {}", required_arg);
|
||||
return Ok(failed_tool_result(format!(
|
||||
"missing required field {required_arg}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let script_body = match fs::read_to_string(&script_path) {
|
||||
Ok(value) => {
|
||||
eprintln!("[execute_browser_script_impl] 脚本读取成功, 长度: {} 字节", value.len());
|
||||
value
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[execute_browser_script_impl] 脚本读取失败: {}", err);
|
||||
return Ok(failed_tool_result(format!(
|
||||
"failed to read browser script {}: {err}",
|
||||
script_path.display()
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone()));
|
||||
eprintln!("[execute_browser_script_impl] 包装后脚本长度: {} 字节", wrapped_script.len());
|
||||
eprintln!("[execute_browser_script_impl] 包装后脚本前500字符: {}",
|
||||
if wrapped_script.len() > 500 { &wrapped_script[..500] } else { &wrapped_script });
|
||||
eprintln!("[execute_browser_script_impl] 调用 browser_tool.invoke(Action::Eval)...");
|
||||
|
||||
let target_url = args.get("target_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("http://{}", expected_domain));
|
||||
eprintln!("[execute_browser_script_impl] target_url: {}", target_url);
|
||||
let result = match browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": wrapped_script,
|
||||
"target_url": target_url,
|
||||
}),
|
||||
&expected_domain,
|
||||
) {
|
||||
Ok(result) => {
|
||||
eprintln!("[execute_browser_script_impl] invoke 成功, result.success: {}", result.success);
|
||||
result
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[execute_browser_script_impl] invoke 失败: {}", err);
|
||||
return Ok(failed_tool_result(err.to_string()))
|
||||
}
|
||||
};
|
||||
|
||||
if !result.success {
|
||||
eprintln!("[execute_browser_script_impl] result.success=false, data: {:?}", result.data);
|
||||
return Ok(failed_tool_result(format_browser_script_error(&result.data)));
|
||||
}
|
||||
|
||||
let payload = result
|
||||
.data
|
||||
.get("text")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| result.data.clone());
|
||||
eprintln!("[execute_browser_script_impl] 返回成功, payload 长度: {:?}", payload.to_string().len());
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: stringify_tool_payload(&payload)?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn wrap_browser_script(script_body: &str, args: &Value) -> String {
|
||||
format!(
|
||||
"(function() {{\nconst args = {};\n{}\n}})()",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Map, Value};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
use crate::pipe::{Action, BrowserPipeTool, ExecutionSurfaceMetadata, Transport};
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::{Action, ExecutionSurfaceMetadata};
|
||||
|
||||
pub const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
pub const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
|
||||
@@ -17,14 +20,14 @@ 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> {
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
pub struct ZeroClawBrowserTool {
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
tool_name: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
pub fn new(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
impl ZeroClawBrowserTool {
|
||||
pub fn new(browser_tool: Arc<dyn BrowserBackend>) -> Self {
|
||||
Self::named(
|
||||
browser_tool,
|
||||
BROWSER_ACTION_TOOL_NAME,
|
||||
@@ -32,7 +35,7 @@ impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_superrpa(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
pub fn new_superrpa(browser_tool: Arc<dyn BrowserBackend>) -> Self {
|
||||
Self::named(
|
||||
browser_tool,
|
||||
SUPERRPA_BROWSER_TOOL_NAME,
|
||||
@@ -41,7 +44,7 @@ impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
}
|
||||
|
||||
fn named(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
tool_name: &'static str,
|
||||
description: &'static str,
|
||||
) -> Self {
|
||||
@@ -58,7 +61,7 @@ impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||
impl Tool for ZeroClawBrowserTool {
|
||||
fn name(&self) -> &str {
|
||||
self.tool_name
|
||||
}
|
||||
|
||||
@@ -88,14 +88,22 @@ pub fn zeroclaw_default_skills_dir(workspace_root: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn resolve_skills_dir(workspace_root: &Path, settings: &DeepSeekSettings) -> PathBuf {
|
||||
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
|
||||
settings
|
||||
.skills_dir
|
||||
.as_deref()
|
||||
.map(normalize_configured_skills_dir)
|
||||
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
|
||||
}
|
||||
|
||||
pub fn resolve_skills_dir_from_sgclaw_settings(
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> PathBuf {
|
||||
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
|
||||
settings
|
||||
.skills_dir
|
||||
.as_deref()
|
||||
.map(normalize_configured_skills_dir)
|
||||
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
|
||||
}
|
||||
|
||||
fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
|
||||
@@ -111,8 +119,3 @@ fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_skills_dir_path(workspace_root: &Path, configured_dir: Option<&Path>) -> PathBuf {
|
||||
configured_dir
|
||||
.map(normalize_configured_skills_dir)
|
||||
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user