@twinsearth/w2m-dsh-plugin 1.9.16 → 1.9.19

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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +3 -3
  5. package/bin/w2m-joint.mjs +36 -5
  6. package/bin/w2m-scrub.mjs +78 -0
  7. package/codex/mcp-servers.toml +1 -1
  8. package/docs/acceptance/1.9.17/RELEASE-1917.json +20 -0
  9. package/docs/acceptance/1.9.19/RELEASE-1919.json +21 -0
  10. package/docs/acceptance/2.0.0-beta.1/RELEASE-200B1.json +23 -0
  11. package/docs/acceptance-ops/2026-10-28/AG-SMOKE.json +24 -0
  12. package/docs/acceptance-ops/2026-10-28/G0-3-106.53.197.47.json +35 -0
  13. package/docs/acceptance-ops/2026-10-28/G0-3-202.182.123.154.json +35 -0
  14. package/docs/acceptance-ops/2026-10-28/G0-3-43.142.74.71.json +35 -0
  15. package/docs/acceptance-ops/2026-10-28/R1-OPERATOR-TOKEN.json +19 -0
  16. package/docs/acceptance-ops/2026-10-28/R3-CAPABILITIES.json +51 -0
  17. package/docs/acceptance-ops/2026-10-28/R8-LEDGER-JOURNAL.json +21 -0
  18. package/lib/client.js +474 -368
  19. package/package.json +4 -3
  20. package/plugin.json +1 -1
  21. package/src/agent/agent.mjs +6 -1
  22. package/src/agent/sinks/dir-sink.mjs +59 -13
  23. package/src/plugin/fleet-chat-route.mjs +7 -1
  24. package/src/plugin/joint-dev/audit-file.mjs +70 -37
  25. package/src/plugin/joint-dev/merge-engine.mjs +102 -0
  26. package/src/plugin/joint-dev/plan-store.mjs +117 -32
  27. package/src/plugin/joint-dev/relay-dispatch.mjs +171 -0
  28. package/src/plugin/operator-key.mjs +73 -0
  29. package/src/plugin/operator-token-route.mjs +71 -0
  30. package/src/plugin/tools-joint.mjs +97 -0
  31. package/src/plugin/tools.mjs +8 -3
  32. package/src/relay/fleet-messages.mjs +22 -0
  33. package/src/relay/offer-journal.mjs +71 -0
  34. package/src/relay/p0-projection.mjs +10 -0
  35. package/src/relay/report.mjs +40 -2
  36. package/src/relay/scrub.mjs +94 -81
  37. package/src/relay/server.mjs +2043 -2038
  38. package/src/relay/state.mjs +3351 -3300
  39. package/src/util/artifact-ref.mjs +88 -0
  40. package/src/util/redline-core.mjs +2 -1
package/lib/client.js CHANGED
@@ -493,47 +493,54 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
493
493
  '.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
494
494
  '.w2mChat_unseen{margin-left:6px;min-width:16px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-accent-default);color:var(--dsw-alias-label-on-accent);font-size:10px;line-height:16px;text-align:center}',
495
495
  // v1.6.10: the transcript is selectable text; screenshots get a preview and a copy affordance.
496
- '.w2mChatTab{padding:4px 10px;border-radius:6px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;font:inherit;cursor:pointer}',
497
- '.w2mChat_panelFull{position:fixed;inset:5vh 8vw;width:auto;max-height:90vh;z-index:70}',
498
- '.w2mChat_emojiRow{display:flex;gap:4px;padding:4px 8px;flex-wrap:wrap}',
499
- '.w2mChat_emoji{padding:2px 4px;border:0;background:transparent;font-size:16px;line-height:1;cursor:pointer}',
500
- '.w2mFleetStatus{display:flex;gap:16px;align-items:center;padding:2px 12px;font-size:11px;opacity:.8;white-space:nowrap}',
501
- '.w2mFleetStatusItem{white-space:nowrap}',
502
- '.w2mSearchToggle{height:26px;width:26px;border:0;border-radius:6px;background:transparent;color:inherit;font-size:14px;cursor:pointer}',
503
- '.w2mSearchToggle:hover{background:rgba(127,127,127,.14)}',
504
- '.w2mSearchBox{display:flex;align-items:center;gap:4px;padding:2px 4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-background-secondary)}',
505
- '.w2mSearchInput{height:24px;min-width:220px;border:0;background:transparent;color:inherit;font:inherit;outline:none}',
506
- '.w2mSearchBtn{height:24px;min-width:24px;border:0;border-radius:4px;background:transparent;color:inherit;cursor:pointer}',
507
- '.w2mSearchBtn:hover{background:rgba(127,127,127,.14)}',
508
- '.w2mGpw_admin{padding:24px 32px 48px;box-sizing:border-box;max-width:1400px;margin:0 auto;color:var(--dsw-alias-label-primary);font-size:13px}',
509
- '.w2mGpw_adminHead{margin-bottom:16px}',
510
- '.w2mGpw_adminTitle{margin:0 0 6px;font-size:22px;line-height:30px;font-weight:600}',
511
- '.w2mGpw_adminSub{margin:0;font-size:13px;color:var(--dsw-alias-label-tertiary)}',
512
- '.w2mGpw_adminNote{margin:0 0 12px;padding:8px 12px;border-radius:6px;border:1px solid rgba(220,120,80,.5);background:rgba(220,120,80,.12);font-size:12px}',
513
- '.w2mGpw_adminCard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:14px 16px;margin-bottom:14px;background:var(--dsw-alias-background-secondary)}',
514
- '.w2mGpw_adminToolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
515
- '.w2mGpw_adminInput{height:30px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:transparent;color:inherit;font:inherit;min-width:220px}',
516
- '.w2mGpw_adminBtn{height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:transparent;color:inherit;font:inherit;cursor:pointer}',
517
- '.w2mGpw_adminBtn:hover{background:rgba(127,127,127,.12)}',
518
- '.w2mGpw_adminBtn:disabled{opacity:.5;cursor:default}',
519
- '.w2mGpw_adminTabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}',
520
- '.w2mGpw_adminTab{height:26px;padding:0 10px;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;cursor:pointer;opacity:.75}',
521
- '.w2mGpw_adminTab.is-active{background:rgba(64,132,255,.18);opacity:1;font-weight:600}',
522
- '.w2mGpw_adminTable{width:100%;border-collapse:collapse;font-size:13px}',
523
- '.w2mGpw_adminTable th{text-align:left;font-weight:600;padding:10px 12px;border-bottom:1px solid rgba(127,127,127,.25);white-space:nowrap}',
524
- '.w2mGpw_adminTable td{padding:10px 12px;border-bottom:1px solid rgba(127,127,127,.12);vertical-align:middle}',
525
- '.w2mGpw_adminEmpty{opacity:.7;text-align:center !important;padding:22px 12px !important}',
526
- '.w2mGpw_adminPath{max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
527
- '.w2mGpw_adminBadge{display:inline-block;padding:1px 8px;border-radius:10px;border:1px solid rgba(127,127,127,.35);font-size:12px}',
528
- '.w2mGpw_adminBadge[data-kind="whitelist"]{border-color:rgba(80,180,120,.55);background:rgba(80,180,120,.12)}',
529
- '.w2mGpw_adminBadge[data-kind="blacklist"]{border-color:rgba(220,120,80,.55);background:rgba(220,120,80,.12)}',
530
- '.w2mGpw_sideEntry{justify-content:flex-start;text-align:left;font:inherit}',
531
- '.w2mGpw_mainWrap{height:100%;min-height:0;overflow-y:auto;padding:24px 16px 48px;box-sizing:border-box}',
532
- '.w2mGpw_mainWrap .w2mGpw_inline{max-width:960px;margin:0 auto}',
533
- '.w2mGpw_mainWrap .w2mGpw_card{width:100%;max-width:none;max-height:none;position:static;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-background-secondary)}',
534
- '.w2mGpw_settingsBody .w2mGpw_inline{max-width:960px;margin:0 auto}',
535
- '.w2mChat_view{position:static;inset:auto;width:100%;max-height:none;height:100%;border-radius:0}',
536
- '.w2mChat_view .w2mChat_list{max-height:none;flex:1;min-height:0}',
496
+ '.w2mChatTab{padding:4px 10px;border-radius:6px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;font:inherit;cursor:pointer}',
497
+ '.w2mChat_panelFull{position:fixed;inset:5vh 8vw;width:auto;max-height:90vh;z-index:70}',
498
+ '.w2mChat_sticker{font-size:16px;line-height:1}',
499
+ '.w2mChat_stickerBig{font-size:28px;line-height:1.1}',
500
+ '.w2mChat_emojiRow{display:flex;gap:4px;padding:4px 8px;flex-wrap:wrap}',
501
+ '.w2mChat_emoji{padding:2px 4px;border:0;background:transparent;font-size:16px;line-height:1;cursor:pointer}',
502
+ '.w2mFleetStatus{display:flex;gap:16px;align-items:center;padding:2px 12px;font-size:11px;opacity:.8;white-space:nowrap}',
503
+ '.w2mFleetStatusItem{white-space:nowrap}',
504
+ '.w2mSearchToggle{height:26px;width:26px;border:0;border-radius:6px;background:transparent;color:inherit;font-size:14px;cursor:pointer}',
505
+ '.w2mSearchToggle:hover{background:rgba(127,127,127,.14)}',
506
+ '.w2mSearchBox{display:flex;align-items:center;gap:4px;padding:2px 4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-background-secondary)}',
507
+ '.w2mSearchInput{height:24px;min-width:220px;border:0;background:transparent;color:inherit;font:inherit;outline:none}',
508
+ '.w2mSearchCount{font-size:11px;opacity:.75;min-width:34px;text-align:center}',
509
+ '.w2mSearchControls{display:inline-flex;align-items:center;gap:4px}',
510
+ '.w2mScrollUp{font-size:12px;padding:2px 6px;border:1px solid rgba(128,128,128,.4);border-radius:4px;background:transparent;color:inherit;cursor:pointer}',
511
+ '::highlight(w2m-conversation-search){background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)}',
512
+ '.w2mSearchMark{background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1);border-radius:2px}',
513
+ '.w2mSearchBtn{height:24px;min-width:24px;border:0;border-radius:4px;background:transparent;color:inherit;cursor:pointer}',
514
+ '.w2mSearchBtn:hover{background:rgba(127,127,127,.14)}',
515
+ '.w2mGpw_admin{padding:24px 32px 48px;box-sizing:border-box;max-width:1400px;margin:0 auto;color:var(--dsw-alias-label-primary);font-size:13px}',
516
+ '.w2mGpw_adminHead{margin-bottom:16px}',
517
+ '.w2mGpw_adminTitle{margin:0 0 6px;font-size:22px;line-height:30px;font-weight:600}',
518
+ '.w2mGpw_adminSub{margin:0;font-size:13px;color:var(--dsw-alias-label-tertiary)}',
519
+ '.w2mGpw_adminNote{margin:0 0 12px;padding:8px 12px;border-radius:6px;border:1px solid rgba(220,120,80,.5);background:rgba(220,120,80,.12);font-size:12px}',
520
+ '.w2mGpw_adminCard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:14px 16px;margin-bottom:14px;background:var(--dsw-alias-background-secondary)}',
521
+ '.w2mGpw_adminToolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
522
+ '.w2mGpw_adminInput{height:30px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:transparent;color:inherit;font:inherit;min-width:220px}',
523
+ '.w2mGpw_adminBtn{height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:transparent;color:inherit;font:inherit;cursor:pointer}',
524
+ '.w2mGpw_adminBtn:hover{background:rgba(127,127,127,.12)}',
525
+ '.w2mGpw_adminBtn:disabled{opacity:.5;cursor:default}',
526
+ '.w2mGpw_adminTabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}',
527
+ '.w2mGpw_adminTab{height:26px;padding:0 10px;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;cursor:pointer;opacity:.75}',
528
+ '.w2mGpw_adminTab.is-active{background:rgba(64,132,255,.18);opacity:1;font-weight:600}',
529
+ '.w2mGpw_adminTable{width:100%;border-collapse:collapse;font-size:13px}',
530
+ '.w2mGpw_adminTable th{text-align:left;font-weight:600;padding:10px 12px;border-bottom:1px solid rgba(127,127,127,.25);white-space:nowrap}',
531
+ '.w2mGpw_adminTable td{padding:10px 12px;border-bottom:1px solid rgba(127,127,127,.12);vertical-align:middle}',
532
+ '.w2mGpw_adminEmpty{opacity:.7;text-align:center !important;padding:22px 12px !important}',
533
+ '.w2mGpw_adminPath{max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
534
+ '.w2mGpw_adminBadge{display:inline-block;padding:1px 8px;border-radius:10px;border:1px solid rgba(127,127,127,.35);font-size:12px}',
535
+ '.w2mGpw_adminBadge[data-kind="whitelist"]{border-color:rgba(80,180,120,.55);background:rgba(80,180,120,.12)}',
536
+ '.w2mGpw_adminBadge[data-kind="blacklist"]{border-color:rgba(220,120,80,.55);background:rgba(220,120,80,.12)}',
537
+ '.w2mGpw_sideEntry{justify-content:flex-start;text-align:left;font:inherit}',
538
+ '.w2mGpw_mainWrap{height:100%;min-height:0;overflow-y:auto;padding:24px 16px 48px;box-sizing:border-box}',
539
+ '.w2mGpw_mainWrap .w2mGpw_inline{max-width:960px;margin:0 auto}',
540
+ '.w2mGpw_mainWrap .w2mGpw_card{width:100%;max-width:none;max-height:none;position:static;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-background-secondary)}',
541
+ '.w2mGpw_settingsBody .w2mGpw_inline{max-width:960px;margin:0 auto}',
542
+ '.w2mChat_view{position:static;inset:auto;width:100%;max-height:none;height:100%;border-radius:0}',
543
+ '.w2mChat_view .w2mChat_list{max-height:none;flex:1;min-height:0}',
537
544
  '.w2mGpw_fullPage{position:fixed;inset:0;z-index:80;display:flex;flex-direction:column;background:var(--dsw-alias-background-primary)}',
538
545
  '.w2mGpw_fullHead{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid rgba(127,127,127,.25)}',
539
546
  '.w2mGpw_fullTitle{font-size:14px;font-weight:600}',
@@ -800,6 +807,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
800
807
  },
801
808
  }, [
802
809
  h('span', { key: 'i', className: 'w2mGpw_sideIcon', 'aria-hidden': 'true' }, '🌐'),
810
+ h('span', { key: 'l', className: 'w2mGpw_sideLabel' }, 'GPW'),
803
811
  ]);
804
812
  }
805
813
 
@@ -814,239 +822,322 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
814
822
  ]);
815
823
  }
816
824
 
817
- /** v1.9.6: the GPW page rendered in the main workspace (slot main, key w2m-gpw). */
818
- function GpwMainPage({ t }) {
819
- return h('div', { className: 'w2mGpw_mainWrap' }, h(GpwAdminPage, { t }));
820
- }
821
-
822
- /**
823
- * v1.9.7: the GPW management page, drawn as an admin console (title, filter bar, action
824
- * buttons, table with row actions) rather than the floating popup card. The popup stays for
825
- * the floating launcher; this page is what the sidebar row and the Settings tab open.
826
- */
827
- function GpwAdminPage({ sessionId }) {
828
- const [state, setState] = React.useState(null);
829
- const [error, setError] = React.useState(null);
830
- const [busy, setBusy] = React.useState(false);
831
- const [tab, setTab] = React.useState('all');
832
- const [query, setQuery] = React.useState('');
833
- const [manual, setManual] = React.useState('');
834
- const call = async (url, init) => {
835
- try {
836
- const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }) }, ...init });
837
- let body = null;
838
- try { body = await response.json(); } catch { body = null; }
839
- if (response.status === 404) return { ok: false, routeMissing: true, error: 'GPW route missing' };
840
- if (response.ok !== true || body?.ok === false) return { ok: false, error: body?.error ?? ('HTTP ' + response.status) };
841
- return { ok: true, body };
842
- } catch (err) {
843
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
844
- }
845
- };
846
- const load = React.useCallback(async () => {
847
- const result = await call('/api/w2m/gpw/state', { method: 'GET' });
848
- if (result.ok) { setState(result.body); setError(null); } else setError(result.routeMissing ? 'GPW 未启用(在插件页开启 dsh-gpw)' : (result.error ?? 'error'));
849
- }, []);
850
- React.useEffect(() => {
851
- load();
852
- const timer = setInterval(load, 3000);
853
- return () => clearInterval(timer);
854
- }, [load]);
855
- const add = async (list, value) => {
856
- if (value.trim() === '') return;
857
- setBusy(true);
858
- try {
859
- const result = await call('/api/w2m/gpw/folders', { method: 'POST', body: JSON.stringify({ action: 'add', list, path: value.trim() }) });
860
- if (result.ok) { setManual(''); await load(); } else setError(result.error ?? 'error');
861
- } finally { setBusy(false); }
862
- };
863
- const remove = async (list, value) => {
864
- setBusy(true);
865
- try { await call('/api/w2m/gpw/folders', { method: 'POST', body: JSON.stringify({ action: 'remove', list, path: value }) }); await load(); } finally { setBusy(false); }
866
- };
867
- const decide = async (id, decision) => {
868
- setBusy(true);
869
- try { await call('/api/w2m/gpw/decide', { method: 'POST', body: JSON.stringify({ id, decision }) }); await load(); } finally { setBusy(false); }
870
- };
871
- const folders = state?.folders ?? { whitelist: [], blacklist: [] };
872
- const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
873
- const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
874
- const rows = [
875
- ...(Array.isArray(folders.whitelist) ? folders.whitelist : []).map((path) => ({ path, list: 'whitelist' })),
876
- ...(Array.isArray(folders.blacklist) ? folders.blacklist : []).map((path) => ({ path, list: 'blacklist' })),
877
- ].filter((row) => (tab === 'all' || tab === row.list) && (query === '' || row.path.toLowerCase().includes(query.toLowerCase())));
878
- const tabBtn = (key, label) => h('button', {
879
- key,
880
- type: 'button',
881
- className: 'w2mGpw_adminTab' + (tab === key ? ' is-active' : ''),
882
- onClick: () => setTab(key),
883
- }, label);
884
- return h('div', { className: 'w2mGpw_admin' }, [
885
- h('div', { key: 'head', className: 'w2mGpw_adminHead' }, [
886
- h('h1', { key: 'h', className: 'w2mGpw_adminTitle' }, 'GPW 文件策略'),
887
- h('div', { key: 'sub', className: 'w2mGpw_adminSub' }, '白名单放行、黑名单拒绝,名单外的调用进入待审批队列'),
888
- ]),
889
- error === null ? null : h('div', { key: 'err', className: 'w2mGpw_adminNote' }, error),
890
- state?.tampered === true ? h('div', { key: 'tampered', className: 'w2mGpw_adminNote' }, '名单被篡改,已锁定:' + (state?.tampered_reason ?? '')) : null,
891
- h('div', { key: 'toolbar', className: 'w2mGpw_adminCard' }, [
892
- h('div', { key: 'row', className: 'w2mGpw_adminToolbar' }, [
893
- h('input', { key: 'q', className: 'w2mGpw_adminInput', value: query, placeholder: '搜索路径', onChange: (event) => setQuery(event.target.value) }),
894
- h('input', { key: 'm', className: 'w2mGpw_adminInput', value: manual, placeholder: '输入绝对路径', onChange: (event) => setManual(event.target.value) }),
895
- h('button', { key: 'w', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => add('whitelist', manual) }, '添加到白名单'),
896
- h('button', { key: 'b', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => add('blacklist', manual) }, '添加到黑名单'),
897
- h('button', { key: 'r', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => load() }, '刷新'),
898
- ]),
899
- ]),
900
- h('div', { key: 'card', className: 'w2mGpw_adminCard' }, [
901
- h('div', { key: 'tabs', className: 'w2mGpw_adminTabs' }, [tabBtn('all', '全部'), tabBtn('whitelist', '白名单'), tabBtn('blacklist', '黑名单'), tabBtn('pending', '待审批 (' + pending.length + ')')]),
902
- tab === 'pending'
903
- ? h('table', { key: 'ptable', className: 'w2mGpw_adminTable' }, [
904
- h('thead', { key: 'thead' }, h('tr', null, [h('th', { key: 't' }, '工具'), h('th', { key: 'p' }, '路径'), h('th', { key: 's' }, '会话'), h('th', { key: 'a' }, '操作')])),
905
- h('tbody', { key: 'tbody' }, pending.length === 0
906
- ? h('tr', null, h('td', { colSpan: 4, className: 'w2mGpw_adminEmpty' }, '没有待审批的调用'))
907
- : pending.map((item) => h('tr', { key: item.id }, [
908
- h('td', { key: 't' }, item.tool ?? 'tool'),
909
- h('td', { key: 'p', className: 'w2mGpw_adminPath' }, item.path ?? item.folder ?? '-'),
910
- h('td', { key: 's' }, item.session_id ?? '-'),
911
- h('td', { key: 'a' }, [
912
- h('button', { key: 'ok', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => decide(item.id, 'allow') }, '允许'),
913
- ' ',
914
- h('button', { key: 'no', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => decide(item.id, 'deny') }, '拒绝'),
915
- ]),
916
- ]))),
917
- ])
918
- : h('table', { key: 'table', className: 'w2mGpw_adminTable' }, [
919
- h('thead', { key: 'thead' }, h('tr', null, [h('th', { key: 'p' }, '文件夹路径'), h('th', { key: 'k' }, '类型'), h('th', { key: 's' }, '状态'), h('th', { key: 'a' }, '操作')])),
920
- h('tbody', { key: 'tbody' }, rows.length === 0
921
- ? h('tr', null, h('td', { colSpan: 4, className: 'w2mGpw_adminEmpty' }, '暂无记录'))
922
- : rows.map((row) => h('tr', { key: row.list + ':' + row.path }, [
923
- h('td', { key: 'p', className: 'w2mGpw_adminPath', title: row.path }, row.path),
924
- h('td', { key: 'k' }, h('span', { className: 'w2mGpw_adminBadge', 'data-kind': row.list }, row.list === 'whitelist' ? '白名单' : '黑名单')),
925
- h('td', { key: 's' }, '启用'),
926
- h('td', { key: 'a' }, h('button', { key: 'x', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => remove(row.list, row.path) }, '移除')),
927
- ]))),
928
- ]),
929
- ]),
930
- ]);
931
- }
932
-
933
- /**
934
- * v1.9.11: in-conversation full-text search with visible yellow highlights.
935
- *
936
- * The previous version only called window.find, which the desktop shell does not reliably
937
- * surface. This walks the rendered conversation text and registers the matches through the CSS
938
- * Custom Highlight API (no DOM mutation, so React's tree stays intact), then scrolls to the
939
- * focused match. Matches stay yellow until the query changes or the box closes.
940
- */
941
- const SEARCH_HIGHLIGHT = 'w2m-conversation-search';
942
- function clearSearchHighlight() {
943
- try { if (typeof CSS !== 'undefined' && CSS.highlights) CSS.highlights.delete(SEARCH_HIGHLIGHT); } catch { /* unsupported */ }
944
- }
945
- function ConversationSearch() {
946
- const [open, setOpen] = React.useState(false);
947
- const [query, setQuery] = React.useState('');
948
- const [count, setCount] = React.useState(0);
949
- const [cursor, setCursor] = React.useState(-1);
950
- const rangesRef = React.useRef([]);
951
- const inputRef = React.useRef(null);
952
- React.useEffect(() => { if (open && inputRef.current && inputRef.current.focus) inputRef.current.focus(); }, [open]);
953
- const collect = React.useCallback(() => {
954
- if (query === '' || typeof document === 'undefined') return [];
955
- const needle = query.toLowerCase();
956
- const ranges = [];
957
- try {
958
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
959
- acceptNode(node) {
960
- const parent = node.parentElement;
961
- if (parent === null) return NodeFilter.FILTER_REJECT;
962
- const tag = parent.tagName;
963
- if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') return NodeFilter.FILTER_REJECT;
964
- if (parent.closest && parent.closest('[data-w2m-search]') !== null) return NodeFilter.FILTER_REJECT;
965
- return node.nodeValue && node.nodeValue.trim() !== '' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
966
- },
967
- });
968
- let node = walker.nextNode();
969
- while (node !== null && ranges.length < 500) {
970
- const text = node.nodeValue.toLowerCase();
971
- let index = text.indexOf(needle);
972
- while (index >= 0) {
973
- const range = document.createRange();
974
- range.setStart(node, index);
975
- range.setEnd(node, index + query.length);
976
- ranges.push(range);
977
- index = text.indexOf(needle, index + query.length);
978
- if (ranges.length >= 500) break;
979
- }
980
- node = walker.nextNode();
981
- }
982
- } catch { /* finding is best effort */ }
983
- return ranges;
984
- }, [query]);
985
- const paint = React.useCallback(() => {
986
- const ranges = collect();
987
- rangesRef.current = ranges;
988
- setCount(ranges.length);
989
- try {
990
- if (typeof CSS !== 'undefined' && CSS.highlights && typeof Highlight === 'function') {
991
- CSS.highlights.set(SEARCH_HIGHLIGHT, new Highlight(...ranges));
992
- }
993
- } catch { /* older engine: the count still helps */ }
994
- if (ranges.length > 0) {
995
- setCursor(0);
996
- try {
997
- const parent = ranges[0].startContainer.parentElement;
998
- if (parent && parent.scrollIntoView) parent.scrollIntoView({ block: 'center', behavior: 'smooth' });
999
- } catch { /* scroll is best effort */ }
1000
- } else setCursor(-1);
1001
- return ranges.length;
1002
- }, [collect]);
1003
- React.useEffect(() => {
1004
- if (!open) { clearSearchHighlight(); setCount(0); setCursor(-1); return undefined; }
1005
- paint();
1006
- return () => clearSearchHighlight();
1007
- }, [open, query, paint]);
1008
- const step = (backwards) => {
1009
- const ranges = rangesRef.current;
1010
- if (ranges.length === 0) { paint(); return; }
1011
- const next = (cursor + (backwards ? -1 : 1) + ranges.length) % ranges.length;
1012
- setCursor(next);
1013
- try {
1014
- const parent = ranges[next].startContainer.parentElement;
1015
- if (parent && parent.scrollIntoView) parent.scrollIntoView({ block: 'center', behavior: 'smooth' });
1016
- } catch { /* scroll is best effort */ }
1017
- };
1018
- if (!open) {
1019
- return h('button', {
1020
- type: 'button',
1021
- className: 'w2mSearchToggle',
1022
- title: '搜索本对话',
1023
- 'aria-label': '搜索本对话',
1024
- onClick: () => setOpen(true),
1025
- }, '🔍');
1026
- }
1027
- return h('div', { className: 'w2mSearchBox', 'data-w2m-search': 'box' }, [
1028
- h('input', {
1029
- key: 'q',
1030
- ref: inputRef,
1031
- className: 'w2mSearchInput',
1032
- value: query,
1033
- placeholder: '搜索本对话全部内容',
1034
- onChange: (event) => setQuery(event.target.value),
1035
- onKeyDown: (event) => {
1036
- if (event.key === 'Enter') { event.preventDefault(); step(event.shiftKey); }
1037
- if (event.key === 'Escape') { setOpen(false); setQuery(''); }
1038
- },
1039
- }),
1040
- h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (query === '' ? '' : '0') : whenCount(count, cursor)),
1041
- h('button', { key: 'prev', type: 'button', className: 'w2mSearchBtn', title: '上一个', onClick: () => step(true) }, '↑'),
1042
- h('button', { key: 'next', type: 'button', className: 'w2mSearchBtn', title: '下一个', onClick: () => step(false) }, '↓'),
1043
- h('button', { key: 'close', type: 'button', className: 'w2mSearchBtn', title: '关闭搜索', onClick: () => { setOpen(false); setQuery(''); clearSearchHighlight(); } }, '×'),
1044
- ]);
1045
- }
1046
- function whenCount(total, at) {
1047
- return (at >= 0 ? at + 1 : 0) + '/' + total;
825
+ /** v1.9.6: the GPW page rendered in the main workspace (slot main, key w2m-gpw). */
826
+ function GpwMainPage({ t }) {
827
+ return h('div', { className: 'w2mGpw_mainWrap' }, h(GpwAdminPage, { t }));
828
+ }
829
+
830
+ /**
831
+ * v1.9.7: the GPW management page, drawn as an admin console (title, filter bar, action
832
+ * buttons, table with row actions) rather than the floating popup card. The popup stays for
833
+ * the floating launcher; this page is what the sidebar row and the Settings tab open.
834
+ */
835
+ function GpwAdminPage({ sessionId }) {
836
+ const [state, setState] = React.useState(null);
837
+ const [error, setError] = React.useState(null);
838
+ const [busy, setBusy] = React.useState(false);
839
+ const [tab, setTab] = React.useState('all');
840
+ const [query, setQuery] = React.useState('');
841
+ const [manual, setManual] = React.useState('');
842
+ const [tokenInput, setTokenInput] = React.useState('');
843
+ const [tokenState, setTokenState] = React.useState(null);
844
+ const call = async (url, init) => {
845
+ try {
846
+ const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }) }, ...init });
847
+ let body = null;
848
+ try { body = await response.json(); } catch { body = null; }
849
+ if (response.status === 404) return { ok: false, routeMissing: true, error: 'GPW route missing' };
850
+ if (response.ok !== true || body?.ok === false) return { ok: false, error: body?.error ?? ('HTTP ' + response.status) };
851
+ return { ok: true, body };
852
+ } catch (err) {
853
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
854
+ }
855
+ };
856
+ const load = React.useCallback(async () => {
857
+ const result = await call('/api/w2m/gpw/state', { method: 'GET' });
858
+ if (result.ok) { setState(result.body); setError(null); } else setError(result.routeMissing ? 'GPW 未启用(在插件页开启 dsh-gpw)' : (result.error ?? 'error'));
859
+ }, []);
860
+ React.useEffect(() => {
861
+ load();
862
+ const timer = setInterval(load, 3000);
863
+ return () => clearInterval(timer);
864
+ }, [load]);
865
+ const loadToken = React.useCallback(async () => {
866
+ const result = await call('/api/w2m/operator-token', { method: 'GET' });
867
+ if (result.ok) setTokenState(result.body);
868
+ }, []);
869
+ React.useEffect(() => { loadToken(); }, [loadToken]);
870
+ const saveToken = async () => {
871
+ if (tokenInput.trim() === '') return;
872
+ setBusy(true);
873
+ try {
874
+ const result = await call('/api/w2m/operator-token', { method: 'POST', body: JSON.stringify({ token: tokenInput }) });
875
+ if (result.ok) { setTokenInput(''); await loadToken(); } else setError(result.error ?? 'token save failed');
876
+ } finally { setBusy(false); }
877
+ };
878
+ const add = async (list, value) => {
879
+ if (value.trim() === '') return;
880
+ setBusy(true);
881
+ try {
882
+ const result = await call('/api/w2m/gpw/folders', { method: 'POST', body: JSON.stringify({ action: 'add', list, path: value.trim() }) });
883
+ if (result.ok) { setManual(''); await load(); } else setError(result.error ?? 'error');
884
+ } finally { setBusy(false); }
885
+ };
886
+ const remove = async (list, value) => {
887
+ setBusy(true);
888
+ try { await call('/api/w2m/gpw/folders', { method: 'POST', body: JSON.stringify({ action: 'remove', list, path: value }) }); await load(); } finally { setBusy(false); }
889
+ };
890
+ const decide = async (id, decision) => {
891
+ setBusy(true);
892
+ try { await call('/api/w2m/gpw/decide', { method: 'POST', body: JSON.stringify({ id, decision }) }); await load(); } finally { setBusy(false); }
893
+ };
894
+ const folders = state?.folders ?? { whitelist: [], blacklist: [] };
895
+ const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
896
+ const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
897
+ const rows = [
898
+ ...(Array.isArray(folders.whitelist) ? folders.whitelist : []).map((path) => ({ path, list: 'whitelist' })),
899
+ ...(Array.isArray(folders.blacklist) ? folders.blacklist : []).map((path) => ({ path, list: 'blacklist' })),
900
+ ].filter((row) => (tab === 'all' || tab === row.list) && (query === '' || row.path.toLowerCase().includes(query.toLowerCase())));
901
+ const tabBtn = (key, label) => h('button', {
902
+ key,
903
+ type: 'button',
904
+ className: 'w2mGpw_adminTab' + (tab === key ? ' is-active' : ''),
905
+ onClick: () => setTab(key),
906
+ }, label);
907
+ return h('div', { className: 'w2mGpw_admin' }, [
908
+ h('div', { key: 'head', className: 'w2mGpw_adminHead' }, [
909
+ h('h1', { key: 'h', className: 'w2mGpw_adminTitle' }, 'GPW 文件策略'),
910
+ h('div', { key: 'sub', className: 'w2mGpw_adminSub' }, '白名单放行、黑名单拒绝,名单外的调用进入待审批队列'),
911
+ ]),
912
+ error === null ? null : h('div', { key: 'err', className: 'w2mGpw_adminNote' }, error),
913
+ state?.tampered === true ? h('div', { key: 'tampered', className: 'w2mGpw_adminNote' }, '名单被篡改,已锁定:' + (state?.tampered_reason ?? '')) : null,
914
+ h('div', { key: 'token', className: 'w2mGpw_adminCard' }, [
915
+ h('div', { key: 'row', className: 'w2mGpw_adminToolbar' }, [
916
+ h('input', { key: 'i', className: 'w2mGpw_adminInput', type: 'password', value: tokenInput, placeholder: 'relay operator token(保存后只显示指纹)', onChange: (event) => setTokenInput(event.target.value) }),
917
+ h('button', { key: 's', type: 'button', className: 'w2mGpw_adminBtn w2mGpw_tokenSave', disabled: busy, onClick: saveToken }, '保存 token'),
918
+ ]),
919
+ h('div', { key: 'st', className: 'w2mGpw_adminSub' }, tokenState?.configured === true ? ('已配置 · ' + String(tokenState.source ?? '') + ' · ' + String(tokenState.fingerprint ?? '').slice(0, 12)) : '未配置 operator token(保存后写入 0600 文件,只显示指纹)'),
920
+ ]),
921
+ h('div', { key: 'toolbar', className: 'w2mGpw_adminCard' }, [
922
+ h('div', { key: 'row', className: 'w2mGpw_adminToolbar' }, [
923
+ h('input', { key: 'q', className: 'w2mGpw_adminInput', value: query, placeholder: '搜索路径', onChange: (event) => setQuery(event.target.value) }),
924
+ h('input', { key: 'm', className: 'w2mGpw_adminInput', value: manual, placeholder: '输入绝对路径', onChange: (event) => setManual(event.target.value) }),
925
+ h('button', { key: 'w', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => add('whitelist', manual) }, '添加到白名单'),
926
+ h('button', { key: 'b', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => add('blacklist', manual) }, '添加到黑名单'),
927
+ h('button', { key: 'r', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => load() }, '刷新'),
928
+ ]),
929
+ ]),
930
+ h('div', { key: 'card', className: 'w2mGpw_adminCard' }, [
931
+ h('div', { key: 'tabs', className: 'w2mGpw_adminTabs' }, [tabBtn('all', '全部'), tabBtn('whitelist', '白名单'), tabBtn('blacklist', '黑名单'), tabBtn('pending', '待审批 (' + pending.length + ')')]),
932
+ tab === 'pending'
933
+ ? h('table', { key: 'ptable', className: 'w2mGpw_adminTable' }, [
934
+ h('thead', { key: 'thead' }, h('tr', null, [h('th', { key: 't' }, '工具'), h('th', { key: 'p' }, '路径'), h('th', { key: 's' }, '会话'), h('th', { key: 'a' }, '操作')])),
935
+ h('tbody', { key: 'tbody' }, pending.length === 0
936
+ ? h('tr', null, h('td', { colSpan: 4, className: 'w2mGpw_adminEmpty' }, '没有待审批的调用'))
937
+ : pending.map((item) => h('tr', { key: item.id }, [
938
+ h('td', { key: 't' }, item.tool ?? 'tool'),
939
+ h('td', { key: 'p', className: 'w2mGpw_adminPath' }, item.path ?? item.folder ?? '-'),
940
+ h('td', { key: 's' }, item.session_id ?? '-'),
941
+ h('td', { key: 'a' }, [
942
+ h('button', { key: 'ok', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => decide(item.id, 'allow') }, '允许'),
943
+ ' ',
944
+ h('button', { key: 'no', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => decide(item.id, 'deny') }, '拒绝'),
945
+ ]),
946
+ ]))),
947
+ ])
948
+ : h('table', { key: 'table', className: 'w2mGpw_adminTable' }, [
949
+ h('thead', { key: 'thead' }, h('tr', null, [h('th', { key: 'p' }, '文件夹路径'), h('th', { key: 'k' }, '类型'), h('th', { key: 's' }, '状态'), h('th', { key: 'a' }, '操作')])),
950
+ h('tbody', { key: 'tbody' }, rows.length === 0
951
+ ? h('tr', null, h('td', { colSpan: 4, className: 'w2mGpw_adminEmpty' }, '暂无记录'))
952
+ : rows.map((row) => h('tr', { key: row.list + ':' + row.path }, [
953
+ h('td', { key: 'p', className: 'w2mGpw_adminPath', title: row.path }, row.path),
954
+ h('td', { key: 'k' }, h('span', { className: 'w2mGpw_adminBadge', 'data-kind': row.list }, row.list === 'whitelist' ? '白名单' : '黑名单')),
955
+ h('td', { key: 's' }, '启用'),
956
+ h('td', { key: 'a' }, h('button', { key: 'x', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => remove(row.list, row.path) }, '移除')),
957
+ ]))),
958
+ ]),
959
+ ]),
960
+ ]);
961
+ }
962
+
963
+ /**
964
+ * v1.9.11: in-conversation full-text search with visible yellow highlights.
965
+ *
966
+ * The previous version only called window.find, which the desktop shell does not reliably
967
+ * surface. This walks the rendered conversation text and registers the matches through the CSS
968
+ * Custom Highlight API (no DOM mutation, so React's tree stays intact), then scrolls to the
969
+ * focused match. Matches stay yellow until the query changes or the box closes.
970
+ */
971
+ function searchRoots() {
972
+ const roots = [];
973
+ const seen = new Set();
974
+ const visit = (node) => {
975
+ if (!node || seen.has(node)) return;
976
+ seen.add(node);
977
+ if (node.nodeType === 1 || node.nodeType === 11) {
978
+ if (node.shadowRoot) { roots.push(node.shadowRoot); visit(node.shadowRoot); }
979
+ const kids = node.children ?? [];
980
+ for (let i = 0; i < kids.length; i += 1) visit(kids[i]);
1048
981
  }
1049
-
982
+ };
983
+ if (typeof document !== 'undefined' && document.body) { roots.push(document.body); visit(document.body); }
984
+ return roots;
985
+ }
986
+ function clearSearchMarks() {
987
+ if (typeof document === 'undefined') return;
988
+ const marks = document.querySelectorAll('mark.w2mSearchMark');
989
+ for (const mark of marks) {
990
+ const parent = mark.parentNode;
991
+ if (parent === null) continue;
992
+ parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark);
993
+ if (parent.normalize) parent.normalize();
994
+ }
995
+ }
996
+ function paintSearchMarks(query) {
997
+ clearSearchMarks();
998
+ if (typeof document === 'undefined' || query === '') return 0;
999
+ const needle = query.toLowerCase();
1000
+ let total = 0;
1001
+ for (const root of searchRoots()) {
1002
+ let walker;
1003
+ try {
1004
+ walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
1005
+ acceptNode(node) {
1006
+ const parent = node.parentElement;
1007
+ if (parent === null) return NodeFilter.FILTER_REJECT;
1008
+ const tag = parent.tagName;
1009
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') return NodeFilter.FILTER_REJECT;
1010
+ if (parent.closest && parent.closest('[data-w2m-search]') !== null) return NodeFilter.FILTER_REJECT;
1011
+ if (parent.closest && parent.closest('mark.w2mSearchMark') !== null) return NodeFilter.FILTER_REJECT;
1012
+ return node.nodeValue && node.nodeValue.trim() !== '' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
1013
+ },
1014
+ });
1015
+ } catch { continue; }
1016
+ const nodes = [];
1017
+ let node = walker.nextNode();
1018
+ while (node !== null) { nodes.push(node); node = walker.nextNode(); }
1019
+ for (const textNode of nodes) {
1020
+ const text = textNode.nodeValue ?? '';
1021
+ const lower = text.toLowerCase();
1022
+ const hits = [];
1023
+ let index = lower.indexOf(needle);
1024
+ while (index >= 0) { hits.push(index); index = lower.indexOf(needle, index + query.length); }
1025
+ for (let k = hits.length - 1; k >= 0; k -= 1) {
1026
+ try {
1027
+ const range = document.createRange();
1028
+ range.setStart(textNode, hits[k]);
1029
+ range.setEnd(textNode, hits[k] + query.length);
1030
+ const mark = document.createElement('mark');
1031
+ mark.className = 'w2mSearchMark';
1032
+ mark.setAttribute('style', 'background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)');
1033
+ range.surroundContents(mark);
1034
+ total += 1;
1035
+ } catch { /* a range that crosses elements cannot be wrapped; skip it */ }
1036
+ }
1037
+ }
1038
+ }
1039
+ return total;
1040
+ }
1041
+ function findScrollSurface() {
1042
+ if (typeof document === 'undefined') return null;
1043
+ let best = null;
1044
+ for (const root of searchRoots()) {
1045
+ let all = [];
1046
+ try { all = root.querySelectorAll ? [...root.querySelectorAll('*')] : []; } catch { continue; }
1047
+ for (const el of all) {
1048
+ if (el.scrollHeight <= el.clientHeight + 40) continue;
1049
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null;
1050
+ const overflow = style === null ? '' : style.overflowY;
1051
+ if (overflow !== 'auto' && overflow !== 'scroll') continue;
1052
+ if (best === null || el.scrollHeight > best.scrollHeight) best = el;
1053
+ }
1054
+ }
1055
+ return best;
1056
+ }
1057
+ function ConversationSearch() {
1058
+ const [open, setOpen] = React.useState(false);
1059
+ const [query, setQuery] = React.useState('');
1060
+ const [count, setCount] = React.useState(0);
1061
+ const [cursor, setCursor] = React.useState(-1);
1062
+ const inputRef = React.useRef(null);
1063
+ const holdRef = React.useRef(null);
1064
+ const longFiredRef = React.useRef(false);
1065
+ React.useEffect(() => { if (open && inputRef.current && inputRef.current.focus) inputRef.current.focus(); }, [open]);
1066
+ React.useEffect(() => {
1067
+ if (!open) { clearSearchMarks(); setCount(0); setCursor(-1); return undefined; }
1068
+ const total = paintSearchMarks(query);
1069
+ setCount(total);
1070
+ setCursor(total > 0 ? 0 : -1);
1071
+ const first = document.querySelector('mark.w2mSearchMark');
1072
+ if (first && first.scrollIntoView) first.scrollIntoView({ block: 'center', behavior: 'smooth' });
1073
+ return () => clearSearchMarks();
1074
+ }, [open, query]);
1075
+ const step = (backwards) => {
1076
+ const marks = [...document.querySelectorAll('mark.w2mSearchMark')];
1077
+ if (marks.length === 0) { paintSearchMarks(query); return; }
1078
+ const next = (cursor + (backwards ? -1 : 1) + marks.length) % marks.length;
1079
+ setCursor(next);
1080
+ if (marks[next] && marks[next].scrollIntoView) marks[next].scrollIntoView({ block: 'center', behavior: 'smooth' });
1081
+ };
1082
+ const scrollTurns = () => {
1083
+ const surface = findScrollSurface();
1084
+ if (surface !== null) surface.scrollBy({ top: -Math.round(Math.max(240, surface.clientHeight * 1.8)), behavior: 'smooth' });
1085
+ else if (typeof window !== 'undefined') window.scrollBy({ top: -Math.round(window.innerHeight * 1.8), behavior: 'smooth' });
1086
+ };
1087
+ const scrollFirstTurn = () => {
1088
+ const surface = findScrollSurface();
1089
+ if (surface !== null) surface.scrollTo({ top: 0, behavior: 'smooth' });
1090
+ else if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
1091
+ };
1092
+ if (!open) {
1093
+ return h('span', { className: 'w2mSearchControls' }, [
1094
+ h('button', {
1095
+ key: 'up',
1096
+ type: 'button',
1097
+ className: 'w2mScrollUp',
1098
+ title: '向上滚动 3 轮(长按 3 秒到第一轮)',
1099
+ 'aria-label': '向上滚动 3 轮,长按 3 秒到第一轮',
1100
+ onMouseDown: () => {
1101
+ longFiredRef.current = false;
1102
+ holdRef.current = setTimeout(() => { longFiredRef.current = true; scrollFirstTurn(); }, 3000);
1103
+ },
1104
+ onMouseUp: () => { if (holdRef.current) { clearTimeout(holdRef.current); holdRef.current = null; } },
1105
+ onMouseLeave: () => { if (holdRef.current) { clearTimeout(holdRef.current); holdRef.current = null; } },
1106
+ onClick: () => { if (longFiredRef.current) { longFiredRef.current = false; return; } scrollTurns(); },
1107
+ }, '⇧3'),
1108
+ h('button', {
1109
+ key: 'find',
1110
+ type: 'button',
1111
+ className: 'w2mSearchToggle',
1112
+ title: '搜索本对话',
1113
+ 'aria-label': '搜索本对话',
1114
+ onClick: () => setOpen(true),
1115
+ }, '🔍'),
1116
+ ]);
1117
+ }
1118
+ return h('div', { className: 'w2mSearchBox', 'data-w2m-search': 'box' }, [
1119
+ h('input', {
1120
+ key: 'q',
1121
+ ref: inputRef,
1122
+ className: 'w2mSearchInput',
1123
+ value: query,
1124
+ placeholder: '搜索本对话全部内容',
1125
+ onChange: (event) => setQuery(event.target.value),
1126
+ onKeyDown: (event) => {
1127
+ if (event.key === 'Enter') { event.preventDefault(); step(event.shiftKey); }
1128
+ if (event.key === 'Escape') { setOpen(false); setQuery(''); }
1129
+ },
1130
+ }),
1131
+ h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (query === '' ? '' : '0') : whenCount(count, cursor)),
1132
+ h('button', { key: 'prev', type: 'button', className: 'w2mSearchBtn', title: '上一个', onClick: () => step(true) }, '↑'),
1133
+ h('button', { key: 'next', type: 'button', className: 'w2mSearchBtn', title: '下一个', onClick: () => step(false) }, '↓'),
1134
+ h('button', { key: 'close', type: 'button', className: 'w2mSearchBtn', title: '关闭搜索', onClick: () => { setOpen(false); setQuery(''); clearSearchMarks(); } }, '×'),
1135
+ ]);
1136
+ }
1137
+ function whenCount(total, at) {
1138
+ return (at >= 0 ? at + 1 : 0) + '/' + total;
1139
+ }
1140
+
1050
1141
  /** v1.9.4: the GPW tab in Settings, between Models and Plugins. */
1051
1142
  function GpwSettingsPage({ t }) {
1052
1143
  return h('div', { className: 'w2mGpw_settingsBody' }, h(GpwPanel, { t, sessionId: undefined, inline: true, onClose: () => {} }));
@@ -1602,46 +1693,61 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1602
1693
  return message.origin === 'session' ? 'session' : 'chat';
1603
1694
  }
1604
1695
 
1605
- /** v1.9.4: the fleet-chat tab in the top bar; opens the full workspace page. */
1606
- function _FleetChatTab() {
1607
- return h('button', {
1608
- type: 'button',
1609
- className: 'w2mChatTab',
1610
- title: '编队聊天',
1611
- onClick: () => { try { window.dispatchEvent(new CustomEvent('w2m-chat-full')); } catch { /* headless */ } },
1612
- }, '编队聊天');
1613
- }
1614
-
1615
- /** v1.9.4: the bottom bar: fleet and server online/total counts, always visible. */
1616
- function FleetStatusBar() {
1617
- const [snapshot, setSnapshot] = React.useState(null);
1618
- React.useEffect(() => {
1619
- let alive = true;
1620
- const load = async () => {
1621
- try {
1622
- const response = await fetch('/api/w2m/target?session_id=__global__', { credentials: 'same-origin', headers: { accept: 'application/json' } });
1623
- const body = response.ok ? await response.json() : null;
1624
- if (alive) setSnapshot(body?.state ?? body ?? null);
1625
- } catch {
1626
- if (alive) setSnapshot(null);
1627
- }
1628
- };
1629
- load();
1630
- const timer = setInterval(load, 5000);
1631
- return () => { alive = false; clearInterval(timer); };
1632
- }, []);
1633
- const fleet = snapshot?.fleet ?? null;
1634
- const server = snapshot?.server ?? null;
1635
- const fleetText = fleet && Number.isFinite(fleet.online) && Number.isFinite(fleet.total) ? fleet.online + '/' + fleet.total : '—';
1636
- const serverText = server && Number.isFinite(server.devices_online) && Number.isFinite(server.devices_total) ? server.devices_online + '/' + server.devices_total : '—';
1637
- return h('div', { className: 'w2mFleetStatus', title: '编队与服务器在线数量' }, [
1638
- h('span', { key: 'f', className: 'w2mFleetStatusItem' }, '本编队在线 ' + fleetText),
1639
- h('span', { key: 's', className: 'w2mFleetStatusItem' }, '全部机器在线 ' + serverText),
1640
- ]);
1641
- }
1642
-
1643
- function FleetChat({ sessionId, viewMode }) {
1644
- const CHAT_EMOJIS = ['👍', '✅', '🚀', '😄', '❤️', '🎉', '🔧', '📌'];
1696
+ /** v1.9.4: the fleet-chat tab in the top bar; opens the full workspace page. */
1697
+ function _FleetChatTab() {
1698
+ return h('button', {
1699
+ type: 'button',
1700
+ className: 'w2mChatTab',
1701
+ title: '编队聊天',
1702
+ onClick: () => { try { window.dispatchEvent(new CustomEvent('w2m-chat-full')); } catch { /* headless */ } },
1703
+ }, '编队聊天');
1704
+ }
1705
+
1706
+ /** v1.9.4: the bottom bar: fleet and server online/total counts, always visible. */
1707
+ function FleetStatusBar() {
1708
+ const [snapshot, setSnapshot] = React.useState(null);
1709
+ React.useEffect(() => {
1710
+ let alive = true;
1711
+ const load = async () => {
1712
+ try {
1713
+ const response = await fetch('/api/w2m/target?session_id=__global__', { credentials: 'same-origin', headers: { accept: 'application/json' } });
1714
+ const body = response.ok ? await response.json() : null;
1715
+ if (alive) setSnapshot(body?.state ?? body ?? null);
1716
+ } catch {
1717
+ if (alive) setSnapshot(null);
1718
+ }
1719
+ };
1720
+ load();
1721
+ const timer = setInterval(load, 5000);
1722
+ return () => { alive = false; clearInterval(timer); };
1723
+ }, []);
1724
+ const fleet = snapshot?.fleet ?? null;
1725
+ const server = snapshot?.server ?? null;
1726
+ const fleetText = fleet && Number.isFinite(fleet.online) && Number.isFinite(fleet.total) ? fleet.online + '/' + fleet.total : '—';
1727
+ const serverText = server && Number.isFinite(server.devices_online) && Number.isFinite(server.devices_total) ? server.devices_online + '/' + server.devices_total : '—';
1728
+ return h('div', { className: 'w2mFleetStatus', title: '编队与服务器在线数量' }, [
1729
+ h('span', { key: 'f', className: 'w2mFleetStatusItem' }, '本编队在线 ' + fleetText),
1730
+ h('span', { key: 's', className: 'w2mFleetStatusItem' }, '全部机器在线 ' + serverText),
1731
+ ]);
1732
+ }
1733
+
1734
+ function FleetChat({ sessionId, viewMode }) {
1735
+ // v1.9.18: chat emotes are local slash commands, never image bytes. The relay only sees text.
1736
+ const CHAT_COMMANDS = ['/微笑', '/火箭', '/烟花'];
1737
+ // v1.9.18: the sticker pack is local to the plugin. Commands travel as text; rendering never requests bytes.
1738
+ const CHAT_STICKER_GLYPHS = { '/微笑': '\u{1F60A}', '/火箭': '\u{1F680}', '/烟花': '\u{1F386}' };
1739
+ const CHAT_STICKER_RE = /(\/微笑|\/火箭|\/烟花)/g;
1740
+ function renderChatText(text) {
1741
+ const raw = String(text ?? '');
1742
+ const whole = CHAT_STICKER_GLYPHS[raw.trim()];
1743
+ if (whole !== undefined) return h('span', { className: 'w2mChat_stickerBig', 'data-sticker': raw.trim(), title: raw.trim() }, whole);
1744
+ const parts = raw.split(CHAT_STICKER_RE);
1745
+ if (parts.length === 1) return raw;
1746
+ return parts.map((part, index) => (CHAT_STICKER_GLYPHS[part] !== undefined
1747
+ ? h('span', { key: 'st' + index, className: 'w2mChat_sticker', 'data-sticker': part, title: part }, CHAT_STICKER_GLYPHS[part])
1748
+ : part));
1749
+ }
1750
+ const CHAT_EMOJIS = CHAT_COMMANDS;
1645
1751
  const [open, setOpen] = React.useState(false);
1646
1752
  const [messages, setMessages] = React.useState([]);
1647
1753
  const [usage, setUsage] = React.useState(null);
@@ -1664,13 +1770,13 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1664
1770
  const [busy, setBusy] = React.useState(false);
1665
1771
  const [localMachineId, setLocalMachineId] = React.useState(null);
1666
1772
  // v1.7.2: screenshot state is clipboard-only (no preview); `toast` is the only visible feedback.
1667
- const [shotBusy, setShotBusy] = React.useState(false);
1668
- const [chatFull, setChatFull] = React.useState(false);
1669
- React.useEffect(() => { if (viewMode === true) setOpen(true); }, [viewMode]);
1670
- React.useEffect(() => {
1671
- const onFull = () => { setChatFull(true); setOpen(true); };
1672
- window.addEventListener('w2m-chat-full', onFull);
1673
- return () => window.removeEventListener('w2m-chat-full', onFull);
1773
+ const [shotBusy, setShotBusy] = React.useState(false);
1774
+ const [chatFull, setChatFull] = React.useState(false);
1775
+ React.useEffect(() => { if (viewMode === true) setOpen(true); }, [viewMode]);
1776
+ React.useEffect(() => {
1777
+ const onFull = () => { setChatFull(true); setOpen(true); };
1778
+ window.addEventListener('w2m-chat-full', onFull);
1779
+ return () => window.removeEventListener('w2m-chat-full', onFull);
1674
1780
  }, []);
1675
1781
  const [toast, setToast] = React.useState(null);
1676
1782
  // v1.6.10: screenshot support (button + Ctrl+P). The blob is previewed and copied to the
@@ -1947,12 +2053,12 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1947
2053
  var kind = messageKind(message, localMachineId);
1948
2054
  return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind, onClick: () => { const selection = typeof window.getSelection === 'function' ? String(window.getSelection() ?? '') : ''; if (selection !== '') return; setReplyTo({ id: message?.message_id ?? null, threadId: message?.thread_id ?? null, text: typeof message?.text === 'string' ? message.text : '' }); } },
1949
2055
  h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : '', h('button', { key: 'copy', type: 'button', className: 'w2mChat_copy', title: '复制这条消息', onClick: (event) => { event.stopPropagation(); copyText(String(message?.text ?? '')); } }, '⧉'), message?.thread_id ? h('span', { className: 'w2mChat_thread' }, '#' + message.thread_id) : null),
1950
- h('div', { className: 'w2mChat_text' }, message?.text ?? ''));
2056
+ h('div', { className: 'w2mChat_text' }, renderChatText(message?.text ?? '')));
1951
2057
  }),
1952
2058
  ].filter((node) => node !== null))),
1953
2059
 
1954
2060
 
1955
- (chatFull || viewMode === true) ? h('div', { className: 'w2mChat_emojiRow' }, CHAT_EMOJIS.map((emoji) => h('button', { key: emoji, type: 'button', className: 'w2mChat_emoji', onClick: () => setText((prev) => prev + emoji) }, emoji))) : null,
2061
+ (chatFull || viewMode === true) ? h('div', { className: 'w2mChat_emojiRow' }, CHAT_EMOJIS.map((emoji) => h('button', { key: emoji, type: 'button', className: 'w2mChat_emoji', onClick: () => setText((prev) => prev + emoji) }, emoji))) : null,
1956
2062
  h('div', { className: 'w2mChat_compose' },
1957
2063
  replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
1958
2064
  h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
@@ -2188,17 +2294,17 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
2188
2294
  } catch (error) {
2189
2295
  console.error('@twinsearth/w2m-dsh-plugin: the sidebar GPW entry was not mounted; the composer and launcher still work', error);
2190
2296
  }
2191
- // v1.9.6: the page the sidebar row switches to; the key must match the panellist id.
2192
- try {
2193
- slots.inject('main', () => slots.register({
2194
- name: 'main',
2195
- key: 'w2m-gpw',
2196
- label: () => 'GPW',
2197
- inject: () => ({ t }),
2198
- }, GpwMainPage));
2199
- } catch (error) {
2200
- console.error('@twinsearth/w2m-dsh-plugin: the GPW main page was not mounted', error);
2201
- }
2297
+ // v1.9.6: the page the sidebar row switches to; the key must match the panellist id.
2298
+ try {
2299
+ slots.inject('main', () => slots.register({
2300
+ name: 'main',
2301
+ key: 'w2m-gpw',
2302
+ label: () => 'GPW',
2303
+ inject: () => ({ t }),
2304
+ }, GpwMainPage));
2305
+ } catch (error) {
2306
+ console.error('@twinsearth/w2m-dsh-plugin: the GPW main page was not mounted', error);
2307
+ }
2202
2308
  // v1.9.3: check-update + auto-update on the plugin settings page.
2203
2309
  try {
2204
2310
  slots.inject('settings.section', () => slots.register({
@@ -2250,41 +2356,41 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
2250
2356
 
2251
2357
 
2252
2358
 
2253
- // v1.9.9: in-conversation search, in the session header next to Pin.
2254
- try {
2255
- slots.inject('conversation.session.header.actions', () => slots.register({
2256
- name: 'conversation.session.header.actions',
2257
- id: 'w2m-conversation-search',
2258
- order: 90,
2259
- label: () => '搜索本对话',
2260
- inject: () => ({}),
2261
- }, ConversationSearch));
2262
- } catch (error) {
2263
- console.error('@twinsearth/w2m-dsh-plugin: the conversation search control was not mounted', error);
2264
- }
2265
- // v1.9.6: fleet chat as a real conversation View tab (after Chat and Rules), rendering the
2266
- // full chat page in the main workspace.
2267
- try {
2268
- slots.inject('conversation.view', () => slots.register({
2269
- name: 'conversation.view',
2270
- id: 'w2m-chat',
2271
- order: 2,
2272
- label: () => '编队群组',
2273
- inject: (sessionId) => ({ sessionId, viewMode: true }),
2274
- }, FleetChat));
2275
- } catch (error) {
2276
- console.error('@twinsearth/w2m-dsh-plugin: the fleet-chat view tab was not mounted', error);
2277
- }
2278
- try {
2279
- slots.inject('shell.bottom', () => slots.register({
2280
- name: 'shell.bottom',
2281
- id: 'w2m-fleet-status',
2282
- label: () => 'W2M 编队状态',
2283
- inject: () => ({}),
2284
- }, FleetStatusBar));
2285
- } catch (error) {
2286
- console.error('@twinsearth/w2m-dsh-plugin: the fleet status bar was not mounted', error);
2287
- }
2359
+ // v1.9.9: in-conversation search, in the session header next to Pin.
2360
+ try {
2361
+ slots.inject('conversation.session.header.actions', () => slots.register({
2362
+ name: 'conversation.session.header.actions',
2363
+ id: 'w2m-conversation-search',
2364
+ order: 90,
2365
+ label: () => '搜索本对话',
2366
+ inject: () => ({}),
2367
+ }, ConversationSearch));
2368
+ } catch (error) {
2369
+ console.error('@twinsearth/w2m-dsh-plugin: the conversation search control was not mounted', error);
2370
+ }
2371
+ // v1.9.6: fleet chat as a real conversation View tab (after Chat and Rules), rendering the
2372
+ // full chat page in the main workspace.
2373
+ try {
2374
+ slots.inject('conversation.view', () => slots.register({
2375
+ name: 'conversation.view',
2376
+ id: 'w2m-chat',
2377
+ order: 2,
2378
+ label: () => '编队群组',
2379
+ inject: (sessionId) => ({ sessionId, viewMode: true }),
2380
+ }, FleetChat));
2381
+ } catch (error) {
2382
+ console.error('@twinsearth/w2m-dsh-plugin: the fleet-chat view tab was not mounted', error);
2383
+ }
2384
+ try {
2385
+ slots.inject('shell.bottom', () => slots.register({
2386
+ name: 'shell.bottom',
2387
+ id: 'w2m-fleet-status',
2388
+ label: () => 'W2M 编队状态',
2389
+ inject: () => ({}),
2390
+ }, FleetStatusBar));
2391
+ } catch (error) {
2392
+ console.error('@twinsearth/w2m-dsh-plugin: the fleet status bar was not mounted', error);
2393
+ }
2288
2394
  // The fleet-chat panel: the same session-scoped seat, one slot after the target control.
2289
2395
  // Wrapped in its own try/catch on purpose — the target control is the primary half of this
2290
2396
  // bundle, and a chat panel that cannot mount must not take the page (or the app) down with it.