@twinsearth/w2m-dsh-plugin 3.1.0 → 3.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +79 -0
- package/README.md +16 -1
- package/codex/mcp-servers.toml +1 -1
- package/cordis.patch.yml +5 -0
- package/docs/INTEROP-CONTRACT.md +126 -0
- package/docs/acceptance/3.2.0/RELEASE-3200.json +47 -0
- package/docs/acceptance/3.2.1/RELEASE-3210.json +50 -0
- package/docs/acceptance/3.2.2/RELEASE-3220.json +53 -0
- package/lib/client.js +1216 -3
- package/lib/contract.js +22 -0
- package/package.json +7 -4
- package/plugin.json +1 -1
- package/src/interop/contract/LICENSE +661 -0
- package/src/interop/contract/PROVENANCE.md +26 -0
- package/src/interop/contract/client.js +93 -0
- package/src/interop/contract/constants.js +94 -0
- package/src/interop/contract/envelope.js +83 -0
- package/src/interop/contract/index.js +20 -0
- package/src/interop/contract/negotiate.js +106 -0
- package/src/interop/contract/relay.js +229 -0
- package/src/interop/contract/server.js +77 -0
- package/src/interop/contract/transports.js +319 -0
- package/src/interop/index.mjs +270 -0
- package/src/plugin/tools.mjs +1 -1
package/lib/client.js
CHANGED
|
@@ -496,6 +496,90 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
496
496
|
'.w2mDc_context{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;max-height:88px;overflow:auto}',
|
|
497
497
|
'.w2mDc_actions{display:flex;flex-wrap:wrap;gap:6px}',
|
|
498
498
|
'.w2mDc_advice{font-size:12px;color:var(--dsw-alias-label-secondary);padding:4px 6px;border-left:2px solid var(--dsw-alias-state-business-primary);white-space:pre-wrap}',
|
|
499
|
+
'.w2mDcBadge{position:fixed;right:0;top:50%;transform:translateY(-50%);z-index:58;display:flex;flex-direction:column;align-items:center;gap:5px;box-sizing:border-box;padding:10px 7px;border:.5px solid var(--dsw-alias-border-l2);border-right:none;border-radius:10px 0 0 10px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);color:var(--dsw-alias-label-primary);font-size:12px;line-height:15px;cursor:pointer}',
|
|
500
|
+
'.w2mDcBadge:hover{background:var(--dsw-alias-bg-layer-2)}',
|
|
501
|
+
'.w2mDcBadge[data-tone="red"]{border-color:var(--dsw-alias-state-danger-primary);color:var(--dsw-alias-state-danger-primary)}',
|
|
502
|
+
'.w2mDcBadge[data-tone="warn"]{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary)}',
|
|
503
|
+
'.w2mDcBadgeIcon{font-size:15px;line-height:16px}',
|
|
504
|
+
'.w2mDcBadgeText{writing-mode:vertical-rl;letter-spacing:2px}',
|
|
505
|
+
'.w2mDcBadgeCount{min-width:16px;padding:0 4px;border-radius:999px;background:var(--dsw-alias-state-danger-primary);color:var(--dsw-alias-bg-layer-1);font-size:10px;line-height:15px;text-align:center}',
|
|
506
|
+
'.w2mDcOverlay{position:fixed;inset:0;z-index:74;background:color-mix(in srgb, var(--dsw-alias-label-primary) 22%, transparent);display:flex;justify-content:flex-end}',
|
|
507
|
+
'[hidden]{display:none!important}',
|
|
508
|
+
'.w2mDcBadgeCount[hidden],.w2mDcUnread[hidden],.w2mJqBadgeCount[hidden],.w2mDcError[hidden]{display:none!important}',
|
|
509
|
+
'.w2mDcDrawer{position:relative;display:flex;flex-direction:column;width:min(440px,100vw);height:100%;box-sizing:border-box;border-left:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}',
|
|
510
|
+
'.w2mDcDrawerHead{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:.5px solid var(--dsw-alias-border-default)}',
|
|
511
|
+
'.w2mDcDrawerTitle{flex:1;min-width:0;font-size:13px;font-weight:600}',
|
|
512
|
+
'.w2mDcUnread{min-width:18px;padding:0 5px;border-radius:999px;background:var(--dsw-alias-state-danger-primary);color:var(--dsw-alias-bg-layer-1);font-size:11px;line-height:17px;text-align:center}',
|
|
513
|
+
'.w2mDcFilters,.w2mDcLevels{display:flex;flex-wrap:wrap;gap:6px;padding:6px 12px}',
|
|
514
|
+
'.w2mDcLevels{border-bottom:.5px solid var(--dsw-alias-border-default)}',
|
|
515
|
+
'.w2mDcBody{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px;padding:8px 12px}',
|
|
516
|
+
'.w2mDcGroup{display:flex;flex-direction:column;gap:6px}',
|
|
517
|
+
'.w2mDcGroupTitle{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary)}',
|
|
518
|
+
'.w2mDcTicket{display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--dsw-alias-border-default);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2)}',
|
|
519
|
+
'.w2mDcTicket[data-level="red"]{border-color:var(--dsw-alias-state-danger-primary)}',
|
|
520
|
+
'.w2mDcTicketHead{display:flex;align-items:center;gap:6px;min-width:0}',
|
|
521
|
+
'.w2mDcDot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-business-primary)}',
|
|
522
|
+
'.w2mDcDot[data-level="red"]{background:var(--dsw-alias-state-danger-primary)}',
|
|
523
|
+
'.w2mDcDot[data-level="yellow"]{background:var(--dsw-alias-state-warn-primary)}',
|
|
524
|
+
'.w2mDcDot[data-level="green"]{background:var(--dsw-alias-state-success-primary)}',
|
|
525
|
+
'.w2mDcDot[data-level="gray"]{background:var(--dsw-alias-label-tertiary)}',
|
|
526
|
+
'.w2mDcTicketTitle{flex:1;min-width:0;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
527
|
+
'.w2mDcTag{flex:none;font-size:11px;padding:0 5px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}',
|
|
528
|
+
'.w2mDcMeta,.w2mDcContext summary{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
529
|
+
'.w2mDcContextBody{max-height:120px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:12px;color:var(--dsw-alias-label-secondary);padding-top:2px}',
|
|
530
|
+
'.w2mDcActions{display:flex;flex-wrap:wrap;align-items:center;gap:6px}',
|
|
531
|
+
'.w2mDcBtn{padding:3px 8px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;cursor:pointer}',
|
|
532
|
+
'.w2mDcBtn:hover{background:var(--dsw-alias-bg-layer-2)}',
|
|
533
|
+
'.w2mDcBtn:disabled{opacity:.5;cursor:default}',
|
|
534
|
+
'.w2mDcBtnPrimary{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-bg-layer-1)}',
|
|
535
|
+
'.w2mDcBtnDanger{color:var(--dsw-alias-state-danger-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-danger-primary) 35%, transparent)}',
|
|
536
|
+
'.w2mDcFilterActive{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-bg-layer-1)}',
|
|
537
|
+
'.w2mDcInput{flex:1;min-width:120px;box-sizing:border-box;padding:3px 8px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;outline:none}',
|
|
538
|
+
'.w2mDcAdvice{padding:4px 6px;border-left:2px solid var(--dsw-alias-state-business-primary);font-size:12px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word}',
|
|
539
|
+
'.w2mDcFooter{flex:0 0 auto;display:flex;flex-direction:column;gap:2px;padding:8px 12px;border-top:.5px solid var(--dsw-alias-border-default);font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
540
|
+
'.w2mDcEmpty,.w2mDcError{font-size:12px;color:var(--dsw-alias-label-secondary);padding:6px 0}',
|
|
541
|
+
'.w2mDcError{color:var(--dsw-alias-state-danger-primary)}',
|
|
542
|
+
'.w2mDcRedToast{position:fixed;right:16px;top:16px;z-index:90;display:flex;flex-direction:column;gap:6px;max-width:320px;box-sizing:border-box;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-left:3px solid var(--dsw-alias-state-danger-primary);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:12px;line-height:18px}',
|
|
543
|
+
'.w2mDcRedToastTitle{font-weight:600;color:var(--dsw-alias-state-danger-primary)}',
|
|
544
|
+
'.w2mDcRedToastBody{color:var(--dsw-alias-label-secondary)}',
|
|
545
|
+
'.w2mDcRedToastActions{display:flex;gap:6px}',
|
|
546
|
+
'.w2mDcGpwBtn[data-tone="red"]{color:var(--dsw-alias-state-danger-primary);border-color:var(--dsw-alias-state-danger-primary)}',
|
|
547
|
+
'.w2mJqBadge{position:fixed;right:0;top:calc(50% + 66px);transform:translateY(-50%);z-index:57;display:flex;flex-direction:column;align-items:center;gap:5px;box-sizing:border-box;padding:10px 7px;border:.5px solid var(--dsw-alias-border-l2);border-right:none;border-radius:10px 0 0 10px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);color:var(--dsw-alias-label-primary);font-size:12px;line-height:15px;cursor:pointer}',
|
|
548
|
+
'.w2mJqBadge:hover{background:var(--dsw-alias-bg-layer-2)}',
|
|
549
|
+
'.w2mJqBadge[data-tone="warn"]{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary)}',
|
|
550
|
+
'.w2mJqBadgeIcon{font-size:15px;line-height:16px}',
|
|
551
|
+
'.w2mJqBadgeText{writing-mode:vertical-rl;letter-spacing:2px}',
|
|
552
|
+
'.w2mJqBadgeCount{min-width:16px;padding:0 4px;border-radius:999px;background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-layer-1);font-size:10px;line-height:15px;text-align:center}',
|
|
553
|
+
'.w2mJqDrawer{width:min(460px,100vw)}',
|
|
554
|
+
'.w2mJqHint{padding:8px 12px;border-bottom:.5px solid var(--dsw-alias-border-default);font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary)}',
|
|
555
|
+
'.w2mJqStatus{padding:4px 12px;font-size:11px;color:var(--dsw-alias-label-secondary);min-height:16px}',
|
|
556
|
+
'.w2mJqEntry{display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--dsw-alias-border-default);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2)}',
|
|
557
|
+
'.w2mJqEntryHead{display:flex;align-items:center;gap:6px;min-width:0}',
|
|
558
|
+
'.w2mJqEntryKey{flex:1;min-width:0;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
559
|
+
'.w2mJqEntryTime{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
560
|
+
'.w2mJqDraft{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);word-break:break-word}',
|
|
561
|
+
'.w2mJqQueueSummary{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
562
|
+
'.w2mJqQueueList{display:grid;gap:2px;max-height:140px;overflow:auto}',
|
|
563
|
+
'.w2mJqQueueRow{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
564
|
+
'.w2mJqDockChip{display:inline-flex;align-items:center;gap:6px;margin:2px 0;padding:2px 6px;border:.5px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
|
|
565
|
+
'.w2mJqDockOpen{padding:0 6px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-state-business-primary);font-size:12px;cursor:pointer}',
|
|
566
|
+
'.w2mJqGpwBtn[data-tone="warn"]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}',
|
|
567
|
+
'.w2mDcSessionChip{display:flex;align-items:center;gap:6px;padding:4px 12px;border-bottom:.5px solid var(--dsw-alias-border-default);font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
568
|
+
'.w2mDcSessionText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
569
|
+
'.w2mDcGroupHead{display:flex;align-items:center;gap:6px}',
|
|
570
|
+
'.w2mDcGroupHead .w2mDcGroupTitle{flex:1}',
|
|
571
|
+
'.w2mDcSelected{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}',
|
|
572
|
+
'.w2mDcSidePanels{flex:0 0 auto;border-top:.5px solid var(--dsw-alias-border-default);padding:6px 12px;font-size:12px}',
|
|
573
|
+
'.w2mDcSidePanels summary{cursor:pointer;color:var(--dsw-alias-label-secondary)}',
|
|
574
|
+
'.w2mDcSideBody{display:grid;gap:2px;padding-top:4px;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
575
|
+
'.w2mDcSideRow{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
576
|
+
'.w2mDcRelayBtn{position:fixed;right:0;top:calc(50% - 76px);transform:translateY(-50%);z-index:57;display:flex;flex-direction:column;align-items:center;gap:4px;box-sizing:border-box;padding:8px 7px;border:.5px solid var(--dsw-alias-state-warn-primary);border-right:none;border-radius:10px 0 0 10px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);color:var(--dsw-alias-state-warn-primary);font-size:12px;line-height:15px;cursor:pointer}',
|
|
577
|
+
'.w2mDcRelayBtn[data-tone="red"]{border-color:var(--dsw-alias-state-danger-primary);color:var(--dsw-alias-state-danger-primary)}',
|
|
578
|
+
'.w2mDcRelayText{writing-mode:vertical-rl;letter-spacing:2px}',
|
|
579
|
+
'.w2mDcRelayCount{min-width:16px;padding:0 4px;border-radius:999px;background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-layer-1);font-size:10px;line-height:15px;text-align:center}',
|
|
580
|
+
'.w2mDcRelayBtn[data-tone="red"] .w2mDcRelayCount{background:var(--dsw-alias-state-danger-primary)}',
|
|
581
|
+
'.w2mDcStatusLine{color:var(--dsw-alias-label-secondary)}',
|
|
582
|
+
'.w2mDcBatchResolve,.w2mDcBatchAbandon{font-size:11px;line-height:16px;padding:2px 6px}',
|
|
499
583
|
'.w2mGpw{margin-top:6px;padding-top:6px;border-top:.5px solid var(--dsw-alias-border-default);display:flex;flex-direction:column;gap:4px;max-height:320px;overflow:auto}',
|
|
500
584
|
'.w2mGpw_title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
501
585
|
'.w2mGpw_block{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px}',
|
|
@@ -684,6 +768,1078 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
684
768
|
* ways: typed here and validated by the host, or taken from a recently intercepted call in one click. The lists
|
|
685
769
|
* live in $DSH_HOME/xclient/gpw-folders.json; no profile YAML is edited by this feature.
|
|
686
770
|
*/
|
|
771
|
+
// ─── 决策收件箱 UI(v3.2.1,对齐瓮城 dsh-Fang 的两处入口)──────────────────────────
|
|
772
|
+
//
|
|
773
|
+
// 用户可见的两个入口:
|
|
774
|
+
// 1. 右侧居中的悬浮「收件箱」浮标 + 点击从右向左滑出的抽屉(全局,任何时候都在);
|
|
775
|
+
// 2. GPW 面板头部的「收件箱 N」按钮(设置页与浮标打开的 GPW 面板都有),点开同一个抽屉。
|
|
776
|
+
//
|
|
777
|
+
// 数据来自本包自己的 /api/w2m/decisions*:list + SSE 实时推送(失败回退 4 秒轮询)、
|
|
778
|
+
// resolve(选项/自定义/批量)、abandon、ai/advise、quota、ai 状态。渲染用 DOM 直写而不是
|
|
779
|
+
// React,原因是浮标/抽屉要独立于任何宿主页面存活;GPW 里的按钮是 React,只订阅同一个 store。
|
|
780
|
+
const DC_PREFIX = '/api/w2m/decisions';
|
|
781
|
+
const DC_LEVELS = Object.freeze([
|
|
782
|
+
['red', '红色 · 需要决策', '#C8102E'],
|
|
783
|
+
['yellow', '黄色 · 建议决策', '#B45309'],
|
|
784
|
+
['blue', '蓝色 · 日常建议', '#0A6BE0'],
|
|
785
|
+
['green', '绿色 · 通知汇报', '#17803D'],
|
|
786
|
+
['gray', '灰色 · 已归档/过期', '#6B7280'],
|
|
787
|
+
]);
|
|
788
|
+
/** 每个进入 UI 的 ticket 都可能有本地的 AI 建议/忙碌状态,按 id 挂在 store 上。 */
|
|
789
|
+
const dcEmptyCounts = () => ({ red: 0, yellow: 0, blue: 0, green: 0, gray: 0 });
|
|
790
|
+
|
|
791
|
+
async function dcRequest(path, init = {}) {
|
|
792
|
+
const response = await fetch(path, {
|
|
793
|
+
credentials: 'same-origin',
|
|
794
|
+
...init,
|
|
795
|
+
headers: { accept: 'application/json', ...(init.body === undefined ? {} : { 'content-type': 'application/json' }), ...(init.headers ?? {}) },
|
|
796
|
+
});
|
|
797
|
+
let body = null;
|
|
798
|
+
try { body = await response.json(); } catch { body = null; }
|
|
799
|
+
if (!response.ok) {
|
|
800
|
+
const error = new Error(body?.error ?? 'HTTP ' + response.status);
|
|
801
|
+
error.status = response.status;
|
|
802
|
+
throw error;
|
|
803
|
+
}
|
|
804
|
+
return body ?? {};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* 收件箱唯一数据源:浮标未读数、抽屉列表、GPW 按钮共用同一份状态。
|
|
809
|
+
*
|
|
810
|
+
* 监听者只有两类:DOM 抽屉(每次 emit 重绘)和 GPW 里的 React 按钮(setState)。SSE 连不上
|
|
811
|
+
* 就退回轮询——两种方式都只读,写操作永远由用户点击触发。
|
|
812
|
+
*/
|
|
813
|
+
function createW2mDecisionStore() {
|
|
814
|
+
const state = {
|
|
815
|
+
items: [],
|
|
816
|
+
counts: dcEmptyCounts(),
|
|
817
|
+
loading: false,
|
|
818
|
+
error: '',
|
|
819
|
+
loadedOnce: false,
|
|
820
|
+
ai: null,
|
|
821
|
+
quota: null,
|
|
822
|
+
advice: {},
|
|
823
|
+
busy: '',
|
|
824
|
+
filter: 'pending',
|
|
825
|
+
levelFilter: 'all',
|
|
826
|
+
open: false,
|
|
827
|
+
lastRedPending: 0,
|
|
828
|
+
mode: 'poll',
|
|
829
|
+
// v3.2.3 parity: per-session filter, keyboard selection, batch status, side panels.
|
|
830
|
+
sessionId: '',
|
|
831
|
+
selected: 0,
|
|
832
|
+
status: '',
|
|
833
|
+
forks: null,
|
|
834
|
+
producers: null,
|
|
835
|
+
producersError: '',
|
|
836
|
+
};
|
|
837
|
+
const listeners = new Set();
|
|
838
|
+
let timer = null;
|
|
839
|
+
let stream = null;
|
|
840
|
+
let started = false;
|
|
841
|
+
let disposed = false;
|
|
842
|
+
const emit = () => { for (const listener of [...listeners]) { try { listener(); } catch { /* ignore */ } } };
|
|
843
|
+
const set = (patch) => { Object.assign(state, patch); emit(); };
|
|
844
|
+
|
|
845
|
+
const refresh = async () => {
|
|
846
|
+
if (disposed) return;
|
|
847
|
+
if (state.loading !== true) set({ loading: true });
|
|
848
|
+
try {
|
|
849
|
+
const list = await dcRequest(DC_PREFIX + '?includeArchived=1');
|
|
850
|
+
const items = Array.isArray(list?.items) ? list.items : [];
|
|
851
|
+
const counts = dcEmptyCounts();
|
|
852
|
+
for (const item of items) {
|
|
853
|
+
const key = typeof item?.level === 'string' && counts[item.level] !== undefined ? item.level : 'blue';
|
|
854
|
+
if (item?.status === 'pending' || item?.status === undefined) counts[key] += 1;
|
|
855
|
+
}
|
|
856
|
+
const pending = counts.red + counts.yellow + counts.blue + counts.green;
|
|
857
|
+
const previousRed = state.lastRedPending;
|
|
858
|
+
set({ items, counts, loading: false, error: '', loadedOnce: true, lastRedPending: pending === 0 ? 0 : previousRed });
|
|
859
|
+
if (counts.red > (state.alertedRed ?? 0)) {
|
|
860
|
+
state.alertedRed = counts.red;
|
|
861
|
+
if (state.open !== true) showRedToast(counts.red);
|
|
862
|
+
}
|
|
863
|
+
if (counts.red === 0) state.alertedRed = 0;
|
|
864
|
+
} catch (error) {
|
|
865
|
+
set({ loading: false, error: String(error?.message ?? error), loadedOnce: true });
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const loadSideInfo = async () => {
|
|
870
|
+
const [ai, quota, forks, producers] = await Promise.allSettled([
|
|
871
|
+
dcRequest(DC_PREFIX + '/ai'),
|
|
872
|
+
dcRequest(DC_PREFIX + '/quota'),
|
|
873
|
+
dcRequest(DC_PREFIX + '/forks'),
|
|
874
|
+
dcRequest(DC_PREFIX + '/producers'),
|
|
875
|
+
]);
|
|
876
|
+
if (ai.status === 'fulfilled') set({ ai: ai.value });
|
|
877
|
+
if (quota.status === 'fulfilled') set({ quota: quota.value });
|
|
878
|
+
if (forks.status === 'fulfilled') set({ forks: forks.value });
|
|
879
|
+
if (producers.status === 'fulfilled') set({ producers: producers.value, producersError: '' });
|
|
880
|
+
else set({ producers: null, producersError: String(producers.reason?.message ?? producers.reason ?? 'unavailable') });
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
const start = () => {
|
|
884
|
+
if (started || disposed) return;
|
|
885
|
+
started = true;
|
|
886
|
+
void refresh();
|
|
887
|
+
void loadSideInfo();
|
|
888
|
+
// SSE 优先:/events 推 created/updated 时直接刷新;断线自动回退轮询。
|
|
889
|
+
try {
|
|
890
|
+
if (typeof EventSource === 'function') {
|
|
891
|
+
stream = new EventSource(DC_PREFIX + '/events');
|
|
892
|
+
stream.addEventListener('open', () => set({ mode: 'sse' }));
|
|
893
|
+
const onEvent = () => { void refresh(); };
|
|
894
|
+
stream.addEventListener('created', onEvent);
|
|
895
|
+
stream.addEventListener('updated', onEvent);
|
|
896
|
+
stream.addEventListener('message', onEvent);
|
|
897
|
+
stream.addEventListener('error', () => {
|
|
898
|
+
try { stream.close(); } catch { /* ignore */ }
|
|
899
|
+
stream = null;
|
|
900
|
+
set({ mode: 'poll' });
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
} catch { stream = null; }
|
|
904
|
+
timer = setInterval(() => {
|
|
905
|
+
void refresh();
|
|
906
|
+
if (stream === null) void loadSideInfo();
|
|
907
|
+
}, 4000);
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const stop = () => {
|
|
911
|
+
disposed = true;
|
|
912
|
+
if (timer !== null) clearInterval(timer);
|
|
913
|
+
timer = null;
|
|
914
|
+
try { stream?.close?.(); } catch { /* ignore */ }
|
|
915
|
+
stream = null;
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
const act = async (label, work) => {
|
|
919
|
+
set({ busy: label, error: '' });
|
|
920
|
+
try {
|
|
921
|
+
const out = await work();
|
|
922
|
+
await refresh();
|
|
923
|
+
return out;
|
|
924
|
+
} catch (error) {
|
|
925
|
+
set({ error: String(error?.message ?? error) });
|
|
926
|
+
return null;
|
|
927
|
+
} finally {
|
|
928
|
+
set({ busy: '' });
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
const api = {
|
|
933
|
+
state,
|
|
934
|
+
refresh,
|
|
935
|
+
loadSideInfo,
|
|
936
|
+
start,
|
|
937
|
+
stop,
|
|
938
|
+
subscribe(listener) {
|
|
939
|
+
listeners.add(listener);
|
|
940
|
+
return () => { listeners.delete(listener); };
|
|
941
|
+
},
|
|
942
|
+
snapshot() {
|
|
943
|
+
const pending = state.counts.red + state.counts.yellow + state.counts.blue + state.counts.green;
|
|
944
|
+
const archived = state.items.filter((item) => item?.status !== 'pending' && item?.status !== undefined).length;
|
|
945
|
+
return {
|
|
946
|
+
pending,
|
|
947
|
+
red: state.counts.red,
|
|
948
|
+
total: state.items.length,
|
|
949
|
+
archived,
|
|
950
|
+
mode: state.mode,
|
|
951
|
+
open: state.open,
|
|
952
|
+
error: state.error,
|
|
953
|
+
loading: state.loading,
|
|
954
|
+
aiConfigured: state.ai?.configured === true,
|
|
955
|
+
sessionId: state.sessionId ?? '',
|
|
956
|
+
selected: state.selected ?? 0,
|
|
957
|
+
status: state.status ?? '',
|
|
958
|
+
};
|
|
959
|
+
},
|
|
960
|
+
openDrawer() {
|
|
961
|
+
start();
|
|
962
|
+
set({ open: true });
|
|
963
|
+
return refresh();
|
|
964
|
+
},
|
|
965
|
+
closeDrawer() {
|
|
966
|
+
set({ open: false });
|
|
967
|
+
},
|
|
968
|
+
toggleDrawer() {
|
|
969
|
+
if (state.open) api.closeDrawer();
|
|
970
|
+
else api.openDrawer();
|
|
971
|
+
},
|
|
972
|
+
/** 当前会话(由 conversation.input.left 席位发布)的待处理数量。 */
|
|
973
|
+
pendingForSession(sessionId) {
|
|
974
|
+
const id = String(sessionId ?? '');
|
|
975
|
+
if (id === '') return 0;
|
|
976
|
+
return state.items.filter((item) => (item?.status === undefined || item?.status === 'pending') && String(item?.source?.sessionId ?? '') === id).length;
|
|
977
|
+
},
|
|
978
|
+
setSession(sessionId) {
|
|
979
|
+
set({ sessionId: String(sessionId ?? ''), selected: 0 });
|
|
980
|
+
},
|
|
981
|
+
clearSession() {
|
|
982
|
+
set({ sessionId: '', selected: 0 });
|
|
983
|
+
},
|
|
984
|
+
/** 抽屉当前可见的 ticket(过滤/等级/会话三层),键盘与批量都按这个顺序。 */
|
|
985
|
+
visibleTickets() {
|
|
986
|
+
let items = Array.isArray(state.items) ? state.items : [];
|
|
987
|
+
if (state.filter === 'pending') items = items.filter((item) => item?.status === 'pending' || item?.status === undefined);
|
|
988
|
+
else if (state.filter === 'archived') items = items.filter((item) => item?.status !== 'pending' && item?.status !== undefined);
|
|
989
|
+
if ((state.levelFilter ?? 'all') !== 'all') items = items.filter((item) => (item?.level ?? 'blue') === state.levelFilter);
|
|
990
|
+
if (String(state.sessionId ?? '') !== '') items = items.filter((item) => String(item?.source?.sessionId ?? '') === state.sessionId);
|
|
991
|
+
return items;
|
|
992
|
+
},
|
|
993
|
+
moveSelection(delta) {
|
|
994
|
+
const items = api.visibleTickets();
|
|
995
|
+
if (items.length === 0) return;
|
|
996
|
+
const next = (Number(state.selected) + delta + items.length) % items.length;
|
|
997
|
+
set({ selected: next });
|
|
998
|
+
},
|
|
999
|
+
setSelected(index) {
|
|
1000
|
+
set({ selected: Number(index) || 0 });
|
|
1001
|
+
},
|
|
1002
|
+
async resolveFocusedWithOption(optionIndex) {
|
|
1003
|
+
const items = api.visibleTickets();
|
|
1004
|
+
const ticket = items[Number(state.selected) || 0];
|
|
1005
|
+
if (ticket === undefined) return null;
|
|
1006
|
+
const options = Array.isArray(ticket.options) ? ticket.options : [];
|
|
1007
|
+
const option = options[optionIndex];
|
|
1008
|
+
if (option === undefined) return null;
|
|
1009
|
+
return api.resolve(ticket, option.value);
|
|
1010
|
+
},
|
|
1011
|
+
async resolveBatchLevel(level) {
|
|
1012
|
+
return act('batch-resolve:' + level, async () => {
|
|
1013
|
+
const ids = state.items.filter((item) => (item?.status === 'pending' || item?.status === undefined) && (item?.level ?? 'blue') === level).map((item) => String(item.id));
|
|
1014
|
+
if (ids.length === 0) { set({ status: '该等级没有待处理项' }); return; }
|
|
1015
|
+
const out = await dcRequest(DC_PREFIX + '/resolve', { method: 'POST', body: JSON.stringify({ ids, by: 'w2m-ui' }) });
|
|
1016
|
+
const results = Array.isArray(out?.results) ? out.results : [];
|
|
1017
|
+
const okCount = results.filter((entry) => entry.ok === true).length;
|
|
1018
|
+
const refused = results.filter((entry) => entry.ok !== true).map((entry) => String(entry.reason ?? 'refused'));
|
|
1019
|
+
set({ status: `批量解决 ${okCount}/${ids.length}` + (refused.length ? `,未处理:${refused.join('、')}` : '') });
|
|
1020
|
+
await refresh();
|
|
1021
|
+
});
|
|
1022
|
+
},
|
|
1023
|
+
async abandonBatchLevel(level) {
|
|
1024
|
+
return act('batch-abandon:' + level, async () => {
|
|
1025
|
+
const ids = state.items.filter((item) => (item?.status === 'pending' || item?.status === undefined) && (item?.level ?? 'blue') === level).map((item) => String(item.id));
|
|
1026
|
+
if (ids.length === 0) { set({ status: '该等级没有待处理项' }); return; }
|
|
1027
|
+
const out = await dcRequest(DC_PREFIX + '/abandon', { method: 'POST', body: JSON.stringify({ ids, by: 'w2m-ui' }) });
|
|
1028
|
+
const results = Array.isArray(out?.results) ? out.results : [];
|
|
1029
|
+
const okCount = results.filter((entry) => entry.ok === true).length;
|
|
1030
|
+
set({ status: `批量放弃 ${okCount}/${ids.length}` });
|
|
1031
|
+
await refresh();
|
|
1032
|
+
});
|
|
1033
|
+
},
|
|
1034
|
+
/** 会话席位发布当前会话时触发一次重绘。 */
|
|
1035
|
+
touch() {
|
|
1036
|
+
emit();
|
|
1037
|
+
},
|
|
1038
|
+
setFilter(filter) {
|
|
1039
|
+
set({ filter });
|
|
1040
|
+
},
|
|
1041
|
+
setLevel(level) {
|
|
1042
|
+
set({ levelFilter: level });
|
|
1043
|
+
},
|
|
1044
|
+
async resolve(ticket, option) {
|
|
1045
|
+
return act('resolve:' + ticket.id, () => dcRequest(DC_PREFIX + '/resolve', {
|
|
1046
|
+
method: 'POST',
|
|
1047
|
+
body: JSON.stringify({ id: ticket.id, option: option ?? null, by: 'w2m-ui' }),
|
|
1048
|
+
}));
|
|
1049
|
+
},
|
|
1050
|
+
async resolveCustom(ticket, text) {
|
|
1051
|
+
return act('resolve:' + ticket.id, () => dcRequest(DC_PREFIX + '/resolve', {
|
|
1052
|
+
method: 'POST',
|
|
1053
|
+
body: JSON.stringify({ id: ticket.id, option: null, custom: String(text ?? ''), by: 'w2m-ui' }),
|
|
1054
|
+
}));
|
|
1055
|
+
},
|
|
1056
|
+
async abandon(ticket) {
|
|
1057
|
+
return act('abandon:' + ticket.id, () => dcRequest(DC_PREFIX + '/abandon', {
|
|
1058
|
+
method: 'POST',
|
|
1059
|
+
body: JSON.stringify({ id: ticket.id, by: 'w2m-ui' }),
|
|
1060
|
+
}));
|
|
1061
|
+
},
|
|
1062
|
+
async advise(ticket) {
|
|
1063
|
+
return act('advise:' + ticket.id, async () => {
|
|
1064
|
+
const out = await dcRequest(DC_PREFIX + '/ai/advise', { method: 'POST', body: JSON.stringify({ id: ticket.id }) });
|
|
1065
|
+
const advice = out?.advice ?? out?.result?.advice ?? null;
|
|
1066
|
+
const text = typeof advice === 'string' ? advice : (advice?.text ?? advice?.summary ?? out?.text ?? '');
|
|
1067
|
+
set({ advice: { ...state.advice, [ticket.id]: text === '' ? '(AI 未返回建议文本)' : String(text) } });
|
|
1068
|
+
return out;
|
|
1069
|
+
});
|
|
1070
|
+
},
|
|
1071
|
+
};
|
|
1072
|
+
return api;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
let w2mDcStoreInstance = null;
|
|
1076
|
+
function w2mDcStore() {
|
|
1077
|
+
if (w2mDcStoreInstance === null) w2mDcStoreInstance = createW2mDecisionStore();
|
|
1078
|
+
return w2mDcStoreInstance;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** 红色 ticket 的顶部提醒:和瓮城一样“红票必须被看见”,但不抢焦点、不自动处理。 */
|
|
1082
|
+
let dcRedToast = null;
|
|
1083
|
+
function showRedToast(count) {
|
|
1084
|
+
if (typeof document === 'undefined' || document.body === undefined) return;
|
|
1085
|
+
try { dcRedToast?.remove?.(); } catch { /* ignore */ }
|
|
1086
|
+
const toast = document.createElement('div');
|
|
1087
|
+
toast.className = 'w2mDcRedToast';
|
|
1088
|
+
const title = document.createElement('div');
|
|
1089
|
+
title.className = 'w2mDcRedToastTitle';
|
|
1090
|
+
title.textContent = '红色决策待处理 · ' + count;
|
|
1091
|
+
const body = document.createElement('div');
|
|
1092
|
+
body.className = 'w2mDcRedToastBody';
|
|
1093
|
+
body.textContent = '打开收件箱处理,或稍后继续手头的事。';
|
|
1094
|
+
const actions = document.createElement('div');
|
|
1095
|
+
actions.className = 'w2mDcRedToastActions';
|
|
1096
|
+
const open = document.createElement('button');
|
|
1097
|
+
open.type = 'button';
|
|
1098
|
+
open.className = 'w2mDcBtn';
|
|
1099
|
+
open.textContent = '打开收件箱';
|
|
1100
|
+
open.addEventListener('click', () => { toast.remove(); w2mDcStore().openDrawer(); });
|
|
1101
|
+
const close = document.createElement('button');
|
|
1102
|
+
close.type = 'button';
|
|
1103
|
+
close.className = 'w2mDcBtn';
|
|
1104
|
+
close.textContent = '稍后';
|
|
1105
|
+
close.addEventListener('click', () => toast.remove());
|
|
1106
|
+
actions.append(open, close);
|
|
1107
|
+
toast.append(title, body, actions);
|
|
1108
|
+
document.body.append(toast);
|
|
1109
|
+
dcRedToast = toast;
|
|
1110
|
+
setTimeout(() => { try { toast.remove(); } catch { /* ignore */ } }, 12000).unref?.();
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** 极简 DOM 构造器:这不是 React 的 h,props 按 attribute/事件分开处理。 */
|
|
1114
|
+
function dcEl(tag, props = null, children = []) {
|
|
1115
|
+
const node = document.createElement(tag);
|
|
1116
|
+
if (props !== null) {
|
|
1117
|
+
for (const [key, value] of Object.entries(props)) {
|
|
1118
|
+
if (value === undefined || value === null || value === false) continue;
|
|
1119
|
+
if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value);
|
|
1120
|
+
else if (key === 'className') node.className = value;
|
|
1121
|
+
else if (key === 'text') node.textContent = value;
|
|
1122
|
+
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
|
|
1123
|
+
else node.setAttribute(key, value === true ? '' : String(value));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
for (const child of [].concat(children)) {
|
|
1127
|
+
if (child === null || child === undefined) continue;
|
|
1128
|
+
node.append(typeof child === 'string' ? document.createTextNode(child) : child);
|
|
1129
|
+
}
|
|
1130
|
+
return node;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function dcTime(at) {
|
|
1134
|
+
const value = Number(at);
|
|
1135
|
+
if (!Number.isFinite(value) || value <= 0) return '';
|
|
1136
|
+
try { return new Date(value).toLocaleString(); } catch { return ''; }
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function dcStatusLabel(ticket) {
|
|
1140
|
+
const status = ticket?.status ?? 'pending';
|
|
1141
|
+
if (status === 'pending') return '待处理';
|
|
1142
|
+
if (status === 'resolved') return ticket?.resolvedOption ? '已解决 · ' + String(ticket.resolvedOption) : '已解决';
|
|
1143
|
+
if (status === 'abandoned') return '已放弃';
|
|
1144
|
+
if (status === 'expired') return '已过期';
|
|
1145
|
+
return status;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** 抽屉里的单张 ticket 卡片:选项、自定义答复、AI 建议、放弃。 */
|
|
1149
|
+
function dcTicketCard(store, ticket) {
|
|
1150
|
+
const state = store.state;
|
|
1151
|
+
const busy = state.busy !== '';
|
|
1152
|
+
const pending = ticket?.status === 'pending' || ticket?.status === undefined;
|
|
1153
|
+
const card = dcEl('div', { className: 'w2mDcTicket', 'data-level': ticket?.level ?? 'blue' });
|
|
1154
|
+
const head = dcEl('div', { className: 'w2mDcTicketHead' });
|
|
1155
|
+
head.append(
|
|
1156
|
+
dcEl('span', { className: 'w2mDcDot', 'data-level': ticket?.level ?? 'blue' }),
|
|
1157
|
+
dcEl('span', { className: 'w2mDcTicketTitle', title: ticket?.title ?? '' }, [ticket?.title ?? '(未命名)']),
|
|
1158
|
+
dcEl('span', { className: 'w2mDcTag' }, [dcStatusLabel(ticket)]),
|
|
1159
|
+
);
|
|
1160
|
+
card.append(head);
|
|
1161
|
+
const metaBits = [];
|
|
1162
|
+
if (ticket?.source?.agentRole) metaBits.push(String(ticket.source.agentRole));
|
|
1163
|
+
if (ticket?.source?.sessionId) metaBits.push('会话 ' + String(ticket.source.sessionId).slice(0, 18));
|
|
1164
|
+
if (ticket?.createdAt) metaBits.push(dcTime(ticket.createdAt));
|
|
1165
|
+
if (ticket?.expiresAt) metaBits.push('截止 ' + dcTime(ticket.expiresAt));
|
|
1166
|
+
if (metaBits.length > 0) card.append(dcEl('div', { className: 'w2mDcMeta' }, [metaBits.join(' · ')]));
|
|
1167
|
+
const contextText = String(ticket?.context ?? '').trim();
|
|
1168
|
+
if (contextText !== '') {
|
|
1169
|
+
const details = dcEl('details', { className: 'w2mDcContext' });
|
|
1170
|
+
details.append(dcEl('summary', null, ['上下文']), dcEl('div', { className: 'w2mDcContextBody' }, [contextText]));
|
|
1171
|
+
card.append(details);
|
|
1172
|
+
}
|
|
1173
|
+
const options = Array.isArray(ticket?.options) ? ticket.options : [];
|
|
1174
|
+
if (pending) {
|
|
1175
|
+
if (options.length > 0) {
|
|
1176
|
+
const optionsRow = dcEl('div', { className: 'w2mDcActions' });
|
|
1177
|
+
for (const option of options) {
|
|
1178
|
+
const button = dcEl('button', {
|
|
1179
|
+
type: 'button',
|
|
1180
|
+
className: 'w2mDcBtn ' + (option?.isRecommended === true ? 'w2mDcBtnPrimary' : ''),
|
|
1181
|
+
disabled: busy,
|
|
1182
|
+
title: option?.label ?? '',
|
|
1183
|
+
}, [String(option?.label ?? option?.value ?? '选项')]);
|
|
1184
|
+
button.addEventListener('click', () => { void store.resolve(ticket, option?.value); });
|
|
1185
|
+
optionsRow.append(button);
|
|
1186
|
+
}
|
|
1187
|
+
card.append(optionsRow);
|
|
1188
|
+
}
|
|
1189
|
+
const customRow = dcEl('div', { className: 'w2mDcActions' });
|
|
1190
|
+
const input = dcEl('input', { className: 'w2mDcInput', type: 'text', placeholder: '自定义答复(回车提交)', disabled: busy });
|
|
1191
|
+
const submit = dcEl('button', { type: 'button', className: 'w2mDcBtn', disabled: busy }, ['提交答复']);
|
|
1192
|
+
const send = () => {
|
|
1193
|
+
const text = String(input.value ?? '').trim();
|
|
1194
|
+
if (text === '') return;
|
|
1195
|
+
input.value = '';
|
|
1196
|
+
void store.resolveCustom(ticket, text);
|
|
1197
|
+
};
|
|
1198
|
+
submit.addEventListener('click', send);
|
|
1199
|
+
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); send(); } });
|
|
1200
|
+
customRow.append(input, submit);
|
|
1201
|
+
card.append(customRow);
|
|
1202
|
+
const actions = dcEl('div', { className: 'w2mDcActions' });
|
|
1203
|
+
actions.append(
|
|
1204
|
+
dcEl('button', { type: 'button', className: 'w2mDcBtn', disabled: busy }, ['AI 建议']),
|
|
1205
|
+
dcEl('button', { type: 'button', className: 'w2mDcBtn w2mDcBtnDanger', disabled: busy }, ['放弃']),
|
|
1206
|
+
);
|
|
1207
|
+
const [aiBtn, abandonBtn] = actions.querySelectorAll('button');
|
|
1208
|
+
aiBtn?.addEventListener('click', () => { void store.advise(ticket); });
|
|
1209
|
+
abandonBtn?.addEventListener('click', () => { void store.abandon(ticket); });
|
|
1210
|
+
card.append(actions);
|
|
1211
|
+
if (state.advice[ticket.id]) card.append(dcEl('div', { className: 'w2mDcAdvice' }, [String(state.advice[ticket.id])]));
|
|
1212
|
+
} else {
|
|
1213
|
+
if (ticket?.resolvedOption !== null && ticket?.resolvedOption !== undefined) {
|
|
1214
|
+
card.append(dcEl('div', { className: 'w2mDcMeta' }, ['结果:' + String(ticket.resolvedOption)]));
|
|
1215
|
+
}
|
|
1216
|
+
const actions = dcEl('div', { className: 'w2mDcActions' });
|
|
1217
|
+
actions.append(dcEl('button', { type: 'button', className: 'w2mDcBtn', disabled: busy }, ['AI 建议']));
|
|
1218
|
+
actions.querySelector('button')?.addEventListener('click', () => { void store.advise(ticket); });
|
|
1219
|
+
card.append(actions);
|
|
1220
|
+
if (state.advice[ticket.id]) card.append(dcEl('div', { className: 'w2mDcAdvice' }, [String(state.advice[ticket.id])]));
|
|
1221
|
+
}
|
|
1222
|
+
return card;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* 右侧居中的浮标 + 抽屉。返回 dispose;在 `typeof document === 'undefined'` 的测试环境里
|
|
1227
|
+
* apply 会跳过挂载。
|
|
1228
|
+
*/
|
|
1229
|
+
/** 当前会话(由 conversation.input.left 的席位发布),用于「待选」浮钮与会话筛选。 */
|
|
1230
|
+
let w2mDcCurrentSession = '';
|
|
1231
|
+
function w2mDcSetSession(sessionId) {
|
|
1232
|
+
w2mDcCurrentSession = String(sessionId ?? '');
|
|
1233
|
+
try { w2mDcStore().touch(); } catch { /* store not built yet */ }
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function mountW2mDecisionCenterUI() {
|
|
1237
|
+
if (typeof document === 'undefined' || document.body === undefined) return () => {};
|
|
1238
|
+
const store = w2mDcStore();
|
|
1239
|
+
store.start();
|
|
1240
|
+
const badge = dcEl('button', {
|
|
1241
|
+
type: 'button',
|
|
1242
|
+
className: 'w2mDcBadge',
|
|
1243
|
+
title: '决策收件箱',
|
|
1244
|
+
'aria-label': '决策收件箱',
|
|
1245
|
+
});
|
|
1246
|
+
const badgeIcon = dcEl('span', { className: 'w2mDcBadgeIcon' }, ['▣']);
|
|
1247
|
+
const badgeText = dcEl('span', { className: 'w2mDcBadgeText' }, ['收件箱']);
|
|
1248
|
+
const badgeCount = dcEl('span', { className: 'w2mDcBadgeCount', hidden: true }, ['0']);
|
|
1249
|
+
badge.append(badgeIcon, badgeText, badgeCount);
|
|
1250
|
+
badge.addEventListener('click', () => store.toggleDrawer());
|
|
1251
|
+
|
|
1252
|
+
const overlay = dcEl('div', { className: 'w2mDcOverlay' });
|
|
1253
|
+
overlay.style.display = 'none';
|
|
1254
|
+
const drawer = dcEl('aside', {
|
|
1255
|
+
className: 'w2mDcDrawer',
|
|
1256
|
+
role: 'dialog',
|
|
1257
|
+
'aria-modal': 'true',
|
|
1258
|
+
'aria-label': '决策收件箱',
|
|
1259
|
+
});
|
|
1260
|
+
drawer.style.display = 'none';
|
|
1261
|
+
const header = dcEl('div', { className: 'w2mDcDrawerHead' });
|
|
1262
|
+
const title = dcEl('div', { className: 'w2mDcDrawerTitle' }, ['决策收件箱']);
|
|
1263
|
+
const unread = dcEl('span', { className: 'w2mDcUnread', hidden: true }, ['0']);
|
|
1264
|
+
const headActions = dcEl('div', { className: 'w2mDcActions' });
|
|
1265
|
+
const refreshButton = dcEl('button', { type: 'button', className: 'w2mDcBtn' }, ['刷新']);
|
|
1266
|
+
const closeButton = dcEl('button', { type: 'button', className: 'w2mDcBtn' }, ['关闭']);
|
|
1267
|
+
refreshButton.addEventListener('click', () => { void store.refresh(); void store.loadSideInfo(); });
|
|
1268
|
+
closeButton.addEventListener('click', () => store.closeDrawer());
|
|
1269
|
+
headActions.append(refreshButton, closeButton);
|
|
1270
|
+
header.append(title, unread, headActions);
|
|
1271
|
+
const filters = dcEl('div', { className: 'w2mDcFilters' });
|
|
1272
|
+
filters.append(
|
|
1273
|
+
dcEl('button', { type: 'button', className: 'w2mDcBtn w2mDcFilterActive' }, ['待处理']),
|
|
1274
|
+
dcEl('button', { type: 'button', className: 'w2mDcBtn' }, ['全部']),
|
|
1275
|
+
dcEl('button', { type: 'button', className: 'w2mDcBtn' }, ['已归档']),
|
|
1276
|
+
);
|
|
1277
|
+
const sessionChip = dcEl('div', { className: 'w2mDcSessionChip', hidden: true });
|
|
1278
|
+
const sessionChipText = dcEl('span', { className: 'w2mDcSessionText' }, ['']);
|
|
1279
|
+
const sessionChipClear = dcEl('button', { type: 'button', className: 'w2mDcBtn' }, ['显示全部会话']);
|
|
1280
|
+
sessionChipClear.addEventListener('click', () => store.clearSession());
|
|
1281
|
+
sessionChip.append(sessionChipText, sessionChipClear);
|
|
1282
|
+
const levels = dcEl('div', { className: 'w2mDcLevels' });
|
|
1283
|
+
const levelButtons = [['all', '全部等级'], ...DC_LEVELS.map(([level, label]) => [level, label.split(' · ')[0]])];
|
|
1284
|
+
for (const [level, label] of levelButtons) {
|
|
1285
|
+
const button = dcEl('button', { type: 'button', className: 'w2mDcBtn', 'data-level': level }, [label]);
|
|
1286
|
+
button.addEventListener('click', () => store.setLevel(level));
|
|
1287
|
+
levels.append(button);
|
|
1288
|
+
}
|
|
1289
|
+
const body = dcEl('div', { className: 'w2mDcBody' });
|
|
1290
|
+
const footer = dcEl('div', { className: 'w2mDcFooter' });
|
|
1291
|
+
const sidePanels = dcEl('details', { className: 'w2mDcSidePanels' });
|
|
1292
|
+
sidePanels.append(dcEl('summary', null, ['分叉聚合 / 生产者']));
|
|
1293
|
+
const sideBody = dcEl('div', { className: 'w2mDcSideBody' });
|
|
1294
|
+
sidePanels.append(sideBody);
|
|
1295
|
+
// AI 建议设置:瓮城的收件箱里可以配置 provider/model/endpoint;这里做同一件事的最小形态。
|
|
1296
|
+
const aiConfig = dcEl('details', { className: 'w2mDcAiConfig' });
|
|
1297
|
+
aiConfig.append(dcEl('summary', null, ['AI 建议设置']));
|
|
1298
|
+
const aiBody = dcEl('div', { className: 'w2mDcAiBody' });
|
|
1299
|
+
const aiProvider = dcEl('input', { className: 'w2mDcInput', type: 'text', placeholder: 'provider(如 deepseek)' });
|
|
1300
|
+
const aiModel = dcEl('input', { className: 'w2mDcInput', type: 'text', placeholder: 'model(如 deepseek-chat)' });
|
|
1301
|
+
const aiBaseUrl = dcEl('input', { className: 'w2mDcInput', type: 'text', placeholder: 'baseUrl(可选)' });
|
|
1302
|
+
const aiKey = dcEl('input', { className: 'w2mDcInput', type: 'password', placeholder: 'apiKey(可选,只存在宿主侧)' });
|
|
1303
|
+
const aiSave = dcEl('button', { type: 'button', className: 'w2mDcBtn w2mDcBtnPrimary' }, ['保存']);
|
|
1304
|
+
const aiTest = dcEl('button', { type: 'button', className: 'w2mDcBtn' }, ['测试连接']);
|
|
1305
|
+
const aiStatusLine = dcEl('div', { className: 'w2mDcMeta' }, ['AI 状态未知']);
|
|
1306
|
+
aiSave.addEventListener('click', async () => {
|
|
1307
|
+
try {
|
|
1308
|
+
await dcRequest(DC_PREFIX + '/ai/config', {
|
|
1309
|
+
method: 'POST',
|
|
1310
|
+
body: JSON.stringify({
|
|
1311
|
+
provider: String(aiProvider.value ?? '').trim(),
|
|
1312
|
+
model: String(aiModel.value ?? '').trim(),
|
|
1313
|
+
baseUrl: String(aiBaseUrl.value ?? '').trim(),
|
|
1314
|
+
apiKey: String(aiKey.value ?? '').trim(),
|
|
1315
|
+
}),
|
|
1316
|
+
});
|
|
1317
|
+
aiKey.value = '';
|
|
1318
|
+
aiStatusLine.textContent = '已保存';
|
|
1319
|
+
await store.loadSideInfo();
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
aiStatusLine.textContent = '保存失败:' + String(error?.message ?? error);
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
aiTest.addEventListener('click', async () => {
|
|
1325
|
+
try {
|
|
1326
|
+
const out = await dcRequest(DC_PREFIX + '/ai/test', {
|
|
1327
|
+
method: 'POST',
|
|
1328
|
+
body: JSON.stringify({
|
|
1329
|
+
provider: String(aiProvider.value ?? '').trim(),
|
|
1330
|
+
model: String(aiModel.value ?? '').trim(),
|
|
1331
|
+
baseUrl: String(aiBaseUrl.value ?? '').trim(),
|
|
1332
|
+
apiKey: String(aiKey.value ?? '').trim(),
|
|
1333
|
+
}),
|
|
1334
|
+
});
|
|
1335
|
+
aiStatusLine.textContent = out?.ok === true ? '连接正常' : '测试失败:' + String(out?.reason ?? out?.error ?? 'unknown');
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
aiStatusLine.textContent = '测试失败:' + String(error?.message ?? error);
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
aiBody.append(aiProvider, aiModel, aiBaseUrl, aiKey, dcEl('div', { className: 'w2mDcActions' }, []));
|
|
1341
|
+
aiBody.lastChild.append(aiSave, aiTest);
|
|
1342
|
+
aiBody.append(aiStatusLine);
|
|
1343
|
+
aiConfig.append(aiBody);
|
|
1344
|
+
const empty = dcEl('div', { className: 'w2mDcEmpty' }, ['收件箱是空的。Agent 创建的决策 ticket 会出现在这里。']);
|
|
1345
|
+
const errorLine = dcEl('div', { className: 'w2mDcError', role: 'alert', hidden: true });
|
|
1346
|
+
drawer.append(header, sessionChip, filters, levels, errorLine, body, sidePanels, aiConfig, footer);
|
|
1347
|
+
overlay.append(drawer);
|
|
1348
|
+
document.body.append(badge, overlay);
|
|
1349
|
+
const relay = dcEl('button', { type: 'button', className: 'w2mDcRelayBtn', title: '当前会话的待选决策', 'aria-label': '当前会话的待选决策' });
|
|
1350
|
+
relay.style.display = 'none';
|
|
1351
|
+
const relayText = dcEl('span', { className: 'w2mDcRelayText' }, ['待选']);
|
|
1352
|
+
const relayCount = dcEl('span', { className: 'w2mDcRelayCount' }, ['0']);
|
|
1353
|
+
relay.append(relayText, relayCount);
|
|
1354
|
+
relay.addEventListener('click', () => { store.setSession(w2mDcCurrentSession); store.openDrawer(); });
|
|
1355
|
+
document.body.append(relay);
|
|
1356
|
+
|
|
1357
|
+
overlay.addEventListener('click', (event) => { if (event.target === overlay) store.closeDrawer(); });
|
|
1358
|
+
const onKey = (event) => {
|
|
1359
|
+
if (event.key === 'Escape' && store.state.open) { store.closeDrawer(); return; }
|
|
1360
|
+
if (store.state.open !== true) return;
|
|
1361
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); store.moveSelection(1); }
|
|
1362
|
+
else if (event.key === 'ArrowUp') { event.preventDefault(); store.moveSelection(-1); }
|
|
1363
|
+
else if (event.key === 'r' || event.key === 'R') { void store.refresh(); }
|
|
1364
|
+
else if (/^[1-9]$/.test(event.key)) { void store.resolveFocusedWithOption(Number(event.key) - 1); }
|
|
1365
|
+
};
|
|
1366
|
+
document.addEventListener('keydown', onKey);
|
|
1367
|
+
|
|
1368
|
+
const snapshotOf = () => store.snapshot();
|
|
1369
|
+
const render = () => {
|
|
1370
|
+
const state = store.state;
|
|
1371
|
+
const snap = snapshotOf();
|
|
1372
|
+
if (snap.pending > 0) {
|
|
1373
|
+
badgeCount.hidden = false;
|
|
1374
|
+
badgeCount.textContent = String(snap.pending);
|
|
1375
|
+
} else {
|
|
1376
|
+
badgeCount.hidden = true;
|
|
1377
|
+
}
|
|
1378
|
+
badge.setAttribute('data-tone', snap.red > 0 ? 'red' : snap.pending > 0 ? 'warn' : 'idle');
|
|
1379
|
+
unread.hidden = snap.pending === 0;
|
|
1380
|
+
unread.textContent = String(snap.pending);
|
|
1381
|
+
title.textContent = '决策收件箱' + (snap.pending > 0 ? ' · ' + snap.pending + ' 待处理' : '');
|
|
1382
|
+
// 每会话「待选」浮钮:只有当前会话有待处理项时出现。
|
|
1383
|
+
const sessionId = String(w2mDcCurrentSession ?? '');
|
|
1384
|
+
const sessionPending = store.pendingForSession(sessionId);
|
|
1385
|
+
relay.style.display = sessionPending > 0 ? 'flex' : 'none';
|
|
1386
|
+
relayCount.textContent = String(sessionPending);
|
|
1387
|
+
const sessionRed = state.items.some((item) => (item?.status === undefined || item?.status === 'pending') && String(item?.source?.sessionId ?? '') === sessionId && item?.level === 'red');
|
|
1388
|
+
relay.setAttribute('data-tone', sessionRed ? 'red' : 'warn');
|
|
1389
|
+
overlay.style.display = state.open === true ? 'flex' : 'none';
|
|
1390
|
+
drawer.style.display = state.open === true ? 'flex' : 'none';
|
|
1391
|
+
overlay.setAttribute('aria-hidden', state.open === true ? 'false' : 'true');
|
|
1392
|
+
if (state.open !== true) return;
|
|
1393
|
+
sessionChip.hidden = String(state.sessionId ?? '') === '';
|
|
1394
|
+
sessionChipText.textContent = '只看会话:' + String(state.sessionId ?? '').slice(0, 18);
|
|
1395
|
+
const filterButtons = filters.querySelectorAll('button');
|
|
1396
|
+
const filterMode = state.filter ?? 'pending';
|
|
1397
|
+
['pending', 'all', 'archived'].forEach((mode, index) => {
|
|
1398
|
+
filterButtons[index]?.classList.toggle('w2mDcFilterActive', filterMode === mode);
|
|
1399
|
+
if (filterButtons[index] !== undefined && filterButtons[index].dataset.bound !== '1') {
|
|
1400
|
+
filterButtons[index].dataset.bound = '1';
|
|
1401
|
+
filterButtons[index].addEventListener('click', () => store.setFilter(mode));
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
const levelButtonsAll = levels.querySelectorAll('button');
|
|
1405
|
+
levelButtonsAll.forEach((button) => button.classList.toggle('w2mDcFilterActive', (state.levelFilter ?? 'all') === button.dataset.level));
|
|
1406
|
+
errorLine.hidden = state.error === '';
|
|
1407
|
+
errorLine.textContent = state.error === '' ? '' : '决策中心不可用:' + state.error;
|
|
1408
|
+
const items = store.visibleTickets();
|
|
1409
|
+
body.replaceChildren();
|
|
1410
|
+
if (items.length === 0) {
|
|
1411
|
+
body.append(state.loading === false && state.loadedOnce ? empty : dcEl('div', { className: 'w2mDcEmpty' }, ['正在读取收件箱…']));
|
|
1412
|
+
} else {
|
|
1413
|
+
let index = 0;
|
|
1414
|
+
for (const [level, label] of DC_LEVELS) {
|
|
1415
|
+
const group = items.filter((item) => (item?.level ?? 'blue') === level);
|
|
1416
|
+
if (group.length === 0) continue;
|
|
1417
|
+
const groupBox = dcEl('div', { className: 'w2mDcGroup', 'data-level': level });
|
|
1418
|
+
const groupHead = dcEl('div', { className: 'w2mDcGroupHead' });
|
|
1419
|
+
groupHead.append(dcEl('span', { className: 'w2mDcGroupTitle' }, [label + ' · ' + group.length]));
|
|
1420
|
+
const pendingGroup = group.filter((item) => item?.status === undefined || item?.status === 'pending');
|
|
1421
|
+
if (pendingGroup.length > 1) {
|
|
1422
|
+
const batchResolve = dcEl('button', { type: 'button', className: 'w2mDcBtn w2mDcBatchResolve', disabled: state.busy !== '' || level === 'red' }, ['批量解决']);
|
|
1423
|
+
const batchAbandon = dcEl('button', { type: 'button', className: 'w2mDcBtn w2mDcBatchAbandon', disabled: state.busy !== '' || level === 'red' }, ['批量放弃']);
|
|
1424
|
+
if (level === 'red') { batchResolve.title = '红色必须逐条处理'; batchAbandon.title = '红色必须逐条处理'; }
|
|
1425
|
+
batchResolve.addEventListener('click', () => { void store.resolveBatchLevel(level); });
|
|
1426
|
+
batchAbandon.addEventListener('click', () => { void store.abandonBatchLevel(level); });
|
|
1427
|
+
groupHead.append(batchResolve, batchAbandon);
|
|
1428
|
+
}
|
|
1429
|
+
groupBox.append(groupHead);
|
|
1430
|
+
for (const ticket of group) {
|
|
1431
|
+
const card = dcTicketCard(store, ticket);
|
|
1432
|
+
card.setAttribute('data-index', String(index));
|
|
1433
|
+
if (Number(state.selected) === index) card.classList.add('w2mDcSelected');
|
|
1434
|
+
index += 1;
|
|
1435
|
+
groupBox.append(card);
|
|
1436
|
+
}
|
|
1437
|
+
body.append(groupBox);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
// 分叉聚合 + 生产者面板(宿主不提供时如实说明)。
|
|
1441
|
+
sideBody.replaceChildren();
|
|
1442
|
+
const forks = state.forks;
|
|
1443
|
+
const forkGroups = Array.isArray(forks?.groups) ? forks.groups : Array.isArray(forks?.forks) ? forks.forks : null;
|
|
1444
|
+
if (forkGroups === null) sideBody.append(dcEl('div', { className: 'w2mDcMeta' }, ['分叉聚合不可用']));
|
|
1445
|
+
else if (forkGroups.length === 0) sideBody.append(dcEl('div', { className: 'w2mDcMeta' }, ['暂无分叉数据']));
|
|
1446
|
+
else {
|
|
1447
|
+
for (const group of forkGroups.slice(0, 5)) {
|
|
1448
|
+
sideBody.append(dcEl('div', { className: 'w2mDcSideRow' }, [String(group?.forkId ?? '?') + ' · 总 ' + (Number(group?.total) || 0) + ' · 待 ' + (Number(group?.pending) || 0) + ' · 红 ' + (Number(group?.red) || 0)]));
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const producers = state.producers;
|
|
1452
|
+
const producerList = Array.isArray(producers?.producers) ? producers.producers : Array.isArray(producers?.items) ? producers.items : null;
|
|
1453
|
+
if (producerList === null) sideBody.append(dcEl('div', { className: 'w2mDcMeta' }, ['生产者目录不可用:' + String(state.producersError || '宿主未提供 /producers')]));
|
|
1454
|
+
else if (producerList.length === 0) sideBody.append(dcEl('div', { className: 'w2mDcMeta' }, ['暂无外部生产者']));
|
|
1455
|
+
else {
|
|
1456
|
+
for (const producer of producerList.slice(0, 5)) {
|
|
1457
|
+
sideBody.append(dcEl('div', { className: 'w2mDcSideRow' }, [String(producer?.name ?? producer?.producer ?? producer?.id ?? '?')]));
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
const aiConfigured = state.ai?.configured === true;
|
|
1461
|
+
if (document.activeElement !== aiProvider) aiProvider.value = String(state.ai?.provider ?? '');
|
|
1462
|
+
if (document.activeElement !== aiModel) aiModel.value = String(state.ai?.model ?? '');
|
|
1463
|
+
if (document.activeElement !== aiBaseUrl) aiBaseUrl.value = String(state.ai?.baseUrl ?? '');
|
|
1464
|
+
aiStatusLine.textContent = aiConfigured
|
|
1465
|
+
? '当前:' + String(state.ai?.provider ?? '') + (state.ai?.model ? ' / ' + String(state.ai.model) : '') + '(已配置)'
|
|
1466
|
+
: 'AI 未配置:填入 provider/model/apiKey 后保存,AI 建议按钮才可用。';
|
|
1467
|
+
footer.replaceChildren();
|
|
1468
|
+
if (String(state.status ?? '') !== '') footer.append(dcEl('div', { className: 'w2mDcStatusLine' }, ['状态:' + state.status]));
|
|
1469
|
+
const quota = state.quota;
|
|
1470
|
+
const ai = state.ai;
|
|
1471
|
+
const bits = [];
|
|
1472
|
+
bits.push('连接:' + (snap.mode === 'sse' ? '实时推送' : '轮询'));
|
|
1473
|
+
bits.push('键盘:↑↓ 选择,1-9 按选项处理,r 刷新,Esc 关闭');
|
|
1474
|
+
if (quota !== null && typeof quota === 'object') {
|
|
1475
|
+
const limit = quota?.limits?.maxTicketsPerSessionPerDay;
|
|
1476
|
+
bits.push('今日决策 ' + (Number(quota.total) || 0) + (limit ? ' / 每会话上限 ' + limit : ''));
|
|
1477
|
+
if (Array.isArray(quota.sessions) && quota.sessions.length > 0) {
|
|
1478
|
+
bits.push('会话:' + quota.sessions.slice(0, 3).map((entry) => String(entry.sessionId).slice(0, 10) + '×' + entry.count).join(','));
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
bits.push('AI 建议:' + (ai?.configured === true ? String(ai.provider ?? '') + '/' + String(ai.model ?? '') : '未配置'));
|
|
1482
|
+
for (const bit of bits) footer.append(dcEl('div', { className: 'w2mDcFooterLine' }, [bit]));
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
const unsubscribe = store.subscribe(render);
|
|
1486
|
+
render();
|
|
1487
|
+
return () => {
|
|
1488
|
+
unsubscribe();
|
|
1489
|
+
try { badge.remove(); } catch { /* ignore */ }
|
|
1490
|
+
try { overlay.remove(); } catch { /* ignore */ }
|
|
1491
|
+
document.removeEventListener('keydown', onKey);
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/** 左侧栏的收件箱入口:图标 + 未读红点,点击打开右侧抽屉(对齐瓮城的侧栏入口)。 */
|
|
1496
|
+
function DecisionInboxSidebar() {
|
|
1497
|
+
const store = w2mDcStore();
|
|
1498
|
+
const [snap, setSnap] = React.useState(() => store.snapshot());
|
|
1499
|
+
React.useEffect(() => {
|
|
1500
|
+
store.start();
|
|
1501
|
+
const unsubscribe = store.subscribe(() => setSnap(store.snapshot()));
|
|
1502
|
+
return unsubscribe;
|
|
1503
|
+
}, [store]);
|
|
1504
|
+
const title = snap.pending > 0 ? `决策收件箱:${snap.pending} 条待处理` : '决策收件箱:暂无待处理';
|
|
1505
|
+
return h('button', {
|
|
1506
|
+
type: 'button',
|
|
1507
|
+
className: 'w2mDcSidebarBtn',
|
|
1508
|
+
title,
|
|
1509
|
+
'aria-label': title,
|
|
1510
|
+
'data-tone': snap.red > 0 ? 'red' : snap.pending > 0 ? 'warn' : 'idle',
|
|
1511
|
+
onClick: () => store.openDrawer(),
|
|
1512
|
+
}, [
|
|
1513
|
+
h('span', { key: 'icon', className: 'w2mDcSidebarIcon' }, '▣'),
|
|
1514
|
+
h('span', { key: 'label', className: 'w2mDcSidebarLabel' }, '收件箱'),
|
|
1515
|
+
snap.pending > 0 ? h('span', { key: 'count', className: 'w2mGpw_badge' }, String(snap.pending)) : null,
|
|
1516
|
+
]);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* 会话席位:从 conversation.input.left 拿到当前 sessionId,发布给「待选」浮钮与会话筛选。
|
|
1521
|
+
* 组件本身不渲染任何东西;会话切换时更新模块变量并触发一次抽屉/浮钮重绘。
|
|
1522
|
+
*/
|
|
1523
|
+
function DecisionSessionSeat({ sessionId }) {
|
|
1524
|
+
React.useEffect(() => {
|
|
1525
|
+
w2mDcSetSession(sessionId);
|
|
1526
|
+
return () => { w2mDcSetSession(''); };
|
|
1527
|
+
}, [sessionId]);
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** GPW 面板头部的收件箱按钮:React 组件,只订阅 store 的未读数。 */
|
|
1532
|
+
function DecisionInboxButton({ t: _t }) {
|
|
1533
|
+
const store = w2mDcStore();
|
|
1534
|
+
const [snap, setSnap] = React.useState(() => store.snapshot());
|
|
1535
|
+
React.useEffect(() => {
|
|
1536
|
+
store.start();
|
|
1537
|
+
const unsubscribe = store.subscribe(() => setSnap(store.snapshot()));
|
|
1538
|
+
const timer = setInterval(() => setSnap(store.snapshot()), 4000);
|
|
1539
|
+
return () => { unsubscribe(); clearInterval(timer); };
|
|
1540
|
+
}, [store]);
|
|
1541
|
+
const label = snap.pending > 0 ? '收件箱 ' + snap.pending : '收件箱';
|
|
1542
|
+
const title = snap.pending > 0
|
|
1543
|
+
? '决策收件箱:' + snap.pending + ' 条待处理' + (snap.red > 0 ? ',其中 ' + snap.red + ' 条红色' : '')
|
|
1544
|
+
: '决策收件箱';
|
|
1545
|
+
return h('button', {
|
|
1546
|
+
key: 'decision-inbox',
|
|
1547
|
+
type: 'button',
|
|
1548
|
+
className: 'w2mGpw_btn w2mDcGpwBtn',
|
|
1549
|
+
title,
|
|
1550
|
+
'data-tone': snap.red > 0 ? 'red' : snap.pending > 0 ? 'warn' : 'idle',
|
|
1551
|
+
onClick: () => store.openDrawer(),
|
|
1552
|
+
}, h('span', { key: 'dot', className: 'w2mGpw_dot', 'data-c': snap.red > 0 ? 'red' : snap.pending > 0 ? 'yellow' : 'green' }), label);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// ─── 队列日志 UI(v3.2.2):常驻 composer chip + 右侧浮标 + 抽屉 + GPW 按钮 ────────────
|
|
1556
|
+
//
|
|
1557
|
+
// 瓮城的 queue-journal 只在「有草稿/队列快照」时才在 composer 下方渲染一排按钮;没数据时整块
|
|
1558
|
+
// 消失,看起来就像“未生效”。W2M 这一版把入口做成常驻的:
|
|
1559
|
+
// * composer dock 永远有一枚「队列日志」chip(点开抽屉);
|
|
1560
|
+
// * 右侧中部第二枚浮标「队列」(固定可见);
|
|
1561
|
+
// * GPW 面板头部「队列 N」按钮;
|
|
1562
|
+
// * 抽屉里按时间列出所有会话的草稿/队列快照,可恢复草稿、恢复队列、清除,均需用户点击。
|
|
1563
|
+
const JQ_PREFIX = '/api/w2m/composer/journal';
|
|
1564
|
+
|
|
1565
|
+
/** createQueueJournalModule().apply() 时由模块注入;为空说明模块没挂上。 */
|
|
1566
|
+
let w2mQueueJournalHelpers = null;
|
|
1567
|
+
|
|
1568
|
+
async function jqRequest(path, init = {}) {
|
|
1569
|
+
const response = await fetch(path, {
|
|
1570
|
+
credentials: 'same-origin',
|
|
1571
|
+
...init,
|
|
1572
|
+
headers: { accept: 'application/json', ...(init.body === undefined ? {} : { 'content-type': 'application/json' }), ...(init.headers ?? {}) },
|
|
1573
|
+
});
|
|
1574
|
+
let body = null;
|
|
1575
|
+
try { body = await response.json(); } catch { body = null; }
|
|
1576
|
+
if (!response.ok) {
|
|
1577
|
+
const error = new Error(body?.error ?? 'HTTP ' + response.status);
|
|
1578
|
+
error.status = response.status;
|
|
1579
|
+
throw error;
|
|
1580
|
+
}
|
|
1581
|
+
return body ?? {};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function createQueueJournalStore() {
|
|
1585
|
+
const state = {
|
|
1586
|
+
entries: [],
|
|
1587
|
+
open: false,
|
|
1588
|
+
loading: false,
|
|
1589
|
+
loadedOnce: false,
|
|
1590
|
+
error: '',
|
|
1591
|
+
busy: '',
|
|
1592
|
+
status: '',
|
|
1593
|
+
};
|
|
1594
|
+
const listeners = new Set();
|
|
1595
|
+
let timer = null;
|
|
1596
|
+
let started = false;
|
|
1597
|
+
let disposed = false;
|
|
1598
|
+
const emit = () => { for (const listener of [...listeners]) { try { listener(); } catch { /* ignore */ } } };
|
|
1599
|
+
const set = (patch) => { Object.assign(state, patch); emit(); };
|
|
1600
|
+
|
|
1601
|
+
const refresh = async () => {
|
|
1602
|
+
if (disposed) return;
|
|
1603
|
+
if (state.loading !== true) set({ loading: true });
|
|
1604
|
+
try {
|
|
1605
|
+
const body = await jqRequest(JQ_PREFIX + '?list=1');
|
|
1606
|
+
const entries = Array.isArray(body?.entries) ? body.entries : [];
|
|
1607
|
+
set({ entries, loading: false, error: '', loadedOnce: true });
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
set({ loading: false, error: String(error?.message ?? error), loadedOnce: true });
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
const start = () => {
|
|
1614
|
+
if (started || disposed) return;
|
|
1615
|
+
started = true;
|
|
1616
|
+
void refresh();
|
|
1617
|
+
timer = setInterval(() => { void refresh(); }, 8000);
|
|
1618
|
+
};
|
|
1619
|
+
|
|
1620
|
+
const stop = () => {
|
|
1621
|
+
disposed = true;
|
|
1622
|
+
if (timer !== null) clearInterval(timer);
|
|
1623
|
+
timer = null;
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
const act = async (label, work) => {
|
|
1627
|
+
set({ busy: label, status: '' });
|
|
1628
|
+
try {
|
|
1629
|
+
await work();
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
set({ error: String(error?.message ?? error) });
|
|
1632
|
+
} finally {
|
|
1633
|
+
set({ busy: '' });
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
|
|
1637
|
+
const helpers = () => w2mQueueJournalHelpers;
|
|
1638
|
+
|
|
1639
|
+
const api = {
|
|
1640
|
+
state,
|
|
1641
|
+
refresh,
|
|
1642
|
+
start,
|
|
1643
|
+
stop,
|
|
1644
|
+
subscribe(listener) {
|
|
1645
|
+
listeners.add(listener);
|
|
1646
|
+
return () => { listeners.delete(listener); };
|
|
1647
|
+
},
|
|
1648
|
+
snapshot() {
|
|
1649
|
+
const entries = state.entries;
|
|
1650
|
+
const queued = entries.reduce((sum, entry) => sum + (Array.isArray(entry?.queue) ? entry.queue.length : 0), 0);
|
|
1651
|
+
const drafts = entries.filter((entry) => String(entry?.draft ?? '').trim() !== '').length;
|
|
1652
|
+
return { total: entries.length, queued, drafts, busy: state.busy, error: state.error, open: state.open, loading: state.loading };
|
|
1653
|
+
},
|
|
1654
|
+
openDrawer() {
|
|
1655
|
+
start();
|
|
1656
|
+
set({ open: true });
|
|
1657
|
+
return refresh();
|
|
1658
|
+
},
|
|
1659
|
+
closeDrawer() {
|
|
1660
|
+
set({ open: false });
|
|
1661
|
+
},
|
|
1662
|
+
toggleDrawer() {
|
|
1663
|
+
if (state.open) api.closeDrawer();
|
|
1664
|
+
else api.openDrawer();
|
|
1665
|
+
},
|
|
1666
|
+
async restoreDraft(entry) {
|
|
1667
|
+
return act('draft', async () => {
|
|
1668
|
+
const draftText = String(entry?.draft ?? '');
|
|
1669
|
+
if (draftText === '') { set({ status: '这条记录没有草稿' }); return; }
|
|
1670
|
+
const helper = helpers();
|
|
1671
|
+
const ok = typeof helper?.writeComposer === 'function' ? helper.writeComposer(draftText) : false;
|
|
1672
|
+
set({ status: ok ? '草稿已放入输入框' : '未找到输入框,请先打开一个会话来恢复' });
|
|
1673
|
+
});
|
|
1674
|
+
},
|
|
1675
|
+
async restoreQueue(entry) {
|
|
1676
|
+
return act('queue', async () => {
|
|
1677
|
+
const items = Array.isArray(entry?.queue) ? entry.queue : [];
|
|
1678
|
+
if (items.length === 0) { set({ status: '这条记录没有排队消息' }); return; }
|
|
1679
|
+
const helper = helpers();
|
|
1680
|
+
if (helper === null || typeof helper.queueRestore !== 'function') {
|
|
1681
|
+
set({ status: '宿主路由不可用,无法恢复队列' });
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const sessionId = String(entry?.sessionKey ?? entry?.key ?? '');
|
|
1685
|
+
const restored = await helper.queueRestore(sessionId, items);
|
|
1686
|
+
set({
|
|
1687
|
+
status: restored.ok
|
|
1688
|
+
? `已放回 ${restored.queued} 条排队消息${restored.failed ? `(${restored.failed} 条失败)` : ''}`
|
|
1689
|
+
: restored.error === 'session-not-live'
|
|
1690
|
+
? '会话未运行,队列保留待重试'
|
|
1691
|
+
: '队列恢复失败:' + String(restored.error ?? 'unknown'),
|
|
1692
|
+
});
|
|
1693
|
+
});
|
|
1694
|
+
},
|
|
1695
|
+
async clear(entry) {
|
|
1696
|
+
return act('clear', async () => {
|
|
1697
|
+
await jqRequest(JQ_PREFIX, { method: 'POST', body: JSON.stringify({ key: String(entry?.key ?? ''), draft: '', queue: [] }) });
|
|
1698
|
+
set({ status: '已清除这条记录' });
|
|
1699
|
+
await refresh();
|
|
1700
|
+
});
|
|
1701
|
+
},
|
|
1702
|
+
};
|
|
1703
|
+
return api;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
let w2mJqStoreInstance = null;
|
|
1707
|
+
function w2mJqStore() {
|
|
1708
|
+
if (w2mJqStoreInstance === null) w2mJqStoreInstance = createQueueJournalStore();
|
|
1709
|
+
return w2mJqStoreInstance;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/** 右侧中部的第二枚浮标(在收件箱浮标下方)+ 队列日志抽屉。 */
|
|
1713
|
+
function mountQueueJournalUI() {
|
|
1714
|
+
if (typeof document === 'undefined' || document.body === undefined) return () => {};
|
|
1715
|
+
const store = w2mJqStore();
|
|
1716
|
+
store.start();
|
|
1717
|
+
const badge = dcEl('button', {
|
|
1718
|
+
type: 'button',
|
|
1719
|
+
className: 'w2mJqBadge',
|
|
1720
|
+
title: '队列日志:未发送的草稿与排队消息',
|
|
1721
|
+
'aria-label': '队列日志',
|
|
1722
|
+
});
|
|
1723
|
+
const badgeIcon = dcEl('span', { className: 'w2mJqBadgeIcon' }, ['☰']);
|
|
1724
|
+
const badgeText = dcEl('span', { className: 'w2mJqBadgeText' }, ['队列']);
|
|
1725
|
+
const badgeCount = dcEl('span', { className: 'w2mJqBadgeCount' }, ['0']);
|
|
1726
|
+
badge.append(badgeIcon, badgeText, badgeCount);
|
|
1727
|
+
badge.addEventListener('click', () => store.toggleDrawer());
|
|
1728
|
+
|
|
1729
|
+
const overlay = dcEl('div', { className: 'w2mDcOverlay' });
|
|
1730
|
+
overlay.style.display = 'none';
|
|
1731
|
+
const drawer = dcEl('aside', { className: 'w2mDcDrawer w2mJqDrawer', role: 'dialog', 'aria-modal': 'true', 'aria-label': '队列日志' });
|
|
1732
|
+
drawer.style.display = 'none';
|
|
1733
|
+
const header = dcEl('div', { className: 'w2mDcDrawerHead' });
|
|
1734
|
+
const title = dcEl('div', { className: 'w2mDcDrawerTitle' }, ['队列日志']);
|
|
1735
|
+
const count = dcEl('span', { className: 'w2mDcUnread', hidden: true }, ['0']);
|
|
1736
|
+
const headActions = dcEl('div', { className: 'w2mDcActions' });
|
|
1737
|
+
const refreshButton = dcEl('button', { type: 'button', className: 'w2mDcBtn' }, ['刷新']);
|
|
1738
|
+
const closeButton = dcEl('button', { type: 'button', className: 'w2mDcBtn' }, ['关闭']);
|
|
1739
|
+
refreshButton.addEventListener('click', () => { void store.refresh(); });
|
|
1740
|
+
closeButton.addEventListener('click', () => store.closeDrawer());
|
|
1741
|
+
headActions.append(refreshButton, closeButton);
|
|
1742
|
+
header.append(title, count, headActions);
|
|
1743
|
+
const hint = dcEl('div', { className: 'w2mJqHint' }, ['这里保存的是未发送的草稿与官方队列的只读快照;恢复只在你点击时发生,插件从不自动发送消息。']);
|
|
1744
|
+
const statusLine = dcEl('div', { className: 'w2mJqStatus', role: 'status' }, ['']);
|
|
1745
|
+
const body = dcEl('div', { className: 'w2mDcBody' });
|
|
1746
|
+
const errorLine = dcEl('div', { className: 'w2mDcError', role: 'alert', hidden: true });
|
|
1747
|
+
drawer.append(header, hint, errorLine, statusLine, body);
|
|
1748
|
+
overlay.append(drawer);
|
|
1749
|
+
document.body.append(badge, overlay);
|
|
1750
|
+
overlay.addEventListener('click', (event) => { if (event.target === overlay) store.closeDrawer(); });
|
|
1751
|
+
const onKey = (event) => { if (event.key === 'Escape' && store.state.open) store.closeDrawer(); };
|
|
1752
|
+
document.addEventListener('keydown', onKey);
|
|
1753
|
+
|
|
1754
|
+
const entryCard = (entry) => {
|
|
1755
|
+
const card = dcEl('div', { className: 'w2mJqEntry' });
|
|
1756
|
+
const head = dcEl('div', { className: 'w2mJqEntryHead' });
|
|
1757
|
+
const updated = String(entry?.updatedAt ?? '').replace('T', ' ').slice(0, 16);
|
|
1758
|
+
head.append(
|
|
1759
|
+
dcEl('span', { className: 'w2mJqEntryKey', title: String(entry?.key ?? '') }, [String(entry?.sessionKey || entry?.key || '(未知会话)').slice(0, 34)]),
|
|
1760
|
+
dcEl('span', { className: 'w2mJqEntryTime' }, [updated]),
|
|
1761
|
+
);
|
|
1762
|
+
card.append(head);
|
|
1763
|
+
const draftText = String(entry?.draft ?? '').trim();
|
|
1764
|
+
if (draftText !== '') {
|
|
1765
|
+
card.append(dcEl('div', { className: 'w2mJqDraft', title: draftText }, ['草稿:' + (draftText.length > 140 ? draftText.slice(0, 137) + '…' : draftText)]));
|
|
1766
|
+
}
|
|
1767
|
+
const queue = Array.isArray(entry?.queue) ? entry.queue : [];
|
|
1768
|
+
if (queue.length > 0) {
|
|
1769
|
+
card.append(dcEl('div', { className: 'w2mJqQueueSummary' }, ['排队消息:' + queue.length + ' 条']));
|
|
1770
|
+
const list = dcEl('div', { className: 'w2mJqQueueList' });
|
|
1771
|
+
for (const [index, item] of queue.slice(0, 8).entries()) {
|
|
1772
|
+
const text = String(item?.text ?? '').replace(/\s+/g, ' ').trim();
|
|
1773
|
+
list.append(dcEl('div', { className: 'w2mJqQueueRow', title: text }, [String(index + 1) + '. ' + (text.length > 120 ? text.slice(0, 117) + '…' : text)]));
|
|
1774
|
+
}
|
|
1775
|
+
card.append(list);
|
|
1776
|
+
}
|
|
1777
|
+
const actions = dcEl('div', { className: 'w2mDcActions' });
|
|
1778
|
+
const busy = store.state.busy !== '';
|
|
1779
|
+
const draftButton = dcEl('button', { type: 'button', className: 'w2mDcBtn w2mDcBtnPrimary', disabled: busy || draftText === '' }, ['恢复草稿']);
|
|
1780
|
+
const queueButton = dcEl('button', { type: 'button', className: 'w2mDcBtn', disabled: busy || queue.length === 0 }, ['恢复队列']);
|
|
1781
|
+
const clearButton = dcEl('button', { type: 'button', className: 'w2mDcBtn w2mDcBtnDanger', disabled: busy }, ['清除']);
|
|
1782
|
+
draftButton.addEventListener('click', () => { void store.restoreDraft(entry); });
|
|
1783
|
+
queueButton.addEventListener('click', () => { void store.restoreQueue(entry); });
|
|
1784
|
+
clearButton.addEventListener('click', () => { void store.clear(entry); });
|
|
1785
|
+
actions.append(draftButton, queueButton, clearButton);
|
|
1786
|
+
card.append(actions);
|
|
1787
|
+
return card;
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
const render = () => {
|
|
1791
|
+
const snap = store.snapshot();
|
|
1792
|
+
badgeCount.textContent = String(snap.total);
|
|
1793
|
+
badgeCount.hidden = snap.total === 0;
|
|
1794
|
+
badge.setAttribute('data-tone', snap.total > 0 ? 'warn' : 'idle');
|
|
1795
|
+
count.hidden = snap.total === 0;
|
|
1796
|
+
count.textContent = String(snap.total);
|
|
1797
|
+
title.textContent = '队列日志' + (snap.total > 0 ? ' · ' + snap.total + ' 条保存' : '');
|
|
1798
|
+
overlay.style.display = snap.open === true ? 'flex' : 'none';
|
|
1799
|
+
drawer.style.display = snap.open === true ? 'flex' : 'none';
|
|
1800
|
+
overlay.setAttribute('aria-hidden', snap.open === true ? 'false' : 'true');
|
|
1801
|
+
if (snap.open !== true) return;
|
|
1802
|
+
errorLine.hidden = snap.error === '';
|
|
1803
|
+
errorLine.textContent = snap.error === '' ? '' : '队列日志不可用:' + snap.error;
|
|
1804
|
+
statusLine.textContent = store.state.status;
|
|
1805
|
+
body.replaceChildren();
|
|
1806
|
+
if (snap.total === 0) {
|
|
1807
|
+
body.append(dcEl('div', { className: 'w2mDcEmpty' }, [snap.loading ? '正在读取队列日志…' : '队列日志是空的:输入框里的草稿与排队消息会自动保存到这里(重启/崩溃后可恢复)。']));
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
for (const entry of store.state.entries) body.append(entryCard(entry));
|
|
1811
|
+
};
|
|
1812
|
+
const unsubscribe = store.subscribe(render);
|
|
1813
|
+
render();
|
|
1814
|
+
return () => {
|
|
1815
|
+
unsubscribe();
|
|
1816
|
+
try { badge.remove(); } catch { /* ignore */ }
|
|
1817
|
+
try { overlay.remove(); } catch { /* ignore */ }
|
|
1818
|
+
document.removeEventListener('keydown', onKey);
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/** GPW 面板头部的队列日志按钮。 */
|
|
1823
|
+
function QueueJournalGpwButton({ t: _t }) {
|
|
1824
|
+
const store = w2mJqStore();
|
|
1825
|
+
const [snap, setSnap] = React.useState(() => store.snapshot());
|
|
1826
|
+
React.useEffect(() => {
|
|
1827
|
+
store.start();
|
|
1828
|
+
const unsubscribe = store.subscribe(() => setSnap(store.snapshot()));
|
|
1829
|
+
return unsubscribe;
|
|
1830
|
+
}, [store]);
|
|
1831
|
+
const label = snap.total > 0 ? '队列 ' + snap.total : '队列日志';
|
|
1832
|
+
const title = snap.total > 0 ? '队列日志:' + snap.total + ' 条保存的记录' + (snap.queued > 0 ? ',' + snap.queued + ' 条排队消息' : '') : '队列日志';
|
|
1833
|
+
return h('button', {
|
|
1834
|
+
key: 'queue-journal',
|
|
1835
|
+
type: 'button',
|
|
1836
|
+
className: 'w2mGpw_btn w2mJqGpwBtn',
|
|
1837
|
+
title,
|
|
1838
|
+
'data-tone': snap.total > 0 ? 'warn' : 'idle',
|
|
1839
|
+
onClick: () => store.openDrawer(),
|
|
1840
|
+
}, h('span', { key: 'dot', className: 'w2mGpw_dot', 'data-c': snap.total > 0 ? 'yellow' : 'green' }), label);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
687
1843
|
function GpwPanel({ t, sessionId, onClose, inline }) {
|
|
688
1844
|
const [state, setState] = React.useState(null);
|
|
689
1845
|
const [error, setError] = React.useState(null);
|
|
@@ -781,6 +1937,8 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
781
1937
|
h('div', { key: 'card', className: 'w2mGpw_card' }, [
|
|
782
1938
|
h('div', { key: 'head', className: 'w2mGpw_head' }, [
|
|
783
1939
|
h('span', { key: 'title', className: 'w2mGpw_title' }, t('gpwTitle')),
|
|
1940
|
+
h(DecisionInboxButton, { key: 'inbox', t }),
|
|
1941
|
+
h(QueueJournalGpwButton, { key: 'queue-journal', t }),
|
|
784
1942
|
h('button', { key: 'close', type: 'button', className: 'w2mGpw_close', onClick: onClose }, '×'),
|
|
785
1943
|
]),
|
|
786
1944
|
error === null ? null : h('div', { key: 'err', className: 'w2mGpw_note' }, error),
|
|
@@ -1266,7 +2424,12 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
1266
2424
|
const queue = Array.isArray(entry?.queue) ? entry.queue : []
|
|
1267
2425
|
const hasEntry = Boolean(entry && (entry.draft || queue.length))
|
|
1268
2426
|
|
|
1269
|
-
if (!hasEntry && history.length === 0)
|
|
2427
|
+
if (!hasEntry && history.length === 0) {
|
|
2428
|
+
return h('div', { 'data-fang-entry': 'queue-journal', className: 'w2mJqDockChip' }, [
|
|
2429
|
+
h('span', { key: 'label', className: 'w2mJqDockLabel' }, '队列日志'),
|
|
2430
|
+
h('button', { key: 'open', type: 'button', className: 'w2mJqDockOpen', onClick: () => w2mJqStore().openDrawer() }, '打开'),
|
|
2431
|
+
]);
|
|
2432
|
+
}
|
|
1270
2433
|
|
|
1271
2434
|
const restoreItems = async (items, draft) => {
|
|
1272
2435
|
const draftText = String(draft || '')
|
|
@@ -1409,10 +2572,11 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
1409
2572
|
|
|
1410
2573
|
return {
|
|
1411
2574
|
inject: ['slots'],
|
|
2575
|
+
__w2mHelpers: { journalRequest, journalHistory, queueRestore, writeComposer, findComposer, resolveKey },
|
|
1412
2576
|
apply(ctx) {
|
|
1413
2577
|
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
1414
2578
|
name: 'conversation.composer.dock',
|
|
1415
|
-
id: '
|
|
2579
|
+
id: 'w2m-queue-journal',
|
|
1416
2580
|
order: 90,
|
|
1417
2581
|
}, JournalDock))
|
|
1418
2582
|
|
|
@@ -3949,7 +5113,9 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
|
|
|
3949
5113
|
// /api/w2m/composer/journal*,恢复只由用户点击触发。
|
|
3950
5114
|
try {
|
|
3951
5115
|
if (typeof document !== 'undefined' && ctx !== null && typeof ctx === 'object' && ctx.slots !== undefined) {
|
|
3952
|
-
|
|
5116
|
+
const queueJournalModule = createQueueJournalModule();
|
|
5117
|
+
w2mQueueJournalHelpers = queueJournalModule?.__w2mHelpers ?? null;
|
|
5118
|
+
queueJournalModule.apply({ slots: ctx.slots });
|
|
3953
5119
|
}
|
|
3954
5120
|
} catch (error) {
|
|
3955
5121
|
console.error('@twinsearth/w2m-dsh-plugin: the queue journal was not mounted; the rest of the page is unaffected', error);
|
|
@@ -4050,6 +5216,53 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
|
|
|
4050
5216
|
} catch (error) {
|
|
4051
5217
|
console.error('@twinsearth/w2m-dsh-plugin: the remote-view main page was not mounted', error);
|
|
4052
5218
|
}
|
|
5219
|
+
// v3.2.1: 决策收件箱的侧栏入口(对齐瓮城的收件箱席位);点开与浮标同一个抽屉。
|
|
5220
|
+
try {
|
|
5221
|
+
slots.inject('sidebar.panellist', () => slots.register({
|
|
5222
|
+
name: 'sidebar.panellist',
|
|
5223
|
+
id: 'w2m-decision-inbox',
|
|
5224
|
+
order: 6,
|
|
5225
|
+
label: () => '决策收件箱',
|
|
5226
|
+
inject: () => ({}),
|
|
5227
|
+
}, DecisionInboxSidebar));
|
|
5228
|
+
} catch (error) {
|
|
5229
|
+
console.error('@twinsearth/w2m-dsh-plugin: the decision inbox sidebar entry was not mounted', error);
|
|
5230
|
+
}
|
|
5231
|
+
// v3.2.3: 会话席位,仅用于发布当前 sessionId(「待选」浮钮与会话筛选的数据来源)。
|
|
5232
|
+
try {
|
|
5233
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
5234
|
+
name: 'conversation.input.left',
|
|
5235
|
+
id: 'w2m-decision-session',
|
|
5236
|
+
order: 62,
|
|
5237
|
+
label: 'decision session',
|
|
5238
|
+
inject: (sessionId) => ({ sessionId }),
|
|
5239
|
+
}, DecisionSessionSeat));
|
|
5240
|
+
} catch (error) {
|
|
5241
|
+
console.error('@twinsearth/w2m-dsh-plugin: the decision session seat was not mounted', error);
|
|
5242
|
+
}
|
|
5243
|
+
// v3.2.1: 决策收件箱的右侧浮标 + 抽屉。DOM 直挂,独立于任何页面、插槽或 GPW 是否打开;
|
|
5244
|
+
// 没有 document 的环境(单元测试沙箱)自动跳过。
|
|
5245
|
+
try {
|
|
5246
|
+
if (typeof document !== 'undefined' && document.body !== undefined) {
|
|
5247
|
+
const disposeDecisionInbox = mountW2mDecisionCenterUI();
|
|
5248
|
+
if (typeof disposeDecisionInbox === 'function' && typeof ctx.effect === 'function') {
|
|
5249
|
+
ctx.effect(() => disposeDecisionInbox, 'w2m: decision inbox');
|
|
5250
|
+
}
|
|
5251
|
+
}
|
|
5252
|
+
} catch (error) {
|
|
5253
|
+
console.error('@twinsearth/w2m-dsh-plugin: the decision inbox was not mounted; the rest of the page is unaffected', error);
|
|
5254
|
+
}
|
|
5255
|
+
// v3.2.2: 队列日志的右侧浮标 + 抽屉(DOM 直挂,与 composer chip 共用数据)。
|
|
5256
|
+
try {
|
|
5257
|
+
if (typeof document !== 'undefined' && document.body !== undefined) {
|
|
5258
|
+
const disposeQueueJournal = mountQueueJournalUI();
|
|
5259
|
+
if (typeof disposeQueueJournal === 'function' && typeof ctx.effect === 'function') {
|
|
5260
|
+
ctx.effect(() => disposeQueueJournal, 'w2m: queue journal');
|
|
5261
|
+
}
|
|
5262
|
+
}
|
|
5263
|
+
} catch (error) {
|
|
5264
|
+
console.error('@twinsearth/w2m-dsh-plugin: the queue journal UI was not mounted; the rest of the page is unaffected', error);
|
|
5265
|
+
}
|
|
4053
5266
|
}
|
|
4054
5267
|
|
|
4055
5268
|
/**
|