@twinsearth/w2m-dsh-plugin 2.2.5 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/cordis.patch.yml +11 -0
- package/docs/acceptance/2.2.6/RELEASE-2260.json +30 -0
- package/docs/acceptance/3.0.0/RELEASE-3000.json +50 -0
- package/lib/client.js +219 -60
- package/lib/decision-center.js +11 -0
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/src/decision-center/index.mjs +2265 -0
- package/src/decision-center/kernel.mjs +166 -0
- package/src/decision-center/lib/ai-secret-store.mjs +202 -0
- package/src/decision-center/lib/decision-outbox.mjs +129 -0
- package/src/decision-center/lib/producer-contract.mjs +162 -0
- package/src/decision-center/lib/ssrf-guard.mjs +170 -0
- package/src/decision-center/mount.mjs +26 -0
- package/src/decision-center/routes.mjs +330 -0
- package/src/decision-center/vendor/cosmokit.mjs +421 -0
- package/src/decision-center/vendor/schemastery.mjs +638 -0
- package/src/plugin/tools.mjs +1 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "
|
|
8
|
+
"version": "3.0.0"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "
|
|
15
|
+
"version": "3.0.0",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "
|
|
29
|
+
"version": "3.0.0"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "3.0.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [3.0.0] - 2026-11-14
|
|
8
|
+
|
|
9
|
+
- 决策中心子插件:`@twinsearth/dsh-fang/decision-center`(瓮城)完整移植为
|
|
10
|
+
`@twinsearth/w2m-dsh-plugin/decision-center`。宿主模块逐字移植(`src/decision-center/index.mjs`,
|
|
11
|
+
27 个服务方法、`request_human_decision` 工具),四个依赖(AI 密钥库、决策 outbox、生产者契约、
|
|
12
|
+
SSRF 守卫)与 MIT 内置 schemastery 原样随包;Fang 内核 barrel 由 `kernel.mjs` 顶替,提供同样的
|
|
13
|
+
`z`/`SERVICE`/bus/原子读写/数据目录解析/角色门。
|
|
14
|
+
- 新增 `/api/w2m/decisions*` 14 条路由:列表、单票、配额、分身聚合、生产者目录、外部 ingest、
|
|
15
|
+
SSE 实时推送、解决(含批量与 actionId)、放弃(含批量)、AI 状态/测试/配置/建议/流式建议;
|
|
16
|
+
`routes.mjs` 用 Connection fetch 注册表承载,SSE 事件名与负载与 Fang 一致。
|
|
17
|
+
- 决策中心面板进入 GPW:`GpwSettingsPage` 与 `GpwLauncher` 共用同一个 `GpwPanel`,因此设置页与浮标
|
|
18
|
+
同时显示——4 秒轮询、红票置顶、绿/灰折叠、按票面选项一键解决、无选项可放弃、AI 建议只推荐不执行。
|
|
19
|
+
- 测试:移植 Fang 决策套件 79 项 + 新增路由面 3 项通过;Fang 的 Node-SSE 辅助(decision-events)与 fork 侧 ingest 客户端不随包(W2M 路由走 fetch SSE、没有 fork 半体)。
|
|
20
|
+
`decision-center-fork` 与 `decision-action-integration` 不移植:前者依赖 Fang 母体/分身角色矩阵,
|
|
21
|
+
后者的主体是 Fang orchestrator 与 restart-guard,W2M 里没有对应组件。
|
|
22
|
+
- 包元数据:新增 `./decision-center` 子路径导出与`dsh-decision-center` patch 行(启用),
|
|
23
|
+
`engines.dsh` 保持 `>=0.2.0 <0.3.0`。
|
|
24
|
+
|
|
25
|
+
## [2.2.6] - 2026-11-14
|
|
26
|
+
|
|
27
|
+
- Fixes 搜索本对话: the walk covered only the viewport and stopped counting at six matches, so a
|
|
28
|
+
word that was merely scrolled out of sight reported `0/0` and the feature looked broken. It now
|
|
29
|
+
searches the whole rendered conversation, counts every match, and wraps up to 120 of them (the cap
|
|
30
|
+
bounds paint cost, not the count). The count shows `…` while a query is still being applied.
|
|
31
|
+
- The search settles 1s after the last keystroke instead of 3s (the 3s delay was the second half of
|
|
32
|
+
the same complaint: typing appeared to do nothing). Enter searches immediately.
|
|
33
|
+
- Marks persist after the query settles, so the scroll-triggered repaint is gone; it was re-walking
|
|
34
|
+
the tree on every scroll for a result that no longer needed refreshing.
|
|
35
|
+
- Tests: the client-bundle suite now asserts the 1s settle, the whole-conversation walk (no
|
|
36
|
+
viewport rect filter) and the count-every-match cap, instead of the old 3s/six-mark contract.
|
|
37
|
+
|
|
7
38
|
## [2.2.5] - 2026-11-12
|
|
8
39
|
|
|
9
40
|
- DSH 0.2.2-rc.2 compatibility, measured rather than assumed: the packed artifact mounts and boots in
|
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ w2m-mcp --help
|
|
|
93
93
|
# 复制进你自己的 codex/mcp-servers.toml
|
|
94
94
|
[mcp_servers.w2m]
|
|
95
95
|
command = "npx"
|
|
96
|
-
args = ["-y", "@twinsearth/w2m-dsh-plugin@
|
|
96
|
+
args = ["-y", "@twinsearth/w2m-dsh-plugin@3.0.0", "w2m-mcp"]
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
### 5. 验证过的 release tarball(离线)
|
package/codex/mcp-servers.toml
CHANGED
package/cordis.patch.yml
CHANGED
|
@@ -144,4 +144,15 @@
|
|
|
144
144
|
whitelist: []
|
|
145
145
|
blacklist: []
|
|
146
146
|
pendingTimeoutMs: 120000
|
|
147
|
+
# 决策中心 (decision center): the human-attention queue, ported from dsh-Fang's
|
|
148
|
+
# `@twinsearth/dsh-fang/decision-center` in v3.0.0.
|
|
149
|
+
#
|
|
150
|
+
# Unlike GPW this row is **enabled**: the center is the panel the GPW settings page and the GPW
|
|
151
|
+
# floating launcher render (read through /api/w2m/decisions), and a disabled row would leave that
|
|
152
|
+
# panel with no store behind it. The store lives under $DSH_HOME/xclient/decision-center; the row
|
|
153
|
+
# carries no config so the plugin's own defaults apply.
|
|
154
|
+
- id: dsh-decision-center
|
|
155
|
+
name: '@twinsearth/w2m-dsh-plugin/decision-center'
|
|
156
|
+
config: {}
|
|
157
|
+
|
|
147
158
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.2.6",
|
|
3
|
+
"case": "RELEASE-2260",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": ["b53a37f4-1464-4512-9259-33a7df091e3e"],
|
|
6
|
+
"executed_at": "2026-11-13T19:13:00Z",
|
|
7
|
+
"executor": "lead",
|
|
8
|
+
"task_id": "pre-release-2260",
|
|
9
|
+
"requirement": "v2.2.6: the in-conversation search (search conversation, the header magnifier) counts matches across the whole rendered conversation instead of only the viewport, and starts searching 1s after the last keystroke instead of 3s",
|
|
10
|
+
"commands": [
|
|
11
|
+
["node", "--check", "lib/client.js"],
|
|
12
|
+
["node", "--test", "--test-force-exit", "test/client-bundle.test.mjs"],
|
|
13
|
+
["node", "--test", "--test-force-exit", "test/loader-entry.test.mjs"],
|
|
14
|
+
["node", "scripts/pack.mjs"]
|
|
15
|
+
],
|
|
16
|
+
"result": {
|
|
17
|
+
"outcome": "ok",
|
|
18
|
+
"assertions": {
|
|
19
|
+
"defect": "the walk was restricted to the current viewport and stopped counting at six matches, so a word that existed only above the fold reported 0/0 and the feature looked broken; the 3s settle made every query look ignored for its first three seconds",
|
|
20
|
+
"scope": "paintSearchMarks walks every text node under the conversation region (falling back to document.body), counts every occurrence, and wraps at most 120 of them -- the cap bounds paint cost, not the count; counting stops at 2000 matches so a one-letter query cannot stall the UI",
|
|
21
|
+
"settle": "the query settles 1000ms after the last keystroke (was 3000ms); Enter searches immediately; the count shows an ellipsis while the query is still being applied",
|
|
22
|
+
"no_viewport_filter": "the viewport rectangle filter and its six-match ceiling are gone, and the scroll-triggered repaint is removed because marks persist once painted",
|
|
23
|
+
"syntax": "node --check lib/client.js: exit 0",
|
|
24
|
+
"client_bundle": "test/client-bundle.test.mjs 36/36, including the rewritten contract assertions: 1s settle string, no viewportRect, count is ranges.length, no scroll repaint",
|
|
25
|
+
"loader": "test/loader-entry.test.mjs 10/10 against the packed 2.2.6 artifact in the real DSH 0.2.2-rc.3 desktop runtime (entry active, tools registered, client half composed and activated, page booted in a browser)",
|
|
26
|
+
"installed": "the packed 2.2.6 tarball is installed in this machine's desktop profile from ~/.dsh/w2m/w2m-dsh-plugin-2.2.6.tgz, so the running app serves the fixed client bundle on its next page load",
|
|
27
|
+
"artifact": "scripts/pack.mjs packs dist/twinsearth-w2m-dsh-plugin-2.2.6.tgz deterministically from this tree; the published sha256 is the one release.yml writes into dist/SHA256SUMS beside the tarball, so the record does not restate the hash of the archive that contains it"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "3.0.0",
|
|
3
|
+
"case": "RELEASE-3000",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-11-13T20:45:02.369Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-3000",
|
|
11
|
+
"requirement": "v3.0.0: the Fang decision center is ported to W2M as @twinsearth/w2m-dsh-plugin/decision-center (host module, /api/w2m/decisions* routes, GPW page and GPW launcher panel) and its ported test suites pass",
|
|
12
|
+
"commands": [
|
|
13
|
+
[
|
|
14
|
+
"node",
|
|
15
|
+
"--test",
|
|
16
|
+
"--test-force-exit",
|
|
17
|
+
"test/decision-center.test.mjs",
|
|
18
|
+
"test/decision-routes.test.mjs",
|
|
19
|
+
"test/decision-outbox.test.mjs"
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
"node",
|
|
23
|
+
"--test",
|
|
24
|
+
"--test-force-exit",
|
|
25
|
+
"test/client-bundle.test.mjs"
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
"node",
|
|
29
|
+
"--test",
|
|
30
|
+
"--test-force-exit",
|
|
31
|
+
"test/loader-entry.test.mjs"
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"node",
|
|
35
|
+
"scripts/pack.mjs"
|
|
36
|
+
]
|
|
37
|
+
],
|
|
38
|
+
"result": {
|
|
39
|
+
"outcome": "ok",
|
|
40
|
+
"assertions": {
|
|
41
|
+
"port": "src/decision-center/index.mjs is the Fang plugin module with import specifiers and plugin name rewritten only; its four lib dependencies and the MIT schemastery are copied verbatim, and kernel.mjs supplies the Fang kernel names on W2M paths",
|
|
42
|
+
"service": "apply() provides w2mDecisionCenter with 27 methods and registers request_human_decision / ask_user_choice",
|
|
43
|
+
"routes": "14 /api/w2m/decisions* routes registered on the connection fetch registry, including the SSE events and AI advise streams; create -> list -> item -> quota -> resolve round-trips through HTTP",
|
|
44
|
+
"panel": "DecisionCenterSection renders inside GpwPanel, which GpwSettingsPage embeds and GpwLauncher opens, so the GPW page and the GPW launcher show the same center; it polls every 4s, groups by level, resolves via the ticket options, abandons option-less tickets and only displays AI advice",
|
|
45
|
+
"suites": "82 tests pass: 79 ported Fang decision-center tests + 3 new route-surface tests; the outbox suite is wired separately in scripts.test. The ported Node-SSE helper (decision-events) and fork-side ingest client were not carried over: W2M's routes stream through fetch and it has no fork half",
|
|
46
|
+
"not_ported": "decision-center-fork and decision-action-integration stay out: the former needs the Fang mother/fork role matrix, the latter the Fang orchestrator and restart-guard, neither of which exists in W2M",
|
|
47
|
+
"artifact": "scripts/pack.mjs packs dist/twinsearth-w2m-dsh-plugin-3.0.0.tgz deterministically; the published sha256 is the one release.yml writes into dist/SHA256SUMS, so the record does not restate the hash of the archive that contains it"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -488,6 +488,14 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
488
488
|
'.w2mGpw_tag[data-d="deny"]{border-color:var(--dsw-alias-state-danger-primary);color:var(--dsw-alias-state-danger-primary)}',
|
|
489
489
|
'.w2mGpw_note{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
490
490
|
'.w2mGpw_hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
491
|
+
'.w2mDc_level{display:flex;flex-direction:column;gap:4px}',
|
|
492
|
+
'.w2mDc_ticket{display:flex;flex-direction:column;gap:4px;padding:5px 7px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-default);background:var(--dsw-alias-bg-layer-2)}',
|
|
493
|
+
'.w2mDc_head{display:flex;align-items:center;gap:6px;min-width:0}',
|
|
494
|
+
'.w2mDc_title{flex:1;min-width:0;font-size:12px;line-height:18px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
495
|
+
'.w2mDc_meta{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
496
|
+
'.w2mDc_context{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;max-height:88px;overflow:auto}',
|
|
497
|
+
'.w2mDc_actions{display:flex;flex-wrap:wrap;gap:6px}',
|
|
498
|
+
'.w2mDc_advice{font-size:12px;color:var(--dsw-alias-label-secondary);padding:4px 6px;border-left:2px solid var(--dsw-alias-state-business-primary);white-space:pre-wrap}',
|
|
491
499
|
'.w2mGpw{margin-top:6px;padding-top:6px;border-top:.5px solid var(--dsw-alias-border-default);display:flex;flex-direction:column;gap:4px;max-height:320px;overflow:auto}',
|
|
492
500
|
'.w2mGpw_title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
493
501
|
'.w2mGpw_block{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px}',
|
|
@@ -838,6 +846,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
838
846
|
]),
|
|
839
847
|
])) : []),
|
|
840
848
|
]),
|
|
849
|
+
h(DecisionCenterSection, { key: 'decision-center' }),
|
|
841
850
|
pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
|
|
842
851
|
h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
|
|
843
852
|
...pending.map((item) => h('div', { key: item.id, className: 'w2mGpw_recent' }, [
|
|
@@ -855,6 +864,156 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
855
864
|
]);
|
|
856
865
|
}
|
|
857
866
|
|
|
867
|
+
/**
|
|
868
|
+
* 决策中心(v3.0.0,移植自 dsh-Fang 的决策收件箱):GPW 设置页与 GPW 浮标渲染同一个面板。
|
|
869
|
+
*
|
|
870
|
+
* 数据面就是本包自己的 `/api/w2m/decisions*`(见 src/decision-center/routes.mjs):4 秒轮询,
|
|
871
|
+
* 红票置顶、黄/蓝展开、绿/灰默认折叠;解决/放弃/批量都走服务层,UI 不猜状态。
|
|
872
|
+
* AI 建议只做「推荐」,不替人做决定。
|
|
873
|
+
*/
|
|
874
|
+
function DecisionCenterSection() {
|
|
875
|
+
const LEVELS = [
|
|
876
|
+
['red', '紧急决策', '会阻塞 Agent'],
|
|
877
|
+
['yellow', '建议决策', '影响方向/进度'],
|
|
878
|
+
['blue', '日常建议', '可按推荐处理'],
|
|
879
|
+
['green', '通知与汇报', '默认折叠'],
|
|
880
|
+
['gray', '已归档 / 过期', '只读'],
|
|
881
|
+
];
|
|
882
|
+
const [state, setState] = React.useState({ status: 'loading', items: [], error: '', busy: '', ai: null, advice: {}, collapsed: { green: true, gray: true }, open: true });
|
|
883
|
+
const load = React.useCallback(async () => {
|
|
884
|
+
try {
|
|
885
|
+
const [listResponse, aiResponse] = await Promise.all([
|
|
886
|
+
fetch('/api/w2m/decisions?includeArchived=1', { credentials: 'same-origin' }),
|
|
887
|
+
fetch('/api/w2m/decisions/ai', { credentials: 'same-origin' }).catch(() => null),
|
|
888
|
+
]);
|
|
889
|
+
if (!listResponse.ok) {
|
|
890
|
+
setState((prev) => ({ ...prev, status: listResponse.status === 404 ? 'absent' : 'error', error: 'HTTP ' + listResponse.status }));
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const body = await listResponse.json();
|
|
894
|
+
const aiBody = aiResponse !== null && aiResponse.ok ? await aiResponse.json() : null;
|
|
895
|
+
setState((prev) => ({
|
|
896
|
+
...prev,
|
|
897
|
+
status: 'ready',
|
|
898
|
+
items: Array.isArray(body?.items) ? body.items : [],
|
|
899
|
+
error: '',
|
|
900
|
+
ai: aiBody,
|
|
901
|
+
}));
|
|
902
|
+
} catch (error) {
|
|
903
|
+
setState((prev) => ({ ...prev, status: 'error', error: String(error?.message ?? error) }));
|
|
904
|
+
}
|
|
905
|
+
}, []);
|
|
906
|
+
React.useEffect(() => {
|
|
907
|
+
let alive = true;
|
|
908
|
+
void load();
|
|
909
|
+
const timer = setInterval(() => { if (alive) void load(); }, 4000);
|
|
910
|
+
return () => { alive = false; clearInterval(timer); };
|
|
911
|
+
}, [load]);
|
|
912
|
+
const post = async (path, body) => {
|
|
913
|
+
const response = await fetch(path, {
|
|
914
|
+
method: 'POST',
|
|
915
|
+
credentials: 'same-origin',
|
|
916
|
+
headers: { 'content-type': 'application/json' },
|
|
917
|
+
body: JSON.stringify(body ?? {}),
|
|
918
|
+
});
|
|
919
|
+
const payload = await response.json().catch(() => null);
|
|
920
|
+
if (!response.ok || payload?.ok === false) throw new Error(payload?.error ?? 'HTTP ' + response.status);
|
|
921
|
+
return payload;
|
|
922
|
+
};
|
|
923
|
+
const act = async (kind, ticket, option) => {
|
|
924
|
+
setState((prev) => ({ ...prev, busy: ticket.id, error: '' }));
|
|
925
|
+
try {
|
|
926
|
+
await post('/api/w2m/decisions/' + kind, kind === 'resolve'
|
|
927
|
+
? { id: ticket.id, option, by: 'w2m-gpw' }
|
|
928
|
+
: { id: ticket.id, by: 'w2m-gpw' });
|
|
929
|
+
} catch (error) {
|
|
930
|
+
setState((prev) => ({ ...prev, error: String(error?.message ?? error) }));
|
|
931
|
+
} finally {
|
|
932
|
+
setState((prev) => ({ ...prev, busy: '' }));
|
|
933
|
+
await load();
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
const advise = async (ticket) => {
|
|
937
|
+
setState((prev) => ({ ...prev, busy: 'ai:' + ticket.id, error: '' }));
|
|
938
|
+
try {
|
|
939
|
+
const payload = await post('/api/w2m/decisions/ai/advise', { id: ticket.id });
|
|
940
|
+
const text = payload?.advice?.text ?? payload?.advice ?? payload?.ticket?.advice ?? '(无建议文本)';
|
|
941
|
+
setState((prev) => ({ ...prev, advice: { ...prev.advice, [ticket.id]: typeof text === 'string' ? text : JSON.stringify(text) } }));
|
|
942
|
+
} catch (error) {
|
|
943
|
+
setState((prev) => ({ ...prev, error: String(error?.message ?? error) }));
|
|
944
|
+
} finally {
|
|
945
|
+
setState((prev) => ({ ...prev, busy: '' }));
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
const toggle = (level) => setState((prev) => ({ ...prev, collapsed: { ...prev.collapsed, [level]: !prev.collapsed[level] } }));
|
|
949
|
+
const pending = state.items.filter((item) => item.status === 'pending');
|
|
950
|
+
const title = '决策中心' + (pending.length > 0 ? ' · ' + pending.length + ' 待处理' : '');
|
|
951
|
+
const ticketRow = (ticket) => {
|
|
952
|
+
const options = Array.isArray(ticket.options) ? ticket.options : [];
|
|
953
|
+
return h('div', { key: ticket.id, className: 'w2mDc_ticket' }, [
|
|
954
|
+
h('div', { key: 'head', className: 'w2mDc_head' }, [
|
|
955
|
+
h('span', { key: 'dot', className: 'w2mGpw_dot', 'data-c': ticket.level ?? 'blue' }),
|
|
956
|
+
h('span', { key: 'title', className: 'w2mDc_title', title: ticket.title ?? '' }, ticket.title ?? '(未命名)'),
|
|
957
|
+
h('span', { key: 'meta', className: 'w2mDc_meta' }, (ticket.source?.agentRole ?? ticket.sourceRole ?? '') + (ticket.status !== 'pending' ? ' · ' + ticket.status : '')),
|
|
958
|
+
]),
|
|
959
|
+
ticket.context ? h('div', { key: 'ctx', className: 'w2mDc_context' }, String(ticket.context)) : null,
|
|
960
|
+
h('div', { key: 'actions', className: 'w2mDc_actions' }, [
|
|
961
|
+
...options.slice(0, 4).map((option) => h('button', {
|
|
962
|
+
key: 'opt:' + option.value,
|
|
963
|
+
type: 'button',
|
|
964
|
+
className: 'w2mGpw_btn',
|
|
965
|
+
disabled: state.busy !== '' || ticket.status !== 'pending',
|
|
966
|
+
onClick: () => void act('resolve', ticket, option.value),
|
|
967
|
+
}, option.label ?? option.value)),
|
|
968
|
+
ticket.status === 'pending' ? h('button', {
|
|
969
|
+
key: 'ai',
|
|
970
|
+
type: 'button',
|
|
971
|
+
className: 'w2mGpw_btn',
|
|
972
|
+
disabled: state.busy !== '',
|
|
973
|
+
onClick: () => void advise(ticket),
|
|
974
|
+
}, 'AI 建议') : null,
|
|
975
|
+
ticket.status === 'pending' ? h('button', {
|
|
976
|
+
key: 'abandon',
|
|
977
|
+
type: 'button',
|
|
978
|
+
className: 'w2mGpw_btn',
|
|
979
|
+
disabled: state.busy !== '' || options.length > 0,
|
|
980
|
+
onClick: () => void act('abandon', ticket),
|
|
981
|
+
}, '放弃') : null,
|
|
982
|
+
]),
|
|
983
|
+
state.advice[ticket.id] ? h('div', { key: 'advice', className: 'w2mDc_advice' }, state.advice[ticket.id]) : null,
|
|
984
|
+
]);
|
|
985
|
+
};
|
|
986
|
+
if (state.status === 'absent') {
|
|
987
|
+
return h('div', { className: 'w2mGpw_block' }, [
|
|
988
|
+
h('div', { key: 't', className: 'w2mGpw_title' }, '决策中心'),
|
|
989
|
+
h('div', { key: 'h', className: 'w2mGpw_hint' }, '决策中心插件未启用:在 profile 的 dsh.profile.bundles 或 patch 中启用 @twinsearth/w2m-dsh-plugin/decision-center'),
|
|
990
|
+
]);
|
|
991
|
+
}
|
|
992
|
+
return h('div', { className: 'w2mGpw_block' }, [
|
|
993
|
+
h('div', { key: 'head', className: 'w2mGpw_row' }, [
|
|
994
|
+
h('span', { key: 't', className: 'w2mGpw_title' }, title),
|
|
995
|
+
h('button', { key: 'toggle', type: 'button', className: 'w2mGpw_btn', onClick: () => setState((prev) => ({ ...prev, open: !prev.open })) }, state.open ? '收起' : '展开'),
|
|
996
|
+
h('button', { key: 'reload', type: 'button', className: 'w2mGpw_btn', onClick: () => void load() }, '刷新'),
|
|
997
|
+
]),
|
|
998
|
+
state.error !== '' ? h('div', { key: 'error', className: 'w2mGpw_note' }, '决策中心不可用:' + state.error) : null,
|
|
999
|
+
state.status === 'loading' ? h('div', { key: 'loading', className: 'w2mGpw_hint' }, '正在读取决策队列…') : null,
|
|
1000
|
+
state.open ? h('div', { key: 'levels' }, LEVELS.map(([level, label, hint]) => {
|
|
1001
|
+
const tickets = state.items.filter((item) => (item.level ?? 'blue') === level && (level === 'gray' ? item.status !== 'pending' : true));
|
|
1002
|
+
if (tickets.length === 0) return null;
|
|
1003
|
+
const collapsed = state.collapsed[level] === true;
|
|
1004
|
+
return h('div', { key: level, className: 'w2mDc_level' }, [
|
|
1005
|
+
h('div', { key: 'h', className: 'w2mGpw_row' }, [
|
|
1006
|
+
h('span', { key: 'dot', className: 'w2mGpw_dot', 'data-c': level }),
|
|
1007
|
+
h('span', { key: 'label', className: 'w2mGpw_title' }, label + ' · ' + tickets.length),
|
|
1008
|
+
h('span', { key: 'hint', className: 'w2mGpw_hint' }, hint),
|
|
1009
|
+
h('button', { key: 'fold', type: 'button', className: 'w2mGpw_small', onClick: () => toggle(level) }, collapsed ? '+' : '-'),
|
|
1010
|
+
]),
|
|
1011
|
+
collapsed ? null : h('div', { key: 'list', className: 'w2mDc_level' }, tickets.map(ticketRow)),
|
|
1012
|
+
]);
|
|
1013
|
+
})) : null,
|
|
1014
|
+
]);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
858
1017
|
/** v1.9.4: the left sidebar entry: globe + GPW, between Plugins and Automation. */
|
|
859
1018
|
function GpwSidebarIcon() {
|
|
860
1019
|
return h('button', {
|
|
@@ -1109,12 +1268,22 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
1109
1268
|
* v1.9.11: in-conversation full-text search with visible yellow highlights.
|
|
1110
1269
|
*
|
|
1111
1270
|
* The previous version only called window.find, which the desktop shell does not reliably
|
|
1112
|
-
* surface. This walks the rendered conversation text and registers the matches through
|
|
1113
|
-
*
|
|
1114
|
-
* focused match. Matches stay yellow until the query changes or the box closes.
|
|
1271
|
+
* surface. This walks the rendered conversation text and registers the matches through local
|
|
1272
|
+
* `mark` elements (no CSS Custom Highlight dependency, so a match is always visible), then
|
|
1273
|
+
* scrolls to the focused match. Matches stay yellow until the query changes or the box closes.
|
|
1274
|
+
*
|
|
1275
|
+
* v2.2.6: the walk covers the whole rendered conversation, not just the viewport. The old
|
|
1276
|
+
* viewport-only rule reported `0` for text that was merely scrolled out of sight, and capped the
|
|
1277
|
+
* count at six, which made a matching conversation look unsearchable. The cap now only bounds how
|
|
1278
|
+
* many nodes are wrapped (paint cost), never how many matches are counted.
|
|
1115
1279
|
*/
|
|
1116
1280
|
function searchRoots() {
|
|
1117
1281
|
const roots = [];
|
|
1282
|
+
if (typeof document === 'undefined' || !document.body) return roots;
|
|
1283
|
+
const region = typeof document.querySelector === 'function'
|
|
1284
|
+
? (document.querySelector('[data-conversation-region]') ?? document.querySelector('[data-conversation-session]'))
|
|
1285
|
+
: null;
|
|
1286
|
+
const start = region ?? document.body;
|
|
1118
1287
|
const seen = new Set();
|
|
1119
1288
|
const visit = (node) => {
|
|
1120
1289
|
if (!node || seen.has(node)) return;
|
|
@@ -1125,7 +1294,8 @@ function searchRoots() {
|
|
|
1125
1294
|
for (let i = 0; i < kids.length; i += 1) visit(kids[i]);
|
|
1126
1295
|
}
|
|
1127
1296
|
};
|
|
1128
|
-
|
|
1297
|
+
roots.push(start);
|
|
1298
|
+
visit(start);
|
|
1129
1299
|
return roots;
|
|
1130
1300
|
}
|
|
1131
1301
|
function clearSearchMarks() {
|
|
@@ -1139,28 +1309,25 @@ function clearSearchMarks() {
|
|
|
1139
1309
|
}
|
|
1140
1310
|
}
|
|
1141
1311
|
/**
|
|
1142
|
-
* Search rules (v2.0.5 UI): a pure number up to 100 is not a useful query in a
|
|
1143
|
-
* ids/ports/timestamps, so it is refused outright (no marks, no scroll, no
|
|
1144
|
-
*
|
|
1312
|
+
* Search rules (v2.0.5 UI, revised in v2.2.6): a pure number up to 100 is not a useful query in a
|
|
1313
|
+
* conversation full of ids/ports/timestamps, so it is refused outright (no marks, no scroll, no
|
|
1314
|
+
* count). Every rendered match is counted; only the first {@link SEARCH_MAX_MARKS} are wrapped, so a
|
|
1315
|
+
* long conversation stays responsive without making a match outside the viewport invisible.
|
|
1145
1316
|
*/
|
|
1146
|
-
const SEARCH_MAX_MARKS =
|
|
1317
|
+
const SEARCH_MAX_MARKS = 120;
|
|
1318
|
+
/** Hard ceiling on counted matches, so a one-letter query in a huge conversation cannot stall the UI. */
|
|
1319
|
+
const SEARCH_MAX_COUNT = 2000;
|
|
1147
1320
|
function searchQueryAllowed(query) {
|
|
1148
1321
|
const text = String(query ?? '').trim();
|
|
1149
1322
|
if (text === '') return false;
|
|
1150
1323
|
if (/^\d+$/.test(text)) return Number(text) > 100;
|
|
1151
1324
|
return true;
|
|
1152
1325
|
}
|
|
1153
|
-
function viewportRect() {
|
|
1154
|
-
const width = typeof window !== 'undefined' ? window.innerWidth : 1024;
|
|
1155
|
-
const height = typeof window !== 'undefined' ? window.innerHeight : 768;
|
|
1156
|
-
return { left: 0, top: 0, right: width, bottom: height };
|
|
1157
|
-
}
|
|
1158
1326
|
function paintSearchMarks(query) {
|
|
1159
1327
|
clearSearchMarks();
|
|
1160
1328
|
if (typeof document === 'undefined' || query === '' || !searchQueryAllowed(query)) return 0;
|
|
1161
1329
|
const needle = query.toLowerCase();
|
|
1162
|
-
const
|
|
1163
|
-
let total = 0;
|
|
1330
|
+
const ranges = [];
|
|
1164
1331
|
outer: for (const root of searchRoots()) {
|
|
1165
1332
|
let walker;
|
|
1166
1333
|
try {
|
|
@@ -1176,37 +1343,37 @@ function paintSearchMarks(query) {
|
|
|
1176
1343
|
},
|
|
1177
1344
|
});
|
|
1178
1345
|
} catch { continue; }
|
|
1179
|
-
const nodes = [];
|
|
1180
1346
|
let node = walker.nextNode();
|
|
1181
|
-
while (node !== null) {
|
|
1182
|
-
|
|
1183
|
-
// Cheap pre-filter: a text node whose element is entirely outside the viewport cannot paint.
|
|
1184
|
-
const host = textNode.parentElement;
|
|
1185
|
-
const hostRect = host !== null && typeof host.getBoundingClientRect === 'function' ? host.getBoundingClientRect() : null;
|
|
1186
|
-
if (hostRect !== null && (hostRect.bottom < view.top || hostRect.top > view.bottom)) continue;
|
|
1187
|
-
const text = textNode.nodeValue ?? '';
|
|
1347
|
+
while (node !== null) {
|
|
1348
|
+
const text = node.nodeValue ?? '';
|
|
1188
1349
|
const lower = text.toLowerCase();
|
|
1189
|
-
const hits = [];
|
|
1190
1350
|
let index = lower.indexOf(needle);
|
|
1191
|
-
while (index >= 0) {
|
|
1192
|
-
for (const hit of hits) {
|
|
1351
|
+
while (index >= 0) {
|
|
1193
1352
|
try {
|
|
1194
1353
|
const range = document.createRange();
|
|
1195
|
-
range.setStart(
|
|
1196
|
-
range.setEnd(
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
mark.setAttribute('style', 'background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)');
|
|
1202
|
-
range.surroundContents(mark);
|
|
1203
|
-
total += 1;
|
|
1204
|
-
if (total >= SEARCH_MAX_MARKS) break outer;
|
|
1205
|
-
} catch { /* a range that crosses elements cannot be wrapped; skip it */ }
|
|
1354
|
+
range.setStart(node, index);
|
|
1355
|
+
range.setEnd(node, index + query.length);
|
|
1356
|
+
ranges.push(range);
|
|
1357
|
+
} catch { /* an unselectable node cannot carry a match */ }
|
|
1358
|
+
if (ranges.length >= SEARCH_MAX_COUNT) break outer;
|
|
1359
|
+
index = lower.indexOf(needle, index + query.length);
|
|
1206
1360
|
}
|
|
1361
|
+
node = walker.nextNode();
|
|
1207
1362
|
}
|
|
1208
1363
|
}
|
|
1209
|
-
|
|
1364
|
+
// Wrap from the end so the offsets of the matches still ahead of us stay valid: wrapping one range
|
|
1365
|
+
// splits its text node, and a split above an earlier range cannot move that range.
|
|
1366
|
+
const wrapCount = Math.min(ranges.length, SEARCH_MAX_MARKS);
|
|
1367
|
+
for (let i = wrapCount - 1; i >= 0; i -= 1) {
|
|
1368
|
+
const range = ranges[i];
|
|
1369
|
+
try {
|
|
1370
|
+
const mark = document.createElement('mark');
|
|
1371
|
+
mark.className = 'w2mSearchMark';
|
|
1372
|
+
mark.setAttribute('style', 'background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)');
|
|
1373
|
+
range.surroundContents(mark);
|
|
1374
|
+
} catch { /* a range that crosses elements cannot be wrapped; it stays counted */ }
|
|
1375
|
+
}
|
|
1376
|
+
return ranges.length;
|
|
1210
1377
|
}
|
|
1211
1378
|
/**
|
|
1212
1379
|
* Probe-scroll: assign a tiny scrollTop and see whether the browser keeps it.
|
|
@@ -1440,25 +1607,27 @@ function ConversationSearch() {
|
|
|
1440
1607
|
const [query, setQuery] = React.useState('');
|
|
1441
1608
|
const [count, setCount] = React.useState(0);
|
|
1442
1609
|
const [cursor, setCursor] = React.useState(-1);
|
|
1610
|
+
const [pending, setPending] = React.useState(false);
|
|
1443
1611
|
const inputRef = React.useRef(null);
|
|
1444
1612
|
React.useEffect(() => { if (open && inputRef.current && inputRef.current.focus) inputRef.current.focus(); }, [open]);
|
|
1445
|
-
// v2.
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1613
|
+
// v2.2.6: wait 1s after the last keystroke before walking the conversation (the v2.0.5 delay was
|
|
1614
|
+
// 3s, which read as "search does nothing" and was the reported complaint). One pass per settled
|
|
1615
|
+
// query is still what keeps typing cheap; the walk now covers the whole conversation and counts
|
|
1616
|
+
// every match, so a hit outside the current viewport is no longer reported as zero.
|
|
1449
1617
|
const appliedRef = React.useRef('');
|
|
1450
1618
|
const paintRef = React.useRef(() => {});
|
|
1451
1619
|
paintRef.current = (focus = false) => {
|
|
1452
1620
|
const total = paintSearchMarks(appliedRef.current);
|
|
1453
1621
|
setCount(total);
|
|
1454
1622
|
setCursor(total > 0 ? 0 : -1);
|
|
1623
|
+
setPending(false);
|
|
1455
1624
|
if (focus === true) {
|
|
1456
1625
|
const first = document.querySelector('mark.w2mSearchMark');
|
|
1457
1626
|
if (first && first.scrollIntoView) first.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
1458
1627
|
}
|
|
1459
1628
|
};
|
|
1460
1629
|
React.useEffect(() => {
|
|
1461
|
-
if (!open) { appliedRef.current = ''; clearSearchMarks(); setCount(0); setCursor(-1); }
|
|
1630
|
+
if (!open) { appliedRef.current = ''; clearSearchMarks(); setCount(0); setCursor(-1); setPending(false); }
|
|
1462
1631
|
}, [open]);
|
|
1463
1632
|
React.useEffect(() => {
|
|
1464
1633
|
if (!open) return undefined;
|
|
@@ -1468,25 +1637,13 @@ function ConversationSearch() {
|
|
|
1468
1637
|
clearSearchMarks();
|
|
1469
1638
|
setCount(0);
|
|
1470
1639
|
setCursor(-1);
|
|
1640
|
+
setPending(false);
|
|
1471
1641
|
return undefined;
|
|
1472
1642
|
}
|
|
1473
|
-
|
|
1643
|
+
setPending(true);
|
|
1644
|
+
const timer = setTimeout(() => { appliedRef.current = query; paintRef.current(true); }, 1000);
|
|
1474
1645
|
return () => clearTimeout(timer);
|
|
1475
1646
|
}, [open, query]);
|
|
1476
|
-
React.useEffect(() => {
|
|
1477
|
-
if (!open) return undefined;
|
|
1478
|
-
let timer = null;
|
|
1479
|
-
const onScroll = () => {
|
|
1480
|
-
if (appliedRef.current === '') return;
|
|
1481
|
-
if (timer !== null) clearTimeout(timer);
|
|
1482
|
-
timer = setTimeout(() => { paintRef.current(false); }, 150);
|
|
1483
|
-
};
|
|
1484
|
-
window.addEventListener('scroll', onScroll, true);
|
|
1485
|
-
return () => {
|
|
1486
|
-
if (timer !== null) clearTimeout(timer);
|
|
1487
|
-
window.removeEventListener('scroll', onScroll, true);
|
|
1488
|
-
};
|
|
1489
|
-
}, [open]);
|
|
1490
1647
|
const step = (backwards) => {
|
|
1491
1648
|
if (appliedRef.current === '') return;
|
|
1492
1649
|
let marks = [...document.querySelectorAll('mark.w2mSearchMark')];
|
|
@@ -1519,12 +1676,14 @@ function ConversationSearch() {
|
|
|
1519
1676
|
onKeyDown: (event) => {
|
|
1520
1677
|
if (event.key === 'Enter') {
|
|
1521
1678
|
event.preventDefault();
|
|
1522
|
-
if (searchQueryAllowed(query)) { appliedRef.current = query; paintRef.current(true);
|
|
1679
|
+
if (searchQueryAllowed(query)) { appliedRef.current = query; paintRef.current(true); }
|
|
1523
1680
|
}
|
|
1524
1681
|
if (event.key === 'Escape') { setOpen(false); setQuery(''); }
|
|
1525
1682
|
},
|
|
1526
1683
|
}),
|
|
1527
|
-
h('span', { key: 'count', className: 'w2mSearchCount' },
|
|
1684
|
+
h('span', { key: 'count', className: 'w2mSearchCount' }, pending
|
|
1685
|
+
? '…'
|
|
1686
|
+
: (count === 0 ? (searchQueryAllowed(query) ? '0' : '') : whenCount(count, cursor))),
|
|
1528
1687
|
h('button', { key: 'prev', type: 'button', className: 'w2mSearchBtn', title: '上一个', onClick: () => step(true) }, '↑'),
|
|
1529
1688
|
h('button', { key: 'next', type: 'button', className: 'w2mSearchBtn', title: '下一个', onClick: () => step(false) }, '↓'),
|
|
1530
1689
|
h('button', { key: 'close', type: 'button', className: 'w2mSearchBtn', title: '关闭搜索', onClick: () => { setOpen(false); setQuery(''); clearSearchMarks(); } }, '×'),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-facing entry for W2M's decision center: `@twinsearth/w2m-dsh-plugin/decision-center`.
|
|
3
|
+
*
|
|
4
|
+
* The documented subpath a profile row names to mount the center. A seam, not a second
|
|
5
|
+
* implementation -- the code lives in `src/decision-center/`, and `mount.mjs` is where the ported
|
|
6
|
+
* plugin module and its HTTP routes become one mountable unit.
|
|
7
|
+
*
|
|
8
|
+
* Like GPW, it is deliberately **not** re-exported from `lib/index.js`: the package root installs by
|
|
9
|
+
* default, and a human-attention queue must not arrive because someone installed a fleet tool.
|
|
10
|
+
*/
|
|
11
|
+
export { apply, inject, name, Config, _internals } from '../src/decision-center/mount.mjs';
|