@twinsearth/w2m-dsh-plugin 2.2.6 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -488,6 +488,14 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
488
488
  '.w2mGpw_tag[data-d="deny"]{border-color:var(--dsw-alias-state-danger-primary);color:var(--dsw-alias-state-danger-primary)}',
489
489
  '.w2mGpw_note{font-size:12px;color:var(--dsw-alias-label-secondary)}',
490
490
  '.w2mGpw_hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
491
+ '.w2mDc_level{display:flex;flex-direction:column;gap:4px}',
492
+ '.w2mDc_ticket{display:flex;flex-direction:column;gap:4px;padding:5px 7px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-default);background:var(--dsw-alias-bg-layer-2)}',
493
+ '.w2mDc_head{display:flex;align-items:center;gap:6px;min-width:0}',
494
+ '.w2mDc_title{flex:1;min-width:0;font-size:12px;line-height:18px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
495
+ '.w2mDc_meta{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
496
+ '.w2mDc_context{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;max-height:88px;overflow:auto}',
497
+ '.w2mDc_actions{display:flex;flex-wrap:wrap;gap:6px}',
498
+ '.w2mDc_advice{font-size:12px;color:var(--dsw-alias-label-secondary);padding:4px 6px;border-left:2px solid var(--dsw-alias-state-business-primary);white-space:pre-wrap}',
491
499
  '.w2mGpw{margin-top:6px;padding-top:6px;border-top:.5px solid var(--dsw-alias-border-default);display:flex;flex-direction:column;gap:4px;max-height:320px;overflow:auto}',
492
500
  '.w2mGpw_title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary)}',
493
501
  '.w2mGpw_block{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px}',
@@ -838,6 +846,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
838
846
  ]),
839
847
  ])) : []),
840
848
  ]),
849
+ h(DecisionCenterSection, { key: 'decision-center' }),
841
850
  pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
842
851
  h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
843
852
  ...pending.map((item) => h('div', { key: item.id, className: 'w2mGpw_recent' }, [
@@ -855,6 +864,590 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
855
864
  ]);
856
865
  }
857
866
 
867
+ /**
868
+ * 决策中心(v3.0.0,移植自 dsh-Fang 的决策收件箱):GPW 设置页与 GPW 浮标渲染同一个面板。
869
+ *
870
+ * 数据面就是本包自己的 `/api/w2m/decisions*`(见 src/decision-center/routes.mjs):4 秒轮询,
871
+ * 红票置顶、黄/蓝展开、绿/灰默认折叠;解决/放弃/批量都走服务层,UI 不猜状态。
872
+ * AI 建议只做「推荐」,不替人做决定。
873
+ */
874
+ function DecisionCenterSection() {
875
+ const LEVELS = [
876
+ ['red', '紧急决策', '会阻塞 Agent'],
877
+ ['yellow', '建议决策', '影响方向/进度'],
878
+ ['blue', '日常建议', '可按推荐处理'],
879
+ ['green', '通知与汇报', '默认折叠'],
880
+ ['gray', '已归档 / 过期', '只读'],
881
+ ];
882
+ const [state, setState] = React.useState({ status: 'loading', items: [], error: '', busy: '', ai: null, advice: {}, collapsed: { green: true, gray: true }, open: true });
883
+ const load = React.useCallback(async () => {
884
+ try {
885
+ const [listResponse, aiResponse] = await Promise.all([
886
+ fetch('/api/w2m/decisions?includeArchived=1', { credentials: 'same-origin' }),
887
+ fetch('/api/w2m/decisions/ai', { credentials: 'same-origin' }).catch(() => null),
888
+ ]);
889
+ if (!listResponse.ok) {
890
+ setState((prev) => ({ ...prev, status: listResponse.status === 404 ? 'absent' : 'error', error: 'HTTP ' + listResponse.status }));
891
+ return;
892
+ }
893
+ const body = await listResponse.json();
894
+ const aiBody = aiResponse !== null && aiResponse.ok ? await aiResponse.json() : null;
895
+ setState((prev) => ({
896
+ ...prev,
897
+ status: 'ready',
898
+ items: Array.isArray(body?.items) ? body.items : [],
899
+ error: '',
900
+ ai: aiBody,
901
+ }));
902
+ } catch (error) {
903
+ setState((prev) => ({ ...prev, status: 'error', error: String(error?.message ?? error) }));
904
+ }
905
+ }, []);
906
+ React.useEffect(() => {
907
+ let alive = true;
908
+ void load();
909
+ const timer = setInterval(() => { if (alive) void load(); }, 4000);
910
+ return () => { alive = false; clearInterval(timer); };
911
+ }, [load]);
912
+ const post = async (path, body) => {
913
+ const response = await fetch(path, {
914
+ method: 'POST',
915
+ credentials: 'same-origin',
916
+ headers: { 'content-type': 'application/json' },
917
+ body: JSON.stringify(body ?? {}),
918
+ });
919
+ const payload = await response.json().catch(() => null);
920
+ if (!response.ok || payload?.ok === false) throw new Error(payload?.error ?? 'HTTP ' + response.status);
921
+ return payload;
922
+ };
923
+ const act = async (kind, ticket, option) => {
924
+ setState((prev) => ({ ...prev, busy: ticket.id, error: '' }));
925
+ try {
926
+ await post('/api/w2m/decisions/' + kind, kind === 'resolve'
927
+ ? { id: ticket.id, option, by: 'w2m-gpw' }
928
+ : { id: ticket.id, by: 'w2m-gpw' });
929
+ } catch (error) {
930
+ setState((prev) => ({ ...prev, error: String(error?.message ?? error) }));
931
+ } finally {
932
+ setState((prev) => ({ ...prev, busy: '' }));
933
+ await load();
934
+ }
935
+ };
936
+ const advise = async (ticket) => {
937
+ setState((prev) => ({ ...prev, busy: 'ai:' + ticket.id, error: '' }));
938
+ try {
939
+ const payload = await post('/api/w2m/decisions/ai/advise', { id: ticket.id });
940
+ const text = payload?.advice?.text ?? payload?.advice ?? payload?.ticket?.advice ?? '(无建议文本)';
941
+ setState((prev) => ({ ...prev, advice: { ...prev.advice, [ticket.id]: typeof text === 'string' ? text : JSON.stringify(text) } }));
942
+ } catch (error) {
943
+ setState((prev) => ({ ...prev, error: String(error?.message ?? error) }));
944
+ } finally {
945
+ setState((prev) => ({ ...prev, busy: '' }));
946
+ }
947
+ };
948
+ const toggle = (level) => setState((prev) => ({ ...prev, collapsed: { ...prev.collapsed, [level]: !prev.collapsed[level] } }));
949
+ const pending = state.items.filter((item) => item.status === 'pending');
950
+ const title = '决策中心' + (pending.length > 0 ? ' · ' + pending.length + ' 待处理' : '');
951
+ const ticketRow = (ticket) => {
952
+ const options = Array.isArray(ticket.options) ? ticket.options : [];
953
+ return h('div', { key: ticket.id, className: 'w2mDc_ticket' }, [
954
+ h('div', { key: 'head', className: 'w2mDc_head' }, [
955
+ h('span', { key: 'dot', className: 'w2mGpw_dot', 'data-c': ticket.level ?? 'blue' }),
956
+ h('span', { key: 'title', className: 'w2mDc_title', title: ticket.title ?? '' }, ticket.title ?? '(未命名)'),
957
+ h('span', { key: 'meta', className: 'w2mDc_meta' }, (ticket.source?.agentRole ?? ticket.sourceRole ?? '') + (ticket.status !== 'pending' ? ' · ' + ticket.status : '')),
958
+ ]),
959
+ ticket.context ? h('div', { key: 'ctx', className: 'w2mDc_context' }, String(ticket.context)) : null,
960
+ h('div', { key: 'actions', className: 'w2mDc_actions' }, [
961
+ ...options.slice(0, 4).map((option) => h('button', {
962
+ key: 'opt:' + option.value,
963
+ type: 'button',
964
+ className: 'w2mGpw_btn',
965
+ disabled: state.busy !== '' || ticket.status !== 'pending',
966
+ onClick: () => void act('resolve', ticket, option.value),
967
+ }, option.label ?? option.value)),
968
+ ticket.status === 'pending' ? h('button', {
969
+ key: 'ai',
970
+ type: 'button',
971
+ className: 'w2mGpw_btn',
972
+ disabled: state.busy !== '',
973
+ onClick: () => void advise(ticket),
974
+ }, 'AI 建议') : null,
975
+ ticket.status === 'pending' ? h('button', {
976
+ key: 'abandon',
977
+ type: 'button',
978
+ className: 'w2mGpw_btn',
979
+ disabled: state.busy !== '' || options.length > 0,
980
+ onClick: () => void act('abandon', ticket),
981
+ }, '放弃') : null,
982
+ ]),
983
+ state.advice[ticket.id] ? h('div', { key: 'advice', className: 'w2mDc_advice' }, state.advice[ticket.id]) : null,
984
+ ]);
985
+ };
986
+ if (state.status === 'absent') {
987
+ return h('div', { className: 'w2mGpw_block' }, [
988
+ h('div', { key: 't', className: 'w2mGpw_title' }, '决策中心'),
989
+ h('div', { key: 'h', className: 'w2mGpw_hint' }, '决策中心插件未启用:在 profile 的 dsh.profile.bundles 或 patch 中启用 @twinsearth/w2m-dsh-plugin/decision-center'),
990
+ ]);
991
+ }
992
+ return h('div', { className: 'w2mGpw_block' }, [
993
+ h('div', { key: 'head', className: 'w2mGpw_row' }, [
994
+ h('span', { key: 't', className: 'w2mGpw_title' }, title),
995
+ h('button', { key: 'toggle', type: 'button', className: 'w2mGpw_btn', onClick: () => setState((prev) => ({ ...prev, open: !prev.open })) }, state.open ? '收起' : '展开'),
996
+ h('button', { key: 'reload', type: 'button', className: 'w2mGpw_btn', onClick: () => void load() }, '刷新'),
997
+ ]),
998
+ state.error !== '' ? h('div', { key: 'error', className: 'w2mGpw_note' }, '决策中心不可用:' + state.error) : null,
999
+ state.status === 'loading' ? h('div', { key: 'loading', className: 'w2mGpw_hint' }, '正在读取决策队列…') : null,
1000
+ state.open ? h('div', { key: 'levels' }, LEVELS.map(([level, label, hint]) => {
1001
+ const tickets = state.items.filter((item) => (item.level ?? 'blue') === level && (level === 'gray' ? item.status !== 'pending' : true));
1002
+ if (tickets.length === 0) return null;
1003
+ const collapsed = state.collapsed[level] === true;
1004
+ return h('div', { key: level, className: 'w2mDc_level' }, [
1005
+ h('div', { key: 'h', className: 'w2mGpw_row' }, [
1006
+ h('span', { key: 'dot', className: 'w2mGpw_dot', 'data-c': level }),
1007
+ h('span', { key: 'label', className: 'w2mGpw_title' }, label + ' · ' + tickets.length),
1008
+ h('span', { key: 'hint', className: 'w2mGpw_hint' }, hint),
1009
+ h('button', { key: 'fold', type: 'button', className: 'w2mGpw_small', onClick: () => toggle(level) }, collapsed ? '+' : '-'),
1010
+ ]),
1011
+ collapsed ? null : h('div', { key: 'list', className: 'w2mDc_level' }, tickets.map(ticketRow)),
1012
+ ]);
1013
+ })) : null,
1014
+ ]);
1015
+ }
1016
+
1017
+ /**
1018
+ * 队列日志(v3.1.0,移植自 @twinsearth/dsh-fang-queue-journal 0.1.5)。
1019
+ *
1020
+ * 草稿/队列的持久化镜像是宿主服务 `/api/w2m/composer/journal*`(见 src/queue-journal/index.mjs);
1021
+ * 恢复必须由用户显式点击,本模块自身从不发送消息。Fang 的独立模块入口在这里展开成同一个客户端
1022
+ * bundle 内的工厂函数,React 与 h 复用外层。
1023
+ */
1024
+ function createQueueJournalModule() {
1025
+
1026
+ const DEBOUNCE_MS = 250
1027
+ const FALLBACK_MS = 15_000
1028
+
1029
+ /** 从 URL 里尝试取会话 id(兼容多种路由形态)。 */
1030
+ function sessionKeyFromUrl() {
1031
+ const hash = String(location.hash || '').replace(/^#/, '')
1032
+ const fromHash = /sessions?\/([^/?#]+)/.exec(hash)
1033
+ const fromPath = /sessions?\/([^/?#]+)/.exec(location.pathname)
1034
+ const q = new URLSearchParams(location.search).get('session') || new URLSearchParams(location.search).get('sessionId')
1035
+ return String(q || fromHash?.[1] || fromPath?.[1] || '').slice(0, 200)
1036
+ }
1037
+
1038
+ /** 从 DOM 上找宿主暴露的会话 id(DSH 可能在容器上打 data 属性)。 */
1039
+ function sessionKeyFromDom() {
1040
+ const selectors = ['[data-session-id]', '[data-sessionid]', '[data-current-session]', '[data-active-session]']
1041
+ for (const selector of selectors) {
1042
+ const el = document.querySelector(selector)
1043
+ const _value = el?.getAttribute(selector.replace(/[[\]]/g, '').split('=')[0]) || ''
1044
+ const attr = selector.slice(1, -1)
1045
+ const v = el?.getAttribute(attr)
1046
+ if (v) return String(v).slice(0, 200)
1047
+ }
1048
+ return ''
1049
+ }
1050
+
1051
+ /** 本页实例键:sessionStorage 里稳定存在,避免多会话挤在同一个 key。 */
1052
+ function instanceKey() {
1053
+ try {
1054
+ const cached = sessionStorage.getItem('fang-queue-journal-instance')
1055
+ if (cached) return cached
1056
+ const generated = 'page:' + Math.random().toString(36).slice(2, 10) + ':' + Date.now().toString(36)
1057
+ sessionStorage.setItem('fang-queue-journal-instance', generated)
1058
+ return generated
1059
+ } catch { return 'page:unknown' }
1060
+ }
1061
+
1062
+ let activeKey = ''
1063
+ let activeSessionId = ''
1064
+ function resolveKey() {
1065
+ // 优先级:slot 给的 SessionId > DOM > URL > 本页实例键(绝不使用 "/")
1066
+ if (activeSessionId) return activeSessionId
1067
+ const fromUrl = sessionKeyFromUrl()
1068
+ const fromDom = sessionKeyFromDom()
1069
+ const key = String(fromDom || fromUrl || instanceKey())
1070
+ activeKey = key === '/' || key === '' ? instanceKey() : key
1071
+ return activeKey
1072
+ }
1073
+
1074
+ async function fangToken() { return 'w2m-same-origin' }
1075
+
1076
+ async function journalRequest(method, entry, keyOverride) {
1077
+ const token = await fangToken()
1078
+ if (!token) return null
1079
+ const key = keyOverride || resolveKey()
1080
+ const res = await fetch(method === 'POST' ? '/api/w2m/composer/journal' : '/api/w2m/composer/journal?key=' + encodeURIComponent(key), {
1081
+ method,
1082
+ headers: { 'content-type': 'application/json', 'x-fang-auth': token },
1083
+ body: method === 'POST'
1084
+ ? JSON.stringify({ key, sessionId: activeSessionId || undefined, sessionKey: key, path: location.hash || location.pathname, ...entry })
1085
+ : undefined,
1086
+ })
1087
+ if (!res.ok) return null
1088
+ try { return await res.json() } catch { return null }
1089
+ }
1090
+
1091
+ async function journalHistory(limit = 10) {
1092
+ const token = await fangToken()
1093
+ if (!token) return []
1094
+ try {
1095
+ const res = await fetch('/api/w2m/composer/journal/history?limit=' + Number(limit) + '&sessionKey=' + encodeURIComponent(resolveKey()), {
1096
+ headers: { accept: 'application/json', 'x-fang-auth': token },
1097
+ })
1098
+ if (!res.ok) return []
1099
+ const body = await res.json()
1100
+ return Array.isArray(body?.items) ? body.items : []
1101
+ } catch { return [] }
1102
+ }
1103
+
1104
+ async function queueRestore(sessionId, items) {
1105
+ const token = await fangToken()
1106
+ if (!token) return { ok: false, error: 'auth-unavailable' }
1107
+ const res = await fetch('/api/w2m/composer/queue', {
1108
+ method: 'POST',
1109
+ headers: { 'content-type': 'application/json', 'x-fang-auth': token },
1110
+ body: JSON.stringify({
1111
+ sessionId,
1112
+ items: items.slice(0, 100).map((item) => ({ text: String(item?.text ?? '').slice(0, 4000) })),
1113
+ }),
1114
+ })
1115
+ let body = null
1116
+ try { body = await res.json() } catch { /* non-json */ }
1117
+ if (!res.ok) return { ok: false, error: body?.error || 'http-' + res.status, detail: body?.detail || '' }
1118
+ return { ok: true, queued: Number(body?.queued) || 0, failed: Number(body?.failed) || 0 }
1119
+ }
1120
+
1121
+ function findComposer() {
1122
+ const nodes = [...document.querySelectorAll('textarea, [contenteditable="true"]')]
1123
+ .filter((el) => {
1124
+ if (el.closest?.('[data-fang-entry]')) return false
1125
+ const r = el.getBoundingClientRect()
1126
+ return r.width > 80 && r.height > 16
1127
+ })
1128
+ return nodes.sort((a, b) => {
1129
+ const ra = a.getBoundingClientRect()
1130
+ const rb = b.getBoundingClientRect()
1131
+ return (rb.width * rb.height) - (ra.width * ra.height)
1132
+ })[0] || null
1133
+ }
1134
+
1135
+ function readComposer(el) {
1136
+ if (!el) return ''
1137
+ return el.tagName === 'TEXTAREA' ? String(el.value || '') : String(el.innerText || '')
1138
+ }
1139
+
1140
+ function writeComposer(text) {
1141
+ const el = findComposer()
1142
+ if (!el) return false
1143
+ el.focus()
1144
+ if (el.tagName === 'TEXTAREA') {
1145
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
1146
+ if (setter) setter.call(el, text)
1147
+ else el.value = text
1148
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1149
+ } else {
1150
+ try {
1151
+ document.execCommand('selectAll', false, null)
1152
+ document.execCommand('insertText', false, text)
1153
+ } catch { /* fall back */ }
1154
+ if (!String(el.innerText || '').includes(text.slice(0, 20))) {
1155
+ el.textContent = text
1156
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }))
1157
+ }
1158
+ }
1159
+ el.focus()
1160
+ return true
1161
+ }
1162
+
1163
+ function setDraftViaApi(props, text) {
1164
+ try {
1165
+ if (props?.inputActions && typeof props.inputActions.setDraft === 'function') {
1166
+ props.inputActions.setDraft(String(text || ''))
1167
+ return true
1168
+ }
1169
+ } catch { /* fall back to the DOM */ }
1170
+ return writeComposer(String(text || ''))
1171
+ }
1172
+
1173
+ const QUEUE_COUNT_RE = /(\d+)\s*(条排队消息|queued messages|件のキュー済みメッセージ|mensagens na fila|messages en file d'attente|nachrichten in der warteschlange|сообщения в очереди|mensajes en cola|mensagens na fila)/i
1174
+ const QUEUE_ACTION_LABELS = /^(插话发送|编辑排队消息|删除排队消息|保存排队消息|取消编辑|发送中…|排队消息图片|queued message image|edit queued message|remove queued message|save queued message|cancel edit|sending…|steer)$/i
1175
+
1176
+ /** 从 QueueDock 行里去掉操作按钮,只留消息正文。 */
1177
+ function queueRowText(row) {
1178
+ try {
1179
+ const clone = row.cloneNode(true)
1180
+ for (const node of clone.querySelectorAll('button, [role="button"]')) node.remove()
1181
+ const lines = String(clone.innerText || clone.textContent || '')
1182
+ .split(/\r?\n/)
1183
+ .map((line) => line.trim())
1184
+ .filter((line) => line && !QUEUE_ACTION_LABELS.test(line))
1185
+ return lines.join(' ').trim()
1186
+ } catch { return '' }
1187
+ }
1188
+
1189
+ /**
1190
+ * DSH QueueDock 的 CSS 是 hashed class(F07MqG_*),不能靠 class*=queue。
1191
+ * 以 header 的“N 条排队消息”文案定位,再通过 aria-controls 找到列表。
1192
+ */
1193
+ function scanQueueDock() {
1194
+ const out = []
1195
+ const seen = new Set()
1196
+ const pushText = (text) => {
1197
+ const value = String(text || '').trim()
1198
+ if (!value || value.length > 4000 || seen.has(value)) return
1199
+ seen.add(value)
1200
+ out.push({ text: value, at: Date.now() })
1201
+ }
1202
+ const docks = document.querySelectorAll('[data-queue-dock]')
1203
+ for (const dock of docks) {
1204
+ for (const row of dock.querySelectorAll('li')) pushText(queueRowText(row))
1205
+ }
1206
+ if (docks.length > 0) return out
1207
+
1208
+ const headers = [...document.querySelectorAll('button[aria-controls], [role="button"][aria-controls]')]
1209
+ .filter((el) => QUEUE_COUNT_RE.test(String(el.textContent || '')))
1210
+ for (const header of headers) {
1211
+ const listId = header.getAttribute('aria-controls')
1212
+ const list = (listId ? document.getElementById(listId) : null) || header.parentElement?.querySelector('ul')
1213
+ if (!list) continue
1214
+ for (const row of list.querySelectorAll('li')) pushText(queueRowText(row))
1215
+ }
1216
+ return out
1217
+ }
1218
+
1219
+ function scanQueue() {
1220
+ const found = scanQueueDock()
1221
+ if (found.length > 0) return found.slice(-100)
1222
+ const seen = new Set()
1223
+ const out = []
1224
+ const nodes = document.querySelectorAll('[class*="queue" i], [data-testid*="queue" i], [aria-label*="queue" i], [class*="pending" i], [data-testid*="pending" i], [aria-label*="排队"], [title*="排队"]')
1225
+ for (const el of nodes) {
1226
+ const text = String(el.innerText || '').trim()
1227
+ if (!text || text.length > 4000 || seen.has(text)) continue
1228
+ seen.add(text)
1229
+ out.push({ text, at: Date.now() })
1230
+ }
1231
+ return out.slice(-100)
1232
+ }
1233
+
1234
+ /** 回读校验:确认服务端存下来的队列文本与本地一致(并检测 "????" 乱码)。 */
1235
+ async function verifySaved(expectedQueue) {
1236
+ const body = await journalRequest('GET')
1237
+ const saved = Array.isArray(body?.entry?.queue) ? body.entry.queue.map((item) => String(item?.text || '')) : []
1238
+ const expected = expectedQueue.map((item) => String(item?.text || ''))
1239
+ const sameLength = saved.length === expected.length
1240
+ const sameText = sameLength && saved.every((text, index) => text === expected[index])
1241
+ const looksMojibake = saved.some((text) => /\?{3,}/.test(text) && !/\?{3,}/.test(expected[saved.indexOf(text)] || ''))
1242
+ return { ok: sameText && !looksMojibake, sameLength, sameText, looksMojibake, saved }
1243
+ }
1244
+
1245
+ function JournalDock(props) {
1246
+ const sessionId = String(props?.sessionId || '')
1247
+ const [entry, setEntry] = React.useState(null)
1248
+ const [history, setHistory] = React.useState([])
1249
+ const [status, setStatus] = React.useState('')
1250
+ const [showQueue, setShowQueue] = React.useState(false)
1251
+ const [showHistory, setShowHistory] = React.useState(false)
1252
+
1253
+ React.useEffect(() => {
1254
+ activeSessionId = sessionId
1255
+ setEntry(null)
1256
+ setShowQueue(false)
1257
+ setShowHistory(false)
1258
+ let alive = true
1259
+ journalRequest('GET').then(async (body) => {
1260
+ if (alive) setEntry(body?.entry ?? null)
1261
+ }).catch(() => {})
1262
+ journalHistory(10).then((items) => { if (alive) setHistory(items) }).catch(() => {})
1263
+ return () => { alive = false }
1264
+ }, [sessionId])
1265
+
1266
+ const queue = Array.isArray(entry?.queue) ? entry.queue : []
1267
+ const hasEntry = Boolean(entry && (entry.draft || queue.length))
1268
+
1269
+ if (!hasEntry && history.length === 0) return null
1270
+
1271
+ const restoreItems = async (items, draft) => {
1272
+ const draftText = String(draft || '')
1273
+ const draftOk = draftText ? setDraftViaApi(props, draftText) : true
1274
+ if (!draftOk) { setStatus('未找到输入框,请先打开会话'); return }
1275
+ if (items.length === 0) {
1276
+ await journalRequest('POST', { draft: '', queue: [] })
1277
+ setEntry(null)
1278
+ setStatus('草稿已放入官方输入框')
1279
+ return
1280
+ }
1281
+ if (!sessionId) { setStatus('草稿已恢复;队列需要先选中会话'); return }
1282
+ const restored = await queueRestore(sessionId, items)
1283
+ if (!restored.ok) {
1284
+ setStatus(restored.error === 'session-not-live'
1285
+ ? '草稿已恢复;会话未运行,队列保留待重试'
1286
+ : '草稿已恢复;队列恢复失败:' + restored.error)
1287
+ return
1288
+ }
1289
+ await journalRequest('POST', { draft: '', queue: [] })
1290
+ setEntry(null)
1291
+ setStatus(`已恢复 ${restored.queued} 条排队消息${restored.failed ? `(${restored.failed} 条失败)` : ''}`)
1292
+ }
1293
+
1294
+ const rowText = (item) => {
1295
+ const text = String(item?.text || '').replace(/\s+/g, ' ').trim()
1296
+ return text.length > 140 ? text.slice(0, 137) + '…' : text
1297
+ }
1298
+
1299
+ const snapshotLabel = (snap) => {
1300
+ const at = String(snap?.updatedAt || '')
1301
+ const count = Array.isArray(snap?.queue) ? snap.queue.length : 0
1302
+ return `${at ? at.replace('T', ' ').slice(0, 16) : '?'} · ${count} 条`
1303
+ }
1304
+
1305
+ return h('div', {
1306
+ 'data-fang-entry': 'queue-journal',
1307
+ style: {
1308
+ display: 'inline-flex', flexDirection: 'column', gap: 6, margin: '2px 0', maxWidth: '100%',
1309
+ padding: '3px 6px', border: '1px solid rgba(127,127,127,.3)', borderRadius: 9,
1310
+ fontSize: 12, background: 'rgba(127,127,127,.06)',
1311
+ },
1312
+ },
1313
+ h('div', { style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
1314
+ hasEntry ? h('button', {
1315
+ type: 'button',
1316
+ title: `恢复草稿${queue.length ? `,并把这 ${queue.length} 条排队消息放回官方输入队列` : ''}`,
1317
+ 'aria-label': '恢复全部草稿与队列',
1318
+ onClick: () => { void restoreItems(queue, entry?.draft) },
1319
+ style: { height: 26, padding: '0 8px', borderRadius: 7, cursor: 'pointer' },
1320
+ }, queue.length ? `恢复全部 (${queue.length + (entry?.draft ? 1 : 0)})` : '恢复草稿') : null,
1321
+ hasEntry && queue.length > 0 ? h('button', {
1322
+ type: 'button', title: '展开保存的排队消息列表', 'aria-expanded': showQueue ? 'true' : 'false',
1323
+ onClick: () => setShowQueue((value) => !value),
1324
+ style: { height: 26, padding: '0 8px', borderRadius: 7, cursor: 'pointer' },
1325
+ }, `队列 ${queue.length}`) : null,
1326
+ history.length > 0 ? h('button', {
1327
+ type: 'button', title: '从历史快照中挑选一份恢复(主记录被覆盖时的兜底)',
1328
+ 'aria-expanded': showHistory ? 'true' : 'false',
1329
+ onClick: () => setShowHistory((value) => !value),
1330
+ style: { height: 26, padding: '0 8px', borderRadius: 7, cursor: 'pointer' },
1331
+ }, `历史 ${history.length}`) : null,
1332
+ hasEntry ? h('button', {
1333
+ type: 'button', title: '清除本地保存的草稿与排队快照', 'aria-label': '清除草稿',
1334
+ onClick: async () => { await journalRequest('POST', { draft: '', queue: [] }); setEntry(null); setStatus('已清除记录') },
1335
+ style: { height: 26, padding: '0 8px', borderRadius: 7, cursor: 'pointer' },
1336
+ }, '清除') : null,
1337
+ ),
1338
+ showQueue && queue.length > 0 ? h('div', {
1339
+ role: 'list', style: { display: 'grid', gap: 4, maxHeight: 180, overflow: 'auto', padding: '2px 0' },
1340
+ }, ...queue.map((item, index) => h('div', {
1341
+ role: 'listitem', key: `${index}:${item?.at || ''}`, title: String(item?.text || ''),
1342
+ style: { padding: '3px 6px', border: '1px solid rgba(127,127,127,.22)', borderRadius: 6, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
1343
+ }, `${index + 1}. ${rowText(item)}`))) : null,
1344
+ showHistory && history.length > 0 ? h('div', {
1345
+ role: 'list', style: { display: 'grid', gap: 4, maxHeight: 200, overflow: 'auto', padding: '2px 0' },
1346
+ }, ...history.slice(0, 10).map((snap, index) => h('div', {
1347
+ role: 'listitem', key: `h${index}:${snap?.updatedAt || ''}`,
1348
+ style: { display: 'flex', alignItems: 'center', gap: 6, padding: '3px 6px', border: '1px solid rgba(127,127,127,.22)', borderRadius: 6 },
1349
+ }, h('span', { style: { flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, snapshotLabel(snap)),
1350
+ h('button', {
1351
+ type: 'button', title: '把这份快照里的队列放回官方队列',
1352
+ onClick: async () => { await restoreItems(Array.isArray(snap?.queue) ? snap.queue : [], '') },
1353
+ style: { height: 22, padding: '0 6px', borderRadius: 6, cursor: 'pointer' },
1354
+ }, '恢复')))) : null,
1355
+ h('span', {
1356
+ role: 'status', 'aria-live': 'polite',
1357
+ style: status ? { padding: '1px 2px', opacity: .8 } : { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' },
1358
+ }, status))
1359
+ }
1360
+
1361
+ let saveTimer = null
1362
+ let lastSignature = ''
1363
+ let flushTimer = null
1364
+
1365
+ function commitSave() {
1366
+ const text = readComposer(findComposer())
1367
+ const queue = scanQueue()
1368
+ const signature = JSON.stringify({ key: resolveKey(), text, queue: queue.map((item) => item.text) })
1369
+ if (signature === lastSignature) return
1370
+ lastSignature = signature
1371
+ void (async () => {
1372
+ const saved = await journalRequest('POST', { draft: text, queue })
1373
+ if (!saved) return
1374
+ const check = await verifySaved(queue)
1375
+ if (!check.ok) {
1376
+ // 回读不一致(或被截断/乱码):重试一次,并把诊断留在控制台
1377
+ try { window.console?.warn?.('[fang-queue-journal] 回读校验未通过', check) } catch { /* ignore */ }
1378
+ const retried = await journalRequest('POST', { draft: text, queue })
1379
+ if (retried) await verifySaved(queue)
1380
+ }
1381
+ })()
1382
+ }
1383
+
1384
+ function scheduleSave() {
1385
+ if (saveTimer) clearTimeout(saveTimer)
1386
+ saveTimer = setTimeout(() => { saveTimer = null; commitSave() }, DEBOUNCE_MS)
1387
+ }
1388
+
1389
+ /** 关闭/切页前同步 flush:fetch keepalive 保证请求活着离开页面。 */
1390
+ function flushNow() {
1391
+ const text = readComposer(findComposer())
1392
+ const queue = scanQueue()
1393
+ const signature = JSON.stringify({ key: resolveKey(), text, queue: queue.map((item) => item.text) })
1394
+ if (signature === lastSignature) return
1395
+ lastSignature = signature
1396
+ void (async () => {
1397
+ const token = await fangToken()
1398
+ if (!token) return
1399
+ try {
1400
+ fetch('/api/w2m/composer/journal', {
1401
+ method: 'POST',
1402
+ keepalive: true,
1403
+ headers: { 'content-type': 'application/json', 'x-fang-auth': token },
1404
+ body: JSON.stringify({ key: resolveKey(), sessionKey: resolveKey(), sessionId: activeSessionId || undefined, draft: text, queue, path: location.hash || location.pathname }),
1405
+ })
1406
+ } catch { /* ignore */ }
1407
+ })()
1408
+ }
1409
+
1410
+ return {
1411
+ inject: ['slots'],
1412
+ apply(ctx) {
1413
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
1414
+ name: 'conversation.composer.dock',
1415
+ id: 'fang-queue-journal',
1416
+ order: 90,
1417
+ }, JournalDock))
1418
+
1419
+ // ① 草稿输入:捕获阶段监听,避免被宿主 stopPropagation 吞掉
1420
+ document.addEventListener('input', (event) => {
1421
+ const target = event.target
1422
+ if (!target || (target.tagName !== 'TEXTAREA' && target.getAttribute?.('contenteditable') !== 'true')) return
1423
+ scheduleSave()
1424
+ }, true)
1425
+
1426
+ // ② 队列变化:只盯 QueueDock(body 全量只用 childList,避免 characterData 风暴)
1427
+ try {
1428
+ const queueObserver = new MutationObserver(() => scheduleSave())
1429
+ queueObserver.observe(document.body, { childList: true, subtree: true })
1430
+ // 有 [data-queue-dock] 时再额外盯 characterData(行文本可能原地更新)
1431
+ const docksObserver = new MutationObserver(() => scheduleSave())
1432
+ for (const dock of document.querySelectorAll('[data-queue-dock]')) {
1433
+ docksObserver.observe(dock, { childList: true, subtree: true, characterData: true })
1434
+ }
1435
+ queueObserver.__docksObserver = docksObserver
1436
+ } catch { /* DOM unavailable */ }
1437
+
1438
+ // ③ 15s 兜底(覆盖“宿主重渲染后新 dock 未被观察到”的情况)
1439
+ flushTimer = setInterval(() => scheduleSave(), FALLBACK_MS)
1440
+ try { flushTimer?.unref?.() } catch { /* browser timer */ }
1441
+
1442
+ // ④ 关闭/切页前 flush
1443
+ const onPageHide = () => flushNow()
1444
+ const onVisibility = () => { if (document.visibilityState === 'hidden') flushNow() }
1445
+ window.addEventListener('pagehide', onPageHide)
1446
+ document.addEventListener('visibilitychange', onVisibility)
1447
+ window.addEventListener('beforeunload', onPageHide)
1448
+ },
1449
+ }
1450
+ }
858
1451
  /** v1.9.4: the left sidebar entry: globe + GPW, between Plugins and Automation. */
859
1452
  function GpwSidebarIcon() {
860
1453
  return h('button', {
@@ -3352,6 +3945,16 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
3352
3945
 
3353
3946
 
3354
3947
 
3948
+ // v3.1.0: 队列日志(移植自 @twinsearth/dsh-fang-queue-journal):把草稿与队列镜像存到
3949
+ // /api/w2m/composer/journal*,恢复只由用户点击触发。
3950
+ try {
3951
+ if (typeof document !== 'undefined' && ctx !== null && typeof ctx === 'object' && ctx.slots !== undefined) {
3952
+ createQueueJournalModule().apply({ slots: ctx.slots });
3953
+ }
3954
+ } catch (error) {
3955
+ console.error('@twinsearth/w2m-dsh-plugin: the queue journal was not mounted; the rest of the page is unaffected', error);
3956
+ }
3957
+
3355
3958
  // v1.9.9: in-conversation search, in the session header next to Pin.
3356
3959
  try {
3357
3960
  slots.inject('conversation.session.header.actions', () => slots.register({
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Package-facing entry for W2M's decision center: `@twinsearth/w2m-dsh-plugin/decision-center`.
3
+ *
4
+ * The documented subpath a profile row names to mount the center. A seam, not a second
5
+ * implementation -- the code lives in `src/decision-center/`, and `mount.mjs` is where the ported
6
+ * plugin module and its HTTP routes become one mountable unit.
7
+ *
8
+ * Like GPW, it is deliberately **not** re-exported from `lib/index.js`: the package root installs by
9
+ * default, and a human-attention queue must not arrive because someone installed a fleet tool.
10
+ */
11
+ export { apply, inject, name, Config, _internals } from '../src/decision-center/mount.mjs';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Package-facing entry for W2M's queue journal: `@twinsearth/w2m-dsh-plugin/queue-journal`.
3
+ *
4
+ * The documented subpath a profile row names to enable draft/queue persistence. The host logic is
5
+ * `src/queue-journal/index.mjs`; the composer UI ships in the package's one client bundle
6
+ * (`lib/client.js`), the same shape GPW and the decision center use.
7
+ */
8
+ export {
9
+ apply,
10
+ inject,
11
+ name,
12
+ QUEUE_JOURNAL_ROUTE_PREFIX,
13
+ QUEUE_JOURNAL_SERVICE,
14
+ QUEUE_RESTORE_ROUTE_PATH,
15
+ } from '../src/queue-journal/index.mjs';