@twinsearth/w2m-dsh-plugin 1.9.15 → 1.9.17

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/lib/client.js CHANGED
@@ -493,47 +493,50 @@ 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_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
+ '.w2mSearchCount{font-size:11px;opacity:.75;min-width:34px;text-align:center}',
507
+ '::highlight(w2m-conversation-search){background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)}',
508
+ '.w2mSearchMark{background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1);border-radius:2px}',
509
+ '.w2mSearchBtn{height:24px;min-width:24px;border:0;border-radius:4px;background:transparent;color:inherit;cursor:pointer}',
510
+ '.w2mSearchBtn:hover{background:rgba(127,127,127,.14)}',
511
+ '.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}',
512
+ '.w2mGpw_adminHead{margin-bottom:16px}',
513
+ '.w2mGpw_adminTitle{margin:0 0 6px;font-size:22px;line-height:30px;font-weight:600}',
514
+ '.w2mGpw_adminSub{margin:0;font-size:13px;color:var(--dsw-alias-label-tertiary)}',
515
+ '.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}',
516
+ '.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)}',
517
+ '.w2mGpw_adminToolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
518
+ '.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}',
519
+ '.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}',
520
+ '.w2mGpw_adminBtn:hover{background:rgba(127,127,127,.12)}',
521
+ '.w2mGpw_adminBtn:disabled{opacity:.5;cursor:default}',
522
+ '.w2mGpw_adminTabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}',
523
+ '.w2mGpw_adminTab{height:26px;padding:0 10px;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;cursor:pointer;opacity:.75}',
524
+ '.w2mGpw_adminTab.is-active{background:rgba(64,132,255,.18);opacity:1;font-weight:600}',
525
+ '.w2mGpw_adminTable{width:100%;border-collapse:collapse;font-size:13px}',
526
+ '.w2mGpw_adminTable th{text-align:left;font-weight:600;padding:10px 12px;border-bottom:1px solid rgba(127,127,127,.25);white-space:nowrap}',
527
+ '.w2mGpw_adminTable td{padding:10px 12px;border-bottom:1px solid rgba(127,127,127,.12);vertical-align:middle}',
528
+ '.w2mGpw_adminEmpty{opacity:.7;text-align:center !important;padding:22px 12px !important}',
529
+ '.w2mGpw_adminPath{max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
530
+ '.w2mGpw_adminBadge{display:inline-block;padding:1px 8px;border-radius:10px;border:1px solid rgba(127,127,127,.35);font-size:12px}',
531
+ '.w2mGpw_adminBadge[data-kind="whitelist"]{border-color:rgba(80,180,120,.55);background:rgba(80,180,120,.12)}',
532
+ '.w2mGpw_adminBadge[data-kind="blacklist"]{border-color:rgba(220,120,80,.55);background:rgba(220,120,80,.12)}',
533
+ '.w2mGpw_sideEntry{justify-content:flex-start;text-align:left;font:inherit}',
534
+ '.w2mGpw_mainWrap{height:100%;min-height:0;overflow-y:auto;padding:24px 16px 48px;box-sizing:border-box}',
535
+ '.w2mGpw_mainWrap .w2mGpw_inline{max-width:960px;margin:0 auto}',
536
+ '.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)}',
537
+ '.w2mGpw_settingsBody .w2mGpw_inline{max-width:960px;margin:0 auto}',
538
+ '.w2mChat_view{position:static;inset:auto;width:100%;max-height:none;height:100%;border-radius:0}',
539
+ '.w2mChat_view .w2mChat_list{max-height:none;flex:1;min-height:0}',
537
540
  '.w2mGpw_fullPage{position:fixed;inset:0;z-index:80;display:flex;flex-direction:column;background:var(--dsw-alias-background-primary)}',
538
541
  '.w2mGpw_fullHead{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid rgba(127,127,127,.25)}',
539
542
  '.w2mGpw_fullTitle{font-size:14px;font-weight:600}',
@@ -800,6 +803,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
800
803
  },
801
804
  }, [
802
805
  h('span', { key: 'i', className: 'w2mGpw_sideIcon', 'aria-hidden': 'true' }, '🌐'),
806
+ h('span', { key: 'l', className: 'w2mGpw_sideLabel' }, 'GPW'),
803
807
  ]);
804
808
  }
805
809
 
@@ -814,239 +818,261 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
814
818
  ]);
815
819
  }
816
820
 
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;
1048
- }
1049
-
821
+ /** v1.9.6: the GPW page rendered in the main workspace (slot main, key w2m-gpw). */
822
+ function GpwMainPage({ t }) {
823
+ return h('div', { className: 'w2mGpw_mainWrap' }, h(GpwAdminPage, { t }));
824
+ }
825
+
826
+ /**
827
+ * v1.9.7: the GPW management page, drawn as an admin console (title, filter bar, action
828
+ * buttons, table with row actions) rather than the floating popup card. The popup stays for
829
+ * the floating launcher; this page is what the sidebar row and the Settings tab open.
830
+ */
831
+ function GpwAdminPage({ sessionId }) {
832
+ const [state, setState] = React.useState(null);
833
+ const [error, setError] = React.useState(null);
834
+ const [busy, setBusy] = React.useState(false);
835
+ const [tab, setTab] = React.useState('all');
836
+ const [query, setQuery] = React.useState('');
837
+ const [manual, setManual] = React.useState('');
838
+ const [tokenInput, setTokenInput] = React.useState('');
839
+ const [tokenState, setTokenState] = React.useState(null);
840
+ const call = async (url, init) => {
841
+ try {
842
+ const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }) }, ...init });
843
+ let body = null;
844
+ try { body = await response.json(); } catch { body = null; }
845
+ if (response.status === 404) return { ok: false, routeMissing: true, error: 'GPW route missing' };
846
+ if (response.ok !== true || body?.ok === false) return { ok: false, error: body?.error ?? ('HTTP ' + response.status) };
847
+ return { ok: true, body };
848
+ } catch (err) {
849
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
850
+ }
851
+ };
852
+ const load = React.useCallback(async () => {
853
+ const result = await call('/api/w2m/gpw/state', { method: 'GET' });
854
+ if (result.ok) { setState(result.body); setError(null); } else setError(result.routeMissing ? 'GPW 未启用(在插件页开启 dsh-gpw)' : (result.error ?? 'error'));
855
+ }, []);
856
+ React.useEffect(() => {
857
+ load();
858
+ const timer = setInterval(load, 3000);
859
+ return () => clearInterval(timer);
860
+ }, [load]);
861
+ const loadToken = React.useCallback(async () => {
862
+ const result = await call('/api/w2m/operator-token', { method: 'GET' });
863
+ if (result.ok) setTokenState(result.body);
864
+ }, []);
865
+ React.useEffect(() => { loadToken(); }, [loadToken]);
866
+ const saveToken = async () => {
867
+ if (tokenInput.trim() === '') return;
868
+ setBusy(true);
869
+ try {
870
+ const result = await call('/api/w2m/operator-token', { method: 'POST', body: JSON.stringify({ token: tokenInput }) });
871
+ if (result.ok) { setTokenInput(''); await loadToken(); } else setError(result.error ?? 'token save failed');
872
+ } finally { setBusy(false); }
873
+ };
874
+ const add = async (list, value) => {
875
+ if (value.trim() === '') return;
876
+ setBusy(true);
877
+ try {
878
+ const result = await call('/api/w2m/gpw/folders', { method: 'POST', body: JSON.stringify({ action: 'add', list, path: value.trim() }) });
879
+ if (result.ok) { setManual(''); await load(); } else setError(result.error ?? 'error');
880
+ } finally { setBusy(false); }
881
+ };
882
+ const remove = async (list, value) => {
883
+ setBusy(true);
884
+ try { await call('/api/w2m/gpw/folders', { method: 'POST', body: JSON.stringify({ action: 'remove', list, path: value }) }); await load(); } finally { setBusy(false); }
885
+ };
886
+ const decide = async (id, decision) => {
887
+ setBusy(true);
888
+ try { await call('/api/w2m/gpw/decide', { method: 'POST', body: JSON.stringify({ id, decision }) }); await load(); } finally { setBusy(false); }
889
+ };
890
+ const folders = state?.folders ?? { whitelist: [], blacklist: [] };
891
+ const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
892
+ const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
893
+ const rows = [
894
+ ...(Array.isArray(folders.whitelist) ? folders.whitelist : []).map((path) => ({ path, list: 'whitelist' })),
895
+ ...(Array.isArray(folders.blacklist) ? folders.blacklist : []).map((path) => ({ path, list: 'blacklist' })),
896
+ ].filter((row) => (tab === 'all' || tab === row.list) && (query === '' || row.path.toLowerCase().includes(query.toLowerCase())));
897
+ const tabBtn = (key, label) => h('button', {
898
+ key,
899
+ type: 'button',
900
+ className: 'w2mGpw_adminTab' + (tab === key ? ' is-active' : ''),
901
+ onClick: () => setTab(key),
902
+ }, label);
903
+ return h('div', { className: 'w2mGpw_admin' }, [
904
+ h('div', { key: 'head', className: 'w2mGpw_adminHead' }, [
905
+ h('h1', { key: 'h', className: 'w2mGpw_adminTitle' }, 'GPW 文件策略'),
906
+ h('div', { key: 'sub', className: 'w2mGpw_adminSub' }, '白名单放行、黑名单拒绝,名单外的调用进入待审批队列'),
907
+ ]),
908
+ error === null ? null : h('div', { key: 'err', className: 'w2mGpw_adminNote' }, error),
909
+ state?.tampered === true ? h('div', { key: 'tampered', className: 'w2mGpw_adminNote' }, '名单被篡改,已锁定:' + (state?.tampered_reason ?? '')) : null,
910
+ h('div', { key: 'token', className: 'w2mGpw_adminCard' }, [
911
+ h('div', { key: 'row', className: 'w2mGpw_adminToolbar' }, [
912
+ h('input', { key: 'i', className: 'w2mGpw_adminInput', type: 'password', value: tokenInput, placeholder: 'relay operator token(保存后只显示指纹)', onChange: (event) => setTokenInput(event.target.value) }),
913
+ h('button', { key: 's', type: 'button', className: 'w2mGpw_adminBtn w2mGpw_tokenSave', disabled: busy, onClick: saveToken }, '保存 token'),
914
+ ]),
915
+ h('div', { key: 'st', className: 'w2mGpw_adminSub' }, tokenState?.configured === true ? ('已配置 · ' + String(tokenState.source ?? '') + ' · ' + String(tokenState.fingerprint ?? '').slice(0, 12)) : '未配置 operator token(保存后写入 0600 文件,只显示指纹)'),
916
+ ]),
917
+ h('div', { key: 'toolbar', className: 'w2mGpw_adminCard' }, [
918
+ h('div', { key: 'row', className: 'w2mGpw_adminToolbar' }, [
919
+ h('input', { key: 'q', className: 'w2mGpw_adminInput', value: query, placeholder: '搜索路径', onChange: (event) => setQuery(event.target.value) }),
920
+ h('input', { key: 'm', className: 'w2mGpw_adminInput', value: manual, placeholder: '输入绝对路径', onChange: (event) => setManual(event.target.value) }),
921
+ h('button', { key: 'w', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => add('whitelist', manual) }, '添加到白名单'),
922
+ h('button', { key: 'b', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => add('blacklist', manual) }, '添加到黑名单'),
923
+ h('button', { key: 'r', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => load() }, '刷新'),
924
+ ]),
925
+ ]),
926
+ h('div', { key: 'card', className: 'w2mGpw_adminCard' }, [
927
+ h('div', { key: 'tabs', className: 'w2mGpw_adminTabs' }, [tabBtn('all', '全部'), tabBtn('whitelist', '白名单'), tabBtn('blacklist', '黑名单'), tabBtn('pending', '待审批 (' + pending.length + ')')]),
928
+ tab === 'pending'
929
+ ? h('table', { key: 'ptable', className: 'w2mGpw_adminTable' }, [
930
+ h('thead', { key: 'thead' }, h('tr', null, [h('th', { key: 't' }, '工具'), h('th', { key: 'p' }, '路径'), h('th', { key: 's' }, '会话'), h('th', { key: 'a' }, '操作')])),
931
+ h('tbody', { key: 'tbody' }, pending.length === 0
932
+ ? h('tr', null, h('td', { colSpan: 4, className: 'w2mGpw_adminEmpty' }, '没有待审批的调用'))
933
+ : pending.map((item) => h('tr', { key: item.id }, [
934
+ h('td', { key: 't' }, item.tool ?? 'tool'),
935
+ h('td', { key: 'p', className: 'w2mGpw_adminPath' }, item.path ?? item.folder ?? '-'),
936
+ h('td', { key: 's' }, item.session_id ?? '-'),
937
+ h('td', { key: 'a' }, [
938
+ h('button', { key: 'ok', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => decide(item.id, 'allow') }, '允许'),
939
+ ' ',
940
+ h('button', { key: 'no', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => decide(item.id, 'deny') }, '拒绝'),
941
+ ]),
942
+ ]))),
943
+ ])
944
+ : h('table', { key: 'table', className: 'w2mGpw_adminTable' }, [
945
+ h('thead', { key: 'thead' }, h('tr', null, [h('th', { key: 'p' }, '文件夹路径'), h('th', { key: 'k' }, '类型'), h('th', { key: 's' }, '状态'), h('th', { key: 'a' }, '操作')])),
946
+ h('tbody', { key: 'tbody' }, rows.length === 0
947
+ ? h('tr', null, h('td', { colSpan: 4, className: 'w2mGpw_adminEmpty' }, '暂无记录'))
948
+ : rows.map((row) => h('tr', { key: row.list + ':' + row.path }, [
949
+ h('td', { key: 'p', className: 'w2mGpw_adminPath', title: row.path }, row.path),
950
+ h('td', { key: 'k' }, h('span', { className: 'w2mGpw_adminBadge', 'data-kind': row.list }, row.list === 'whitelist' ? '白名单' : '黑名单')),
951
+ h('td', { key: 's' }, '启用'),
952
+ h('td', { key: 'a' }, h('button', { key: 'x', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: () => remove(row.list, row.path) }, '移除')),
953
+ ]))),
954
+ ]),
955
+ ]),
956
+ ]);
957
+ }
958
+
959
+ /**
960
+ * v1.9.11: in-conversation full-text search with visible yellow highlights.
961
+ *
962
+ * The previous version only called window.find, which the desktop shell does not reliably
963
+ * surface. This walks the rendered conversation text and registers the matches through the CSS
964
+ * Custom Highlight API (no DOM mutation, so React's tree stays intact), then scrolls to the
965
+ * focused match. Matches stay yellow until the query changes or the box closes.
966
+ */
967
+ const SEARCH_HIGHLIGHT = 'w2m-conversation-search';
968
+ function clearSearchHighlight() {
969
+ try { if (typeof CSS !== 'undefined' && CSS.highlights) CSS.highlights.delete(SEARCH_HIGHLIGHT); } catch { /* unsupported */ }
970
+ }
971
+ function ConversationSearch() {
972
+ const [open, setOpen] = React.useState(false);
973
+ const [query, setQuery] = React.useState('');
974
+ const [count, setCount] = React.useState(0);
975
+ const [cursor, setCursor] = React.useState(-1);
976
+ const rangesRef = React.useRef([]);
977
+ const inputRef = React.useRef(null);
978
+ React.useEffect(() => { if (open && inputRef.current && inputRef.current.focus) inputRef.current.focus(); }, [open]);
979
+ const collect = React.useCallback(() => {
980
+ if (query === '' || typeof document === 'undefined') return [];
981
+ const needle = query.toLowerCase();
982
+ const ranges = [];
983
+ try {
984
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
985
+ acceptNode(node) {
986
+ const parent = node.parentElement;
987
+ if (parent === null) return NodeFilter.FILTER_REJECT;
988
+ const tag = parent.tagName;
989
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') return NodeFilter.FILTER_REJECT;
990
+ if (parent.closest && parent.closest('[data-w2m-search]') !== null) return NodeFilter.FILTER_REJECT;
991
+ return node.nodeValue && node.nodeValue.trim() !== '' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
992
+ },
993
+ });
994
+ let node = walker.nextNode();
995
+ while (node !== null && ranges.length < 500) {
996
+ const text = node.nodeValue.toLowerCase();
997
+ let index = text.indexOf(needle);
998
+ while (index >= 0) {
999
+ const range = document.createRange();
1000
+ range.setStart(node, index);
1001
+ range.setEnd(node, index + query.length);
1002
+ ranges.push(range);
1003
+ index = text.indexOf(needle, index + query.length);
1004
+ if (ranges.length >= 500) break;
1005
+ }
1006
+ node = walker.nextNode();
1007
+ }
1008
+ } catch { /* finding is best effort */ }
1009
+ return ranges;
1010
+ }, [query]);
1011
+ const paint = React.useCallback(() => {
1012
+ const ranges = collect();
1013
+ rangesRef.current = ranges;
1014
+ setCount(ranges.length);
1015
+ try {
1016
+ if (typeof CSS !== 'undefined' && CSS.highlights && typeof Highlight === 'function') {
1017
+ CSS.highlights.set(SEARCH_HIGHLIGHT, new Highlight(...ranges));
1018
+ }
1019
+ } catch { /* older engine: the count still helps */ }
1020
+ if (ranges.length > 0) {
1021
+ setCursor(0);
1022
+ try {
1023
+ const parent = ranges[0].startContainer.parentElement;
1024
+ if (parent && parent.scrollIntoView) parent.scrollIntoView({ block: 'center', behavior: 'smooth' });
1025
+ } catch { /* scroll is best effort */ }
1026
+ } else setCursor(-1);
1027
+ return ranges.length;
1028
+ }, [collect]);
1029
+ React.useEffect(() => {
1030
+ if (!open) { clearSearchHighlight(); setCount(0); setCursor(-1); return undefined; }
1031
+ paint();
1032
+ return () => clearSearchHighlight();
1033
+ }, [open, query, paint]);
1034
+ const step = (backwards) => {
1035
+ const ranges = rangesRef.current;
1036
+ if (ranges.length === 0) { paint(); return; }
1037
+ const next = (cursor + (backwards ? -1 : 1) + ranges.length) % ranges.length;
1038
+ setCursor(next);
1039
+ try {
1040
+ const parent = ranges[next].startContainer.parentElement;
1041
+ if (parent && parent.scrollIntoView) parent.scrollIntoView({ block: 'center', behavior: 'smooth' });
1042
+ } catch { /* scroll is best effort */ }
1043
+ };
1044
+ if (!open) {
1045
+ return h('button', {
1046
+ type: 'button',
1047
+ className: 'w2mSearchToggle',
1048
+ title: '搜索本对话',
1049
+ 'aria-label': '搜索本对话',
1050
+ onClick: () => setOpen(true),
1051
+ }, '🔍');
1052
+ }
1053
+ return h('div', { className: 'w2mSearchBox', 'data-w2m-search': 'box' }, [
1054
+ h('input', {
1055
+ key: 'q',
1056
+ ref: inputRef,
1057
+ className: 'w2mSearchInput',
1058
+ value: query,
1059
+ placeholder: '搜索本对话全部内容',
1060
+ onChange: (event) => setQuery(event.target.value),
1061
+ onKeyDown: (event) => {
1062
+ if (event.key === 'Enter') { event.preventDefault(); step(event.shiftKey); }
1063
+ if (event.key === 'Escape') { setOpen(false); setQuery(''); }
1064
+ },
1065
+ }),
1066
+ h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (query === '' ? '' : '0') : whenCount(count, cursor)),
1067
+ h('button', { key: 'prev', type: 'button', className: 'w2mSearchBtn', title: '上一个', onClick: () => step(true) }, '↑'),
1068
+ h('button', { key: 'next', type: 'button', className: 'w2mSearchBtn', title: '下一个', onClick: () => step(false) }, '↓'),
1069
+ h('button', { key: 'close', type: 'button', className: 'w2mSearchBtn', title: '关闭搜索', onClick: () => { setOpen(false); setQuery(''); clearSearchHighlight(); } }, '×'),
1070
+ ]);
1071
+ }
1072
+ function whenCount(total, at) {
1073
+ return (at >= 0 ? at + 1 : 0) + '/' + total;
1074
+ }
1075
+
1050
1076
  /** v1.9.4: the GPW tab in Settings, between Models and Plugins. */
1051
1077
  function GpwSettingsPage({ t }) {
1052
1078
  return h('div', { className: 'w2mGpw_settingsBody' }, h(GpwPanel, { t, sessionId: undefined, inline: true, onClose: () => {} }));
@@ -1602,45 +1628,45 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1602
1628
  return message.origin === 'session' ? 'session' : 'chat';
1603
1629
  }
1604
1630
 
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 }) {
1631
+ /** v1.9.4: the fleet-chat tab in the top bar; opens the full workspace page. */
1632
+ function _FleetChatTab() {
1633
+ return h('button', {
1634
+ type: 'button',
1635
+ className: 'w2mChatTab',
1636
+ title: '编队聊天',
1637
+ onClick: () => { try { window.dispatchEvent(new CustomEvent('w2m-chat-full')); } catch { /* headless */ } },
1638
+ }, '编队聊天');
1639
+ }
1640
+
1641
+ /** v1.9.4: the bottom bar: fleet and server online/total counts, always visible. */
1642
+ function FleetStatusBar() {
1643
+ const [snapshot, setSnapshot] = React.useState(null);
1644
+ React.useEffect(() => {
1645
+ let alive = true;
1646
+ const load = async () => {
1647
+ try {
1648
+ const response = await fetch('/api/w2m/target?session_id=__global__', { credentials: 'same-origin', headers: { accept: 'application/json' } });
1649
+ const body = response.ok ? await response.json() : null;
1650
+ if (alive) setSnapshot(body?.state ?? body ?? null);
1651
+ } catch {
1652
+ if (alive) setSnapshot(null);
1653
+ }
1654
+ };
1655
+ load();
1656
+ const timer = setInterval(load, 5000);
1657
+ return () => { alive = false; clearInterval(timer); };
1658
+ }, []);
1659
+ const fleet = snapshot?.fleet ?? null;
1660
+ const server = snapshot?.server ?? null;
1661
+ const fleetText = fleet && Number.isFinite(fleet.online) && Number.isFinite(fleet.total) ? fleet.online + '/' + fleet.total : '—';
1662
+ const serverText = server && Number.isFinite(server.devices_online) && Number.isFinite(server.devices_total) ? server.devices_online + '/' + server.devices_total : '—';
1663
+ return h('div', { className: 'w2mFleetStatus', title: '编队与服务器在线数量' }, [
1664
+ h('span', { key: 'f', className: 'w2mFleetStatusItem' }, '本编队在线 ' + fleetText),
1665
+ h('span', { key: 's', className: 'w2mFleetStatusItem' }, '全部机器在线 ' + serverText),
1666
+ ]);
1667
+ }
1668
+
1669
+ function FleetChat({ sessionId, viewMode }) {
1644
1670
  const CHAT_EMOJIS = ['👍', '✅', '🚀', '😄', '❤️', '🎉', '🔧', '📌'];
1645
1671
  const [open, setOpen] = React.useState(false);
1646
1672
  const [messages, setMessages] = React.useState([]);
@@ -1664,13 +1690,13 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1664
1690
  const [busy, setBusy] = React.useState(false);
1665
1691
  const [localMachineId, setLocalMachineId] = React.useState(null);
1666
1692
  // 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);
1693
+ const [shotBusy, setShotBusy] = React.useState(false);
1694
+ const [chatFull, setChatFull] = React.useState(false);
1695
+ React.useEffect(() => { if (viewMode === true) setOpen(true); }, [viewMode]);
1696
+ React.useEffect(() => {
1697
+ const onFull = () => { setChatFull(true); setOpen(true); };
1698
+ window.addEventListener('w2m-chat-full', onFull);
1699
+ return () => window.removeEventListener('w2m-chat-full', onFull);
1674
1700
  }, []);
1675
1701
  const [toast, setToast] = React.useState(null);
1676
1702
  // v1.6.10: screenshot support (button + Ctrl+P). The blob is previewed and copied to the
@@ -1952,7 +1978,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1952
1978
  ].filter((node) => node !== null))),
1953
1979
 
1954
1980
 
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,
1981
+ (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
1982
  h('div', { className: 'w2mChat_compose' },
1957
1983
  replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
1958
1984
  h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
@@ -2188,17 +2214,17 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
2188
2214
  } catch (error) {
2189
2215
  console.error('@twinsearth/w2m-dsh-plugin: the sidebar GPW entry was not mounted; the composer and launcher still work', error);
2190
2216
  }
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
- }
2217
+ // v1.9.6: the page the sidebar row switches to; the key must match the panellist id.
2218
+ try {
2219
+ slots.inject('main', () => slots.register({
2220
+ name: 'main',
2221
+ key: 'w2m-gpw',
2222
+ label: () => 'GPW',
2223
+ inject: () => ({ t }),
2224
+ }, GpwMainPage));
2225
+ } catch (error) {
2226
+ console.error('@twinsearth/w2m-dsh-plugin: the GPW main page was not mounted', error);
2227
+ }
2202
2228
  // v1.9.3: check-update + auto-update on the plugin settings page.
2203
2229
  try {
2204
2230
  slots.inject('settings.section', () => slots.register({
@@ -2250,41 +2276,41 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
2250
2276
 
2251
2277
 
2252
2278
 
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
- }
2279
+ // v1.9.9: in-conversation search, in the session header next to Pin.
2280
+ try {
2281
+ slots.inject('conversation.session.header.actions', () => slots.register({
2282
+ name: 'conversation.session.header.actions',
2283
+ id: 'w2m-conversation-search',
2284
+ order: 90,
2285
+ label: () => '搜索本对话',
2286
+ inject: () => ({}),
2287
+ }, ConversationSearch));
2288
+ } catch (error) {
2289
+ console.error('@twinsearth/w2m-dsh-plugin: the conversation search control was not mounted', error);
2290
+ }
2291
+ // v1.9.6: fleet chat as a real conversation View tab (after Chat and Rules), rendering the
2292
+ // full chat page in the main workspace.
2293
+ try {
2294
+ slots.inject('conversation.view', () => slots.register({
2295
+ name: 'conversation.view',
2296
+ id: 'w2m-chat',
2297
+ order: 2,
2298
+ label: () => '编队群组',
2299
+ inject: (sessionId) => ({ sessionId, viewMode: true }),
2300
+ }, FleetChat));
2301
+ } catch (error) {
2302
+ console.error('@twinsearth/w2m-dsh-plugin: the fleet-chat view tab was not mounted', error);
2303
+ }
2304
+ try {
2305
+ slots.inject('shell.bottom', () => slots.register({
2306
+ name: 'shell.bottom',
2307
+ id: 'w2m-fleet-status',
2308
+ label: () => 'W2M 编队状态',
2309
+ inject: () => ({}),
2310
+ }, FleetStatusBar));
2311
+ } catch (error) {
2312
+ console.error('@twinsearth/w2m-dsh-plugin: the fleet status bar was not mounted', error);
2313
+ }
2288
2314
  // The fleet-chat panel: the same session-scoped seat, one slot after the target control.
2289
2315
  // Wrapped in its own try/catch on purpose — the target control is the primary half of this
2290
2316
  // bundle, and a chat panel that cannot mount must not take the page (or the app) down with it.