@twinsearth/w2m-dsh-plugin 1.9.3 → 1.9.6
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 +16 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.4/RELEASE-194.json +23 -0
- package/docs/acceptance/1.9.5/RELEASE-195.json +23 -0
- package/docs/acceptance/1.9.6/RELEASE-196.json +22 -0
- package/lib/client.js +2041 -1881
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/plugin/tools.mjs +1 -1
package/lib/client.js
CHANGED
|
@@ -1,1910 +1,2070 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DSH Web client half of `@twinsearth/w2m-dsh-plugin`: the composer's target-machine control.
|
|
3
|
-
*
|
|
4
|
-
* # What this file is
|
|
5
|
-
*
|
|
6
|
-
* This is the *shipped* client bundle, not compiled output: the package has no build step and no
|
|
7
|
-
* dependencies, so the half the browser loads is written here in the exact module format the DSH
|
|
8
|
-
* web shell's loader consumes (`window.__ModuleLoader__.load({ id, factory })`, a lazy CJS table
|
|
9
|
-
* whose factory runs at materialization). `package.json` declares it with
|
|
10
|
-
* `dsh.client: { platform: 'web' }` and `exports['./client'] = './lib/client.js'`, which is what the
|
|
11
|
-
* host scans for. There is no second copy to keep in sync: this file **is** the source, and
|
|
12
|
-
* `test/client-bundle.test.mjs` is where its entry shape is read.
|
|
13
|
-
*
|
|
14
|
-
* # A failed client entry is fatal, and silence is not a neutral failure
|
|
15
|
-
*
|
|
16
|
-
* Measured twice in production: when this entry's `apply` throws, the page does not degrade — the
|
|
17
|
-
* shell audits every entry after activation and refuses to boot with
|
|
18
|
-
* `web boot: 1 entry did not activate / @twinsearth/w2m-dsh-plugin: failed`, which in the desktop
|
|
19
|
-
* app is forwarded to the main process and written as a `web-boot` crash: **the app does not start**.
|
|
20
|
-
* The audit prints the entry's *name* and its fiber state and nothing else, so the cause has to be
|
|
21
|
-
* carried by the failing code itself; {@link apply} therefore reports its own error to
|
|
22
|
-
* `console.error`, which is the error-level renderer console the crash log already captures.
|
|
23
|
-
*
|
|
24
|
-
* The failure that cost those two outages was a single property read. Cordis resolves `ctx.<name>`
|
|
25
|
-
* through the fiber's `inject` list: a service the fiber never declared **throws**
|
|
26
|
-
* (`cannot get property "locale" without inject`) whether or not some plugin provides it, and every
|
|
27
|
-
* name that *is* declared becomes a required dependency the entry waits for. Both halves of that rule
|
|
28
|
-
* are load-bearing here — see {@link inject} for what this bundle declares, and how the optional
|
|
29
|
-
* locale carrier is read instead.
|
|
30
|
-
*
|
|
31
|
-
* Two consequences, both deliberate:
|
|
32
|
-
*
|
|
33
|
-
* * **No JSX and no bundler.** `React.createElement` keeps the file plain JavaScript a reviewer
|
|
34
|
-
* can read, and the module system's platform seed table already holds React (`react`,
|
|
35
|
-
* `react/jsx-runtime`, `react-dom`, Cordis and the shell's static UI libraries are frozen seeds
|
|
36
|
-
* for every dynamic bundle), so `require('react')` resolves without this package shipping or
|
|
37
|
-
* declaring anything.
|
|
38
|
-
* * **Only React is required.** `@deepseek-ai/dsh-client-ui-primitives` is *available* to a
|
|
39
|
-
* dynamic bundle — the shipped permission control next to this one uses its `Menu` — but a
|
|
40
|
-
* package that must stay dependency-free should not take a rendering dependency for one
|
|
41
|
-
* dropdown. The control is styled with the shell's own theme tokens instead, which is what
|
|
42
|
-
* makes it correct in both themes rather than "similar in one".
|
|
43
|
-
*
|
|
44
|
-
* # Where it sits
|
|
45
|
-
*
|
|
46
|
-
* `conversation.input.left` — `kind: list`, `scope: session`, `replaceRisk: none`. The shipped
|
|
47
|
-
* permission control owns `conversation.input.permission` (`single`, `replaceRisk:
|
|
48
|
-
* shadows-shipped-ui`) and is **not** touched: this entry is appended to the composer's left group,
|
|
49
|
-
* so it renders to the right of the permission control and never competes with it for a seat.
|
|
50
|
-
*
|
|
51
|
-
* # What it shows
|
|
52
|
-
*
|
|
53
|
-
* The machine this session's work will be dispatched to, and — on click — the fleet the relay
|
|
54
|
-
* currently holds. Liveness comes from the host plugin, never from this file: a machine is
|
|
55
|
-
* selectable because the relay lists it as online, and one that is not listed that way is rendered
|
|
56
|
-
* with its state and cannot be chosen. A selection is written to the host process, so it survives a
|
|
57
|
-
* reload and is visible to `w2m_run`, which is where it actually means something.
|
|
58
|
-
*/
|
|
59
|
-
|
|
60
|
-
window.__ModuleLoader__.load({
|
|
61
|
-
id: '@twinsearth/w2m-dsh-plugin',
|
|
62
|
-
factory: (require) => {
|
|
63
|
-
var module = { exports: {} };
|
|
64
|
-
var exports = module.exports;
|
|
65
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
66
|
-
|
|
67
|
-
const React = require('react');
|
|
68
|
-
|
|
69
|
-
// -------------------------------------------------------------------------------------------
|
|
70
|
-
// Wire
|
|
71
|
-
// -------------------------------------------------------------------------------------------
|
|
72
|
-
//
|
|
73
|
-
// Mirrors TARGET_ROUTE_PATH in src/plugin/fleet-target.mjs, `/api` prefix included: the host's
|
|
74
|
-
// Connection service mounts its exact-fetch-route registry under that channel and refuses any
|
|
75
|
-
// path that does not start with `/api/`. Plain same-origin `fetch` rather than a Remote method:
|
|
76
|
-
// the route is mounted inside the host's authentication fence, so the page's own cookie is the
|
|
77
|
-
// credential and there is no descriptor to keep in sync.
|
|
78
|
-
const ROUTE = '/api/w2m/target';
|
|
79
|
-
// v1.4.2: the UI-activated Node-code-execution switch (host: src/plugin/exec-capability-route.mjs).
|
|
80
|
-
const CAPABILITY_ROUTE = '/api/w2m/capability';
|
|
81
|
-
// v1.6.8: npm update check + verified install (host: src/plugin/update-route.mjs).
|
|
82
|
-
const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* The locale namespace and its two dictionaries.
|
|
86
|
-
*
|
|
87
|
-
* Registered through `ctx.locale` when the composition provides it, exactly as the shipped
|
|
88
|
-
* permission control beside this one does; the Chinese dictionary is the fallback when it does
|
|
89
|
-
* not, because a missing localisation service is not a reason for an operator to lose the
|
|
90
|
-
* control. Only the *visible* strings are here. A refusal from the host is reported in the
|
|
91
|
-
* host's own words — it is a diagnostic naming a machine, a relay or an HTTP status, and
|
|
92
|
-
* translating it would put a second, drifting copy of that vocabulary in the browser.
|
|
93
|
-
*/
|
|
94
|
-
const NS = 'w2m.target';
|
|
95
|
-
const DICT = {
|
|
96
|
-
zh: {
|
|
97
|
-
all: '全部机器',
|
|
98
|
-
machine: '机器',
|
|
99
|
-
local: '本机',
|
|
100
|
-
online: '在线',
|
|
101
|
-
noRoster: '无列表',
|
|
102
|
-
empty: '机器列表里没有机器',
|
|
103
|
-
costAll: '本次会话的派发会送到所有在线机器',
|
|
104
|
-
costOne: '本次会话的派发会送到 {name}',
|
|
105
|
-
unconfirmed: '(未确认在线)',
|
|
106
|
-
targetLabel: '目标机器:{name}',
|
|
107
|
-
unreachable: '无法连接插件',
|
|
108
|
-
routeMissing: '这个 Harness 没有提供插件目标路由',
|
|
109
|
-
fleetUnreadable: '机器列表读取失败',
|
|
110
|
-
relays: 'Relay 节点',
|
|
111
|
-
relayPrimary: '主 relay',
|
|
112
|
-
relayStandby: '备用',
|
|
113
|
-
relayOffline: '离线',
|
|
114
|
-
relayFold: '另有 {count} 个已折叠,点击展开',
|
|
115
|
-
relayUnfold: '收起折叠的 relay',
|
|
116
|
-
relayTemporary: '临时 Relay 节点(回退配置)',
|
|
117
|
-
globalSummary: '全服:{fleetsOnline}/{fleets} 个编队 · {online}/{total} 台在线',
|
|
118
|
-
globalSummaryWithSource: '主 relay({source})全服:{fleetsOnline}/{fleets} 个编队 · {online}/{total} 台在线',
|
|
119
|
-
fleetSummary: '本编队:{online}/{total} 台在线',
|
|
120
|
-
globalUnreachable: '主 relay 不可达({url})· 全服统计不可用',
|
|
121
|
-
updateButton: '检查插件更新',
|
|
122
|
-
updateChecking: '正在检查 npm…',
|
|
123
|
-
updating: '正在更新…',
|
|
124
|
-
updateCurrent: '已是最新({version})',
|
|
125
|
-
updateInstalled: '已更新到 {version}',
|
|
126
|
-
restartHint: '请重启 DSH 以载入最新插件',
|
|
127
|
-
capLabel: 'Node 执行:{state}',
|
|
128
|
-
capDenied: '拒绝',
|
|
129
|
-
capTemporary: '临时 24 小时',
|
|
130
|
-
capPermanent: '永久',
|
|
131
|
-
capWarning: '开启后该机器可执行任意 Node 代码(等效远程代码执行)',
|
|
132
|
-
capMenuLabel: 'Node 代码执行能力',
|
|
133
|
-
capRemaining: '剩余 {hours} 小时',
|
|
134
|
-
capRouteMissing: '这个 Harness 没有提供能力路由',
|
|
135
|
-
capRefused: '能力切换被拒绝:{error}',
|
|
136
|
-
gpwTitle: 'GPW 文件夹策略',
|
|
137
|
-
gpwDisabled: 'GPW 未启用(在插件页开启 dsh-gpw)',
|
|
138
|
-
gpwWhitelist: '白名单(通过)',
|
|
139
|
-
gpwBlacklist: '黑名单(拒绝)',
|
|
140
|
-
gpwAddWhite: '添加白名单文件夹',
|
|
141
|
-
gpwAddBlack: '添加黑名单文件夹',
|
|
142
|
-
gpwRemove: '移除',
|
|
143
|
-
gpwPending: '待审批(C 类:名单外)',
|
|
144
|
-
gpwLauncher: 'GPW 文件夹',
|
|
145
|
-
gpwManual: '手动输入路径',
|
|
146
|
-
gpwManualPlaceholder: '例如 D:\\work\\data(绝对路径)',
|
|
147
|
-
gpwAddManual: '添加',
|
|
148
|
-
gpwRecent: '最近被拦截',
|
|
149
|
-
gpwRecentEmpty: '暂无被拦截记录',
|
|
150
|
-
gpwAddWhiteShort: '加入白名单',
|
|
151
|
-
gpwAddBlackShort: '加入黑名单',
|
|
152
|
-
gpwClose: '关闭',
|
|
153
|
-
gpwBadPath: '路径无效:{error}',
|
|
154
|
-
gpwInputHint: '目录需已存在;添加后立即生效。',
|
|
155
|
-
gpwPendingEmpty: '当前没有待审批调用',
|
|
156
|
-
gpwAllowOnce: '允许本次',
|
|
157
|
-
gpwDenyOnce: '拒绝本次',
|
|
158
|
-
gpwMore: '另有 {count} 个,展开',
|
|
159
|
-
gpwFold: '收起',
|
|
160
|
-
gpwAddWhiteAllow: '加入白名单并允许',
|
|
161
|
-
gpwAddBlackDeny: '加入黑名单并拒绝',
|
|
162
|
-
gpwPendingShort: 'GPW 待审批:{tool}',
|
|
163
|
-
gpwPendingCount: '有 {count} 个调用等待审批',
|
|
164
|
-
gpwOpenPanel: '打开面板',
|
|
165
|
-
gpwTampered: '名单完整性校验失败({reason}),名单外路径已锁定。',
|
|
166
|
-
gpwReset: '重置名单(清空并重新签名)',
|
|
167
|
-
gpwResetConfirm: '确认重置?将清空白名单和黑名单的全部条目。',
|
|
168
|
-
},
|
|
169
|
-
en: {
|
|
170
|
-
all: 'All machines',
|
|
171
|
-
machine: 'Machine',
|
|
172
|
-
local: 'This machine',
|
|
173
|
-
online: 'online',
|
|
174
|
-
noRoster: 'no roster',
|
|
175
|
-
empty: 'the fleet lists no machines',
|
|
176
|
-
costAll: 'Work from this session goes to every online machine',
|
|
177
|
-
costOne: 'Work from this session goes to {name}',
|
|
178
|
-
unconfirmed: ' (not confirmed online)',
|
|
179
|
-
targetLabel: 'target machine: {name}',
|
|
180
|
-
unreachable: 'the plugin could not be reached',
|
|
181
|
-
routeMissing: 'this host does not serve the W2M target route',
|
|
182
|
-
fleetUnreadable: 'the fleet could not be read',
|
|
183
|
-
relays: 'Relay nodes',
|
|
184
|
-
relayPrimary: 'primary',
|
|
185
|
-
relayStandby: 'standby',
|
|
186
|
-
relayOffline: 'offline',
|
|
187
|
-
relayFold: '{count} more folded — expand',
|
|
188
|
-
relayUnfold: 'collapse the folded relays',
|
|
189
|
-
relayTemporary: 'temporary relay nodes (fallback config)',
|
|
190
|
-
globalSummary: 'fleets {fleetsOnline}/{fleets} · machines {online}/{total} online',
|
|
191
|
-
globalSummaryWithSource: 'primary relay ({source}) · fleets {fleetsOnline}/{fleets} · machines {online}/{total}',
|
|
192
|
-
fleetSummary: 'this fleet: {online}/{total} online',
|
|
193
|
-
globalUnreachable: 'primary relay unreachable ({url}) · server totals unavailable',
|
|
194
|
-
updateButton: 'check for plugin update',
|
|
195
|
-
updateChecking: 'checking npm…',
|
|
196
|
-
updating: 'updating…',
|
|
197
|
-
updateCurrent: 'up to date ({version})',
|
|
198
|
-
updateInstalled: 'updated to {version}',
|
|
199
|
-
restartHint: 'restart DSH to load the new plugin',
|
|
200
|
-
capLabel: 'Node exec: {state}',
|
|
201
|
-
capDenied: 'denied',
|
|
202
|
-
capTemporary: 'temporary 24 h',
|
|
203
|
-
capPermanent: 'permanent',
|
|
204
|
-
capWarning: 'once on, this machine runs arbitrary Node code (equivalent to remote code execution)',
|
|
205
|
-
capMenuLabel: 'Node code execution capability',
|
|
206
|
-
capRemaining: '{hours} h left',
|
|
207
|
-
capRouteMissing: 'this host does not serve the capability route',
|
|
208
|
-
capRefused: 'the capability switch was refused: {error}',
|
|
209
|
-
gpwTitle: 'GPW folder policy',
|
|
210
|
-
gpwDisabled: 'GPW is not enabled (turn on dsh-gpw in the Plugins page)',
|
|
211
|
-
gpwWhitelist: 'Whitelist (pass)',
|
|
212
|
-
gpwBlacklist: 'Blacklist (deny)',
|
|
213
|
-
gpwAddWhite: 'Add whitelist folder',
|
|
214
|
-
gpwAddBlack: 'Add blacklist folder',
|
|
215
|
-
gpwRemove: 'Remove',
|
|
216
|
-
gpwPending: 'Pending approval (class C: on neither list)',
|
|
217
|
-
gpwPendingEmpty: 'No calls are waiting for approval',
|
|
218
|
-
gpwAllowOnce: 'Allow this call',
|
|
219
|
-
gpwDenyOnce: 'Deny this call',
|
|
220
|
-
gpwMore: 'Show {count} more',
|
|
221
|
-
gpwFold: 'Show less',
|
|
222
|
-
gpwAddWhiteAllow: 'Always allow (add to whitelist)',
|
|
223
|
-
gpwAddBlackDeny: 'Always deny (add to blacklist)',
|
|
224
|
-
gpwPendingShort: 'GPW pending: {tool}',
|
|
225
|
-
gpwPendingCount: '{count} call(s) waiting for approval',
|
|
226
|
-
gpwOpenPanel: 'Open panel',
|
|
227
|
-
gpwTampered: 'Folder lists failed integrity check ({reason}); non-listed paths are locked.',
|
|
228
|
-
gpwReset: 'Reset lists (clear + re-sign)',
|
|
229
|
-
gpwResetConfirm: 'Reset the lists? Every whitelist and blacklist entry will be cleared.',
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* A translator over {@link DICT}, preferring the shell's locale service.
|
|
235
|
-
*
|
|
236
|
-
* `{name}` substitution is deliberately the only templating: these strings have one variable
|
|
237
|
-
* each, and a message format would be a dependency this package does not have.
|
|
238
|
-
*
|
|
239
|
-
* @param localeOf - the shell's locale service, or a function returning it (read at lookup time).
|
|
240
|
-
* Passed in rather than read from `ctx` here: the service is optional, and reading an optional
|
|
241
|
-
* service off the context is the one operation in this file that can kill the page (see
|
|
242
|
-
* {@link readOptionalService}). A function is accepted because the service may arrive *after*
|
|
243
|
-
* this entry activates — the shell's `bind()` is live, so a deferred read still localises.
|
|
244
|
-
* @returns {(key: string, values?: object) => string}
|
|
245
|
-
*/
|
|
246
|
-
function makeTranslator(localeOf) {
|
|
247
|
-
const resolveLocale = () => (typeof localeOf === 'function' ? localeOf() : localeOf);
|
|
248
|
-
return (key, values) => {
|
|
249
|
-
const locale = resolveLocale();
|
|
250
|
-
const bound = typeof locale?.bind === 'function' ? locale.bind(NS) : null;
|
|
251
|
-
const template = bound === null
|
|
252
|
-
? (DICT.zh[key] ?? DICT.en[key] ?? key)
|
|
253
|
-
: bound(key, values);
|
|
254
|
-
const text = typeof template === 'string' && template !== '' ? template : (DICT.en[key] ?? key);
|
|
255
|
-
if (values === undefined) return text;
|
|
256
|
-
return text.replace(/\{(\w+)\}/g, (match, name) => (values[name] === undefined ? match : String(values[name])));
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Read a service the composition may or may not provide, without the inject requirement.
|
|
262
|
-
*
|
|
263
|
-
* `ctx.get(name)` is Cordis's inject-free lookup, and it is the only correct way to read an
|
|
264
|
-
* *optional* service from a plugin: `ctx[name]` throws for anything the fiber did not declare as
|
|
265
|
-
* a dependency, and declaring it would make the service *required* — an entry whose declared
|
|
266
|
-
* dependency never arrives never activates either, which is fatal in exactly the same way. So a
|
|
267
|
-
* carrier that must not be able to block the control (`locale`) is read here, and a service the
|
|
268
|
-
* control cannot work without (`slots`) is declared in {@link inject}.
|
|
269
|
-
*
|
|
270
|
-
* @param ctx - the client plugin context.
|
|
271
|
-
* @param name - the service name.
|
|
272
|
-
* @returns the service, or undefined when the composition has none (or this is not a Cordis
|
|
273
|
-
* context at all, which is how `test/client-bundle.test.mjs` applies this bundle).
|
|
274
|
-
*/
|
|
275
|
-
function readOptionalService(ctx, name) {
|
|
276
|
-
return typeof ctx?.get === 'function' ? ctx.get(name) : undefined;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Register {@link DICT} with the shell's locale service whenever that service exists.
|
|
281
|
-
*
|
|
282
|
-
* Two paths, one outcome. When the service is already up, the registration is an effect of this
|
|
283
|
-
* entry and goes away with it. When it is not up yet, the registration rides a **child fiber**
|
|
284
|
-
* (`ctx.inject`) that waits for `locale` without holding this entry back: declaring `locale` in
|
|
285
|
-
* {@link inject} instead would make it required, and a composition without a locale service
|
|
286
|
-
* would then report this entry as `pending` — which the shell's audit treats exactly like
|
|
287
|
-
* `failed`, i.e. the page does not boot. A missing localisation service must never cost an
|
|
288
|
-
* operator the control, and must never cost them the app.
|
|
289
|
-
*
|
|
290
|
-
* @param ctx - the client plugin context.
|
|
291
|
-
* @param locale - the shell's locale service, or undefined when the composition has none.
|
|
292
|
-
*/
|
|
293
|
-
function registerDictionaries(ctx, locale) {
|
|
294
|
-
if (typeof locale?.register === 'function') {
|
|
295
|
-
ctx.effect(() => locale.register(NS, DICT), 'w2m: composer target control dictionaries');
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
if (typeof ctx?.inject !== 'function') return;
|
|
299
|
-
ctx.inject(['locale'], (localeCtx) => {
|
|
300
|
-
localeCtx.effect(() => localeCtx.locale.register(NS, DICT), 'w2m: composer target control dictionaries');
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** One JSON call to the host half, folded into a value: a refusal is never an exception here. */
|
|
305
|
-
async function callHost(options) {
|
|
306
|
-
let response;
|
|
307
|
-
try {
|
|
308
|
-
response = await fetch(options.url, options.init);
|
|
309
|
-
} catch (error) {
|
|
310
|
-
return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}`, status: 0 };
|
|
311
|
-
}
|
|
312
|
-
if (response.status === 404) {
|
|
313
|
-
return { ok: false, status: 404, error: 'this host does not serve the W2M target route' };
|
|
314
|
-
}
|
|
315
|
-
let body = null;
|
|
316
|
-
try {
|
|
317
|
-
body = await response.json();
|
|
318
|
-
} catch {
|
|
319
|
-
return { ok: false, status: response.status, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
|
|
320
|
-
}
|
|
321
|
-
if (response.ok !== true) {
|
|
322
|
-
return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
|
|
323
|
-
}
|
|
324
|
-
return { ok: true, status: response.status, state: body };
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const readState = (sessionId, signal) => callHost({
|
|
328
|
-
url: `${ROUTE}?session_id=${encodeURIComponent(sessionId)}`,
|
|
329
|
-
init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
const writeSelection = (sessionId, machineId, machineName) => callHost({
|
|
333
|
-
url: ROUTE,
|
|
334
|
-
init: {
|
|
335
|
-
method: 'POST',
|
|
336
|
-
credentials: 'same-origin',
|
|
337
|
-
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
338
|
-
body: JSON.stringify({ session_id: sessionId, machine_id: machineId, machine_name: machineName }),
|
|
339
|
-
},
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
/** One JSON call to the capability route: refusals are values, exactly like the target route. */
|
|
343
|
-
async function callCapability(options) {
|
|
344
|
-
let response;
|
|
345
|
-
try {
|
|
346
|
-
response = await fetch(options.url, options.init);
|
|
347
|
-
} catch (error) {
|
|
348
|
-
return { ok: false, error: 'the plugin could not be reached: ' + (error instanceof Error ? error.message : String(error)) };
|
|
349
|
-
}
|
|
350
|
-
if (response.status === 404) return { ok: false, status: 404, routeMissing: true, error: 'capability route missing' };
|
|
351
|
-
let body = null;
|
|
352
|
-
try { body = await response.json(); } catch { return { ok: false, error: 'the capability route answered HTTP ' + response.status } }
|
|
353
|
-
if (response.ok !== true) return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : 'HTTP ' + response.status };
|
|
354
|
-
return { ok: true, capacity: body?.capability ?? null };
|
|
355
|
-
}
|
|
356
|
-
const readCapability = (signal) => callCapability({ url: CAPABILITY_ROUTE, init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal } });
|
|
357
|
-
const writeCapability = (state, ttlMs) => callCapability({ url: CAPABILITY_ROUTE, init: { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(ttlMs === undefined ? { state } : { state, ttl_ms: ttlMs }) } });
|
|
358
|
-
|
|
359
|
-
const labelOf = (state, t) => state?.selected?.machine_id != null
|
|
360
|
-
? (state.selected.name ?? state.selected.machine_id)
|
|
361
|
-
: t('all');
|
|
362
|
-
|
|
363
|
-
/** The one-line reason the list cannot be trusted, or null when it can. */
|
|
364
|
-
function fleetProblem(state, t) {
|
|
365
|
-
if (state === null || state === undefined) return null;
|
|
366
|
-
if (state.host?.detail_unavailable === true) return t('routeMissing');
|
|
367
|
-
if (state.fleet?.ok === true) return null;
|
|
368
|
-
return state.fleet?.error ?? t('fleetUnreadable');
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// -------------------------------------------------------------------------------------------
|
|
372
|
-
// Styles
|
|
373
|
-
// -------------------------------------------------------------------------------------------
|
|
374
|
-
//
|
|
375
|
-
// Token colours only: `--dsw-*` is the shell's own theme surface, so the control follows the
|
|
376
|
-
// light/dark switch instead of inventing a palette that is wrong in one of them. The geometry
|
|
377
|
-
// (28px height, `--dsw-radius-sm`, 13px/500 label, 4px gap, 8px leading pad) is copied from the
|
|
378
|
-
// shipped permission trigger in the same row, and the status dot uses the theme's state colours:
|
|
379
|
-
// business blue for this machine, success green for a reachable peer, warn/error for the two
|
|
380
|
-
// states that are not "ready".
|
|
381
|
-
const TAG_ID = '@twinsearth/w2m-dsh-plugin/TargetSelect.css';
|
|
382
|
-
const CSS = [
|
|
383
|
-
'.w2mTarget_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;max-width:220px;min-width:0;',
|
|
384
|
-
'padding:0 4px 0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;',
|
|
385
|
-
'color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
|
|
386
|
-
'.w2mTarget_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
387
|
-
'.w2mTarget_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
|
|
388
|
-
'.w2mTarget_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
|
|
389
|
-
'.w2mTarget_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);',
|
|
390
|
-
'box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
|
|
391
|
-
'.w2mTarget_dot[data-state="local"]{background:var(--dsw-alias-state-business-primary)}',
|
|
392
|
-
'.w2mTarget_dot[data-state="online"]{background:var(--dsw-alias-state-success-primary)}',
|
|
393
|
-
'.w2mTarget_dot[data-state="stale"]{background:transparent;border:1px solid var(--dsw-alias-state-warn-primary)}',
|
|
394
|
-
'.w2mTarget_dot[data-state="none"]{background:transparent;border:1px solid var(--dsw-alias-label-caption)}',
|
|
395
|
-
'.w2mTarget_label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
396
|
-
'.w2mTarget_chevron{flex:none;display:inline-flex;color:var(--dsw-alias-label-caption);transition:transform .12s}',
|
|
397
|
-
'.w2mTarget_chevron[data-open="true"]{transform:rotate(180deg)}',
|
|
398
|
-
'.w2mTarget_menu{position:fixed;z-index:60;box-sizing:border-box;min-width:200px;max-width:300px;',
|
|
399
|
-
'padding:4px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);',
|
|
400
|
-
'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
|
|
401
|
-
'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
402
|
-
'.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
|
|
403
|
-
'.w2mTarget_relayFold{margin-top:2px;padding:4px 0;border-top:.5px solid var(--dsw-alias-border-l2)}',
|
|
404
|
-
'.w2mTarget_relayToggle{display:block;width:100%;box-sizing:border-box;padding:4px 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;text-align:left;cursor:pointer}',
|
|
405
|
-
'.w2mTarget_relayToggle[data-temporary="true"]{color:var(--dsw-alias-state-error-primary);font-weight:600}',
|
|
406
|
-
'.w2mTarget_relayFold[data-temporary="true"]{border-top-color:var(--dsw-alias-state-error-primary)}',
|
|
407
|
-
'.w2mTarget_relayRow{display:flex;align-items:center;gap:6px;padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
408
|
-
'.w2mTarget_relayRole{flex:none;min-width:44px;color:var(--dsw-alias-label-tertiary)}',
|
|
409
|
-
'.w2mTarget_relayUrl{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
410
|
-
'.w2mTarget_relayRtt{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
|
|
411
|
-
'.w2mTarget_relayHint{padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
412
|
-
'.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
|
|
413
|
-
'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
|
|
414
|
-
'.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
415
|
-
'.w2mTarget_option:disabled{cursor:default;color:var(--dsw-alias-label-dimmed)}',
|
|
416
|
-
'.w2mTarget_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
|
|
417
|
-
'.w2mTarget_row{display:flex;align-items:center;gap:8px}',
|
|
418
|
-
'.w2mTarget_name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
419
|
-
'.w2mTarget_detail{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400}',
|
|
420
|
-
'.w2mTarget_option:disabled .w2mTarget_detail{color:var(--dsw-alias-label-caption)}',
|
|
421
|
-
'.w2mCap{position:relative;display:inline-flex;align-items:center}',
|
|
422
|
-
'.w2mCap_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
|
|
423
|
-
'.w2mCap_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
424
|
-
'.w2mCap_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
|
|
425
|
-
'.w2mCap_trigger[data-on="true"]{color:var(--dsw-alias-state-warn-primary)}',
|
|
426
|
-
'.w2mCap_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
|
|
427
|
-
'.w2mCap_dot[data-on="true"]{background:var(--dsw-alias-state-warn-primary)}',
|
|
428
|
-
'.w2mGpw_launcher{position:fixed;right:16px;bottom:96px;z-index:55;padding:6px 10px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);cursor:pointer}',
|
|
429
|
-
'.w2mGpw_launcher:hover{background:var(--dsw-alias-bg-layer-2)}',
|
|
430
|
-
'.w2mGpw_prompt{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary)}',
|
|
431
|
-
'.w2mGpw_promptLabel{font-size:12px;color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-primary))}',
|
|
432
|
-
'.w2mGpw_badge{display:inline-block;min-width:16px;padding:0 5px;margin-left:4px;border-radius:999px;border:.5px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-bg-layer-1);font-size:11px;line-height:14px;text-align:center}',
|
|
433
|
-
'.w2mGpw_overlay{position:fixed;inset:0;z-index:70;background:color-mix(in srgb, var(--dsw-alias-label-primary) 24%, transparent);display:flex;align-items:flex-end;justify-content:flex-end;padding:16px}',
|
|
434
|
-
'.w2mGpw_card{width:420px;max-width:calc(100vw - 32px);max-height:min(80vh,640px);overflow:auto;box-sizing:border-box;padding:10px 12px;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
|
|
435
|
-
'.w2mGpw_head{display:flex;align-items:center;justify-content:space-between}',
|
|
436
|
-
'.w2mGpw_close{width:24px;height:24px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:16px;line-height:16px}',
|
|
437
|
-
'.w2mGpw_title{font-size:12px;font-weight:600}',
|
|
438
|
-
'.w2mGpw_block{display:flex;flex-direction:column;gap:4px;margin-top:2px;padding-top:6px;border-top:.5px solid var(--dsw-alias-border-default)}',
|
|
439
|
-
'.w2mGpw_row{display:flex;align-items:center;gap:6px;min-width:0}',
|
|
440
|
-
'.w2mGpw_path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
441
|
-
'.w2mGpw_input{box-sizing:border-box;width:100%;padding:5px 8px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;outline:none}',
|
|
442
|
-
'.w2mGpw_dot{flex:none;display:inline-block;width:8px;height:8px;border-radius:999px;margin-right:4px}',
|
|
443
|
-
'.w2mGpw_dot[data-c="green"]{background:var(--dsw-alias-state-success-primary)}',
|
|
444
|
-
'.w2mGpw_dot[data-c="blue"]{background:var(--dsw-alias-state-business-primary)}',
|
|
445
|
-
'.w2mGpw_dot[data-c="yellow"]{background:var(--dsw-alias-state-warn-primary)}',
|
|
446
|
-
'.w2mGpw_dot[data-c="red"]{background:var(--dsw-alias-state-danger-primary)}',
|
|
447
|
-
'.w2mGpw_legend{display:flex;gap:10px;align-items:center;font-size:12px}',
|
|
448
|
-
'.w2mGpw_legendItem{display:inline-flex;align-items:center;gap:2px}',
|
|
449
|
-
'.w2mGpw_actions{display:flex;flex-wrap:wrap;gap:6px}',
|
|
450
|
-
'.w2mGpw_btn{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}',
|
|
451
|
-
'.w2mGpw_btn:disabled{opacity:.5;cursor:default}',
|
|
452
|
-
'.w2mGpw_small{width:22px;padding:0;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}',
|
|
453
|
-
'.w2mGpw_recent{border:.5px solid var(--dsw-alias-border-default);border-radius:var(--dsw-radius-md);padding:4px 6px;display:flex;flex-direction:column;gap:3px}',
|
|
454
|
-
'.w2mGpw_tag{flex:none;font-size:11px;padding:0 5px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l2)}',
|
|
455
|
-
'.w2mGpw_tag[data-d="deny"]{border-color:var(--dsw-alias-state-danger-primary);color:var(--dsw-alias-state-danger-primary)}',
|
|
456
|
-
'.w2mGpw_note{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
457
|
-
'.w2mGpw_hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
458
|
-
'.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}',
|
|
459
|
-
'.w2mGpw_title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
460
|
-
'.w2mGpw_block{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px}',
|
|
461
|
-
'.w2mGpw_row{display:flex;align-items:center;gap:4px}',
|
|
462
|
-
'.w2mGpw_row .w2mGpw_path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
463
|
-
'.w2mGpw_path{font-size:11px;color:var(--dsw-alias-label-secondary);word-break:break-all}',
|
|
464
|
-
'.w2mGpw_small{width:20px;padding:0}',
|
|
465
|
-
'.w2mGpw_pending{border:.5px solid var(--dsw-alias-border-default);border-radius:6px;padding:4px 6px;display:flex;flex-direction:column;gap:2px}',
|
|
466
|
-
'.w2mGpw_actions{display:flex;gap:6px}',
|
|
467
|
-
'.w2mCap_menu{position:fixed;z-index:60;box-sizing:border-box;width:280px;padding:4px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
468
|
-
'.w2mCap_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
|
|
469
|
-
'.w2mCap_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
470
|
-
'.w2mCap_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
|
|
471
|
-
'.w2mCap_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
|
|
472
|
-
'.w2mChat{position:relative;display:inline-flex;align-items:center}',
|
|
473
|
-
'.w2mChat_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
|
|
474
|
-
'.w2mChat_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
475
|
-
'.w2mChat_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
|
|
476
|
-
'.w2mChat_panel{position:fixed;right:16px;bottom:88px;z-index:60;display:flex;flex-direction:column;width:340px;max-height:420px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);overflow:hidden}',
|
|
477
|
-
'.w2mChat_head{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:.5px solid var(--dsw-alias-border-l2);font-weight:600}',
|
|
478
|
-
'.w2mChat_usage{margin-left:auto;font-weight:400;color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
479
|
-
'.w2mChat_close{border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:16px;line-height:16px;padding:0 2px}',
|
|
480
|
-
'.w2mChat_list{flex:1;overflow-y:auto;padding:8px 10px;display:flex;flex-direction:column;gap:8px;min-height:120px}',
|
|
481
|
-
'.w2mChat_empty{color:var(--dsw-alias-label-tertiary)}',
|
|
482
|
-
'.w2mChat_msg{display:flex;flex-direction:column;gap:2px}',
|
|
483
|
-
'.w2mChat_meta{color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
484
|
-
'.w2mChat_to{font:inherit;font-size:11px;max-width:120px;border-radius:6px;border:.5px solid var(--dsw-alias-border-default);background:var(--dsw-alias-surface-default);color:var(--dsw-alias-label-primary)}',
|
|
485
|
-
'.w2mChat_reply{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-secondary);border-top:.5px solid var(--dsw-alias-border-default)}',
|
|
486
|
-
'.w2mChat_replyCancel{margin-left:auto;border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer}',
|
|
487
|
-
'.w2mChat_msg{cursor:pointer}',
|
|
488
|
-
'.w2mChat_thread{margin-left:6px;opacity:.7}',
|
|
489
|
-
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
490
|
-
'.w2mChat_board{display:flex;flex-direction:column;gap:2px;padding:6px 10px;border-bottom:.5px solid var(--dsw-alias-border-default)}',
|
|
491
|
-
'.w2mChat_boardTitle{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
492
|
-
'.w2mChat_boardRow{font-size:11px;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}',
|
|
493
|
-
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
494
|
-
'.w2mChat_unseen{margin-left:6px;min-width:16px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-accent-default);color:var(--dsw-alias-label-on-accent);font-size:10px;line-height:16px;text-align:center}',
|
|
495
|
-
// v1.6.10: the transcript is selectable text; screenshots get a preview and a copy affordance.
|
|
496
|
-
'.
|
|
497
|
-
'.
|
|
498
|
-
'.
|
|
499
|
-
'.
|
|
500
|
-
'.
|
|
501
|
-
'.
|
|
502
|
-
|
|
503
|
-
'.
|
|
504
|
-
'.
|
|
505
|
-
|
|
506
|
-
'.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
1
|
+
/**
|
|
2
|
+
* DSH Web client half of `@twinsearth/w2m-dsh-plugin`: the composer's target-machine control.
|
|
3
|
+
*
|
|
4
|
+
* # What this file is
|
|
5
|
+
*
|
|
6
|
+
* This is the *shipped* client bundle, not compiled output: the package has no build step and no
|
|
7
|
+
* dependencies, so the half the browser loads is written here in the exact module format the DSH
|
|
8
|
+
* web shell's loader consumes (`window.__ModuleLoader__.load({ id, factory })`, a lazy CJS table
|
|
9
|
+
* whose factory runs at materialization). `package.json` declares it with
|
|
10
|
+
* `dsh.client: { platform: 'web' }` and `exports['./client'] = './lib/client.js'`, which is what the
|
|
11
|
+
* host scans for. There is no second copy to keep in sync: this file **is** the source, and
|
|
12
|
+
* `test/client-bundle.test.mjs` is where its entry shape is read.
|
|
13
|
+
*
|
|
14
|
+
* # A failed client entry is fatal, and silence is not a neutral failure
|
|
15
|
+
*
|
|
16
|
+
* Measured twice in production: when this entry's `apply` throws, the page does not degrade — the
|
|
17
|
+
* shell audits every entry after activation and refuses to boot with
|
|
18
|
+
* `web boot: 1 entry did not activate / @twinsearth/w2m-dsh-plugin: failed`, which in the desktop
|
|
19
|
+
* app is forwarded to the main process and written as a `web-boot` crash: **the app does not start**.
|
|
20
|
+
* The audit prints the entry's *name* and its fiber state and nothing else, so the cause has to be
|
|
21
|
+
* carried by the failing code itself; {@link apply} therefore reports its own error to
|
|
22
|
+
* `console.error`, which is the error-level renderer console the crash log already captures.
|
|
23
|
+
*
|
|
24
|
+
* The failure that cost those two outages was a single property read. Cordis resolves `ctx.<name>`
|
|
25
|
+
* through the fiber's `inject` list: a service the fiber never declared **throws**
|
|
26
|
+
* (`cannot get property "locale" without inject`) whether or not some plugin provides it, and every
|
|
27
|
+
* name that *is* declared becomes a required dependency the entry waits for. Both halves of that rule
|
|
28
|
+
* are load-bearing here — see {@link inject} for what this bundle declares, and how the optional
|
|
29
|
+
* locale carrier is read instead.
|
|
30
|
+
*
|
|
31
|
+
* Two consequences, both deliberate:
|
|
32
|
+
*
|
|
33
|
+
* * **No JSX and no bundler.** `React.createElement` keeps the file plain JavaScript a reviewer
|
|
34
|
+
* can read, and the module system's platform seed table already holds React (`react`,
|
|
35
|
+
* `react/jsx-runtime`, `react-dom`, Cordis and the shell's static UI libraries are frozen seeds
|
|
36
|
+
* for every dynamic bundle), so `require('react')` resolves without this package shipping or
|
|
37
|
+
* declaring anything.
|
|
38
|
+
* * **Only React is required.** `@deepseek-ai/dsh-client-ui-primitives` is *available* to a
|
|
39
|
+
* dynamic bundle — the shipped permission control next to this one uses its `Menu` — but a
|
|
40
|
+
* package that must stay dependency-free should not take a rendering dependency for one
|
|
41
|
+
* dropdown. The control is styled with the shell's own theme tokens instead, which is what
|
|
42
|
+
* makes it correct in both themes rather than "similar in one".
|
|
43
|
+
*
|
|
44
|
+
* # Where it sits
|
|
45
|
+
*
|
|
46
|
+
* `conversation.input.left` — `kind: list`, `scope: session`, `replaceRisk: none`. The shipped
|
|
47
|
+
* permission control owns `conversation.input.permission` (`single`, `replaceRisk:
|
|
48
|
+
* shadows-shipped-ui`) and is **not** touched: this entry is appended to the composer's left group,
|
|
49
|
+
* so it renders to the right of the permission control and never competes with it for a seat.
|
|
50
|
+
*
|
|
51
|
+
* # What it shows
|
|
52
|
+
*
|
|
53
|
+
* The machine this session's work will be dispatched to, and — on click — the fleet the relay
|
|
54
|
+
* currently holds. Liveness comes from the host plugin, never from this file: a machine is
|
|
55
|
+
* selectable because the relay lists it as online, and one that is not listed that way is rendered
|
|
56
|
+
* with its state and cannot be chosen. A selection is written to the host process, so it survives a
|
|
57
|
+
* reload and is visible to `w2m_run`, which is where it actually means something.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
window.__ModuleLoader__.load({
|
|
61
|
+
id: '@twinsearth/w2m-dsh-plugin',
|
|
62
|
+
factory: (require) => {
|
|
63
|
+
var module = { exports: {} };
|
|
64
|
+
var exports = module.exports;
|
|
65
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
66
|
+
|
|
67
|
+
const React = require('react');
|
|
68
|
+
|
|
69
|
+
// -------------------------------------------------------------------------------------------
|
|
70
|
+
// Wire
|
|
71
|
+
// -------------------------------------------------------------------------------------------
|
|
72
|
+
//
|
|
73
|
+
// Mirrors TARGET_ROUTE_PATH in src/plugin/fleet-target.mjs, `/api` prefix included: the host's
|
|
74
|
+
// Connection service mounts its exact-fetch-route registry under that channel and refuses any
|
|
75
|
+
// path that does not start with `/api/`. Plain same-origin `fetch` rather than a Remote method:
|
|
76
|
+
// the route is mounted inside the host's authentication fence, so the page's own cookie is the
|
|
77
|
+
// credential and there is no descriptor to keep in sync.
|
|
78
|
+
const ROUTE = '/api/w2m/target';
|
|
79
|
+
// v1.4.2: the UI-activated Node-code-execution switch (host: src/plugin/exec-capability-route.mjs).
|
|
80
|
+
const CAPABILITY_ROUTE = '/api/w2m/capability';
|
|
81
|
+
// v1.6.8: npm update check + verified install (host: src/plugin/update-route.mjs).
|
|
82
|
+
const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The locale namespace and its two dictionaries.
|
|
86
|
+
*
|
|
87
|
+
* Registered through `ctx.locale` when the composition provides it, exactly as the shipped
|
|
88
|
+
* permission control beside this one does; the Chinese dictionary is the fallback when it does
|
|
89
|
+
* not, because a missing localisation service is not a reason for an operator to lose the
|
|
90
|
+
* control. Only the *visible* strings are here. A refusal from the host is reported in the
|
|
91
|
+
* host's own words — it is a diagnostic naming a machine, a relay or an HTTP status, and
|
|
92
|
+
* translating it would put a second, drifting copy of that vocabulary in the browser.
|
|
93
|
+
*/
|
|
94
|
+
const NS = 'w2m.target';
|
|
95
|
+
const DICT = {
|
|
96
|
+
zh: {
|
|
97
|
+
all: '全部机器',
|
|
98
|
+
machine: '机器',
|
|
99
|
+
local: '本机',
|
|
100
|
+
online: '在线',
|
|
101
|
+
noRoster: '无列表',
|
|
102
|
+
empty: '机器列表里没有机器',
|
|
103
|
+
costAll: '本次会话的派发会送到所有在线机器',
|
|
104
|
+
costOne: '本次会话的派发会送到 {name}',
|
|
105
|
+
unconfirmed: '(未确认在线)',
|
|
106
|
+
targetLabel: '目标机器:{name}',
|
|
107
|
+
unreachable: '无法连接插件',
|
|
108
|
+
routeMissing: '这个 Harness 没有提供插件目标路由',
|
|
109
|
+
fleetUnreadable: '机器列表读取失败',
|
|
110
|
+
relays: 'Relay 节点',
|
|
111
|
+
relayPrimary: '主 relay',
|
|
112
|
+
relayStandby: '备用',
|
|
113
|
+
relayOffline: '离线',
|
|
114
|
+
relayFold: '另有 {count} 个已折叠,点击展开',
|
|
115
|
+
relayUnfold: '收起折叠的 relay',
|
|
116
|
+
relayTemporary: '临时 Relay 节点(回退配置)',
|
|
117
|
+
globalSummary: '全服:{fleetsOnline}/{fleets} 个编队 · {online}/{total} 台在线',
|
|
118
|
+
globalSummaryWithSource: '主 relay({source})全服:{fleetsOnline}/{fleets} 个编队 · {online}/{total} 台在线',
|
|
119
|
+
fleetSummary: '本编队:{online}/{total} 台在线',
|
|
120
|
+
globalUnreachable: '主 relay 不可达({url})· 全服统计不可用',
|
|
121
|
+
updateButton: '检查插件更新',
|
|
122
|
+
updateChecking: '正在检查 npm…',
|
|
123
|
+
updating: '正在更新…',
|
|
124
|
+
updateCurrent: '已是最新({version})',
|
|
125
|
+
updateInstalled: '已更新到 {version}',
|
|
126
|
+
restartHint: '请重启 DSH 以载入最新插件',
|
|
127
|
+
capLabel: 'Node 执行:{state}',
|
|
128
|
+
capDenied: '拒绝',
|
|
129
|
+
capTemporary: '临时 24 小时',
|
|
130
|
+
capPermanent: '永久',
|
|
131
|
+
capWarning: '开启后该机器可执行任意 Node 代码(等效远程代码执行)',
|
|
132
|
+
capMenuLabel: 'Node 代码执行能力',
|
|
133
|
+
capRemaining: '剩余 {hours} 小时',
|
|
134
|
+
capRouteMissing: '这个 Harness 没有提供能力路由',
|
|
135
|
+
capRefused: '能力切换被拒绝:{error}',
|
|
136
|
+
gpwTitle: 'GPW 文件夹策略',
|
|
137
|
+
gpwDisabled: 'GPW 未启用(在插件页开启 dsh-gpw)',
|
|
138
|
+
gpwWhitelist: '白名单(通过)',
|
|
139
|
+
gpwBlacklist: '黑名单(拒绝)',
|
|
140
|
+
gpwAddWhite: '添加白名单文件夹',
|
|
141
|
+
gpwAddBlack: '添加黑名单文件夹',
|
|
142
|
+
gpwRemove: '移除',
|
|
143
|
+
gpwPending: '待审批(C 类:名单外)',
|
|
144
|
+
gpwLauncher: 'GPW 文件夹',
|
|
145
|
+
gpwManual: '手动输入路径',
|
|
146
|
+
gpwManualPlaceholder: '例如 D:\\work\\data(绝对路径)',
|
|
147
|
+
gpwAddManual: '添加',
|
|
148
|
+
gpwRecent: '最近被拦截',
|
|
149
|
+
gpwRecentEmpty: '暂无被拦截记录',
|
|
150
|
+
gpwAddWhiteShort: '加入白名单',
|
|
151
|
+
gpwAddBlackShort: '加入黑名单',
|
|
152
|
+
gpwClose: '关闭',
|
|
153
|
+
gpwBadPath: '路径无效:{error}',
|
|
154
|
+
gpwInputHint: '目录需已存在;添加后立即生效。',
|
|
155
|
+
gpwPendingEmpty: '当前没有待审批调用',
|
|
156
|
+
gpwAllowOnce: '允许本次',
|
|
157
|
+
gpwDenyOnce: '拒绝本次',
|
|
158
|
+
gpwMore: '另有 {count} 个,展开',
|
|
159
|
+
gpwFold: '收起',
|
|
160
|
+
gpwAddWhiteAllow: '加入白名单并允许',
|
|
161
|
+
gpwAddBlackDeny: '加入黑名单并拒绝',
|
|
162
|
+
gpwPendingShort: 'GPW 待审批:{tool}',
|
|
163
|
+
gpwPendingCount: '有 {count} 个调用等待审批',
|
|
164
|
+
gpwOpenPanel: '打开面板',
|
|
165
|
+
gpwTampered: '名单完整性校验失败({reason}),名单外路径已锁定。',
|
|
166
|
+
gpwReset: '重置名单(清空并重新签名)',
|
|
167
|
+
gpwResetConfirm: '确认重置?将清空白名单和黑名单的全部条目。',
|
|
168
|
+
},
|
|
169
|
+
en: {
|
|
170
|
+
all: 'All machines',
|
|
171
|
+
machine: 'Machine',
|
|
172
|
+
local: 'This machine',
|
|
173
|
+
online: 'online',
|
|
174
|
+
noRoster: 'no roster',
|
|
175
|
+
empty: 'the fleet lists no machines',
|
|
176
|
+
costAll: 'Work from this session goes to every online machine',
|
|
177
|
+
costOne: 'Work from this session goes to {name}',
|
|
178
|
+
unconfirmed: ' (not confirmed online)',
|
|
179
|
+
targetLabel: 'target machine: {name}',
|
|
180
|
+
unreachable: 'the plugin could not be reached',
|
|
181
|
+
routeMissing: 'this host does not serve the W2M target route',
|
|
182
|
+
fleetUnreadable: 'the fleet could not be read',
|
|
183
|
+
relays: 'Relay nodes',
|
|
184
|
+
relayPrimary: 'primary',
|
|
185
|
+
relayStandby: 'standby',
|
|
186
|
+
relayOffline: 'offline',
|
|
187
|
+
relayFold: '{count} more folded — expand',
|
|
188
|
+
relayUnfold: 'collapse the folded relays',
|
|
189
|
+
relayTemporary: 'temporary relay nodes (fallback config)',
|
|
190
|
+
globalSummary: 'fleets {fleetsOnline}/{fleets} · machines {online}/{total} online',
|
|
191
|
+
globalSummaryWithSource: 'primary relay ({source}) · fleets {fleetsOnline}/{fleets} · machines {online}/{total}',
|
|
192
|
+
fleetSummary: 'this fleet: {online}/{total} online',
|
|
193
|
+
globalUnreachable: 'primary relay unreachable ({url}) · server totals unavailable',
|
|
194
|
+
updateButton: 'check for plugin update',
|
|
195
|
+
updateChecking: 'checking npm…',
|
|
196
|
+
updating: 'updating…',
|
|
197
|
+
updateCurrent: 'up to date ({version})',
|
|
198
|
+
updateInstalled: 'updated to {version}',
|
|
199
|
+
restartHint: 'restart DSH to load the new plugin',
|
|
200
|
+
capLabel: 'Node exec: {state}',
|
|
201
|
+
capDenied: 'denied',
|
|
202
|
+
capTemporary: 'temporary 24 h',
|
|
203
|
+
capPermanent: 'permanent',
|
|
204
|
+
capWarning: 'once on, this machine runs arbitrary Node code (equivalent to remote code execution)',
|
|
205
|
+
capMenuLabel: 'Node code execution capability',
|
|
206
|
+
capRemaining: '{hours} h left',
|
|
207
|
+
capRouteMissing: 'this host does not serve the capability route',
|
|
208
|
+
capRefused: 'the capability switch was refused: {error}',
|
|
209
|
+
gpwTitle: 'GPW folder policy',
|
|
210
|
+
gpwDisabled: 'GPW is not enabled (turn on dsh-gpw in the Plugins page)',
|
|
211
|
+
gpwWhitelist: 'Whitelist (pass)',
|
|
212
|
+
gpwBlacklist: 'Blacklist (deny)',
|
|
213
|
+
gpwAddWhite: 'Add whitelist folder',
|
|
214
|
+
gpwAddBlack: 'Add blacklist folder',
|
|
215
|
+
gpwRemove: 'Remove',
|
|
216
|
+
gpwPending: 'Pending approval (class C: on neither list)',
|
|
217
|
+
gpwPendingEmpty: 'No calls are waiting for approval',
|
|
218
|
+
gpwAllowOnce: 'Allow this call',
|
|
219
|
+
gpwDenyOnce: 'Deny this call',
|
|
220
|
+
gpwMore: 'Show {count} more',
|
|
221
|
+
gpwFold: 'Show less',
|
|
222
|
+
gpwAddWhiteAllow: 'Always allow (add to whitelist)',
|
|
223
|
+
gpwAddBlackDeny: 'Always deny (add to blacklist)',
|
|
224
|
+
gpwPendingShort: 'GPW pending: {tool}',
|
|
225
|
+
gpwPendingCount: '{count} call(s) waiting for approval',
|
|
226
|
+
gpwOpenPanel: 'Open panel',
|
|
227
|
+
gpwTampered: 'Folder lists failed integrity check ({reason}); non-listed paths are locked.',
|
|
228
|
+
gpwReset: 'Reset lists (clear + re-sign)',
|
|
229
|
+
gpwResetConfirm: 'Reset the lists? Every whitelist and blacklist entry will be cleared.',
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A translator over {@link DICT}, preferring the shell's locale service.
|
|
235
|
+
*
|
|
236
|
+
* `{name}` substitution is deliberately the only templating: these strings have one variable
|
|
237
|
+
* each, and a message format would be a dependency this package does not have.
|
|
238
|
+
*
|
|
239
|
+
* @param localeOf - the shell's locale service, or a function returning it (read at lookup time).
|
|
240
|
+
* Passed in rather than read from `ctx` here: the service is optional, and reading an optional
|
|
241
|
+
* service off the context is the one operation in this file that can kill the page (see
|
|
242
|
+
* {@link readOptionalService}). A function is accepted because the service may arrive *after*
|
|
243
|
+
* this entry activates — the shell's `bind()` is live, so a deferred read still localises.
|
|
244
|
+
* @returns {(key: string, values?: object) => string}
|
|
245
|
+
*/
|
|
246
|
+
function makeTranslator(localeOf) {
|
|
247
|
+
const resolveLocale = () => (typeof localeOf === 'function' ? localeOf() : localeOf);
|
|
248
|
+
return (key, values) => {
|
|
249
|
+
const locale = resolveLocale();
|
|
250
|
+
const bound = typeof locale?.bind === 'function' ? locale.bind(NS) : null;
|
|
251
|
+
const template = bound === null
|
|
252
|
+
? (DICT.zh[key] ?? DICT.en[key] ?? key)
|
|
253
|
+
: bound(key, values);
|
|
254
|
+
const text = typeof template === 'string' && template !== '' ? template : (DICT.en[key] ?? key);
|
|
255
|
+
if (values === undefined) return text;
|
|
256
|
+
return text.replace(/\{(\w+)\}/g, (match, name) => (values[name] === undefined ? match : String(values[name])));
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Read a service the composition may or may not provide, without the inject requirement.
|
|
262
|
+
*
|
|
263
|
+
* `ctx.get(name)` is Cordis's inject-free lookup, and it is the only correct way to read an
|
|
264
|
+
* *optional* service from a plugin: `ctx[name]` throws for anything the fiber did not declare as
|
|
265
|
+
* a dependency, and declaring it would make the service *required* — an entry whose declared
|
|
266
|
+
* dependency never arrives never activates either, which is fatal in exactly the same way. So a
|
|
267
|
+
* carrier that must not be able to block the control (`locale`) is read here, and a service the
|
|
268
|
+
* control cannot work without (`slots`) is declared in {@link inject}.
|
|
269
|
+
*
|
|
270
|
+
* @param ctx - the client plugin context.
|
|
271
|
+
* @param name - the service name.
|
|
272
|
+
* @returns the service, or undefined when the composition has none (or this is not a Cordis
|
|
273
|
+
* context at all, which is how `test/client-bundle.test.mjs` applies this bundle).
|
|
274
|
+
*/
|
|
275
|
+
function readOptionalService(ctx, name) {
|
|
276
|
+
return typeof ctx?.get === 'function' ? ctx.get(name) : undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Register {@link DICT} with the shell's locale service whenever that service exists.
|
|
281
|
+
*
|
|
282
|
+
* Two paths, one outcome. When the service is already up, the registration is an effect of this
|
|
283
|
+
* entry and goes away with it. When it is not up yet, the registration rides a **child fiber**
|
|
284
|
+
* (`ctx.inject`) that waits for `locale` without holding this entry back: declaring `locale` in
|
|
285
|
+
* {@link inject} instead would make it required, and a composition without a locale service
|
|
286
|
+
* would then report this entry as `pending` — which the shell's audit treats exactly like
|
|
287
|
+
* `failed`, i.e. the page does not boot. A missing localisation service must never cost an
|
|
288
|
+
* operator the control, and must never cost them the app.
|
|
289
|
+
*
|
|
290
|
+
* @param ctx - the client plugin context.
|
|
291
|
+
* @param locale - the shell's locale service, or undefined when the composition has none.
|
|
292
|
+
*/
|
|
293
|
+
function registerDictionaries(ctx, locale) {
|
|
294
|
+
if (typeof locale?.register === 'function') {
|
|
295
|
+
ctx.effect(() => locale.register(NS, DICT), 'w2m: composer target control dictionaries');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (typeof ctx?.inject !== 'function') return;
|
|
299
|
+
ctx.inject(['locale'], (localeCtx) => {
|
|
300
|
+
localeCtx.effect(() => localeCtx.locale.register(NS, DICT), 'w2m: composer target control dictionaries');
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** One JSON call to the host half, folded into a value: a refusal is never an exception here. */
|
|
305
|
+
async function callHost(options) {
|
|
306
|
+
let response;
|
|
307
|
+
try {
|
|
308
|
+
response = await fetch(options.url, options.init);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}`, status: 0 };
|
|
311
|
+
}
|
|
312
|
+
if (response.status === 404) {
|
|
313
|
+
return { ok: false, status: 404, error: 'this host does not serve the W2M target route' };
|
|
314
|
+
}
|
|
315
|
+
let body = null;
|
|
316
|
+
try {
|
|
317
|
+
body = await response.json();
|
|
318
|
+
} catch {
|
|
319
|
+
return { ok: false, status: response.status, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
|
|
320
|
+
}
|
|
321
|
+
if (response.ok !== true) {
|
|
322
|
+
return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, status: response.status, state: body };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const readState = (sessionId, signal) => callHost({
|
|
328
|
+
url: `${ROUTE}?session_id=${encodeURIComponent(sessionId)}`,
|
|
329
|
+
init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const writeSelection = (sessionId, machineId, machineName) => callHost({
|
|
333
|
+
url: ROUTE,
|
|
334
|
+
init: {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
credentials: 'same-origin',
|
|
337
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
338
|
+
body: JSON.stringify({ session_id: sessionId, machine_id: machineId, machine_name: machineName }),
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
/** One JSON call to the capability route: refusals are values, exactly like the target route. */
|
|
343
|
+
async function callCapability(options) {
|
|
344
|
+
let response;
|
|
345
|
+
try {
|
|
346
|
+
response = await fetch(options.url, options.init);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
return { ok: false, error: 'the plugin could not be reached: ' + (error instanceof Error ? error.message : String(error)) };
|
|
349
|
+
}
|
|
350
|
+
if (response.status === 404) return { ok: false, status: 404, routeMissing: true, error: 'capability route missing' };
|
|
351
|
+
let body = null;
|
|
352
|
+
try { body = await response.json(); } catch { return { ok: false, error: 'the capability route answered HTTP ' + response.status } }
|
|
353
|
+
if (response.ok !== true) return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : 'HTTP ' + response.status };
|
|
354
|
+
return { ok: true, capacity: body?.capability ?? null };
|
|
355
|
+
}
|
|
356
|
+
const readCapability = (signal) => callCapability({ url: CAPABILITY_ROUTE, init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal } });
|
|
357
|
+
const writeCapability = (state, ttlMs) => callCapability({ url: CAPABILITY_ROUTE, init: { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(ttlMs === undefined ? { state } : { state, ttl_ms: ttlMs }) } });
|
|
358
|
+
|
|
359
|
+
const labelOf = (state, t) => state?.selected?.machine_id != null
|
|
360
|
+
? (state.selected.name ?? state.selected.machine_id)
|
|
361
|
+
: t('all');
|
|
362
|
+
|
|
363
|
+
/** The one-line reason the list cannot be trusted, or null when it can. */
|
|
364
|
+
function fleetProblem(state, t) {
|
|
365
|
+
if (state === null || state === undefined) return null;
|
|
366
|
+
if (state.host?.detail_unavailable === true) return t('routeMissing');
|
|
367
|
+
if (state.fleet?.ok === true) return null;
|
|
368
|
+
return state.fleet?.error ?? t('fleetUnreadable');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// -------------------------------------------------------------------------------------------
|
|
372
|
+
// Styles
|
|
373
|
+
// -------------------------------------------------------------------------------------------
|
|
374
|
+
//
|
|
375
|
+
// Token colours only: `--dsw-*` is the shell's own theme surface, so the control follows the
|
|
376
|
+
// light/dark switch instead of inventing a palette that is wrong in one of them. The geometry
|
|
377
|
+
// (28px height, `--dsw-radius-sm`, 13px/500 label, 4px gap, 8px leading pad) is copied from the
|
|
378
|
+
// shipped permission trigger in the same row, and the status dot uses the theme's state colours:
|
|
379
|
+
// business blue for this machine, success green for a reachable peer, warn/error for the two
|
|
380
|
+
// states that are not "ready".
|
|
381
|
+
const TAG_ID = '@twinsearth/w2m-dsh-plugin/TargetSelect.css';
|
|
382
|
+
const CSS = [
|
|
383
|
+
'.w2mTarget_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;max-width:220px;min-width:0;',
|
|
384
|
+
'padding:0 4px 0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;',
|
|
385
|
+
'color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
|
|
386
|
+
'.w2mTarget_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
387
|
+
'.w2mTarget_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
|
|
388
|
+
'.w2mTarget_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
|
|
389
|
+
'.w2mTarget_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);',
|
|
390
|
+
'box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
|
|
391
|
+
'.w2mTarget_dot[data-state="local"]{background:var(--dsw-alias-state-business-primary)}',
|
|
392
|
+
'.w2mTarget_dot[data-state="online"]{background:var(--dsw-alias-state-success-primary)}',
|
|
393
|
+
'.w2mTarget_dot[data-state="stale"]{background:transparent;border:1px solid var(--dsw-alias-state-warn-primary)}',
|
|
394
|
+
'.w2mTarget_dot[data-state="none"]{background:transparent;border:1px solid var(--dsw-alias-label-caption)}',
|
|
395
|
+
'.w2mTarget_label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
396
|
+
'.w2mTarget_chevron{flex:none;display:inline-flex;color:var(--dsw-alias-label-caption);transition:transform .12s}',
|
|
397
|
+
'.w2mTarget_chevron[data-open="true"]{transform:rotate(180deg)}',
|
|
398
|
+
'.w2mTarget_menu{position:fixed;z-index:60;box-sizing:border-box;min-width:200px;max-width:300px;',
|
|
399
|
+
'padding:4px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);',
|
|
400
|
+
'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
|
|
401
|
+
'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
402
|
+
'.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
|
|
403
|
+
'.w2mTarget_relayFold{margin-top:2px;padding:4px 0;border-top:.5px solid var(--dsw-alias-border-l2)}',
|
|
404
|
+
'.w2mTarget_relayToggle{display:block;width:100%;box-sizing:border-box;padding:4px 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;text-align:left;cursor:pointer}',
|
|
405
|
+
'.w2mTarget_relayToggle[data-temporary="true"]{color:var(--dsw-alias-state-error-primary);font-weight:600}',
|
|
406
|
+
'.w2mTarget_relayFold[data-temporary="true"]{border-top-color:var(--dsw-alias-state-error-primary)}',
|
|
407
|
+
'.w2mTarget_relayRow{display:flex;align-items:center;gap:6px;padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
408
|
+
'.w2mTarget_relayRole{flex:none;min-width:44px;color:var(--dsw-alias-label-tertiary)}',
|
|
409
|
+
'.w2mTarget_relayUrl{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
410
|
+
'.w2mTarget_relayRtt{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
|
|
411
|
+
'.w2mTarget_relayHint{padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
412
|
+
'.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
|
|
413
|
+
'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
|
|
414
|
+
'.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
415
|
+
'.w2mTarget_option:disabled{cursor:default;color:var(--dsw-alias-label-dimmed)}',
|
|
416
|
+
'.w2mTarget_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
|
|
417
|
+
'.w2mTarget_row{display:flex;align-items:center;gap:8px}',
|
|
418
|
+
'.w2mTarget_name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
419
|
+
'.w2mTarget_detail{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400}',
|
|
420
|
+
'.w2mTarget_option:disabled .w2mTarget_detail{color:var(--dsw-alias-label-caption)}',
|
|
421
|
+
'.w2mCap{position:relative;display:inline-flex;align-items:center}',
|
|
422
|
+
'.w2mCap_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
|
|
423
|
+
'.w2mCap_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
424
|
+
'.w2mCap_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
|
|
425
|
+
'.w2mCap_trigger[data-on="true"]{color:var(--dsw-alias-state-warn-primary)}',
|
|
426
|
+
'.w2mCap_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
|
|
427
|
+
'.w2mCap_dot[data-on="true"]{background:var(--dsw-alias-state-warn-primary)}',
|
|
428
|
+
'.w2mGpw_launcher{position:fixed;right:16px;bottom:96px;z-index:55;padding:6px 10px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);cursor:pointer}',
|
|
429
|
+
'.w2mGpw_launcher:hover{background:var(--dsw-alias-bg-layer-2)}',
|
|
430
|
+
'.w2mGpw_prompt{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary)}',
|
|
431
|
+
'.w2mGpw_promptLabel{font-size:12px;color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-primary))}',
|
|
432
|
+
'.w2mGpw_badge{display:inline-block;min-width:16px;padding:0 5px;margin-left:4px;border-radius:999px;border:.5px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-bg-layer-1);font-size:11px;line-height:14px;text-align:center}',
|
|
433
|
+
'.w2mGpw_overlay{position:fixed;inset:0;z-index:70;background:color-mix(in srgb, var(--dsw-alias-label-primary) 24%, transparent);display:flex;align-items:flex-end;justify-content:flex-end;padding:16px}',
|
|
434
|
+
'.w2mGpw_card{width:420px;max-width:calc(100vw - 32px);max-height:min(80vh,640px);overflow:auto;box-sizing:border-box;padding:10px 12px;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
|
|
435
|
+
'.w2mGpw_head{display:flex;align-items:center;justify-content:space-between}',
|
|
436
|
+
'.w2mGpw_close{width:24px;height:24px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:16px;line-height:16px}',
|
|
437
|
+
'.w2mGpw_title{font-size:12px;font-weight:600}',
|
|
438
|
+
'.w2mGpw_block{display:flex;flex-direction:column;gap:4px;margin-top:2px;padding-top:6px;border-top:.5px solid var(--dsw-alias-border-default)}',
|
|
439
|
+
'.w2mGpw_row{display:flex;align-items:center;gap:6px;min-width:0}',
|
|
440
|
+
'.w2mGpw_path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
441
|
+
'.w2mGpw_input{box-sizing:border-box;width:100%;padding:5px 8px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;outline:none}',
|
|
442
|
+
'.w2mGpw_dot{flex:none;display:inline-block;width:8px;height:8px;border-radius:999px;margin-right:4px}',
|
|
443
|
+
'.w2mGpw_dot[data-c="green"]{background:var(--dsw-alias-state-success-primary)}',
|
|
444
|
+
'.w2mGpw_dot[data-c="blue"]{background:var(--dsw-alias-state-business-primary)}',
|
|
445
|
+
'.w2mGpw_dot[data-c="yellow"]{background:var(--dsw-alias-state-warn-primary)}',
|
|
446
|
+
'.w2mGpw_dot[data-c="red"]{background:var(--dsw-alias-state-danger-primary)}',
|
|
447
|
+
'.w2mGpw_legend{display:flex;gap:10px;align-items:center;font-size:12px}',
|
|
448
|
+
'.w2mGpw_legendItem{display:inline-flex;align-items:center;gap:2px}',
|
|
449
|
+
'.w2mGpw_actions{display:flex;flex-wrap:wrap;gap:6px}',
|
|
450
|
+
'.w2mGpw_btn{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}',
|
|
451
|
+
'.w2mGpw_btn:disabled{opacity:.5;cursor:default}',
|
|
452
|
+
'.w2mGpw_small{width:22px;padding:0;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}',
|
|
453
|
+
'.w2mGpw_recent{border:.5px solid var(--dsw-alias-border-default);border-radius:var(--dsw-radius-md);padding:4px 6px;display:flex;flex-direction:column;gap:3px}',
|
|
454
|
+
'.w2mGpw_tag{flex:none;font-size:11px;padding:0 5px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l2)}',
|
|
455
|
+
'.w2mGpw_tag[data-d="deny"]{border-color:var(--dsw-alias-state-danger-primary);color:var(--dsw-alias-state-danger-primary)}',
|
|
456
|
+
'.w2mGpw_note{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
457
|
+
'.w2mGpw_hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
458
|
+
'.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}',
|
|
459
|
+
'.w2mGpw_title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
460
|
+
'.w2mGpw_block{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px}',
|
|
461
|
+
'.w2mGpw_row{display:flex;align-items:center;gap:4px}',
|
|
462
|
+
'.w2mGpw_row .w2mGpw_path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
463
|
+
'.w2mGpw_path{font-size:11px;color:var(--dsw-alias-label-secondary);word-break:break-all}',
|
|
464
|
+
'.w2mGpw_small{width:20px;padding:0}',
|
|
465
|
+
'.w2mGpw_pending{border:.5px solid var(--dsw-alias-border-default);border-radius:6px;padding:4px 6px;display:flex;flex-direction:column;gap:2px}',
|
|
466
|
+
'.w2mGpw_actions{display:flex;gap:6px}',
|
|
467
|
+
'.w2mCap_menu{position:fixed;z-index:60;box-sizing:border-box;width:280px;padding:4px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
468
|
+
'.w2mCap_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
|
|
469
|
+
'.w2mCap_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
470
|
+
'.w2mCap_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
|
|
471
|
+
'.w2mCap_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
|
|
472
|
+
'.w2mChat{position:relative;display:inline-flex;align-items:center}',
|
|
473
|
+
'.w2mChat_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
|
|
474
|
+
'.w2mChat_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
475
|
+
'.w2mChat_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
|
|
476
|
+
'.w2mChat_panel{position:fixed;right:16px;bottom:88px;z-index:60;display:flex;flex-direction:column;width:340px;max-height:420px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);overflow:hidden}',
|
|
477
|
+
'.w2mChat_head{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:.5px solid var(--dsw-alias-border-l2);font-weight:600}',
|
|
478
|
+
'.w2mChat_usage{margin-left:auto;font-weight:400;color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
479
|
+
'.w2mChat_close{border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:16px;line-height:16px;padding:0 2px}',
|
|
480
|
+
'.w2mChat_list{flex:1;overflow-y:auto;padding:8px 10px;display:flex;flex-direction:column;gap:8px;min-height:120px}',
|
|
481
|
+
'.w2mChat_empty{color:var(--dsw-alias-label-tertiary)}',
|
|
482
|
+
'.w2mChat_msg{display:flex;flex-direction:column;gap:2px}',
|
|
483
|
+
'.w2mChat_meta{color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
484
|
+
'.w2mChat_to{font:inherit;font-size:11px;max-width:120px;border-radius:6px;border:.5px solid var(--dsw-alias-border-default);background:var(--dsw-alias-surface-default);color:var(--dsw-alias-label-primary)}',
|
|
485
|
+
'.w2mChat_reply{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-secondary);border-top:.5px solid var(--dsw-alias-border-default)}',
|
|
486
|
+
'.w2mChat_replyCancel{margin-left:auto;border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer}',
|
|
487
|
+
'.w2mChat_msg{cursor:pointer}',
|
|
488
|
+
'.w2mChat_thread{margin-left:6px;opacity:.7}',
|
|
489
|
+
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
490
|
+
'.w2mChat_board{display:flex;flex-direction:column;gap:2px;padding:6px 10px;border-bottom:.5px solid var(--dsw-alias-border-default)}',
|
|
491
|
+
'.w2mChat_boardTitle{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
492
|
+
'.w2mChat_boardRow{font-size:11px;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}',
|
|
493
|
+
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
494
|
+
'.w2mChat_unseen{margin-left:6px;min-width:16px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-accent-default);color:var(--dsw-alias-label-on-accent);font-size:10px;line-height:16px;text-align:center}',
|
|
495
|
+
// v1.6.10: the transcript is selectable text; screenshots get a preview and a copy affordance.
|
|
496
|
+
'.w2mChatTab{padding:4px 10px;border-radius:6px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;font:inherit;cursor:pointer}',
|
|
497
|
+
'.w2mChat_panelFull{position:fixed;inset:5vh 8vw;width:auto;max-height:90vh;z-index:70}',
|
|
498
|
+
'.w2mChat_emojiRow{display:flex;gap:4px;padding:4px 8px;flex-wrap:wrap}',
|
|
499
|
+
'.w2mChat_emoji{padding:2px 4px;border:0;background:transparent;font-size:16px;line-height:1;cursor:pointer}',
|
|
500
|
+
'.w2mFleetStatus{display:flex;gap:16px;align-items:center;padding:2px 12px;font-size:11px;opacity:.8;white-space:nowrap}',
|
|
501
|
+
'.w2mFleetStatusItem{white-space:nowrap}',
|
|
502
|
+
'.w2mGpw_mainWrap{height:100%;min-height:0;overflow-y:auto;padding:24px 16px 48px;box-sizing:border-box}',
|
|
503
|
+
'.w2mGpw_mainWrap .w2mGpw_inline{max-width:960px;margin:0 auto}',
|
|
504
|
+
'.w2mGpw_mainWrap .w2mGpw_card{width:100%;max-width:none;max-height:none;position:static;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-background-secondary)}',
|
|
505
|
+
'.w2mGpw_settingsBody .w2mGpw_inline{max-width:960px;margin:0 auto}',
|
|
506
|
+
'.w2mChat_view{position:static;inset:auto;width:100%;max-height:none;height:100%;border-radius:0}',
|
|
507
|
+
'.w2mChat_view .w2mChat_list{max-height:none;flex:1;min-height:0}',
|
|
508
|
+
'.w2mGpw_fullPage{position:fixed;inset:0;z-index:80;display:flex;flex-direction:column;background:var(--dsw-alias-background-primary)}',
|
|
509
|
+
'.w2mGpw_fullHead{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid rgba(127,127,127,.25)}',
|
|
510
|
+
'.w2mGpw_fullTitle{font-size:14px;font-weight:600}',
|
|
511
|
+
'.w2mGpw_fullBody{flex:1;min-height:0;overflow:auto;padding:16px}',
|
|
512
|
+
'.w2mGpw_inline{display:flex;flex-direction:column;width:100%;max-width:960px;margin:0 auto}',
|
|
513
|
+
'.w2mGpw_settingsBody{width:100%;max-width:960px}',
|
|
514
|
+
'.w2mGpw_sideEntry{display:flex;align-items:center;gap:6px;width:100%;padding:6px 10px;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer;border-radius:6px}',
|
|
515
|
+
'.w2mGpw_sideEntry:hover{background:rgba(127,127,127,.14)}',
|
|
516
|
+
'.w2mGpw_sideIcon{font-size:15px;line-height:1}',
|
|
517
|
+
'.w2mGpw_sideLabel{font-size:12px;font-weight:600;letter-spacing:.02em}',
|
|
518
|
+
'.w2mUpdateSection{display:flex;flex-direction:column;gap:8px;max-width:640px}',
|
|
519
|
+
'.w2mUpdateStatus{font-size:12px;opacity:.8}',
|
|
520
|
+
'.w2mUpdateRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
|
|
521
|
+
'.w2mUpdateBtn{padding:4px 10px;border-radius:6px;border:1px solid rgba(127,127,127,.4);background:transparent;color:inherit;font:inherit;cursor:pointer}',
|
|
522
|
+
'.w2mUpdateAuto{display:inline-flex;align-items:center;gap:6px;font-size:12px;opacity:.9}',
|
|
523
|
+
|
|
524
|
+
'.w2mChat_panel,.w2mChat_panel *{user-select:text!important;-webkit-user-select:text!important;cursor:text}',
|
|
525
|
+
'.w2mChat_copy{cursor:pointer!important}',
|
|
526
|
+
'.w2mChat_copy{margin-left:6px;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:11px;line-height:14px;padding:0 2px;vertical-align:middle}',
|
|
527
|
+
'.w2mChat_copy:hover{color:var(--dsw-alias-label-primary)}',
|
|
528
|
+
'.w2mChat_shotButton{flex:none;height:28px;padding:0 8px;border:.5px solid var(--dsw-alias-border-default);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-surface-default);color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer}',
|
|
529
|
+
'.w2mChat_toast{position:absolute;left:10px;right:10px;top:46px;z-index:6;display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:8px;border:.5px solid var(--dsw-alias-border-default);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-soft);font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
530
|
+
'.w2mChat_toastClose{margin-left:auto;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:12px;line-height:12px;padding:0 2px}',
|
|
531
|
+
'.w2mChat_text{white-space:pre-wrap;word-break:break-word}',
|
|
532
|
+
'.w2mChat_msg.is-session .w2mChat_text{color:var(--dsw-danger)}', '.w2mChat_msg.is-chat .w2mChat_text{color:var(--dsw-success)}', '.w2mChat_msg.is-remote .w2mChat_text{color:var(--dsw-text)}', '.w2mChat_avatar{display:inline-flex;width:16px;height:16px;margin-right:5px;vertical-align:-3px}',
|
|
533
|
+
'.w2mChat_compose{display:flex;gap:8px;padding:8px 10px;border-top:.5px solid var(--dsw-alias-border-l2);align-items:flex-end}',
|
|
534
|
+
'.w2mChat_input{flex:1;resize:none;box-sizing:border-box;font:inherit;color:inherit;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);padding:6px 8px;outline:none;min-height:38px;max-height:96px}',
|
|
535
|
+
'.w2mChat_send{flex:none;height:28px;padding:0 10px;border:none;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-text-on-business);font:inherit;font-weight:500;cursor:pointer}',
|
|
536
|
+
'.w2mChat_send:disabled{opacity:.5;cursor:default}',
|
|
537
|
+
'.w2mChat_error{padding:0 10px 8px;color:var(--dsw-alias-state-error-primary);font-size:11px}',
|
|
538
|
+
].join('');
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Install the stylesheet once per document, and hand back a disposer for the plugin's lifetime.
|
|
542
|
+
*
|
|
543
|
+
* Idempotent by the tag id, not by a module flag: two plugin instances in one page (a reload
|
|
544
|
+
* that did not evict the old one, two compositions) must not produce two stylesheets, and an
|
|
545
|
+
* unload must take exactly the one this call created. When a tag is already present this is a
|
|
546
|
+
* no-op that returns nothing, because removing a tag this call did not add would be a
|
|
547
|
+
* disposer that breaks whichever instance owns it.
|
|
548
|
+
*
|
|
549
|
+
* @returns {(() => void)|null} Disposer, or null when the stylesheet was already installed.
|
|
550
|
+
*/
|
|
551
|
+
function injectStyle(hostDocument) {
|
|
552
|
+
if (hostDocument === undefined || hostDocument === null) return null;
|
|
553
|
+
if (hostDocument.querySelector(`style[data-plugin-css="${TAG_ID}"]`) !== null) return null;
|
|
554
|
+
const tag = hostDocument.createElement('style');
|
|
555
|
+
tag.dataset.plugin = '@twinsearth/w2m-dsh-plugin';
|
|
556
|
+
tag.dataset.pluginCss = TAG_ID;
|
|
557
|
+
tag.textContent = CSS;
|
|
558
|
+
hostDocument.head.appendChild(tag);
|
|
559
|
+
return () => {
|
|
560
|
+
tag.remove();
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const cls = (name) => `w2mTarget_${name}`;
|
|
565
|
+
const h = React.createElement;
|
|
566
|
+
|
|
567
|
+
/** The status dot: the colour is the machine's state, and the title says it in words. */
|
|
568
|
+
function StatusDot({ kind, title }) {
|
|
569
|
+
return h('span', { className: cls('dot'), 'data-state': kind, title, 'aria-hidden': 'true' });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* This machine is `local` — the one whose composer this is — and the blue dot marks it; a
|
|
574
|
+
* reachable peer is green; anything else is drawn as an outline in the state's own colour so an
|
|
575
|
+
* offline row can never be mistaken for a live one.
|
|
576
|
+
*/
|
|
577
|
+
function dotKind(machine) {
|
|
578
|
+
if (machine.online !== true) return machine.state === 'expired' ? 'none' : 'stale';
|
|
579
|
+
return machine.local === true ? 'local' : 'online';
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** The label the trigger shows before the host has answered: the configured fleet name. */
|
|
583
|
+
const provisionalLabel = (state) => state?.host?.local_machine_name ?? null;
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* `locked` is accepted for parity with the seat's other occupants and deliberately unused: the
|
|
587
|
+
* target is a choice about the *next* dispatch, which is legitimate to change while a turn runs.
|
|
588
|
+
* `t` arrives through the entry's inject face (see {@link apply}).
|
|
589
|
+
*/
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* GPW v2 operator panel + launcher (v1.7.5-beta.1).
|
|
593
|
+
*
|
|
594
|
+
* The launcher is a floating button pinned to the lower-right corner (a slot entry cannot choose its own screen
|
|
595
|
+
* position, so it pins itself with CSS). The panel has the three sections the operator asked for -- whitelist,
|
|
596
|
+
* blacklist, recently intercepted -- plus the pending queue for class-C calls. Folders enter a list in one of two
|
|
597
|
+
* ways: typed here and validated by the host, or taken from a recently intercepted call in one click. The lists
|
|
598
|
+
* live in $DSH_HOME/xclient/gpw-folders.json; no profile YAML is edited by this feature.
|
|
599
|
+
*/
|
|
600
|
+
function GpwPanel({ t, sessionId, onClose, inline }) {
|
|
601
|
+
const [state, setState] = React.useState(null);
|
|
602
|
+
const [error, setError] = React.useState(null);
|
|
603
|
+
const [busy, setBusy] = React.useState(false);
|
|
604
|
+
const [manual, setManual] = React.useState('');
|
|
605
|
+
const [badPath, setBadPath] = React.useState(null);
|
|
606
|
+
const [expanded, setExpanded] = React.useState({});
|
|
607
|
+
const call = React.useCallback(async (url, init) => {
|
|
608
|
+
try {
|
|
609
|
+
const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }) }, ...init });
|
|
610
|
+
let body = null;
|
|
611
|
+
try { body = await response.json(); } catch { body = null; }
|
|
612
|
+
if (response.status === 404) return { ok: false, routeMissing: true, error: 'GPW route missing' };
|
|
613
|
+
if (response.ok !== true || body?.ok === false) return { ok: false, error: body?.error ?? ('HTTP ' + response.status) };
|
|
614
|
+
return { ok: true, body };
|
|
615
|
+
} catch (error) {
|
|
616
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
617
|
+
}
|
|
618
|
+
}, []);
|
|
619
|
+
const load = React.useCallback(async () => {
|
|
620
|
+
const result = await call('/api/w2m/gpw/state', { method: 'GET' });
|
|
621
|
+
if (result.ok) { setState(result.body); setError(null); } else setError(result.routeMissing ? t('gpwDisabled') : (result.error ?? 'error'));
|
|
622
|
+
}, [call, t]);
|
|
623
|
+
React.useEffect(() => {
|
|
624
|
+
load();
|
|
625
|
+
const timer = setInterval(() => { load(); }, 3000);
|
|
626
|
+
return () => clearInterval(timer);
|
|
627
|
+
}, [load]);
|
|
628
|
+
const addFolder = async (list, path) => {
|
|
629
|
+
setBusy(true);
|
|
630
|
+
try {
|
|
631
|
+
const result = await call('/api/w2m/gpw/folders', { method: 'POST', body: JSON.stringify({ action: 'add', list, path }) });
|
|
632
|
+
if (result.ok !== true) { setBadPath(result.error ?? 'error'); return false; }
|
|
633
|
+
setBadPath(null);
|
|
634
|
+
await load();
|
|
635
|
+
return true;
|
|
636
|
+
} finally { setBusy(false); }
|
|
637
|
+
};
|
|
638
|
+
const addTyped = async (list) => {
|
|
639
|
+
const ok = await addFolder(list, manual);
|
|
640
|
+
if (ok === true) setManual('');
|
|
641
|
+
};
|
|
642
|
+
const removeFolder = async (list, path) => {
|
|
643
|
+
setBusy(true);
|
|
644
|
+
try { await call('/api/w2m/gpw/folders', { method: 'POST', body: JSON.stringify({ action: 'remove', list, path }) }); await load(); } finally { setBusy(false); }
|
|
645
|
+
};
|
|
646
|
+
const decide = async (id, decision) => {
|
|
647
|
+
setBusy(true);
|
|
648
|
+
try { await call('/api/w2m/gpw/decide', { method: 'POST', body: JSON.stringify({ id, decision }) }); await load(); } finally { setBusy(false); }
|
|
649
|
+
};
|
|
650
|
+
/** v1.7.9: after a tamper report the signed lists are locked until the operator resets them. */
|
|
651
|
+
const resetLists = async () => {
|
|
652
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function' && window.confirm(t('gpwResetConfirm')) !== true) return;
|
|
653
|
+
setBusy(true);
|
|
654
|
+
try { await call('/api/w2m/gpw/reset', { method: 'POST', body: JSON.stringify({ confirm: 'reset' }) }); await load(); } finally { setBusy(false); }
|
|
655
|
+
};
|
|
656
|
+
/** v1.7.9: lists show three rows by default; the rest fold behind one toggle. */
|
|
657
|
+
const foldItems = (items, key) => (expanded[key] === true ? items : items.slice(0, 3));
|
|
658
|
+
const foldRow = (items, key) => (items.length <= 3 ? null : h('button', {
|
|
659
|
+
key: 'fold-' + key,
|
|
660
|
+
type: 'button',
|
|
661
|
+
className: 'w2mGpw_btn',
|
|
662
|
+
disabled: busy,
|
|
663
|
+
onClick: () => setExpanded((prev) => ({ ...prev, [key]: prev[key] !== true })),
|
|
664
|
+
}, expanded[key] === true ? t('gpwFold') : t('gpwMore', { count: items.length - 3 })));
|
|
665
|
+
const allowWithFolder = async (item, list) => {
|
|
666
|
+
if (typeof item?.folder !== 'string' || item.folder === '') return;
|
|
667
|
+
const added = await addFolder(list, item.folder);
|
|
668
|
+
if (added === true) await decide(item.id, list === 'whitelist' ? 'allow' : 'deny');
|
|
669
|
+
};
|
|
670
|
+
const folderList = (title, list) => {
|
|
671
|
+
const items = Array.isArray(state?.folders?.[list]) ? state.folders[list] : [];
|
|
672
|
+
return h('div', { key: list, className: 'w2mGpw_block' }, [
|
|
673
|
+
h('div', { key: 'title', className: 'w2mGpw_title' }, title),
|
|
674
|
+
...foldItems(items, list).map((path) => h('div', { key: path, className: 'w2mGpw_row' }, [
|
|
675
|
+
h('span', { key: 'p', className: 'w2mGpw_path', title: path }, path),
|
|
676
|
+
h('button', { key: 'x', type: 'button', className: 'w2mGpw_small', disabled: busy, onClick: () => removeFolder(list, path) }, '×'),
|
|
677
|
+
])),
|
|
678
|
+
foldRow(items, list),
|
|
679
|
+
]);
|
|
680
|
+
};
|
|
681
|
+
const recentAll = Array.isArray(state?.recent) ? state.recent : [];
|
|
682
|
+
const recent = recentAll.filter((item) => item?.folder_in === null || item?.folder_in === undefined);
|
|
683
|
+
const recentInLists = recentAll.filter((item) => item?.folder_in === 'whitelist' || item?.folder_in === 'blacklist');
|
|
684
|
+
const [showRecentInLists, setShowRecentInLists] = React.useState(false);
|
|
685
|
+
const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
|
|
686
|
+
// v1.8.1: an approval belongs to the conversation that raised it; another session must never show it.
|
|
687
|
+
const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
|
|
688
|
+
return h('div', { className: inline === true ? 'w2mGpw_inline' : 'w2mGpw_overlay', role: 'dialog', 'aria-label': t('gpwTitle') }, [
|
|
689
|
+
h('div', { key: 'card', className: 'w2mGpw_card' }, [
|
|
690
|
+
h('div', { key: 'head', className: 'w2mGpw_head' }, [
|
|
691
|
+
h('span', { key: 'title', className: 'w2mGpw_title' }, t('gpwTitle')),
|
|
692
|
+
h('button', { key: 'close', type: 'button', className: 'w2mGpw_close', onClick: onClose }, '×'),
|
|
693
|
+
]),
|
|
694
|
+
error === null ? null : h('div', { key: 'err', className: 'w2mGpw_note' }, error),
|
|
695
|
+
state?.tampered === true ? h('div', { key: 'tampered', className: 'w2mGpw_block' }, [
|
|
696
|
+
h('div', { key: 't', className: 'w2mGpw_note' }, t('gpwTampered', { reason: state?.tampered_reason ?? '' })),
|
|
697
|
+
h('div', { key: 'a', className: 'w2mGpw_actions' }, [
|
|
698
|
+
h('button', { key: 'r', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: resetLists }, t('gpwReset')),
|
|
699
|
+
]),
|
|
700
|
+
]) : null,
|
|
701
|
+
h('div', { key: 'colours', className: 'w2mGpw_legend' }, ['green', 'blue', 'yellow', 'red'].map((colour) => h('span', { key: colour, className: 'w2mGpw_legendItem' }, [h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': colour }), String(state?.counters?.colors?.[colour] ?? 0)]))),
|
|
702
|
+
h('div', { key: 'manual', className: 'w2mGpw_manual' }, [
|
|
703
|
+
h('div', { key: 'label', className: 'w2mGpw_title' }, t('gpwManual')),
|
|
704
|
+
h('input', {
|
|
705
|
+
key: 'input',
|
|
706
|
+
className: 'w2mGpw_input',
|
|
707
|
+
value: manual,
|
|
708
|
+
placeholder: t('gpwManualPlaceholder'),
|
|
709
|
+
onChange: (event) => { setManual(event.target.value); setBadPath(null); },
|
|
710
|
+
onKeyDown: (event) => { if (event.key === 'Enter' && manual.trim() !== '') addTyped('whitelist'); },
|
|
711
|
+
}),
|
|
712
|
+
h('div', { key: 'buttons', className: 'w2mGpw_actions' }, [
|
|
713
|
+
h('button', { key: 'w', type: 'button', className: 'w2mGpw_btn', disabled: busy || manual.trim() === '', onClick: () => addTyped('whitelist') }, t('gpwAddWhiteShort')),
|
|
714
|
+
h('button', { key: 'b', type: 'button', className: 'w2mGpw_btn', disabled: busy || manual.trim() === '', onClick: () => addTyped('blacklist') }, t('gpwAddBlackShort')),
|
|
715
|
+
]),
|
|
716
|
+
badPath === null ? null : h('div', { key: 'bad', className: 'w2mGpw_note' }, t('gpwBadPath', { error: badPath })),
|
|
717
|
+
h('div', { key: 'hint', className: 'w2mGpw_hint' }, t('gpwInputHint')),
|
|
718
|
+
]),
|
|
719
|
+
folderList(t('gpwWhitelist'), 'whitelist'),
|
|
720
|
+
folderList(t('gpwBlacklist'), 'blacklist'),
|
|
721
|
+
h('div', { key: 'recent', className: 'w2mGpw_block' }, [
|
|
722
|
+
h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwRecent')),
|
|
723
|
+
recent.length === 0 ? h('div', { key: 'none', className: 'w2mGpw_note' }, t('gpwRecentEmpty')) : null,
|
|
724
|
+
...recent.map((item, index) => h('div', { key: 'r' + index, className: 'w2mGpw_recent' }, [
|
|
725
|
+
h('div', { key: 'r1', className: 'w2mGpw_row' }, [
|
|
726
|
+
h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green', title: item.decision }),
|
|
727
|
+
h('span', { key: 't', className: 'w2mGpw_path', title: item.paths?.[0] ?? '' }, (item.folder ?? item.paths?.[0] ?? item.tool)),
|
|
728
|
+
]),
|
|
729
|
+
item.folder === null || item.folder === undefined ? null : h('div', { key: 'r2', className: 'w2mGpw_actions' }, [
|
|
730
|
+
h('button', { key: 'w', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => addFolder('whitelist', item.folder) }, t('gpwAddWhiteShort')),
|
|
731
|
+
h('button', { key: 'b', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => addFolder('blacklist', item.folder) }, t('gpwAddBlackShort')),
|
|
732
|
+
]),
|
|
733
|
+
])),
|
|
734
|
+
foldRow(recent, 'recent'),
|
|
735
|
+
]),
|
|
736
|
+
recentInLists.length === 0 ? null : h('div', { key: 'recentInLists', className: 'w2mGpw_block' }, [
|
|
737
|
+
h('button', { key: 'toggle', type: 'button', className: 'w2mGpw_btn', 'aria-expanded': showRecentInLists, onClick: () => setShowRecentInLists((value) => !value) }, (showRecentInLists ? '收起' : '展开') + ' · 已在名单中 ' + recentInLists.length + ' 条'),
|
|
738
|
+
...(showRecentInLists ? recentInLists.map((item, index) => h('div', { key: 'inlist' + index, className: 'w2mGpw_recent' }, [
|
|
739
|
+
h('div', { key: 'r1', className: 'w2mGpw_row' }, [
|
|
740
|
+
h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green' }),
|
|
741
|
+
h('span', { key: 't', className: 'w2mGpw_path' }, (item.folder ?? '') + ' · 已加入' + (item.folder_in === 'whitelist' ? '白名单' : '黑名单')),
|
|
742
|
+
]),
|
|
743
|
+
])) : []),
|
|
744
|
+
]),
|
|
745
|
+
pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
|
|
746
|
+
h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
|
|
747
|
+
...pending.map((item) => h('div', { key: item.id, className: 'w2mGpw_recent' }, [
|
|
748
|
+
h('div', { key: 'p1', className: 'w2mGpw_path' }, [h('span', { key: 'c', className: 'w2mGpw_dot', 'data-c': item.color ?? 'yellow' }), item.tool + ' · ' + (item.reason ?? '')]),
|
|
749
|
+
...(Array.isArray(item.paths) ? item.paths.slice(0, 3).map((p, i) => h('div', { key: 'p' + i, className: 'w2mGpw_path' }, p)) : []),
|
|
750
|
+
h('div', { key: 'p2', className: 'w2mGpw_actions' }, [
|
|
751
|
+
h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'allow') }, t('gpwAllowOnce')),
|
|
752
|
+
h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'deny') }, t('gpwDenyOnce')),
|
|
753
|
+
item.folder === null || item.folder === undefined ? null : h('button', { key: 'aw', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'whitelist') }, t('gpwAddWhiteAllow')),
|
|
754
|
+
item.folder === null || item.folder === undefined ? null : h('button', { key: 'ab', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'blacklist') }, t('gpwAddBlackDeny')),
|
|
755
|
+
]),
|
|
756
|
+
])),
|
|
757
|
+
]),
|
|
758
|
+
]),
|
|
759
|
+
]);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** v1.9.4: the left sidebar entry: globe + GPW, between Plugins and Automation. */
|
|
763
|
+
function GpwSidebarIcon() {
|
|
764
|
+
return h('button', {
|
|
765
|
+
type: 'button',
|
|
766
|
+
className: 'w2mGpw_sideEntry',
|
|
767
|
+
'data-w2m-gpw-sidebar': 'true',
|
|
768
|
+
title: 'GPW 文件夹策略',
|
|
769
|
+
onClick: () => {
|
|
770
|
+
try { window.dispatchEvent(new CustomEvent('w2m-gpw-full')); } catch { /* headless */ }
|
|
771
|
+
},
|
|
772
|
+
}, [
|
|
773
|
+
h('span', { key: 'i', className: 'w2mGpw_sideIcon', 'aria-hidden': 'true' }, '🌐'),
|
|
774
|
+
h('span', { key: 'l', className: 'w2mGpw_sideLabel' }, 'GPW'),
|
|
775
|
+
]);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** v1.9.4: the full workspace page opened from the sidebar entry. */
|
|
779
|
+
function GpwFullPage({ t, sessionId, onClose }) {
|
|
780
|
+
return h('div', { className: 'w2mGpw_fullPage', role: 'dialog', 'aria-label': t('gpwTitle') }, [
|
|
781
|
+
h('div', { key: 'head', className: 'w2mGpw_fullHead' }, [
|
|
782
|
+
h('span', { key: 't', className: 'w2mGpw_fullTitle' }, '🌐 ' + t('gpwTitle')),
|
|
783
|
+
h('button', { key: 'x', type: 'button', className: 'w2mGpw_close', onClick: onClose }, '×'),
|
|
784
|
+
]),
|
|
785
|
+
h('div', { key: 'body', className: 'w2mGpw_fullBody' }, h(GpwPanel, { t, sessionId, inline: true, onClose })),
|
|
786
|
+
]);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** v1.9.6: the GPW page rendered in the main workspace (slot main, key w2m-gpw). */
|
|
790
|
+
function GpwMainPage({ t }) {
|
|
791
|
+
return h('div', { className: 'w2mGpw_mainWrap' }, h(GpwPanel, { t, sessionId: undefined, inline: true, onClose: () => {} }));
|
|
552
792
|
}
|
|
553
793
|
|
|
554
|
-
/**
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
794
|
+
/** v1.9.4: the GPW tab in Settings, between Models and Plugins. */
|
|
795
|
+
function GpwSettingsPage({ t }) {
|
|
796
|
+
return h('div', { className: 'w2mGpw_settingsBody' }, h(GpwPanel, { t, sessionId: undefined, inline: true, onClose: () => {} }));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** The floating launcher: a pending badge, and the panel fixed to the lower-right corner. */
|
|
800
|
+
function GpwLauncher({ t, sessionId }) {
|
|
801
|
+
const [open, setOpen] = React.useState(false);
|
|
802
|
+
const [full, setFull] = React.useState(false);
|
|
803
|
+
const [pendingCount, setPendingCount] = React.useState(0);
|
|
804
|
+
React.useEffect(() => {
|
|
805
|
+
let alive = true;
|
|
806
|
+
const load = async () => {
|
|
807
|
+
try {
|
|
808
|
+
const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
809
|
+
const body = response.ok ? await response.json() : null;
|
|
810
|
+
if (alive) setPendingCount(body?.ok === true && Array.isArray(body.pending) ? body.pending.length : 0);
|
|
811
|
+
} catch {
|
|
812
|
+
if (alive) setPendingCount(0);
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
load();
|
|
816
|
+
const timer = setInterval(load, 3000);
|
|
817
|
+
const onOpen = () => setOpen(true);
|
|
818
|
+
const onFull = () => setFull(true);
|
|
819
|
+
window.addEventListener('w2m-gpw-open', onOpen);
|
|
820
|
+
window.addEventListener('w2m-gpw-full', onFull);
|
|
821
|
+
return () => {
|
|
822
|
+
alive = false;
|
|
823
|
+
clearInterval(timer);
|
|
824
|
+
window.removeEventListener('w2m-gpw-open', onOpen);
|
|
825
|
+
window.removeEventListener('w2m-gpw-full', onFull);
|
|
826
|
+
};
|
|
827
|
+
}, []);
|
|
828
|
+
return h(React.Fragment, null, [
|
|
829
|
+
h('button', {
|
|
830
|
+
key: 'launcher',
|
|
831
|
+
type: 'button',
|
|
832
|
+
className: 'w2mGpw_launcher',
|
|
833
|
+
title: pendingCount > 0 ? t('gpwPendingCount', { count: pendingCount }) : t('gpwLauncher'),
|
|
834
|
+
'aria-expanded': open,
|
|
835
|
+
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
836
|
+
}, [
|
|
837
|
+
t('gpwLauncher'),
|
|
838
|
+
pendingCount > 0 ? h('span', { key: 'badge', className: 'w2mGpw_badge' }, String(pendingCount)) : null,
|
|
839
|
+
]),
|
|
840
|
+
open ? h(GpwPanel, { key: 'panel', t, sessionId, onClose: () => setOpen(false) }) : null,
|
|
841
|
+
full ? h(GpwFullPage, { key: 'full', t, sessionId, onClose: () => setFull(false) }) : null,
|
|
842
|
+
]);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* v1.7.9: the inline approval prompt -- an interception asks a question in the composer row
|
|
847
|
+
* (allow once / deny once / open the panel) instead of waiting silently for the operator.
|
|
848
|
+
*/
|
|
849
|
+
function GpwPrompt({ t, sessionId }) {
|
|
850
|
+
const [state, setState] = React.useState(null);
|
|
851
|
+
const [busy, setBusy] = React.useState(false);
|
|
852
|
+
React.useEffect(() => {
|
|
853
|
+
let alive = true;
|
|
854
|
+
const load = async () => {
|
|
855
|
+
try {
|
|
856
|
+
const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
857
|
+
const body = response.ok ? await response.json() : null;
|
|
858
|
+
if (alive) setState(body);
|
|
859
|
+
} catch {
|
|
860
|
+
if (alive) setState(null);
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
load();
|
|
864
|
+
const timer = setInterval(load, 2000);
|
|
865
|
+
return () => {
|
|
866
|
+
alive = false;
|
|
867
|
+
clearInterval(timer);
|
|
868
|
+
};
|
|
869
|
+
}, []);
|
|
870
|
+
const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
|
|
871
|
+
// v1.8.1: an approval belongs to the conversation that raised it; another session must never show it.
|
|
872
|
+
const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
|
|
873
|
+
if (pending.length === 0) return null;
|
|
874
|
+
const item = pending[0];
|
|
875
|
+
const decide = async (decision) => {
|
|
876
|
+
setBusy(true);
|
|
877
|
+
try {
|
|
878
|
+
await fetch('/api/w2m/gpw/decide', {
|
|
879
|
+
method: 'POST',
|
|
880
|
+
credentials: 'same-origin',
|
|
881
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
882
|
+
body: JSON.stringify({ id: item.id, decision }),
|
|
883
|
+
});
|
|
884
|
+
} catch {
|
|
885
|
+
/* the next poll shows the queue again */
|
|
886
|
+
} finally {
|
|
887
|
+
setBusy(false);
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
const openPanel = () => {
|
|
891
|
+
try {
|
|
892
|
+
window.dispatchEvent(new CustomEvent('w2m-gpw-open'));
|
|
893
|
+
} catch {
|
|
894
|
+
/* no window in a headless render */
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
return h('div', { className: 'w2mGpw_prompt', 'data-w2m-gpw-prompt': 'inline' }, [
|
|
898
|
+
h('span', { key: 'label', className: 'w2mGpw_promptLabel' }, pending.length > 1
|
|
899
|
+
? t('gpwPendingCount', { count: pending.length })
|
|
900
|
+
: t('gpwPendingShort', { tool: item.tool ?? 'tool' })),
|
|
901
|
+
h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('allow') }, t('gpwAllowOnce')),
|
|
902
|
+
h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('deny') }, t('gpwDenyOnce')),
|
|
903
|
+
h('button', { key: 'o', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: openPanel }, t('gpwOpenPanel')),
|
|
904
|
+
]);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** v1.6.8: one button beside the relay rows: check npm for a newer plugin, install it, ask for a restart. */
|
|
908
|
+
function PluginUpdate({ t }) {
|
|
909
|
+
const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
|
|
910
|
+
const call = async (method, body) => {
|
|
911
|
+
const response = await fetch(PLUGIN_UPDATE_ROUTE, {
|
|
912
|
+
method, credentials: 'same-origin',
|
|
913
|
+
headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
914
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
915
|
+
});
|
|
916
|
+
let json = null;
|
|
917
|
+
try { json = await response.json(); } catch { /* keep null */ }
|
|
918
|
+
return { status: response.status, json };
|
|
919
|
+
};
|
|
920
|
+
const onClick = async () => {
|
|
921
|
+
setState({ status: 'checking', info: null, error: null });
|
|
922
|
+
try {
|
|
923
|
+
const checked = await call('GET');
|
|
924
|
+
const info = checked.json ?? {};
|
|
925
|
+
if (info.ok !== true) { setState({ status: 'error', info, error: info.error ?? 'update check failed' }); return; }
|
|
926
|
+
if (info.update_available !== true) { setState({ status: 'current', info, error: null }); return; }
|
|
927
|
+
setState({ status: 'applying', info, error: null });
|
|
928
|
+
const applied = await call('POST', { action: 'apply' });
|
|
929
|
+
const result = applied.json ?? {};
|
|
930
|
+
if (result.ok !== true) { setState({ status: 'error', info: result, error: result.error ?? result.reason ?? 'update failed' }); return; }
|
|
931
|
+
setState({ status: 'installed', info: result, error: null });
|
|
932
|
+
} catch (error) {
|
|
933
|
+
setState({ status: 'error', info: null, error: error instanceof Error ? error.message : String(error) });
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
const label = state.status === 'checking' ? t('updateChecking')
|
|
937
|
+
: state.status === 'applying' ? t('updating')
|
|
938
|
+
: state.status === 'current' ? t('updateCurrent', { version: state.info?.current ?? '?' })
|
|
939
|
+
: state.status === 'installed' ? t('updateInstalled', { version: state.info?.to ?? state.info?.latest ?? '?' })
|
|
940
|
+
: t('updateButton');
|
|
941
|
+
const disabled = state.status === 'checking' || state.status === 'applying';
|
|
942
|
+
return h('div', { className: cls('relayHint'), 'data-w2m-update': state.status }, [
|
|
943
|
+
h('button', { key: 'b', type: 'button', className: cls('relayToggle'), disabled, onClick: (event) => { event.stopPropagation(); onClick(); } }, label),
|
|
944
|
+
state.status === 'installed' ? h('div', { key: 'r', className: cls('relayHint') }, t('restartHint')) : null,
|
|
945
|
+
state.status === 'error' ? h('div', { key: 'e', className: cls('relayHint') }, String(state.error ?? 'error')) : null,
|
|
946
|
+
]);
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* v1.9.3: the GPW entry in the LEFT sidebar footer. It is always rendered and carries no close
|
|
950
|
+
* affordance -- the operator asked for a permanent entry in addition to the composer and the
|
|
951
|
+
* floating launcher. The click reuses the launcher's event so there is one panel, not two.
|
|
952
|
+
*/
|
|
953
|
+
function _GpwSidebarFooter() {
|
|
954
|
+
return h('button', {
|
|
955
|
+
type: 'button',
|
|
956
|
+
className: 'w2mGpw_sideEntry',
|
|
957
|
+
'data-w2m-gpw-sidebar': 'true',
|
|
958
|
+
title: 'GPW 文件夹策略',
|
|
959
|
+
onClick: () => {
|
|
960
|
+
try { window.dispatchEvent(new CustomEvent('w2m-gpw-open')); } catch { /* headless render */ }
|
|
961
|
+
},
|
|
962
|
+
}, [
|
|
963
|
+
h('span', { key: 'i', className: 'w2mGpw_sideIcon', 'aria-hidden': 'true' }, '📁'),
|
|
964
|
+
h('span', { key: 'l', className: 'w2mGpw_sideLabel' }, 'GPW'),
|
|
965
|
+
]);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const AUTO_UPDATE_PREF = 'w2m:plugin-auto-update';
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* v1.9.3: the update section on the plugin settings page.
|
|
972
|
+
*
|
|
973
|
+
* Two operator requests meet here: a check that still says something when the installed build is
|
|
974
|
+
* old (the old button lived inside the composer dropdown and was easy to miss), and an auto-update
|
|
975
|
+
* switch that actually performs the install instead of only remembering a preference.
|
|
976
|
+
*/
|
|
977
|
+
function UpdateSettingsSection() {
|
|
978
|
+
const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
|
|
979
|
+
const [auto, setAuto] = React.useState(false);
|
|
980
|
+
React.useEffect(() => {
|
|
981
|
+
try { setAuto(globalThis.localStorage?.getItem(AUTO_UPDATE_PREF) === '1'); } catch { /* storage off */ }
|
|
982
|
+
}, []);
|
|
983
|
+
const run = React.useCallback(async ({ install }) => {
|
|
984
|
+
setState({ status: 'checking', info: null, error: null });
|
|
985
|
+
try {
|
|
986
|
+
const checked = await fetch(PLUGIN_UPDATE_ROUTE, { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
987
|
+
const info = await checked.json().catch(() => ({}));
|
|
988
|
+
if (info.ok !== true) { setState({ status: 'error', info, error: info.error ?? 'update check failed' }); return; }
|
|
989
|
+
if (install !== true || info.update_available !== true) {
|
|
990
|
+
setState({ status: info.update_available === true ? 'available' : 'current', info, error: null });
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
setState({ status: 'applying', info, error: null });
|
|
994
|
+
const applied = await fetch(PLUGIN_UPDATE_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ action: 'apply' }) });
|
|
995
|
+
const result = await applied.json().catch(() => ({}));
|
|
996
|
+
if (result.ok !== true) { setState({ status: 'error', info: result, error: result.error ?? result.reason ?? 'update failed' }); return; }
|
|
997
|
+
setState({ status: 'installed', info: { ...info, ...result }, error: null });
|
|
998
|
+
} catch (error) {
|
|
999
|
+
setState({ status: 'error', info: null, error: error instanceof Error ? error.message : String(error) });
|
|
1000
|
+
}
|
|
1001
|
+
}, []);
|
|
1002
|
+
const toggleAuto = (event) => {
|
|
1003
|
+
const next = event?.target?.checked === true;
|
|
1004
|
+
setAuto(next);
|
|
1005
|
+
try { globalThis.localStorage?.setItem(AUTO_UPDATE_PREF, next ? '1' : '0'); } catch { /* storage off */ }
|
|
1006
|
+
if (next) run({ install: true });
|
|
1007
|
+
};
|
|
1008
|
+
// v1.9.3: state the update picture as soon as the section opens, so an outdated install is
|
|
1009
|
+
// visible without a click; the auto path below only adds the install step.
|
|
1010
|
+
React.useEffect(() => { run({ install: false }); }, [run]);
|
|
1011
|
+
React.useEffect(() => { if (auto) run({ install: true }); }, [auto, run]);
|
|
1012
|
+
// v1.9.4: an installed update becomes live on reload; DSH reloads the client bundle without a reinstall.
|
|
1013
|
+
React.useEffect(() => {
|
|
1014
|
+
if (state.status !== 'installed') return undefined;
|
|
1015
|
+
const timer = setTimeout(() => { try { window.location.reload(); } catch { /* headless */ } }, 1500);
|
|
1016
|
+
return () => clearTimeout(timer);
|
|
1017
|
+
}, [state.status]);
|
|
1018
|
+
const busy = state.status === 'checking' || state.status === 'applying';
|
|
1019
|
+
const statusText = state.status === 'checking' ? '正在检测更新…'
|
|
1020
|
+
: state.status === 'applying' ? '正在安装更新…'
|
|
1021
|
+
: state.status === 'available' ? ('发现新版本 ' + (state.info?.latest ?? '?') + '(当前 ' + (state.info?.current ?? '?') + ')')
|
|
1022
|
+
: state.status === 'installed' ? ('已安装 ' + (state.info?.to ?? state.info?.latest ?? '?') + ',重启后生效')
|
|
1023
|
+
: state.status === 'current' ? ('已是最新版本 ' + (state.info?.current ?? state.info?.latest ?? '?'))
|
|
1024
|
+
: state.status === 'error' ? ('更新失败:' + String(state.error ?? 'unknown'))
|
|
1025
|
+
: '检测插件是否有新版本;开启自动更新后每次启动自动安装。';
|
|
1026
|
+
return h('div', { className: 'w2mUpdateSection', 'data-w2m-update-section': state.status }, [
|
|
1027
|
+
h('div', { key: 's', className: 'w2mUpdateStatus' }, statusText),
|
|
1028
|
+
h('div', { key: 'row', className: 'w2mUpdateRow' }, [
|
|
1029
|
+
h('button', { key: 'check', type: 'button', className: 'w2mUpdateBtn', disabled: busy, onClick: () => run({ install: false }) }, '检测更新'),
|
|
1030
|
+
state.status === 'available'
|
|
1031
|
+
? h('button', { key: 'install', type: 'button', className: 'w2mUpdateBtn', disabled: busy, onClick: () => run({ install: true }) }, '立即更新')
|
|
1032
|
+
: null,
|
|
1033
|
+
h('label', { key: 'auto', className: 'w2mUpdateAuto' }, [
|
|
1034
|
+
h('input', { key: 'c', type: 'checkbox', checked: auto, disabled: state.status === 'applying', onChange: toggleAuto }),
|
|
1035
|
+
h('span', { key: 'l' }, '自动更新'),
|
|
1036
|
+
]),
|
|
1037
|
+
]),
|
|
1038
|
+
]);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
1042
|
+
const [open, setOpen] = React.useState(false);
|
|
1043
|
+
// v1.5.1: relay folding. The snapshot carries the pool; when it holds more than three nodes
|
|
1044
|
+
// the menu shows a summary and this toggle reveals the folded ones.
|
|
1045
|
+
const [relayOpen, setRelayOpen] = React.useState(false);
|
|
1046
|
+
const [reloadSeq, setReloadSeq] = React.useState(0);
|
|
1047
|
+
const [state, setState] = React.useState(null);
|
|
1048
|
+
const [error, setError] = React.useState(null);
|
|
1049
|
+
const [busy, setBusy] = React.useState(false);
|
|
1050
|
+
const anchorRef = React.useRef(null);
|
|
1051
|
+
const menuRef = React.useRef(null);
|
|
1052
|
+
const [placement, setPlacement] = React.useState(null);
|
|
1053
|
+
|
|
1054
|
+
// The host half is the source of truth, so every read replaces the mirror rather than merging
|
|
1055
|
+
// into it: a stale "online" must not outlive the answer that disproved it.
|
|
1056
|
+
React.useEffect(() => {
|
|
1057
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
1058
|
+
const controller = new AbortController();
|
|
1059
|
+
readState(sessionId, controller.signal).then((result) => {
|
|
1060
|
+
if (controller.signal.aborted) return;
|
|
1061
|
+
if (result.ok) {
|
|
1062
|
+
setState(result.state);
|
|
1063
|
+
setError(null);
|
|
1064
|
+
} else {
|
|
1065
|
+
setError(result.error);
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
return () => controller.abort();
|
|
1069
|
+
}, [sessionId, reloadSeq]);
|
|
1070
|
+
|
|
1071
|
+
// Opening the menu is a read: the roster and the selection are re-asked for every time, so a
|
|
1072
|
+
// machine that went away while the menu was closed cannot be clicked as if it were live. The
|
|
1073
|
+
// host caches its own relay read for a few seconds, so this is not a request storm.
|
|
1074
|
+
React.useEffect(() => {
|
|
1075
|
+
if (open) setReloadSeq((seq) => seq + 1);
|
|
1076
|
+
}, [open]);
|
|
1077
|
+
|
|
1078
|
+
React.useEffect(() => {
|
|
1079
|
+
if (!open) return undefined;
|
|
1080
|
+
const reposition = () => {
|
|
1081
|
+
const anchor = anchorRef.current;
|
|
1082
|
+
if (anchor === null) return;
|
|
1083
|
+
const rect = anchor.getBoundingClientRect();
|
|
1084
|
+
const width = Math.min(300, Math.max(200, Math.round(rect.width) + 96));
|
|
1085
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
|
|
1086
|
+
// Above the composer by default — it is at the bottom of the window — and below it only
|
|
1087
|
+
// when the space above is genuinely unusable.
|
|
1088
|
+
const roomAbove = rect.top;
|
|
1089
|
+
const maxHeight = Math.max(160, Math.min(320, roomAbove > 240 ? roomAbove - 16 : window.innerHeight - rect.bottom - 24));
|
|
1090
|
+
setPlacement(roomAbove > 240
|
|
1091
|
+
? { left, width, maxHeight, bottom: window.innerHeight - rect.top + 6, top: 'auto' }
|
|
1092
|
+
: { left, width, maxHeight, top: rect.bottom + 6, bottom: 'auto' });
|
|
1093
|
+
};
|
|
1094
|
+
const onKeyDown = (event) => {
|
|
1095
|
+
if (event.key === 'Escape') {
|
|
1096
|
+
setOpen(false);
|
|
1097
|
+
anchorRef.current?.focus?.();
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
const onPointerDown = (event) => {
|
|
1101
|
+
const target = event.target;
|
|
1102
|
+
if (anchorRef.current?.contains(target) === true) return;
|
|
1103
|
+
if (menuRef.current?.contains(target) === true) return;
|
|
1104
|
+
setOpen(false);
|
|
1105
|
+
};
|
|
1106
|
+
reposition();
|
|
1107
|
+
// `capture` matters: the composer's own editor listens on the document too, and Escape has
|
|
1108
|
+
// to close this menu before anything else reads it.
|
|
1109
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
1110
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
1111
|
+
window.addEventListener('resize', reposition);
|
|
1112
|
+
window.addEventListener('scroll', reposition, true);
|
|
1113
|
+
return () => {
|
|
1114
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
1115
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
1116
|
+
window.removeEventListener('resize', reposition);
|
|
1117
|
+
window.removeEventListener('scroll', reposition, true);
|
|
1118
|
+
};
|
|
1119
|
+
}, [open]);
|
|
1120
|
+
|
|
1121
|
+
const chosen = state?.selected?.machine_id ?? null;
|
|
1122
|
+
const machines = Array.isArray(state?.machines) ? state.machines : [];
|
|
1123
|
+
// v1.5.1: the relay pool the host probed, already folded. `null` means a single-relay host.
|
|
1124
|
+
const relayFold = state?.relays ?? null;
|
|
1125
|
+
const problem = error ?? fleetProblem(state, t);
|
|
1126
|
+
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
1127
|
+
const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
|
|
1128
|
+
const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
|
|
1129
|
+
const selectedOnline = state === null
|
|
1130
|
+
? null
|
|
1131
|
+
: (selectedMachine !== null ? selectedMachine.online === true : (chosen === null ? null : state?.selected?.online ?? null));
|
|
1132
|
+
const dot = chosen === null
|
|
1133
|
+
? 'none'
|
|
1134
|
+
: (selectedOnline === true ? (selectedMachine?.local === true ? 'local' : 'online') : 'stale');
|
|
1135
|
+
const title = problem ?? (chosen === null
|
|
1136
|
+
? t('costAll')
|
|
1137
|
+
: `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
|
|
1138
|
+
|
|
1139
|
+
const choose = (machineId, machineName) => {
|
|
1140
|
+
setOpen(false);
|
|
1141
|
+
if (machineId === chosen) return;
|
|
1142
|
+
setBusy(true);
|
|
1143
|
+
writeSelection(sessionId, machineId, machineName).then((result) => {
|
|
1144
|
+
setBusy(false);
|
|
1145
|
+
if (result.ok) {
|
|
1146
|
+
setState(result.state);
|
|
1147
|
+
setError(null);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
// The write is refused for a reason the operator can act on (the machine went away, the
|
|
1151
|
+
// fleet could not be read), so it is shown where they are looking rather than swallowed.
|
|
1152
|
+
setError(result.error);
|
|
1153
|
+
setReloadSeq((seq) => seq + 1);
|
|
1154
|
+
});
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
const menu = open && placement !== null
|
|
1158
|
+
? h('div', {
|
|
1159
|
+
className: cls('menu'),
|
|
1160
|
+
role: 'listbox',
|
|
1161
|
+
'aria-label': t('targetLabel', { name: label }),
|
|
1162
|
+
ref: menuRef,
|
|
1163
|
+
style: {
|
|
1164
|
+
left: `${placement.left}px`,
|
|
1165
|
+
top: placement.top === 'auto' ? 'auto' : `${placement.top}px`,
|
|
1166
|
+
bottom: placement.bottom === undefined || placement.bottom === 'auto' ? 'auto' : `${placement.bottom}px`,
|
|
1167
|
+
width: `${placement.width}px`,
|
|
1168
|
+
maxHeight: `${placement.maxHeight}px`,
|
|
1169
|
+
overflowY: 'auto',
|
|
1170
|
+
},
|
|
1171
|
+
}, [
|
|
1172
|
+
h('button', {
|
|
1173
|
+
key: '__all__',
|
|
1174
|
+
type: 'button',
|
|
1175
|
+
role: 'option',
|
|
1176
|
+
className: cls('option'),
|
|
1177
|
+
'data-active': chosen === null ? 'true' : 'false',
|
|
1178
|
+
'aria-selected': chosen === null,
|
|
1179
|
+
onClick: () => choose(null, null),
|
|
1180
|
+
title: t('costAll'),
|
|
1181
|
+
}, h('span', { className: cls('row') }, [
|
|
1182
|
+
h(StatusDot, { key: 'dot', kind: 'none', title: t('all') }),
|
|
1183
|
+
h('span', { key: 'name', className: cls('name') }, t('all')),
|
|
1184
|
+
h('span', { key: 'detail', className: cls('detail') }, state?.fleet?.ok === true ? `${state.fleet.online} ${t('online')}` : '—'),
|
|
1185
|
+
])),
|
|
1186
|
+
problem === null ? null : h('div', { key: '__problem__', className: cls('note'), role: 'status' }, problem),
|
|
1187
|
+
machines.length === 0
|
|
1188
|
+
? h('div', { key: '__empty__', className: cls('note') }, problem === null ? t('empty') : t('noRoster'))
|
|
1189
|
+
: machines.map((machine) => h('button', {
|
|
1190
|
+
key: machine.machine_id ?? machine.name ?? Math.random().toString(36).slice(2),
|
|
1191
|
+
type: 'button',
|
|
1192
|
+
role: 'option',
|
|
1193
|
+
className: cls('option'),
|
|
1194
|
+
'data-active': machine.machine_id !== null && machine.machine_id === chosen ? 'true' : 'false',
|
|
1195
|
+
'aria-selected': machine.machine_id !== null && machine.machine_id === chosen,
|
|
1196
|
+
disabled: machine.online !== true,
|
|
1197
|
+
title: machine.online === true
|
|
1198
|
+
? `${machine.name ?? machine.machine_id} — ${machine.local === true ? t('local') : t('online')}`
|
|
1199
|
+
: `${machine.name ?? machine.machine_id} — ${machine.state}`,
|
|
1200
|
+
onClick: () => choose(machine.machine_id, machine.name),
|
|
1201
|
+
}, h('span', { className: cls('row') }, [
|
|
1202
|
+
h(StatusDot, { key: 'dot', kind: dotKind(machine), title: machine.state }),
|
|
1203
|
+
h('span', { key: 'name', className: cls('name') }, machine.name ?? machine.machine_id ?? 'unnamed'),
|
|
1204
|
+
h('span', { key: 'detail', className: cls('detail') }, machine.local === true
|
|
1205
|
+
? t('local')
|
|
1206
|
+
: (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
|
|
1207
|
+
]))),
|
|
1208
|
+
relayFold === null ? null : h('div', {
|
|
1209
|
+
key: '__relays__',
|
|
1210
|
+
className: cls('relayFold'),
|
|
1211
|
+
role: 'group',
|
|
1212
|
+
'aria-label': t('relays'),
|
|
1213
|
+
'data-temporary': relayFold.temporary === true ? 'true' : 'false',
|
|
1214
|
+
}, [
|
|
1215
|
+
h('button', {
|
|
1216
|
+
key: 'toggle',
|
|
1217
|
+
type: 'button',
|
|
1218
|
+
className: cls('relayToggle'),
|
|
1219
|
+
'aria-expanded': relayOpen,
|
|
1220
|
+
'data-temporary': relayFold.temporary === true ? 'true' : 'false',
|
|
1221
|
+
onClick: (event) => { event.stopPropagation(); setRelayOpen((wasOpen) => !wasOpen); },
|
|
1222
|
+
title: relayOpen === true ? t('relayUnfold') : t('relayFold', { count: relayFold.hidden_count }),
|
|
1223
|
+
}, `${t('relays')}: ${relayFold.online}/${relayFold.total} ${t('online')}${relayFold.temporary === true ? ` · ${t('relayTemporary')}` : ''}`),
|
|
1224
|
+
...(relayOpen === true ? [...relayFold.shown, ...relayFold.hidden] : relayFold.shown).map((item, index) => h('div', {
|
|
1225
|
+
key: `relay-${index}`,
|
|
1226
|
+
className: cls('relayRow'),
|
|
1227
|
+
'data-role': item.role,
|
|
1228
|
+
}, [
|
|
1229
|
+
h('span', { key: 'role', className: cls('relayRole') }, item.role === 'primary'
|
|
1230
|
+
? t('relayPrimary')
|
|
1231
|
+
: (item.role === 'standby' ? t('relayStandby') : t('relayOffline'))),
|
|
1232
|
+
h('span', { key: 'url', className: cls('relayUrl') }, String(item.url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '')),
|
|
1233
|
+
h('span', { key: 'rtt', className: cls('relayRtt') }, item.rtt_ms === null || item.rtt_ms === undefined ? '—' : `${item.rtt_ms} ms`),
|
|
1234
|
+
])),
|
|
1235
|
+
// v1.7.3: name the relay the totals came from; "whole server" and "this fleet" are different
|
|
1236
|
+
// numbers, and the panel used to leave the reader to guess which one it was showing.
|
|
1237
|
+
relayFold.server === null || relayFold.server === undefined
|
|
1238
|
+
? (relayFold.server_reachable === false
|
|
1239
|
+
? h('div', { key: 'globalUnreachable', className: cls('relayHint') }, t('globalUnreachable', { url: String(relayFold.server_url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '') || '?' }))
|
|
1240
|
+
: null)
|
|
1241
|
+
: h('div', { key: 'global', className: cls('relayHint') }, t('globalSummaryWithSource', {
|
|
1242
|
+
source: String(relayFold.server_url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '') || '?',
|
|
1243
|
+
fleetsOnline: relayFold.server.fleets_online ?? '—',
|
|
1244
|
+
fleets: relayFold.server.fleets_total ?? '—',
|
|
1245
|
+
online: relayFold.server.devices_online ?? '—',
|
|
1246
|
+
total: relayFold.server.devices_total ?? '—',
|
|
1247
|
+
})),
|
|
1248
|
+
h('div', { key: 'fleet', className: cls('relayHint') }, t('fleetSummary', { online: state?.fleet?.online ?? '—', total: state?.fleet?.total ?? '—' })),
|
|
1249
|
+
h(PluginUpdate, { key: 'update', t }),
|
|
1250
|
+
relayFold.hidden_count > 0 && relayOpen === false
|
|
1251
|
+
? h('div', { key: 'hint', className: cls('relayHint') }, t('relayFold', { count: relayFold.hidden_count }))
|
|
1252
|
+
: null,
|
|
1253
|
+
]),
|
|
1254
|
+
])
|
|
1255
|
+
: null;
|
|
1256
|
+
|
|
1257
|
+
return h(React.Fragment, null, [
|
|
1258
|
+
h('button', {
|
|
1259
|
+
key: 'trigger',
|
|
1260
|
+
type: 'button',
|
|
1261
|
+
ref: anchorRef,
|
|
1262
|
+
className: cls('trigger'),
|
|
1263
|
+
'aria-label': t('targetLabel', { name: label }),
|
|
1264
|
+
'aria-haspopup': 'listbox',
|
|
1265
|
+
'aria-expanded': open,
|
|
1266
|
+
'aria-busy': busy,
|
|
1267
|
+
title,
|
|
1268
|
+
disabled,
|
|
1269
|
+
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
1270
|
+
}, [
|
|
1271
|
+
h(StatusDot, { key: 'dot', kind: dot, title: title ?? '' }),
|
|
1272
|
+
h('span', { key: 'label', className: cls('label') }, label),
|
|
1273
|
+
h('span', { key: 'chevron', className: cls('chevron'), 'data-open': open ? 'true' : 'false', 'aria-hidden': 'true' },
|
|
1274
|
+
h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none' },
|
|
1275
|
+
h('path', {
|
|
1276
|
+
d: 'M3 4.5L6 7.5L9 4.5',
|
|
1277
|
+
stroke: 'currentColor',
|
|
1278
|
+
strokeWidth: 1.5,
|
|
1279
|
+
strokeLinecap: 'round',
|
|
1280
|
+
strokeLinejoin: 'round',
|
|
1281
|
+
}))),
|
|
1282
|
+
]),
|
|
1283
|
+
menu,
|
|
1284
|
+
]);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// -------------------------------------------------------------------------------------------
|
|
1288
|
+
// Fleet chat panel (v1.1.18 addition: the missing browser half of `w2m_chat`)
|
|
1289
|
+
// -------------------------------------------------------------------------------------------
|
|
1290
|
+
//
|
|
1291
|
+
// v1.1.17 shipped fleet chat as a tool only — its own CHANGELOG records "no client UI panel
|
|
1292
|
+
// beyond the generic tool card". This is that panel: a session-scoped broadcast board the
|
|
1293
|
+
// operator and the agent share. Every read and write goes through the host route
|
|
1294
|
+
// `/api/w2m/chat` (host-side `fleet-chat-route.mjs`), so the device token stays in the host
|
|
1295
|
+
// process and this file never holds a credential.
|
|
1296
|
+
const CHAT_ROUTE = '/api/w2m/chat';
|
|
1297
|
+
// v1.7.1: host fallbacks for the DSH desktop shell (renderer capture/clipboard are not granted there).
|
|
1298
|
+
const CAPTURE_ROUTE = '/api/w2m/capture';
|
|
1299
|
+
const CLIPBOARD_ROUTE = '/api/w2m/clipboard';
|
|
1300
|
+
|
|
1301
|
+
/** One JSON call to the host chat route; a refusal is a value, not an exception. */
|
|
1302
|
+
async function chatCall(options) {
|
|
1303
|
+
let response;
|
|
1304
|
+
try {
|
|
1305
|
+
response = await fetch(options.url, options.init);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}` };
|
|
1308
|
+
}
|
|
1309
|
+
let body = null;
|
|
1310
|
+
try {
|
|
1311
|
+
body = await response.json();
|
|
1312
|
+
} catch {
|
|
1313
|
+
return { ok: false, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
|
|
1314
|
+
}
|
|
1315
|
+
if (response.status === 404) return { ok: false, error: 'this host does not serve the W2M chat route' };
|
|
1316
|
+
if (response.ok !== true || body?.ok === false) {
|
|
1317
|
+
return { ok: false, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
|
|
1318
|
+
}
|
|
1319
|
+
return { ok: true, state: body };
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* The fleet-chat control: opens a panel with the session's messages, a compose box and the
|
|
1324
|
+
* relay's current usage. The text stays on the relay; this component keeps only the last view.
|
|
1325
|
+
*/
|
|
1326
|
+
const AVATAR_PALETTE = ['#4c6ef5', '#12b886', '#f59f00', '#e8590c', '#ae3ec9', '#1098ad', '#d6336c', '#5c940d'];
|
|
1327
|
+
|
|
1328
|
+
/** A machine keeps its colour: the index comes from its id, not from the order of the list. */
|
|
1329
|
+
function avatarColor(machineId) {
|
|
1330
|
+
var h = 0;
|
|
1331
|
+
var s = String(machineId == null ? '' : machineId);
|
|
1332
|
+
for (var i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
1333
|
+
return AVATAR_PALETTE[h % AVATAR_PALETTE.length];
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** The DeepSeek whale, filled with the machine colour. */
|
|
1337
|
+
function WhaleAvatar({ machineId }) {
|
|
1338
|
+
return h('span', { className: 'w2mChat_avatar', style: { color: avatarColor(machineId) }, 'aria-hidden': 'true' },
|
|
1339
|
+
h('svg', { viewBox: '0 0 24 24', width: 16, height: 16 },
|
|
1340
|
+
h('path', { fill: 'currentColor', d: 'M2 13c2.2-3.4 5.4-5 9-5 3.1 0 5.6 1.2 7.2 3.2h2.3l-1.6-2.4 1.6.9L22 6.9l.6 3.4 1.4.8-1.4.8-.6 3.4-1.5-2.8-1.6.9 1.6-2.4h-2.2C16.6 8.9 13.9 7.6 11 7.6 6.6 7.6 3.3 9.6 1 13h1Z' })));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** session = this machine typed it into DSH; chat = typed here; remote = another machine. */
|
|
1344
|
+
function messageKind(message, localMachineId) {
|
|
1345
|
+
if (localMachineId == null || message == null || message.machine_id !== localMachineId) return 'remote';
|
|
1346
|
+
return message.origin === 'session' ? 'session' : 'chat';
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** v1.9.4: the fleet-chat tab in the top bar; opens the full workspace page. */
|
|
1350
|
+
function _FleetChatTab() {
|
|
1351
|
+
return h('button', {
|
|
642
1352
|
type: 'button',
|
|
643
|
-
className: '
|
|
644
|
-
|
|
645
|
-
onClick: () =>
|
|
646
|
-
},
|
|
647
|
-
const allowWithFolder = async (item, list) => {
|
|
648
|
-
if (typeof item?.folder !== 'string' || item.folder === '') return;
|
|
649
|
-
const added = await addFolder(list, item.folder);
|
|
650
|
-
if (added === true) await decide(item.id, list === 'whitelist' ? 'allow' : 'deny');
|
|
651
|
-
};
|
|
652
|
-
const folderList = (title, list) => {
|
|
653
|
-
const items = Array.isArray(state?.folders?.[list]) ? state.folders[list] : [];
|
|
654
|
-
return h('div', { key: list, className: 'w2mGpw_block' }, [
|
|
655
|
-
h('div', { key: 'title', className: 'w2mGpw_title' }, title),
|
|
656
|
-
...foldItems(items, list).map((path) => h('div', { key: path, className: 'w2mGpw_row' }, [
|
|
657
|
-
h('span', { key: 'p', className: 'w2mGpw_path', title: path }, path),
|
|
658
|
-
h('button', { key: 'x', type: 'button', className: 'w2mGpw_small', disabled: busy, onClick: () => removeFolder(list, path) }, '×'),
|
|
659
|
-
])),
|
|
660
|
-
foldRow(items, list),
|
|
661
|
-
]);
|
|
662
|
-
};
|
|
663
|
-
const recentAll = Array.isArray(state?.recent) ? state.recent : [];
|
|
664
|
-
const recent = recentAll.filter((item) => item?.folder_in === null || item?.folder_in === undefined);
|
|
665
|
-
const recentInLists = recentAll.filter((item) => item?.folder_in === 'whitelist' || item?.folder_in === 'blacklist');
|
|
666
|
-
const [showRecentInLists, setShowRecentInLists] = React.useState(false);
|
|
667
|
-
const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
|
|
668
|
-
// v1.8.1: an approval belongs to the conversation that raised it; another session must never show it.
|
|
669
|
-
const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
|
|
670
|
-
return h('div', { className: 'w2mGpw_overlay', role: 'dialog', 'aria-label': t('gpwTitle') }, [
|
|
671
|
-
h('div', { key: 'card', className: 'w2mGpw_card' }, [
|
|
672
|
-
h('div', { key: 'head', className: 'w2mGpw_head' }, [
|
|
673
|
-
h('span', { key: 'title', className: 'w2mGpw_title' }, t('gpwTitle')),
|
|
674
|
-
h('button', { key: 'close', type: 'button', className: 'w2mGpw_close', onClick: onClose }, '×'),
|
|
675
|
-
]),
|
|
676
|
-
error === null ? null : h('div', { key: 'err', className: 'w2mGpw_note' }, error),
|
|
677
|
-
state?.tampered === true ? h('div', { key: 'tampered', className: 'w2mGpw_block' }, [
|
|
678
|
-
h('div', { key: 't', className: 'w2mGpw_note' }, t('gpwTampered', { reason: state?.tampered_reason ?? '' })),
|
|
679
|
-
h('div', { key: 'a', className: 'w2mGpw_actions' }, [
|
|
680
|
-
h('button', { key: 'r', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: resetLists }, t('gpwReset')),
|
|
681
|
-
]),
|
|
682
|
-
]) : null,
|
|
683
|
-
h('div', { key: 'colours', className: 'w2mGpw_legend' }, ['green', 'blue', 'yellow', 'red'].map((colour) => h('span', { key: colour, className: 'w2mGpw_legendItem' }, [h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': colour }), String(state?.counters?.colors?.[colour] ?? 0)]))),
|
|
684
|
-
h('div', { key: 'manual', className: 'w2mGpw_manual' }, [
|
|
685
|
-
h('div', { key: 'label', className: 'w2mGpw_title' }, t('gpwManual')),
|
|
686
|
-
h('input', {
|
|
687
|
-
key: 'input',
|
|
688
|
-
className: 'w2mGpw_input',
|
|
689
|
-
value: manual,
|
|
690
|
-
placeholder: t('gpwManualPlaceholder'),
|
|
691
|
-
onChange: (event) => { setManual(event.target.value); setBadPath(null); },
|
|
692
|
-
onKeyDown: (event) => { if (event.key === 'Enter' && manual.trim() !== '') addTyped('whitelist'); },
|
|
693
|
-
}),
|
|
694
|
-
h('div', { key: 'buttons', className: 'w2mGpw_actions' }, [
|
|
695
|
-
h('button', { key: 'w', type: 'button', className: 'w2mGpw_btn', disabled: busy || manual.trim() === '', onClick: () => addTyped('whitelist') }, t('gpwAddWhiteShort')),
|
|
696
|
-
h('button', { key: 'b', type: 'button', className: 'w2mGpw_btn', disabled: busy || manual.trim() === '', onClick: () => addTyped('blacklist') }, t('gpwAddBlackShort')),
|
|
697
|
-
]),
|
|
698
|
-
badPath === null ? null : h('div', { key: 'bad', className: 'w2mGpw_note' }, t('gpwBadPath', { error: badPath })),
|
|
699
|
-
h('div', { key: 'hint', className: 'w2mGpw_hint' }, t('gpwInputHint')),
|
|
700
|
-
]),
|
|
701
|
-
folderList(t('gpwWhitelist'), 'whitelist'),
|
|
702
|
-
folderList(t('gpwBlacklist'), 'blacklist'),
|
|
703
|
-
h('div', { key: 'recent', className: 'w2mGpw_block' }, [
|
|
704
|
-
h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwRecent')),
|
|
705
|
-
recent.length === 0 ? h('div', { key: 'none', className: 'w2mGpw_note' }, t('gpwRecentEmpty')) : null,
|
|
706
|
-
...recent.map((item, index) => h('div', { key: 'r' + index, className: 'w2mGpw_recent' }, [
|
|
707
|
-
h('div', { key: 'r1', className: 'w2mGpw_row' }, [
|
|
708
|
-
h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green', title: item.decision }),
|
|
709
|
-
h('span', { key: 't', className: 'w2mGpw_path', title: item.paths?.[0] ?? '' }, (item.folder ?? item.paths?.[0] ?? item.tool)),
|
|
710
|
-
]),
|
|
711
|
-
item.folder === null || item.folder === undefined ? null : h('div', { key: 'r2', className: 'w2mGpw_actions' }, [
|
|
712
|
-
h('button', { key: 'w', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => addFolder('whitelist', item.folder) }, t('gpwAddWhiteShort')),
|
|
713
|
-
h('button', { key: 'b', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => addFolder('blacklist', item.folder) }, t('gpwAddBlackShort')),
|
|
714
|
-
]),
|
|
715
|
-
])),
|
|
716
|
-
foldRow(recent, 'recent'),
|
|
717
|
-
]),
|
|
718
|
-
recentInLists.length === 0 ? null : h('div', { key: 'recentInLists', className: 'w2mGpw_block' }, [
|
|
719
|
-
h('button', { key: 'toggle', type: 'button', className: 'w2mGpw_btn', 'aria-expanded': showRecentInLists, onClick: () => setShowRecentInLists((value) => !value) }, (showRecentInLists ? '收起' : '展开') + ' · 已在名单中 ' + recentInLists.length + ' 条'),
|
|
720
|
-
...(showRecentInLists ? recentInLists.map((item, index) => h('div', { key: 'inlist' + index, className: 'w2mGpw_recent' }, [
|
|
721
|
-
h('div', { key: 'r1', className: 'w2mGpw_row' }, [
|
|
722
|
-
h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green' }),
|
|
723
|
-
h('span', { key: 't', className: 'w2mGpw_path' }, (item.folder ?? '') + ' · 已加入' + (item.folder_in === 'whitelist' ? '白名单' : '黑名单')),
|
|
724
|
-
]),
|
|
725
|
-
])) : []),
|
|
726
|
-
]),
|
|
727
|
-
pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
|
|
728
|
-
h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
|
|
729
|
-
...pending.map((item) => h('div', { key: item.id, className: 'w2mGpw_recent' }, [
|
|
730
|
-
h('div', { key: 'p1', className: 'w2mGpw_path' }, [h('span', { key: 'c', className: 'w2mGpw_dot', 'data-c': item.color ?? 'yellow' }), item.tool + ' · ' + (item.reason ?? '')]),
|
|
731
|
-
...(Array.isArray(item.paths) ? item.paths.slice(0, 3).map((p, i) => h('div', { key: 'p' + i, className: 'w2mGpw_path' }, p)) : []),
|
|
732
|
-
h('div', { key: 'p2', className: 'w2mGpw_actions' }, [
|
|
733
|
-
h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'allow') }, t('gpwAllowOnce')),
|
|
734
|
-
h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'deny') }, t('gpwDenyOnce')),
|
|
735
|
-
item.folder === null || item.folder === undefined ? null : h('button', { key: 'aw', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'whitelist') }, t('gpwAddWhiteAllow')),
|
|
736
|
-
item.folder === null || item.folder === undefined ? null : h('button', { key: 'ab', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'blacklist') }, t('gpwAddBlackDeny')),
|
|
737
|
-
]),
|
|
738
|
-
])),
|
|
739
|
-
]),
|
|
740
|
-
]),
|
|
741
|
-
]);
|
|
1353
|
+
className: 'w2mChatTab',
|
|
1354
|
+
title: '编队聊天',
|
|
1355
|
+
onClick: () => { try { window.dispatchEvent(new CustomEvent('w2m-chat-full')); } catch { /* headless */ } },
|
|
1356
|
+
}, '编队聊天');
|
|
742
1357
|
}
|
|
743
1358
|
|
|
744
|
-
/**
|
|
745
|
-
function
|
|
746
|
-
const [
|
|
747
|
-
const [pendingCount, setPendingCount] = React.useState(0);
|
|
1359
|
+
/** v1.9.4: the bottom bar: fleet and server online/total counts, always visible. */
|
|
1360
|
+
function FleetStatusBar() {
|
|
1361
|
+
const [snapshot, setSnapshot] = React.useState(null);
|
|
748
1362
|
React.useEffect(() => {
|
|
749
1363
|
let alive = true;
|
|
750
1364
|
const load = async () => {
|
|
751
1365
|
try {
|
|
752
|
-
const response = await fetch('/api/w2m/
|
|
1366
|
+
const response = await fetch('/api/w2m/target?session_id=__global__', { credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
753
1367
|
const body = response.ok ? await response.json() : null;
|
|
754
|
-
if (alive)
|
|
1368
|
+
if (alive) setSnapshot(body?.state ?? body ?? null);
|
|
755
1369
|
} catch {
|
|
756
|
-
if (alive)
|
|
1370
|
+
if (alive) setSnapshot(null);
|
|
757
1371
|
}
|
|
758
1372
|
};
|
|
759
1373
|
load();
|
|
760
|
-
const timer = setInterval(load,
|
|
761
|
-
|
|
762
|
-
window.addEventListener('w2m-gpw-open', onOpen);
|
|
763
|
-
return () => {
|
|
764
|
-
alive = false;
|
|
765
|
-
clearInterval(timer);
|
|
766
|
-
window.removeEventListener('w2m-gpw-open', onOpen);
|
|
767
|
-
};
|
|
1374
|
+
const timer = setInterval(load, 5000);
|
|
1375
|
+
return () => { alive = false; clearInterval(timer); };
|
|
768
1376
|
}, []);
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
777
|
-
}, [
|
|
778
|
-
t('gpwLauncher'),
|
|
779
|
-
pendingCount > 0 ? h('span', { key: 'badge', className: 'w2mGpw_badge' }, String(pendingCount)) : null,
|
|
780
|
-
]),
|
|
781
|
-
open ? h(GpwPanel, { key: 'panel', t, sessionId, onClose: () => setOpen(false) }) : null,
|
|
1377
|
+
const fleet = snapshot?.fleet ?? null;
|
|
1378
|
+
const server = snapshot?.server ?? null;
|
|
1379
|
+
const fleetText = fleet && Number.isFinite(fleet.online) && Number.isFinite(fleet.total) ? fleet.online + '/' + fleet.total : '—';
|
|
1380
|
+
const serverText = server && Number.isFinite(server.devices_online) && Number.isFinite(server.devices_total) ? server.devices_online + '/' + server.devices_total : '—';
|
|
1381
|
+
return h('div', { className: 'w2mFleetStatus', title: '编队与服务器在线数量' }, [
|
|
1382
|
+
h('span', { key: 'f', className: 'w2mFleetStatusItem' }, '本编队在线 ' + fleetText),
|
|
1383
|
+
h('span', { key: 's', className: 'w2mFleetStatusItem' }, '全部机器在线 ' + serverText),
|
|
782
1384
|
]);
|
|
783
1385
|
}
|
|
784
1386
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
const [
|
|
791
|
-
const [
|
|
792
|
-
React.
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}, []);
|
|
810
|
-
const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
|
|
811
|
-
// v1.8.1: an approval belongs to the conversation that raised it; another session must never show it.
|
|
812
|
-
const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
|
|
813
|
-
if (pending.length === 0) return null;
|
|
814
|
-
const item = pending[0];
|
|
815
|
-
const decide = async (decision) => {
|
|
816
|
-
setBusy(true);
|
|
817
|
-
try {
|
|
818
|
-
await fetch('/api/w2m/gpw/decide', {
|
|
819
|
-
method: 'POST',
|
|
820
|
-
credentials: 'same-origin',
|
|
821
|
-
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
822
|
-
body: JSON.stringify({ id: item.id, decision }),
|
|
823
|
-
});
|
|
824
|
-
} catch {
|
|
825
|
-
/* the next poll shows the queue again */
|
|
826
|
-
} finally {
|
|
827
|
-
setBusy(false);
|
|
828
|
-
}
|
|
829
|
-
};
|
|
830
|
-
const openPanel = () => {
|
|
831
|
-
try {
|
|
832
|
-
window.dispatchEvent(new CustomEvent('w2m-gpw-open'));
|
|
833
|
-
} catch {
|
|
834
|
-
/* no window in a headless render */
|
|
835
|
-
}
|
|
836
|
-
};
|
|
837
|
-
return h('div', { className: 'w2mGpw_prompt', 'data-w2m-gpw-prompt': 'inline' }, [
|
|
838
|
-
h('span', { key: 'label', className: 'w2mGpw_promptLabel' }, pending.length > 1
|
|
839
|
-
? t('gpwPendingCount', { count: pending.length })
|
|
840
|
-
: t('gpwPendingShort', { tool: item.tool ?? 'tool' })),
|
|
841
|
-
h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('allow') }, t('gpwAllowOnce')),
|
|
842
|
-
h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('deny') }, t('gpwDenyOnce')),
|
|
843
|
-
h('button', { key: 'o', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: openPanel }, t('gpwOpenPanel')),
|
|
844
|
-
]);
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/** v1.6.8: one button beside the relay rows: check npm for a newer plugin, install it, ask for a restart. */
|
|
848
|
-
function PluginUpdate({ t }) {
|
|
849
|
-
const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
|
|
850
|
-
const call = async (method, body) => {
|
|
851
|
-
const response = await fetch(PLUGIN_UPDATE_ROUTE, {
|
|
852
|
-
method, credentials: 'same-origin',
|
|
853
|
-
headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
854
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
855
|
-
});
|
|
856
|
-
let json = null;
|
|
857
|
-
try { json = await response.json(); } catch { /* keep null */ }
|
|
858
|
-
return { status: response.status, json };
|
|
859
|
-
};
|
|
860
|
-
const onClick = async () => {
|
|
861
|
-
setState({ status: 'checking', info: null, error: null });
|
|
862
|
-
try {
|
|
863
|
-
const checked = await call('GET');
|
|
864
|
-
const info = checked.json ?? {};
|
|
865
|
-
if (info.ok !== true) { setState({ status: 'error', info, error: info.error ?? 'update check failed' }); return; }
|
|
866
|
-
if (info.update_available !== true) { setState({ status: 'current', info, error: null }); return; }
|
|
867
|
-
setState({ status: 'applying', info, error: null });
|
|
868
|
-
const applied = await call('POST', { action: 'apply' });
|
|
869
|
-
const result = applied.json ?? {};
|
|
870
|
-
if (result.ok !== true) { setState({ status: 'error', info: result, error: result.error ?? result.reason ?? 'update failed' }); return; }
|
|
871
|
-
setState({ status: 'installed', info: result, error: null });
|
|
872
|
-
} catch (error) {
|
|
873
|
-
setState({ status: 'error', info: null, error: error instanceof Error ? error.message : String(error) });
|
|
874
|
-
}
|
|
875
|
-
};
|
|
876
|
-
const label = state.status === 'checking' ? t('updateChecking')
|
|
877
|
-
: state.status === 'applying' ? t('updating')
|
|
878
|
-
: state.status === 'current' ? t('updateCurrent', { version: state.info?.current ?? '?' })
|
|
879
|
-
: state.status === 'installed' ? t('updateInstalled', { version: state.info?.to ?? state.info?.latest ?? '?' })
|
|
880
|
-
: t('updateButton');
|
|
881
|
-
const disabled = state.status === 'checking' || state.status === 'applying';
|
|
882
|
-
return h('div', { className: cls('relayHint'), 'data-w2m-update': state.status }, [
|
|
883
|
-
h('button', { key: 'b', type: 'button', className: cls('relayToggle'), disabled, onClick: (event) => { event.stopPropagation(); onClick(); } }, label),
|
|
884
|
-
state.status === 'installed' ? h('div', { key: 'r', className: cls('relayHint') }, t('restartHint')) : null,
|
|
885
|
-
state.status === 'error' ? h('div', { key: 'e', className: cls('relayHint') }, String(state.error ?? 'error')) : null,
|
|
886
|
-
]);
|
|
887
|
-
}
|
|
888
|
-
/**
|
|
889
|
-
* v1.9.3: the GPW entry in the LEFT sidebar footer. It is always rendered and carries no close
|
|
890
|
-
* affordance -- the operator asked for a permanent entry in addition to the composer and the
|
|
891
|
-
* floating launcher. The click reuses the launcher's event so there is one panel, not two.
|
|
892
|
-
*/
|
|
893
|
-
function GpwSidebarFooter() {
|
|
894
|
-
return h('button', {
|
|
895
|
-
type: 'button',
|
|
896
|
-
className: 'w2mGpw_sideEntry',
|
|
897
|
-
'data-w2m-gpw-sidebar': 'true',
|
|
898
|
-
title: 'GPW 文件夹策略',
|
|
899
|
-
onClick: () => {
|
|
900
|
-
try { window.dispatchEvent(new CustomEvent('w2m-gpw-open')); } catch { /* headless render */ }
|
|
901
|
-
},
|
|
902
|
-
}, [
|
|
903
|
-
h('span', { key: 'i', className: 'w2mGpw_sideIcon', 'aria-hidden': 'true' }, '📁'),
|
|
904
|
-
h('span', { key: 'l', className: 'w2mGpw_sideLabel' }, 'GPW'),
|
|
905
|
-
]);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
const AUTO_UPDATE_PREF = 'w2m:plugin-auto-update';
|
|
909
|
-
|
|
910
|
-
/**
|
|
911
|
-
* v1.9.3: the update section on the plugin settings page.
|
|
912
|
-
*
|
|
913
|
-
* Two operator requests meet here: a check that still says something when the installed build is
|
|
914
|
-
* old (the old button lived inside the composer dropdown and was easy to miss), and an auto-update
|
|
915
|
-
* switch that actually performs the install instead of only remembering a preference.
|
|
916
|
-
*/
|
|
917
|
-
function UpdateSettingsSection() {
|
|
918
|
-
const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
|
|
919
|
-
const [auto, setAuto] = React.useState(false);
|
|
920
|
-
React.useEffect(() => {
|
|
921
|
-
try { setAuto(globalThis.localStorage?.getItem(AUTO_UPDATE_PREF) === '1'); } catch { /* storage off */ }
|
|
922
|
-
}, []);
|
|
923
|
-
const run = React.useCallback(async ({ install }) => {
|
|
924
|
-
setState({ status: 'checking', info: null, error: null });
|
|
925
|
-
try {
|
|
926
|
-
const checked = await fetch(PLUGIN_UPDATE_ROUTE, { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
927
|
-
const info = await checked.json().catch(() => ({}));
|
|
928
|
-
if (info.ok !== true) { setState({ status: 'error', info, error: info.error ?? 'update check failed' }); return; }
|
|
929
|
-
if (install !== true || info.update_available !== true) {
|
|
930
|
-
setState({ status: info.update_available === true ? 'available' : 'current', info, error: null });
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
setState({ status: 'applying', info, error: null });
|
|
934
|
-
const applied = await fetch(PLUGIN_UPDATE_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ action: 'apply' }) });
|
|
935
|
-
const result = await applied.json().catch(() => ({}));
|
|
936
|
-
if (result.ok !== true) { setState({ status: 'error', info: result, error: result.error ?? result.reason ?? 'update failed' }); return; }
|
|
937
|
-
setState({ status: 'installed', info: { ...info, ...result }, error: null });
|
|
938
|
-
} catch (error) {
|
|
939
|
-
setState({ status: 'error', info: null, error: error instanceof Error ? error.message : String(error) });
|
|
940
|
-
}
|
|
941
|
-
}, []);
|
|
942
|
-
const toggleAuto = (event) => {
|
|
943
|
-
const next = event?.target?.checked === true;
|
|
944
|
-
setAuto(next);
|
|
945
|
-
try { globalThis.localStorage?.setItem(AUTO_UPDATE_PREF, next ? '1' : '0'); } catch { /* storage off */ }
|
|
946
|
-
if (next) run({ install: true });
|
|
947
|
-
};
|
|
948
|
-
// v1.9.3: state the update picture as soon as the section opens, so an outdated install is
|
|
949
|
-
// visible without a click; the auto path below only adds the install step.
|
|
950
|
-
React.useEffect(() => { run({ install: false }); }, [run]);
|
|
951
|
-
React.useEffect(() => { if (auto) run({ install: true }); }, [auto, run]);
|
|
952
|
-
const busy = state.status === 'checking' || state.status === 'applying';
|
|
953
|
-
const statusText = state.status === 'checking' ? '正在检测更新…'
|
|
954
|
-
: state.status === 'applying' ? '正在安装更新…'
|
|
955
|
-
: state.status === 'available' ? ('发现新版本 ' + (state.info?.latest ?? '?') + '(当前 ' + (state.info?.current ?? '?') + ')')
|
|
956
|
-
: state.status === 'installed' ? ('已安装 ' + (state.info?.to ?? state.info?.latest ?? '?') + ',重启后生效')
|
|
957
|
-
: state.status === 'current' ? ('已是最新版本 ' + (state.info?.current ?? state.info?.latest ?? '?'))
|
|
958
|
-
: state.status === 'error' ? ('更新失败:' + String(state.error ?? 'unknown'))
|
|
959
|
-
: '检测插件是否有新版本;开启自动更新后每次启动自动安装。';
|
|
960
|
-
return h('div', { className: 'w2mUpdateSection', 'data-w2m-update-section': state.status }, [
|
|
961
|
-
h('div', { key: 's', className: 'w2mUpdateStatus' }, statusText),
|
|
962
|
-
h('div', { key: 'row', className: 'w2mUpdateRow' }, [
|
|
963
|
-
h('button', { key: 'check', type: 'button', className: 'w2mUpdateBtn', disabled: busy, onClick: () => run({ install: false }) }, '检测更新'),
|
|
964
|
-
state.status === 'available'
|
|
965
|
-
? h('button', { key: 'install', type: 'button', className: 'w2mUpdateBtn', disabled: busy, onClick: () => run({ install: true }) }, '立即更新')
|
|
966
|
-
: null,
|
|
967
|
-
h('label', { key: 'auto', className: 'w2mUpdateAuto' }, [
|
|
968
|
-
h('input', { key: 'c', type: 'checkbox', checked: auto, disabled: state.status === 'applying', onChange: toggleAuto }),
|
|
969
|
-
h('span', { key: 'l' }, '自动更新'),
|
|
970
|
-
]),
|
|
971
|
-
]),
|
|
972
|
-
]);
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
976
|
-
const [open, setOpen] = React.useState(false);
|
|
977
|
-
// v1.5.1: relay folding. The snapshot carries the pool; when it holds more than three nodes
|
|
978
|
-
// the menu shows a summary and this toggle reveals the folded ones.
|
|
979
|
-
const [relayOpen, setRelayOpen] = React.useState(false);
|
|
980
|
-
const [reloadSeq, setReloadSeq] = React.useState(0);
|
|
981
|
-
const [state, setState] = React.useState(null);
|
|
982
|
-
const [error, setError] = React.useState(null);
|
|
983
|
-
const [busy, setBusy] = React.useState(false);
|
|
984
|
-
const anchorRef = React.useRef(null);
|
|
985
|
-
const menuRef = React.useRef(null);
|
|
986
|
-
const [placement, setPlacement] = React.useState(null);
|
|
987
|
-
|
|
988
|
-
// The host half is the source of truth, so every read replaces the mirror rather than merging
|
|
989
|
-
// into it: a stale "online" must not outlive the answer that disproved it.
|
|
990
|
-
React.useEffect(() => {
|
|
991
|
-
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
992
|
-
const controller = new AbortController();
|
|
993
|
-
readState(sessionId, controller.signal).then((result) => {
|
|
994
|
-
if (controller.signal.aborted) return;
|
|
995
|
-
if (result.ok) {
|
|
996
|
-
setState(result.state);
|
|
997
|
-
setError(null);
|
|
998
|
-
} else {
|
|
999
|
-
setError(result.error);
|
|
1000
|
-
}
|
|
1001
|
-
});
|
|
1002
|
-
return () => controller.abort();
|
|
1003
|
-
}, [sessionId, reloadSeq]);
|
|
1004
|
-
|
|
1005
|
-
// Opening the menu is a read: the roster and the selection are re-asked for every time, so a
|
|
1006
|
-
// machine that went away while the menu was closed cannot be clicked as if it were live. The
|
|
1007
|
-
// host caches its own relay read for a few seconds, so this is not a request storm.
|
|
1008
|
-
React.useEffect(() => {
|
|
1009
|
-
if (open) setReloadSeq((seq) => seq + 1);
|
|
1010
|
-
}, [open]);
|
|
1011
|
-
|
|
1012
|
-
React.useEffect(() => {
|
|
1013
|
-
if (!open) return undefined;
|
|
1014
|
-
const reposition = () => {
|
|
1015
|
-
const anchor = anchorRef.current;
|
|
1016
|
-
if (anchor === null) return;
|
|
1017
|
-
const rect = anchor.getBoundingClientRect();
|
|
1018
|
-
const width = Math.min(300, Math.max(200, Math.round(rect.width) + 96));
|
|
1019
|
-
const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
|
|
1020
|
-
// Above the composer by default — it is at the bottom of the window — and below it only
|
|
1021
|
-
// when the space above is genuinely unusable.
|
|
1022
|
-
const roomAbove = rect.top;
|
|
1023
|
-
const maxHeight = Math.max(160, Math.min(320, roomAbove > 240 ? roomAbove - 16 : window.innerHeight - rect.bottom - 24));
|
|
1024
|
-
setPlacement(roomAbove > 240
|
|
1025
|
-
? { left, width, maxHeight, bottom: window.innerHeight - rect.top + 6, top: 'auto' }
|
|
1026
|
-
: { left, width, maxHeight, top: rect.bottom + 6, bottom: 'auto' });
|
|
1027
|
-
};
|
|
1028
|
-
const onKeyDown = (event) => {
|
|
1029
|
-
if (event.key === 'Escape') {
|
|
1030
|
-
setOpen(false);
|
|
1031
|
-
anchorRef.current?.focus?.();
|
|
1032
|
-
}
|
|
1033
|
-
};
|
|
1034
|
-
const onPointerDown = (event) => {
|
|
1035
|
-
const target = event.target;
|
|
1036
|
-
if (anchorRef.current?.contains(target) === true) return;
|
|
1037
|
-
if (menuRef.current?.contains(target) === true) return;
|
|
1038
|
-
setOpen(false);
|
|
1039
|
-
};
|
|
1040
|
-
reposition();
|
|
1041
|
-
// `capture` matters: the composer's own editor listens on the document too, and Escape has
|
|
1042
|
-
// to close this menu before anything else reads it.
|
|
1043
|
-
document.addEventListener('keydown', onKeyDown, true);
|
|
1044
|
-
document.addEventListener('pointerdown', onPointerDown, true);
|
|
1045
|
-
window.addEventListener('resize', reposition);
|
|
1046
|
-
window.addEventListener('scroll', reposition, true);
|
|
1047
|
-
return () => {
|
|
1048
|
-
document.removeEventListener('keydown', onKeyDown, true);
|
|
1049
|
-
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
1050
|
-
window.removeEventListener('resize', reposition);
|
|
1051
|
-
window.removeEventListener('scroll', reposition, true);
|
|
1052
|
-
};
|
|
1053
|
-
}, [open]);
|
|
1054
|
-
|
|
1055
|
-
const chosen = state?.selected?.machine_id ?? null;
|
|
1056
|
-
const machines = Array.isArray(state?.machines) ? state.machines : [];
|
|
1057
|
-
// v1.5.1: the relay pool the host probed, already folded. `null` means a single-relay host.
|
|
1058
|
-
const relayFold = state?.relays ?? null;
|
|
1059
|
-
const problem = error ?? fleetProblem(state, t);
|
|
1060
|
-
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
1061
|
-
const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
|
|
1062
|
-
const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
|
|
1063
|
-
const selectedOnline = state === null
|
|
1064
|
-
? null
|
|
1065
|
-
: (selectedMachine !== null ? selectedMachine.online === true : (chosen === null ? null : state?.selected?.online ?? null));
|
|
1066
|
-
const dot = chosen === null
|
|
1067
|
-
? 'none'
|
|
1068
|
-
: (selectedOnline === true ? (selectedMachine?.local === true ? 'local' : 'online') : 'stale');
|
|
1069
|
-
const title = problem ?? (chosen === null
|
|
1070
|
-
? t('costAll')
|
|
1071
|
-
: `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
|
|
1072
|
-
|
|
1073
|
-
const choose = (machineId, machineName) => {
|
|
1074
|
-
setOpen(false);
|
|
1075
|
-
if (machineId === chosen) return;
|
|
1076
|
-
setBusy(true);
|
|
1077
|
-
writeSelection(sessionId, machineId, machineName).then((result) => {
|
|
1078
|
-
setBusy(false);
|
|
1079
|
-
if (result.ok) {
|
|
1080
|
-
setState(result.state);
|
|
1081
|
-
setError(null);
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
// The write is refused for a reason the operator can act on (the machine went away, the
|
|
1085
|
-
// fleet could not be read), so it is shown where they are looking rather than swallowed.
|
|
1086
|
-
setError(result.error);
|
|
1087
|
-
setReloadSeq((seq) => seq + 1);
|
|
1088
|
-
});
|
|
1089
|
-
};
|
|
1090
|
-
|
|
1091
|
-
const menu = open && placement !== null
|
|
1092
|
-
? h('div', {
|
|
1093
|
-
className: cls('menu'),
|
|
1094
|
-
role: 'listbox',
|
|
1095
|
-
'aria-label': t('targetLabel', { name: label }),
|
|
1096
|
-
ref: menuRef,
|
|
1097
|
-
style: {
|
|
1098
|
-
left: `${placement.left}px`,
|
|
1099
|
-
top: placement.top === 'auto' ? 'auto' : `${placement.top}px`,
|
|
1100
|
-
bottom: placement.bottom === undefined || placement.bottom === 'auto' ? 'auto' : `${placement.bottom}px`,
|
|
1101
|
-
width: `${placement.width}px`,
|
|
1102
|
-
maxHeight: `${placement.maxHeight}px`,
|
|
1103
|
-
overflowY: 'auto',
|
|
1104
|
-
},
|
|
1105
|
-
}, [
|
|
1106
|
-
h('button', {
|
|
1107
|
-
key: '__all__',
|
|
1108
|
-
type: 'button',
|
|
1109
|
-
role: 'option',
|
|
1110
|
-
className: cls('option'),
|
|
1111
|
-
'data-active': chosen === null ? 'true' : 'false',
|
|
1112
|
-
'aria-selected': chosen === null,
|
|
1113
|
-
onClick: () => choose(null, null),
|
|
1114
|
-
title: t('costAll'),
|
|
1115
|
-
}, h('span', { className: cls('row') }, [
|
|
1116
|
-
h(StatusDot, { key: 'dot', kind: 'none', title: t('all') }),
|
|
1117
|
-
h('span', { key: 'name', className: cls('name') }, t('all')),
|
|
1118
|
-
h('span', { key: 'detail', className: cls('detail') }, state?.fleet?.ok === true ? `${state.fleet.online} ${t('online')}` : '—'),
|
|
1119
|
-
])),
|
|
1120
|
-
problem === null ? null : h('div', { key: '__problem__', className: cls('note'), role: 'status' }, problem),
|
|
1121
|
-
machines.length === 0
|
|
1122
|
-
? h('div', { key: '__empty__', className: cls('note') }, problem === null ? t('empty') : t('noRoster'))
|
|
1123
|
-
: machines.map((machine) => h('button', {
|
|
1124
|
-
key: machine.machine_id ?? machine.name ?? Math.random().toString(36).slice(2),
|
|
1125
|
-
type: 'button',
|
|
1126
|
-
role: 'option',
|
|
1127
|
-
className: cls('option'),
|
|
1128
|
-
'data-active': machine.machine_id !== null && machine.machine_id === chosen ? 'true' : 'false',
|
|
1129
|
-
'aria-selected': machine.machine_id !== null && machine.machine_id === chosen,
|
|
1130
|
-
disabled: machine.online !== true,
|
|
1131
|
-
title: machine.online === true
|
|
1132
|
-
? `${machine.name ?? machine.machine_id} — ${machine.local === true ? t('local') : t('online')}`
|
|
1133
|
-
: `${machine.name ?? machine.machine_id} — ${machine.state}`,
|
|
1134
|
-
onClick: () => choose(machine.machine_id, machine.name),
|
|
1135
|
-
}, h('span', { className: cls('row') }, [
|
|
1136
|
-
h(StatusDot, { key: 'dot', kind: dotKind(machine), title: machine.state }),
|
|
1137
|
-
h('span', { key: 'name', className: cls('name') }, machine.name ?? machine.machine_id ?? 'unnamed'),
|
|
1138
|
-
h('span', { key: 'detail', className: cls('detail') }, machine.local === true
|
|
1139
|
-
? t('local')
|
|
1140
|
-
: (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
|
|
1141
|
-
]))),
|
|
1142
|
-
relayFold === null ? null : h('div', {
|
|
1143
|
-
key: '__relays__',
|
|
1144
|
-
className: cls('relayFold'),
|
|
1145
|
-
role: 'group',
|
|
1146
|
-
'aria-label': t('relays'),
|
|
1147
|
-
'data-temporary': relayFold.temporary === true ? 'true' : 'false',
|
|
1148
|
-
}, [
|
|
1149
|
-
h('button', {
|
|
1150
|
-
key: 'toggle',
|
|
1151
|
-
type: 'button',
|
|
1152
|
-
className: cls('relayToggle'),
|
|
1153
|
-
'aria-expanded': relayOpen,
|
|
1154
|
-
'data-temporary': relayFold.temporary === true ? 'true' : 'false',
|
|
1155
|
-
onClick: (event) => { event.stopPropagation(); setRelayOpen((wasOpen) => !wasOpen); },
|
|
1156
|
-
title: relayOpen === true ? t('relayUnfold') : t('relayFold', { count: relayFold.hidden_count }),
|
|
1157
|
-
}, `${t('relays')}: ${relayFold.online}/${relayFold.total} ${t('online')}${relayFold.temporary === true ? ` · ${t('relayTemporary')}` : ''}`),
|
|
1158
|
-
...(relayOpen === true ? [...relayFold.shown, ...relayFold.hidden] : relayFold.shown).map((item, index) => h('div', {
|
|
1159
|
-
key: `relay-${index}`,
|
|
1160
|
-
className: cls('relayRow'),
|
|
1161
|
-
'data-role': item.role,
|
|
1162
|
-
}, [
|
|
1163
|
-
h('span', { key: 'role', className: cls('relayRole') }, item.role === 'primary'
|
|
1164
|
-
? t('relayPrimary')
|
|
1165
|
-
: (item.role === 'standby' ? t('relayStandby') : t('relayOffline'))),
|
|
1166
|
-
h('span', { key: 'url', className: cls('relayUrl') }, String(item.url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '')),
|
|
1167
|
-
h('span', { key: 'rtt', className: cls('relayRtt') }, item.rtt_ms === null || item.rtt_ms === undefined ? '—' : `${item.rtt_ms} ms`),
|
|
1168
|
-
])),
|
|
1169
|
-
// v1.7.3: name the relay the totals came from; "whole server" and "this fleet" are different
|
|
1170
|
-
// numbers, and the panel used to leave the reader to guess which one it was showing.
|
|
1171
|
-
relayFold.server === null || relayFold.server === undefined
|
|
1172
|
-
? (relayFold.server_reachable === false
|
|
1173
|
-
? h('div', { key: 'globalUnreachable', className: cls('relayHint') }, t('globalUnreachable', { url: String(relayFold.server_url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '') || '?' }))
|
|
1174
|
-
: null)
|
|
1175
|
-
: h('div', { key: 'global', className: cls('relayHint') }, t('globalSummaryWithSource', {
|
|
1176
|
-
source: String(relayFold.server_url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '') || '?',
|
|
1177
|
-
fleetsOnline: relayFold.server.fleets_online ?? '—',
|
|
1178
|
-
fleets: relayFold.server.fleets_total ?? '—',
|
|
1179
|
-
online: relayFold.server.devices_online ?? '—',
|
|
1180
|
-
total: relayFold.server.devices_total ?? '—',
|
|
1181
|
-
})),
|
|
1182
|
-
h('div', { key: 'fleet', className: cls('relayHint') }, t('fleetSummary', { online: state?.fleet?.online ?? '—', total: state?.fleet?.total ?? '—' })),
|
|
1183
|
-
h(PluginUpdate, { key: 'update', t }),
|
|
1184
|
-
relayFold.hidden_count > 0 && relayOpen === false
|
|
1185
|
-
? h('div', { key: 'hint', className: cls('relayHint') }, t('relayFold', { count: relayFold.hidden_count }))
|
|
1186
|
-
: null,
|
|
1187
|
-
]),
|
|
1188
|
-
])
|
|
1189
|
-
: null;
|
|
1190
|
-
|
|
1191
|
-
return h(React.Fragment, null, [
|
|
1192
|
-
h('button', {
|
|
1193
|
-
key: 'trigger',
|
|
1194
|
-
type: 'button',
|
|
1195
|
-
ref: anchorRef,
|
|
1196
|
-
className: cls('trigger'),
|
|
1197
|
-
'aria-label': t('targetLabel', { name: label }),
|
|
1198
|
-
'aria-haspopup': 'listbox',
|
|
1199
|
-
'aria-expanded': open,
|
|
1200
|
-
'aria-busy': busy,
|
|
1201
|
-
title,
|
|
1202
|
-
disabled,
|
|
1203
|
-
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
1204
|
-
}, [
|
|
1205
|
-
h(StatusDot, { key: 'dot', kind: dot, title: title ?? '' }),
|
|
1206
|
-
h('span', { key: 'label', className: cls('label') }, label),
|
|
1207
|
-
h('span', { key: 'chevron', className: cls('chevron'), 'data-open': open ? 'true' : 'false', 'aria-hidden': 'true' },
|
|
1208
|
-
h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none' },
|
|
1209
|
-
h('path', {
|
|
1210
|
-
d: 'M3 4.5L6 7.5L9 4.5',
|
|
1211
|
-
stroke: 'currentColor',
|
|
1212
|
-
strokeWidth: 1.5,
|
|
1213
|
-
strokeLinecap: 'round',
|
|
1214
|
-
strokeLinejoin: 'round',
|
|
1215
|
-
}))),
|
|
1216
|
-
]),
|
|
1217
|
-
menu,
|
|
1218
|
-
]);
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
// -------------------------------------------------------------------------------------------
|
|
1222
|
-
// Fleet chat panel (v1.1.18 addition: the missing browser half of `w2m_chat`)
|
|
1223
|
-
// -------------------------------------------------------------------------------------------
|
|
1224
|
-
//
|
|
1225
|
-
// v1.1.17 shipped fleet chat as a tool only — its own CHANGELOG records "no client UI panel
|
|
1226
|
-
// beyond the generic tool card". This is that panel: a session-scoped broadcast board the
|
|
1227
|
-
// operator and the agent share. Every read and write goes through the host route
|
|
1228
|
-
// `/api/w2m/chat` (host-side `fleet-chat-route.mjs`), so the device token stays in the host
|
|
1229
|
-
// process and this file never holds a credential.
|
|
1230
|
-
const CHAT_ROUTE = '/api/w2m/chat';
|
|
1231
|
-
// v1.7.1: host fallbacks for the DSH desktop shell (renderer capture/clipboard are not granted there).
|
|
1232
|
-
const CAPTURE_ROUTE = '/api/w2m/capture';
|
|
1233
|
-
const CLIPBOARD_ROUTE = '/api/w2m/clipboard';
|
|
1234
|
-
|
|
1235
|
-
/** One JSON call to the host chat route; a refusal is a value, not an exception. */
|
|
1236
|
-
async function chatCall(options) {
|
|
1237
|
-
let response;
|
|
1238
|
-
try {
|
|
1239
|
-
response = await fetch(options.url, options.init);
|
|
1240
|
-
} catch (error) {
|
|
1241
|
-
return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}` };
|
|
1242
|
-
}
|
|
1243
|
-
let body = null;
|
|
1244
|
-
try {
|
|
1245
|
-
body = await response.json();
|
|
1246
|
-
} catch {
|
|
1247
|
-
return { ok: false, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
|
|
1248
|
-
}
|
|
1249
|
-
if (response.status === 404) return { ok: false, error: 'this host does not serve the W2M chat route' };
|
|
1250
|
-
if (response.ok !== true || body?.ok === false) {
|
|
1251
|
-
return { ok: false, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
|
|
1252
|
-
}
|
|
1253
|
-
return { ok: true, state: body };
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
/**
|
|
1257
|
-
* The fleet-chat control: opens a panel with the session's messages, a compose box and the
|
|
1258
|
-
* relay's current usage. The text stays on the relay; this component keeps only the last view.
|
|
1259
|
-
*/
|
|
1260
|
-
const AVATAR_PALETTE = ['#4c6ef5', '#12b886', '#f59f00', '#e8590c', '#ae3ec9', '#1098ad', '#d6336c', '#5c940d'];
|
|
1261
|
-
|
|
1262
|
-
/** A machine keeps its colour: the index comes from its id, not from the order of the list. */
|
|
1263
|
-
function avatarColor(machineId) {
|
|
1264
|
-
var h = 0;
|
|
1265
|
-
var s = String(machineId == null ? '' : machineId);
|
|
1266
|
-
for (var i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
1267
|
-
return AVATAR_PALETTE[h % AVATAR_PALETTE.length];
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
/** The DeepSeek whale, filled with the machine colour. */
|
|
1271
|
-
function WhaleAvatar({ machineId }) {
|
|
1272
|
-
return h('span', { className: 'w2mChat_avatar', style: { color: avatarColor(machineId) }, 'aria-hidden': 'true' },
|
|
1273
|
-
h('svg', { viewBox: '0 0 24 24', width: 16, height: 16 },
|
|
1274
|
-
h('path', { fill: 'currentColor', d: 'M2 13c2.2-3.4 5.4-5 9-5 3.1 0 5.6 1.2 7.2 3.2h2.3l-1.6-2.4 1.6.9L22 6.9l.6 3.4 1.4.8-1.4.8-.6 3.4-1.5-2.8-1.6.9 1.6-2.4h-2.2C16.6 8.9 13.9 7.6 11 7.6 6.6 7.6 3.3 9.6 1 13h1Z' })));
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
/** session = this machine typed it into DSH; chat = typed here; remote = another machine. */
|
|
1278
|
-
function messageKind(message, localMachineId) {
|
|
1279
|
-
if (localMachineId == null || message == null || message.machine_id !== localMachineId) return 'remote';
|
|
1280
|
-
return message.origin === 'session' ? 'session' : 'chat';
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
function FleetChat({ sessionId }) {
|
|
1284
|
-
const [open, setOpen] = React.useState(false);
|
|
1285
|
-
const [messages, setMessages] = React.useState([]);
|
|
1286
|
-
const [usage, setUsage] = React.useState(null);
|
|
1287
|
-
const [localMachineName, setLocalMachineName] = React.useState(null);
|
|
1288
|
-
const [roster, setRoster] = React.useState(null);
|
|
1289
|
-
const [toRecipient, setToRecipient] = React.useState('all');
|
|
1290
|
-
const [replyTo, setReplyTo] = React.useState(null);
|
|
1291
|
-
const [tasks, setTasks] = React.useState([]);
|
|
1292
|
-
const [lastSeenMs, setLastSeenMs] = React.useState(() => {
|
|
1293
|
-
try {
|
|
1294
|
-
const raw = globalThis.localStorage?.getItem('w2m:chat:seen:' + sessionId);
|
|
1295
|
-
const parsed = Number.parseInt(raw ?? '', 10);
|
|
1296
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
1297
|
-
} catch {
|
|
1298
|
-
return 0;
|
|
1299
|
-
}
|
|
1300
|
-
});
|
|
1301
|
-
const [text, setText] = React.useState('');
|
|
1302
|
-
const [error, setError] = React.useState(null);
|
|
1303
|
-
const [busy, setBusy] = React.useState(false);
|
|
1304
|
-
const [localMachineId, setLocalMachineId] = React.useState(null);
|
|
1305
|
-
// v1.7.2: screenshot state is clipboard-only (no preview); `toast` is the only visible feedback.
|
|
1387
|
+
function FleetChat({ sessionId, viewMode }) {
|
|
1388
|
+
const CHAT_EMOJIS = ['👍', '✅', '🚀', '😄', '❤️', '🎉', '🔧', '📌'];
|
|
1389
|
+
const [open, setOpen] = React.useState(false);
|
|
1390
|
+
const [messages, setMessages] = React.useState([]);
|
|
1391
|
+
const [usage, setUsage] = React.useState(null);
|
|
1392
|
+
const [localMachineName, setLocalMachineName] = React.useState(null);
|
|
1393
|
+
const [roster, setRoster] = React.useState(null);
|
|
1394
|
+
const [toRecipient, setToRecipient] = React.useState('all');
|
|
1395
|
+
const [replyTo, setReplyTo] = React.useState(null);
|
|
1396
|
+
const [tasks, setTasks] = React.useState([]);
|
|
1397
|
+
const [lastSeenMs, setLastSeenMs] = React.useState(() => {
|
|
1398
|
+
try {
|
|
1399
|
+
const raw = globalThis.localStorage?.getItem('w2m:chat:seen:' + sessionId);
|
|
1400
|
+
const parsed = Number.parseInt(raw ?? '', 10);
|
|
1401
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
1402
|
+
} catch {
|
|
1403
|
+
return 0;
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
const [text, setText] = React.useState('');
|
|
1407
|
+
const [error, setError] = React.useState(null);
|
|
1408
|
+
const [busy, setBusy] = React.useState(false);
|
|
1409
|
+
const [localMachineId, setLocalMachineId] = React.useState(null);
|
|
1410
|
+
// v1.7.2: screenshot state is clipboard-only (no preview); `toast` is the only visible feedback.
|
|
1306
1411
|
const [shotBusy, setShotBusy] = React.useState(false);
|
|
1307
|
-
const [
|
|
1308
|
-
|
|
1309
|
-
// clipboard; the relay's chat payload stays text, so an image cannot be broadcast as a message.
|
|
1310
|
-
|
|
1311
|
-
const load = React.useCallback(async (signal) => {
|
|
1312
|
-
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
1313
|
-
const result = await chatCall({
|
|
1314
|
-
url: `${CHAT_ROUTE}?session_id=${encodeURIComponent(sessionId)}&limit=50`,
|
|
1315
|
-
init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
|
|
1316
|
-
});
|
|
1317
|
-
if (result.ok) {
|
|
1318
|
-
setMessages(Array.isArray(result.state.messages) ? result.state.messages : []);
|
|
1319
|
-
setLocalMachineId(typeof result.state.local_machine_id === 'string' ? result.state.local_machine_id : null);
|
|
1320
|
-
setLocalMachineName(typeof result.state.local_machine_name === 'string' && result.state.local_machine_name !== '' ? result.state.local_machine_name : null);
|
|
1321
|
-
setUsage(result.state.usage ?? null);
|
|
1322
|
-
setError(null);
|
|
1323
|
-
} else {
|
|
1324
|
-
setError(result.error);
|
|
1325
|
-
}
|
|
1326
|
-
}, [sessionId]);
|
|
1327
|
-
|
|
1328
|
-
React.useEffect(() => {
|
|
1329
|
-
if (!open) return undefined;
|
|
1330
|
-
const controller = new AbortController();
|
|
1331
|
-
load(controller.signal);
|
|
1332
|
-
const timer = setInterval(() => load(controller.signal), 3000);
|
|
1333
|
-
return () => {
|
|
1334
|
-
controller.abort();
|
|
1335
|
-
clearInterval(timer);
|
|
1336
|
-
};
|
|
1337
|
-
}, [open, load]);
|
|
1338
|
-
|
|
1339
|
-
// The chat messages carry a machine id, not a name: the relay stores what a sender proves, and a name is
|
|
1340
|
-
// not one of those proofs. The roster the target control already reads is where names live, so the panel
|
|
1341
|
-
// borrows it once per open instead of inventing a label from the id.
|
|
1342
|
-
React.useEffect(() => {
|
|
1343
|
-
if (!open || typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
1344
|
-
const controller = new AbortController();
|
|
1345
|
-
readState(sessionId, controller.signal).then((result) => {
|
|
1346
|
-
if (controller.signal.aborted) return;
|
|
1347
|
-
setRoster(result.ok ? result.state : null);
|
|
1348
|
-
});
|
|
1349
|
-
return () => controller.abort();
|
|
1350
|
-
}, [open, sessionId]);
|
|
1351
|
-
|
|
1352
|
-
// The task board is the relay's own list: the host proxies it so the panel never holds a device token.
|
|
1353
|
-
React.useEffect(() => {
|
|
1354
|
-
if (!open) return undefined;
|
|
1355
|
-
const controller = new AbortController();
|
|
1356
|
-
chatCall({ url: '/api/w2m/tasks?limit=20', init: { signal: controller.signal } })
|
|
1357
|
-
.then((result) => { if (!controller.signal.aborted) setTasks(result.ok && Array.isArray(result.data?.tasks) ? result.data.tasks : []); })
|
|
1358
|
-
.catch(() => { /* the board is an extra; a failed read must not break the chat */ });
|
|
1359
|
-
return () => controller.abort();
|
|
1360
|
-
}, [open]);
|
|
1361
|
-
|
|
1362
|
-
// Unread is measured against a per-conversation watermark in local storage: the relay stays stateless, and
|
|
1363
|
-
// the count answers "is there anything I have not looked at" without claiming which messages a machine read.
|
|
1364
|
-
React.useEffect(() => {
|
|
1365
|
-
if (!open || messages.length === 0) return undefined;
|
|
1366
|
-
const latest = messages.reduce((max, message) => (Number.isFinite(message?.at_ms) ? Math.max(max, message.at_ms) : max), 0);
|
|
1367
|
-
if (latest <= 0 || latest <= lastSeenMs) return undefined;
|
|
1368
|
-
setLastSeenMs(latest);
|
|
1369
|
-
try { globalThis.localStorage?.setItem('w2m:chat:seen:' + sessionId, String(latest)); } catch { /* storage may be unavailable */ }
|
|
1370
|
-
return undefined;
|
|
1371
|
-
}, [open, messages, sessionId, lastSeenMs]);
|
|
1372
|
-
|
|
1373
|
-
// Consecutive messages that share a thread are shown under one thread head. Grouping only consecutive runs keeps
|
|
1374
|
-
// the reading order the relay assigned -- re-sorting by thread would put an answer above the question it answers.
|
|
1375
|
-
const groupThreads = (list) => {
|
|
1376
|
-
const groups = [];
|
|
1377
|
-
for (const message of list ?? []) {
|
|
1378
|
-
const threadId = typeof message?.thread_id === 'string' && message.thread_id !== '' ? message.thread_id : null;
|
|
1379
|
-
const last = groups[groups.length - 1] ?? null;
|
|
1380
|
-
if (last !== null && last.threadId === threadId) { last.messages.push(message); continue; }
|
|
1381
|
-
groups.push({ threadId, messages: [message] });
|
|
1382
|
-
}
|
|
1383
|
-
return groups;
|
|
1384
|
-
};
|
|
1385
|
-
|
|
1386
|
-
const send = React.useCallback(async () => {
|
|
1387
|
-
const value = text.trim();
|
|
1388
|
-
if (value === '' || busy) return undefined;
|
|
1389
|
-
setBusy(true);
|
|
1390
|
-
const result = await chatCall({
|
|
1391
|
-
url: CHAT_ROUTE,
|
|
1392
|
-
init: {
|
|
1393
|
-
method: 'POST',
|
|
1394
|
-
credentials: 'same-origin',
|
|
1395
|
-
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1396
|
-
body: JSON.stringify({
|
|
1397
|
-
session_id: sessionId,
|
|
1398
|
-
text: value,
|
|
1399
|
-
...(toRecipient === 'all' ? {} : { to: toRecipient }),
|
|
1400
|
-
...(replyTo === null ? {} : { reply_to: replyTo.id, ...(replyTo.threadId === null ? {} : { thread_id: replyTo.threadId }) }),
|
|
1401
|
-
}),
|
|
1402
|
-
},
|
|
1403
|
-
});
|
|
1404
|
-
setBusy(false);
|
|
1405
|
-
if (result.ok) {
|
|
1406
|
-
setText('');
|
|
1407
|
-
setReplyTo(null);
|
|
1408
|
-
await load();
|
|
1409
|
-
} else {
|
|
1410
|
-
setError(result.error);
|
|
1411
|
-
}
|
|
1412
|
-
return undefined;
|
|
1413
|
-
}, [busy, load, sessionId, text]);
|
|
1414
|
-
// v1.6.10: clipboard text with a fallback for hosts where navigator.clipboard is absent.
|
|
1415
|
-
// v1.7.1: copy through the renderer when allowed, then through the host clipboard route.
|
|
1416
|
-
// The DSH desktop shell rejects navigator.clipboard, which is why v1.7.0's copy button failed there.
|
|
1417
|
-
const copyText = React.useCallback((value) => {
|
|
1418
|
-
const textValue = String(value ?? '');
|
|
1419
|
-
if (textValue === '') return undefined;
|
|
1420
|
-
const hostFallback = async () => {
|
|
1421
|
-
try {
|
|
1422
|
-
const response = await fetch(CLIPBOARD_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ text: textValue }) });
|
|
1423
|
-
const body = await response.json().catch(() => null);
|
|
1424
|
-
if (response.ok === true && body?.ok === true) { setToast('已复制(宿主侧)'); return undefined; }
|
|
1425
|
-
} catch { /* fall through to the textarea path */ }
|
|
1426
|
-
try {
|
|
1427
|
-
const area = document.createElement('textarea');
|
|
1428
|
-
area.value = textValue; area.style.position = 'fixed'; area.style.opacity = '0';
|
|
1429
|
-
document.body.appendChild(area); area.select(); document.execCommand('copy'); document.body.removeChild(area);
|
|
1430
|
-
setToast('已复制');
|
|
1431
|
-
} catch { setToast('复制失败'); }
|
|
1432
|
-
return undefined;
|
|
1433
|
-
};
|
|
1434
|
-
try {
|
|
1435
|
-
if (navigator.clipboard?.writeText) {
|
|
1436
|
-
navigator.clipboard.writeText(textValue).then(() => setToast('已复制'), () => { hostFallback(); });
|
|
1437
|
-
return undefined;
|
|
1438
|
-
}
|
|
1439
|
-
} catch { /* fall through */ }
|
|
1440
|
-
hostFallback();
|
|
1441
|
-
return undefined;
|
|
1442
|
-
}, []);
|
|
1443
|
-
|
|
1444
|
-
// v1.6.10: capture one frame of a user-picked screen/window, preview it, and copy the PNG to the
|
|
1445
|
-
// clipboard. getDisplayMedia needs the gesture the button/keypress provides; every failure is
|
|
1446
|
-
// reported in the panel rather than as an unhandled rejection.
|
|
1447
|
-
// v1.7.1: screenshot with a host fallback. The renderer path (getDisplayMedia) is tried first;
|
|
1448
|
-
// when the host forbids it -- the DSH desktop shell does -- the panel captures on the host and
|
|
1449
|
-
// receives JPEG base64, so the button and Ctrl+P work in both places.
|
|
1450
|
-
// v1.7.2: capture and COPY only -- no thumbnail in the panel (the chat got too cramped), and no
|
|
1451
|
-
// print-dialog shortcut stealing. The renderer path is tried first; the DSH desktop shell denies
|
|
1452
|
-
// getDisplayMedia, so the host route captures and copies on the host side instead.
|
|
1453
|
-
const captureScreenshot = React.useCallback(async () => {
|
|
1454
|
-
if (shotBusy) return undefined;
|
|
1455
|
-
setShotBusy(true); setToast(null);
|
|
1456
|
-
const copyImage = async (blob) => {
|
|
1457
|
-
try {
|
|
1458
|
-
if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
|
|
1459
|
-
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
1460
|
-
return true;
|
|
1461
|
-
}
|
|
1462
|
-
} catch { /* host fallback below */ }
|
|
1463
|
-
return false;
|
|
1464
|
-
};
|
|
1465
|
-
try {
|
|
1466
|
-
if (typeof navigator?.mediaDevices?.getDisplayMedia === 'function') {
|
|
1467
|
-
let stream = null;
|
|
1468
|
-
try {
|
|
1469
|
-
stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 5 }, audio: false });
|
|
1470
|
-
const video = document.createElement('video');
|
|
1471
|
-
video.srcObject = stream;
|
|
1472
|
-
await video.play();
|
|
1473
|
-
await new Promise((resolve) => { setTimeout(resolve, 180); });
|
|
1474
|
-
const canvas = document.createElement('canvas');
|
|
1475
|
-
canvas.width = video.videoWidth || 1280;
|
|
1476
|
-
canvas.height = video.videoHeight || 720;
|
|
1477
|
-
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
1478
|
-
const blob = await new Promise((resolve) => { canvas.toBlob(resolve, 'image/png'); });
|
|
1479
|
-
if (blob !== null && await copyImage(blob) === true) {
|
|
1480
|
-
setToast('截图已复制到剪贴板');
|
|
1481
|
-
return undefined;
|
|
1482
|
-
}
|
|
1483
|
-
} catch { /* the host fallback below is the whole point */ }
|
|
1484
|
-
finally { try { stream?.getTracks?.().forEach((track) => track.stop()); } catch { /* gone */ } }
|
|
1485
|
-
}
|
|
1486
|
-
const response = await fetch(CAPTURE_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ copy: true }) });
|
|
1487
|
-
const body = await response.json().catch(() => null);
|
|
1488
|
-
if (response.ok !== true || body?.ok !== true) throw new Error(body?.error ?? ('HTTP ' + response.status));
|
|
1489
|
-
if (body.copied === true) setToast('截图已复制到剪贴板(宿主)');
|
|
1490
|
-
else setToast('截图已生成,但剪贴板不可用');
|
|
1491
|
-
} catch (error) {
|
|
1492
|
-
setToast('截图失败:' + (error instanceof Error ? error.message : String(error)));
|
|
1493
|
-
} finally {
|
|
1494
|
-
setShotBusy(false);
|
|
1495
|
-
}
|
|
1496
|
-
return undefined;
|
|
1497
|
-
}, [shotBusy]);
|
|
1498
|
-
|
|
1499
|
-
// v1.7.2: Ctrl+Shift+P (Cmd+Shift+P) opens the capture while the panel is open. Plain Ctrl+P is left
|
|
1500
|
-
// alone: hijacking the browser/app print shortcut surprised testers and printed the page instead.
|
|
1501
|
-
React.useEffect(() => {
|
|
1502
|
-
if (!open) return undefined;
|
|
1503
|
-
const onKeyDown = (event) => {
|
|
1504
|
-
if (event.defaultPrevented) return;
|
|
1505
|
-
const modifier = event.ctrlKey === true || event.metaKey === true;
|
|
1506
|
-
if (modifier && event.shiftKey === true && event.altKey !== true && String(event.key).toLowerCase() === 'p') {
|
|
1507
|
-
event.preventDefault();
|
|
1508
|
-
captureScreenshot();
|
|
1509
|
-
}
|
|
1510
|
-
};
|
|
1511
|
-
document.addEventListener('keydown', onKeyDown, true);
|
|
1512
|
-
return () => document.removeEventListener('keydown', onKeyDown, true);
|
|
1513
|
-
}, [open, captureScreenshot]);
|
|
1514
|
-
|
|
1515
|
-
// v1.7.3: screenshot feedback is ephemeral: it fades after 5s and can be closed. It must not
|
|
1516
|
-
// sit in the compose row (that squeezed the input) and must not accumulate with more captures.
|
|
1517
|
-
React.useEffect(() => {
|
|
1518
|
-
if (toast === null) return undefined;
|
|
1519
|
-
const timer = setTimeout(() => setToast(null), 5000);
|
|
1520
|
-
return () => clearTimeout(timer);
|
|
1521
|
-
}, [toast]);
|
|
1522
|
-
|
|
1523
|
-
const unseenCount = messages.reduce((count, message) => (Number.isFinite(message?.at_ms) && message.at_ms > lastSeenMs ? count + 1 : count), 0);
|
|
1524
|
-
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
1525
|
-
const usageText = usage === null ? null : `${Math.round((usage.used_24h_bytes ?? 0) / 1024)} KiB/24h · ${usage.messages ?? 0} 条`;
|
|
1526
|
-
const machineLabel = (message) => {
|
|
1527
|
-
const id = typeof message?.machine_id === 'string' ? message.machine_id : null;
|
|
1528
|
-
if (id === null) return '?';
|
|
1529
|
-
const known = Array.isArray(roster?.machines)
|
|
1530
|
-
? roster.machines.find((machine) => machine.machine_id === id) ?? null
|
|
1531
|
-
: null;
|
|
1532
|
-
const mine = id === localMachineId;
|
|
1533
|
-
const name = known?.name ?? message?.machine_name ?? (mine ? localMachineName : null);
|
|
1534
|
-
const detail = mine ? '\u672c\u673a' : (known?.os ?? null);
|
|
1535
|
-
// A machine with no name yet is shown by a short id, never by a full UUID, and the detail says which of the
|
|
1536
|
-
// two it is -- "unknown" next to a UUID is what made the panel look like it was leaking identifiers.
|
|
1537
|
-
const shown = name ?? (id.length > 12 ? `${id.slice(0, 8)}…` : id);
|
|
1538
|
-
return detail === null ? shown : `${shown} · ${detail}`;
|
|
1539
|
-
};
|
|
1540
|
-
const timeLabel = (message) => (Number.isFinite(message?.at_ms) ? new Date(message.at_ms).toLocaleTimeString() : '');
|
|
1541
|
-
|
|
1542
|
-
return h('span', { className: 'w2mChat' },
|
|
1543
|
-
h('button', {
|
|
1544
|
-
type: 'button',
|
|
1545
|
-
className: 'w2mChat_trigger',
|
|
1546
|
-
title: '编队聊天(本会话广播)',
|
|
1547
|
-
'aria-expanded': open,
|
|
1548
|
-
disabled,
|
|
1549
|
-
onClick: () => setOpen((value) => !value),
|
|
1550
|
-
}, '💬', h('span', { className: 'w2mChat_label' }, '编队聊天'), unseenCount > 0 && !open ? h('span', { className: 'w2mChat_unseen' }, String(unseenCount)) : null),
|
|
1551
|
-
open
|
|
1552
|
-
? h('div', { className: 'w2mChat_panel', role: 'dialog', 'aria-label': '编队聊天' },
|
|
1553
|
-
h('div', { className: 'w2mChat_head' },
|
|
1554
|
-
h('select', {
|
|
1555
|
-
className: 'w2mChat_to',
|
|
1556
|
-
value: toRecipient,
|
|
1557
|
-
title: '收件人',
|
|
1558
|
-
onChange: (event) => setToRecipient(event.target.value),
|
|
1559
|
-
}, [
|
|
1560
|
-
h('option', { key: '__all__', value: 'all' }, '全部'),
|
|
1561
|
-
...(Array.isArray(roster?.machines) ? roster.machines.filter((machine) => machine.online === true && typeof machine.machine_id === 'string').map((machine) => h('option', { key: machine.machine_id, value: machine.machine_id }, machine.name ?? machine.machine_id)) : []),
|
|
1562
|
-
]),
|
|
1563
|
-
h('span', null, '编队聊天'),
|
|
1564
|
-
usageText === null ? null : h('span', { className: 'w2mChat_usage' }, usageText),
|
|
1565
|
-
h('button', { type: 'button', className: 'w2mChat_close', title: '关闭', onClick: () => setOpen(false) }, '×')),
|
|
1566
|
-
tasks.length === 0 ? null : h('div', { className: 'w2mChat_board' }, [
|
|
1567
|
-
h('div', { key: 'title', className: 'w2mChat_boardTitle' }, '任务看板'),
|
|
1568
|
-
h('div', { key: 'counts', className: 'w2mChat_boardCounts' }, (() => { const b = { ready: 0, active: 0, waiting: 0, blocked: 0 }; for (const task of tasks) { const status = String(task?.status ?? ''); if (status === 'done' || status === 'completed') continue; if (status === 'failed' || status === 'cancelled' || status === 'refused') b.blocked += 1; else if (status === 'waiting' || (Array.isArray(task?.waiting_on) && task.waiting_on.length > 0)) b.waiting += 1; else if (status === 'active' || status === 'queued' || status === 'offered' || status === 'running') b.active += 1; else b.ready += 1; } return '准备 ' + b.ready + ' · 进行 ' + b.active + ' · 等待 ' + b.waiting + ' · 阻塞 ' + b.blocked; })()),
|
|
1569
|
-
...tasks.slice(0, 6).map((task) => h('div', { key: String(task?.task_id ?? Math.random()), className: 'w2mChat_boardRow' },
|
|
1570
|
-
`${String(task?.task_id ?? '').slice(0, 8)} ${task?.mode ?? '?'} ${task?.status ?? '?'}`)),
|
|
1571
|
-
]),
|
|
1572
|
-
h('div', { className: 'w2mChat_list', onMouseDownCapture: (event) => { if (event.button === 0) event.stopPropagation(); }, onCopy: (event) => event.stopPropagation() },
|
|
1573
|
-
error !== null ? h('div', { className: 'w2mChat_error' }, error) : null,
|
|
1574
|
-
messages.length === 0
|
|
1575
|
-
? (error === null ? h('div', { className: 'w2mChat_empty' }, '本会话还没有消息') : null)
|
|
1576
|
-
: groupThreads(messages).flatMap((group, groupIndex) => [
|
|
1577
|
-
group.threadId === null ? null : h('div', { key: 'thread:' + group.threadId + ':' + groupIndex, className: 'w2mChat_threadHead' }, '#' + group.threadId),
|
|
1578
|
-
...group.messages.map((message) => {
|
|
1579
|
-
var kind = messageKind(message, localMachineId);
|
|
1580
|
-
return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind, onClick: () => { const selection = typeof window.getSelection === 'function' ? String(window.getSelection() ?? '') : ''; if (selection !== '') return; setReplyTo({ id: message?.message_id ?? null, threadId: message?.thread_id ?? null, text: typeof message?.text === 'string' ? message.text : '' }); } },
|
|
1581
|
-
h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : '', h('button', { key: 'copy', type: 'button', className: 'w2mChat_copy', title: '复制这条消息', onClick: (event) => { event.stopPropagation(); copyText(String(message?.text ?? '')); } }, '⧉'), message?.thread_id ? h('span', { className: 'w2mChat_thread' }, '#' + message.thread_id) : null),
|
|
1582
|
-
h('div', { className: 'w2mChat_text' }, message?.text ?? ''));
|
|
1583
|
-
}),
|
|
1584
|
-
].filter((node) => node !== null))),
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
h('div', { className: 'w2mChat_compose' },
|
|
1588
|
-
replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
|
|
1589
|
-
h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
|
|
1590
|
-
h('button', { key: 'cancel', type: 'button', className: 'w2mChat_replyCancel', title: '取消回复', onClick: () => setReplyTo(null) }, '×'),
|
|
1591
|
-
]),
|
|
1592
|
-
h('textarea', {
|
|
1593
|
-
className: 'w2mChat_input',
|
|
1594
|
-
value: text,
|
|
1595
|
-
rows: 2,
|
|
1596
|
-
placeholder: '说点什么,发给本会话的机器…',
|
|
1597
|
-
disabled: busy,
|
|
1598
|
-
onChange: (event) => setText(event.target.value),
|
|
1599
|
-
onKeyDown: (event) => {
|
|
1600
|
-
if (event.key === 'Enter' && event.shiftKey !== true) {
|
|
1601
|
-
event.preventDefault();
|
|
1602
|
-
send();
|
|
1603
|
-
}
|
|
1604
|
-
},
|
|
1605
|
-
}),
|
|
1606
|
-
h('button', { type: 'button', className: 'w2mChat_shotButton', title: '截图并复制(Ctrl+Shift+P)', disabled: shotBusy, onClick: captureScreenshot }, shotBusy ? '截图…' : '📷'),
|
|
1607
|
-
h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
|
|
1608
|
-
: null);
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
/**
|
|
1613
|
-
* v1.4.2: the Node-code-execution capability switch.
|
|
1614
|
-
*
|
|
1615
|
-
* Three explicit decisions, no fourth: deny, temporary (24 h, expiring), permanent. The host owns the file
|
|
1616
|
-
* and the agent reads it before every task, so a choice here takes effect on the next dispatch; the control
|
|
1617
|
-
* never claims the choice succeeded until the host's own answer says so.
|
|
1618
|
-
*/
|
|
1619
|
-
function ExecCapability({ t }) {
|
|
1620
|
-
const [open, setOpen] = React.useState(false);
|
|
1621
|
-
const [capability, setCapability] = React.useState(null);
|
|
1622
|
-
const [error, setError] = React.useState(null);
|
|
1623
|
-
const [busy, setBusy] = React.useState(false);
|
|
1624
|
-
const anchorRef = React.useRef(null);
|
|
1625
|
-
const menuRef = React.useRef(null);
|
|
1626
|
-
const [placement, setPlacement] = React.useState(null);
|
|
1627
|
-
|
|
1628
|
-
React.useEffect(() => {
|
|
1629
|
-
const controller = new AbortController();
|
|
1630
|
-
readCapability(controller.signal).then((result) => {
|
|
1631
|
-
if (controller.signal.aborted) return;
|
|
1632
|
-
if (result.ok) { setCapability(result.capacity); setError(null); }
|
|
1633
|
-
else setError(result.routeMissing ? t('capRouteMissing') : result.error);
|
|
1634
|
-
});
|
|
1635
|
-
return () => controller.abort();
|
|
1636
|
-
}, []);
|
|
1637
|
-
|
|
1412
|
+
const [chatFull, setChatFull] = React.useState(false);
|
|
1413
|
+
React.useEffect(() => { if (viewMode === true) setOpen(true); }, [viewMode]);
|
|
1638
1414
|
React.useEffect(() => {
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
}
|
|
1662
|
-
}, [
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1415
|
+
const onFull = () => { setChatFull(true); setOpen(true); };
|
|
1416
|
+
window.addEventListener('w2m-chat-full', onFull);
|
|
1417
|
+
return () => window.removeEventListener('w2m-chat-full', onFull);
|
|
1418
|
+
}, []);
|
|
1419
|
+
const [toast, setToast] = React.useState(null);
|
|
1420
|
+
// v1.6.10: screenshot support (button + Ctrl+P). The blob is previewed and copied to the
|
|
1421
|
+
// clipboard; the relay's chat payload stays text, so an image cannot be broadcast as a message.
|
|
1422
|
+
|
|
1423
|
+
const load = React.useCallback(async (signal) => {
|
|
1424
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
1425
|
+
const result = await chatCall({
|
|
1426
|
+
url: `${CHAT_ROUTE}?session_id=${encodeURIComponent(sessionId)}&limit=50`,
|
|
1427
|
+
init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
|
|
1428
|
+
});
|
|
1429
|
+
if (result.ok) {
|
|
1430
|
+
setMessages(Array.isArray(result.state.messages) ? result.state.messages : []);
|
|
1431
|
+
setLocalMachineId(typeof result.state.local_machine_id === 'string' ? result.state.local_machine_id : null);
|
|
1432
|
+
setLocalMachineName(typeof result.state.local_machine_name === 'string' && result.state.local_machine_name !== '' ? result.state.local_machine_name : null);
|
|
1433
|
+
setUsage(result.state.usage ?? null);
|
|
1434
|
+
setError(null);
|
|
1435
|
+
} else {
|
|
1436
|
+
setError(result.error);
|
|
1437
|
+
}
|
|
1438
|
+
}, [sessionId]);
|
|
1439
|
+
|
|
1440
|
+
React.useEffect(() => {
|
|
1441
|
+
if (!open) return undefined;
|
|
1442
|
+
const controller = new AbortController();
|
|
1443
|
+
load(controller.signal);
|
|
1444
|
+
const timer = setInterval(() => load(controller.signal), 3000);
|
|
1445
|
+
return () => {
|
|
1446
|
+
controller.abort();
|
|
1447
|
+
clearInterval(timer);
|
|
1448
|
+
};
|
|
1449
|
+
}, [open, load]);
|
|
1450
|
+
|
|
1451
|
+
// The chat messages carry a machine id, not a name: the relay stores what a sender proves, and a name is
|
|
1452
|
+
// not one of those proofs. The roster the target control already reads is where names live, so the panel
|
|
1453
|
+
// borrows it once per open instead of inventing a label from the id.
|
|
1454
|
+
React.useEffect(() => {
|
|
1455
|
+
if (!open || typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
1456
|
+
const controller = new AbortController();
|
|
1457
|
+
readState(sessionId, controller.signal).then((result) => {
|
|
1458
|
+
if (controller.signal.aborted) return;
|
|
1459
|
+
setRoster(result.ok ? result.state : null);
|
|
1460
|
+
});
|
|
1461
|
+
return () => controller.abort();
|
|
1462
|
+
}, [open, sessionId]);
|
|
1463
|
+
|
|
1464
|
+
// The task board is the relay's own list: the host proxies it so the panel never holds a device token.
|
|
1465
|
+
React.useEffect(() => {
|
|
1466
|
+
if (!open) return undefined;
|
|
1467
|
+
const controller = new AbortController();
|
|
1468
|
+
chatCall({ url: '/api/w2m/tasks?limit=20', init: { signal: controller.signal } })
|
|
1469
|
+
.then((result) => { if (!controller.signal.aborted) setTasks(result.ok && Array.isArray(result.data?.tasks) ? result.data.tasks : []); })
|
|
1470
|
+
.catch(() => { /* the board is an extra; a failed read must not break the chat */ });
|
|
1471
|
+
return () => controller.abort();
|
|
1472
|
+
}, [open]);
|
|
1473
|
+
|
|
1474
|
+
// Unread is measured against a per-conversation watermark in local storage: the relay stays stateless, and
|
|
1475
|
+
// the count answers "is there anything I have not looked at" without claiming which messages a machine read.
|
|
1476
|
+
React.useEffect(() => {
|
|
1477
|
+
if (!open || messages.length === 0) return undefined;
|
|
1478
|
+
const latest = messages.reduce((max, message) => (Number.isFinite(message?.at_ms) ? Math.max(max, message.at_ms) : max), 0);
|
|
1479
|
+
if (latest <= 0 || latest <= lastSeenMs) return undefined;
|
|
1480
|
+
setLastSeenMs(latest);
|
|
1481
|
+
try { globalThis.localStorage?.setItem('w2m:chat:seen:' + sessionId, String(latest)); } catch { /* storage may be unavailable */ }
|
|
1482
|
+
return undefined;
|
|
1483
|
+
}, [open, messages, sessionId, lastSeenMs]);
|
|
1484
|
+
|
|
1485
|
+
// Consecutive messages that share a thread are shown under one thread head. Grouping only consecutive runs keeps
|
|
1486
|
+
// the reading order the relay assigned -- re-sorting by thread would put an answer above the question it answers.
|
|
1487
|
+
const groupThreads = (list) => {
|
|
1488
|
+
const groups = [];
|
|
1489
|
+
for (const message of list ?? []) {
|
|
1490
|
+
const threadId = typeof message?.thread_id === 'string' && message.thread_id !== '' ? message.thread_id : null;
|
|
1491
|
+
const last = groups[groups.length - 1] ?? null;
|
|
1492
|
+
if (last !== null && last.threadId === threadId) { last.messages.push(message); continue; }
|
|
1493
|
+
groups.push({ threadId, messages: [message] });
|
|
1494
|
+
}
|
|
1495
|
+
return groups;
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
const send = React.useCallback(async () => {
|
|
1499
|
+
const value = text.trim();
|
|
1500
|
+
if (value === '' || busy) return undefined;
|
|
1501
|
+
setBusy(true);
|
|
1502
|
+
const result = await chatCall({
|
|
1503
|
+
url: CHAT_ROUTE,
|
|
1504
|
+
init: {
|
|
1505
|
+
method: 'POST',
|
|
1506
|
+
credentials: 'same-origin',
|
|
1507
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1508
|
+
body: JSON.stringify({
|
|
1509
|
+
session_id: sessionId,
|
|
1510
|
+
text: value,
|
|
1511
|
+
...(toRecipient === 'all' ? {} : { to: toRecipient }),
|
|
1512
|
+
...(replyTo === null ? {} : { reply_to: replyTo.id, ...(replyTo.threadId === null ? {} : { thread_id: replyTo.threadId }) }),
|
|
1513
|
+
}),
|
|
1514
|
+
},
|
|
1515
|
+
});
|
|
1516
|
+
setBusy(false);
|
|
1517
|
+
if (result.ok) {
|
|
1518
|
+
setText('');
|
|
1519
|
+
setReplyTo(null);
|
|
1520
|
+
await load();
|
|
1521
|
+
} else {
|
|
1522
|
+
setError(result.error);
|
|
1523
|
+
}
|
|
1524
|
+
return undefined;
|
|
1525
|
+
}, [busy, load, sessionId, text]);
|
|
1526
|
+
// v1.6.10: clipboard text with a fallback for hosts where navigator.clipboard is absent.
|
|
1527
|
+
// v1.7.1: copy through the renderer when allowed, then through the host clipboard route.
|
|
1528
|
+
// The DSH desktop shell rejects navigator.clipboard, which is why v1.7.0's copy button failed there.
|
|
1529
|
+
const copyText = React.useCallback((value) => {
|
|
1530
|
+
const textValue = String(value ?? '');
|
|
1531
|
+
if (textValue === '') return undefined;
|
|
1532
|
+
const hostFallback = async () => {
|
|
1533
|
+
try {
|
|
1534
|
+
const response = await fetch(CLIPBOARD_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ text: textValue }) });
|
|
1535
|
+
const body = await response.json().catch(() => null);
|
|
1536
|
+
if (response.ok === true && body?.ok === true) { setToast('已复制(宿主侧)'); return undefined; }
|
|
1537
|
+
} catch { /* fall through to the textarea path */ }
|
|
1538
|
+
try {
|
|
1539
|
+
const area = document.createElement('textarea');
|
|
1540
|
+
area.value = textValue; area.style.position = 'fixed'; area.style.opacity = '0';
|
|
1541
|
+
document.body.appendChild(area); area.select(); document.execCommand('copy'); document.body.removeChild(area);
|
|
1542
|
+
setToast('已复制');
|
|
1543
|
+
} catch { setToast('复制失败'); }
|
|
1544
|
+
return undefined;
|
|
1545
|
+
};
|
|
1546
|
+
try {
|
|
1547
|
+
if (navigator.clipboard?.writeText) {
|
|
1548
|
+
navigator.clipboard.writeText(textValue).then(() => setToast('已复制'), () => { hostFallback(); });
|
|
1549
|
+
return undefined;
|
|
1550
|
+
}
|
|
1551
|
+
} catch { /* fall through */ }
|
|
1552
|
+
hostFallback();
|
|
1553
|
+
return undefined;
|
|
1554
|
+
}, []);
|
|
1555
|
+
|
|
1556
|
+
// v1.6.10: capture one frame of a user-picked screen/window, preview it, and copy the PNG to the
|
|
1557
|
+
// clipboard. getDisplayMedia needs the gesture the button/keypress provides; every failure is
|
|
1558
|
+
// reported in the panel rather than as an unhandled rejection.
|
|
1559
|
+
// v1.7.1: screenshot with a host fallback. The renderer path (getDisplayMedia) is tried first;
|
|
1560
|
+
// when the host forbids it -- the DSH desktop shell does -- the panel captures on the host and
|
|
1561
|
+
// receives JPEG base64, so the button and Ctrl+P work in both places.
|
|
1562
|
+
// v1.7.2: capture and COPY only -- no thumbnail in the panel (the chat got too cramped), and no
|
|
1563
|
+
// print-dialog shortcut stealing. The renderer path is tried first; the DSH desktop shell denies
|
|
1564
|
+
// getDisplayMedia, so the host route captures and copies on the host side instead.
|
|
1565
|
+
const captureScreenshot = React.useCallback(async () => {
|
|
1566
|
+
if (shotBusy) return undefined;
|
|
1567
|
+
setShotBusy(true); setToast(null);
|
|
1568
|
+
const copyImage = async (blob) => {
|
|
1569
|
+
try {
|
|
1570
|
+
if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
|
|
1571
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
1572
|
+
return true;
|
|
1573
|
+
}
|
|
1574
|
+
} catch { /* host fallback below */ }
|
|
1575
|
+
return false;
|
|
1576
|
+
};
|
|
1577
|
+
try {
|
|
1578
|
+
if (typeof navigator?.mediaDevices?.getDisplayMedia === 'function') {
|
|
1579
|
+
let stream = null;
|
|
1580
|
+
try {
|
|
1581
|
+
stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 5 }, audio: false });
|
|
1582
|
+
const video = document.createElement('video');
|
|
1583
|
+
video.srcObject = stream;
|
|
1584
|
+
await video.play();
|
|
1585
|
+
await new Promise((resolve) => { setTimeout(resolve, 180); });
|
|
1586
|
+
const canvas = document.createElement('canvas');
|
|
1587
|
+
canvas.width = video.videoWidth || 1280;
|
|
1588
|
+
canvas.height = video.videoHeight || 720;
|
|
1589
|
+
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
1590
|
+
const blob = await new Promise((resolve) => { canvas.toBlob(resolve, 'image/png'); });
|
|
1591
|
+
if (blob !== null && await copyImage(blob) === true) {
|
|
1592
|
+
setToast('截图已复制到剪贴板');
|
|
1593
|
+
return undefined;
|
|
1594
|
+
}
|
|
1595
|
+
} catch { /* the host fallback below is the whole point */ }
|
|
1596
|
+
finally { try { stream?.getTracks?.().forEach((track) => track.stop()); } catch { /* gone */ } }
|
|
1597
|
+
}
|
|
1598
|
+
const response = await fetch(CAPTURE_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ copy: true }) });
|
|
1599
|
+
const body = await response.json().catch(() => null);
|
|
1600
|
+
if (response.ok !== true || body?.ok !== true) throw new Error(body?.error ?? ('HTTP ' + response.status));
|
|
1601
|
+
if (body.copied === true) setToast('截图已复制到剪贴板(宿主)');
|
|
1602
|
+
else setToast('截图已生成,但剪贴板不可用');
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
setToast('截图失败:' + (error instanceof Error ? error.message : String(error)));
|
|
1605
|
+
} finally {
|
|
1606
|
+
setShotBusy(false);
|
|
1607
|
+
}
|
|
1608
|
+
return undefined;
|
|
1609
|
+
}, [shotBusy]);
|
|
1610
|
+
|
|
1611
|
+
// v1.7.2: Ctrl+Shift+P (Cmd+Shift+P) opens the capture while the panel is open. Plain Ctrl+P is left
|
|
1612
|
+
// alone: hijacking the browser/app print shortcut surprised testers and printed the page instead.
|
|
1613
|
+
React.useEffect(() => {
|
|
1614
|
+
if (!open) return undefined;
|
|
1615
|
+
const onKeyDown = (event) => {
|
|
1616
|
+
if (event.defaultPrevented) return;
|
|
1617
|
+
const modifier = event.ctrlKey === true || event.metaKey === true;
|
|
1618
|
+
if (modifier && event.shiftKey === true && event.altKey !== true && String(event.key).toLowerCase() === 'p') {
|
|
1619
|
+
event.preventDefault();
|
|
1620
|
+
captureScreenshot();
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
1624
|
+
return () => document.removeEventListener('keydown', onKeyDown, true);
|
|
1625
|
+
}, [open, captureScreenshot]);
|
|
1626
|
+
|
|
1627
|
+
// v1.7.3: screenshot feedback is ephemeral: it fades after 5s and can be closed. It must not
|
|
1628
|
+
// sit in the compose row (that squeezed the input) and must not accumulate with more captures.
|
|
1629
|
+
React.useEffect(() => {
|
|
1630
|
+
if (toast === null) return undefined;
|
|
1631
|
+
const timer = setTimeout(() => setToast(null), 5000);
|
|
1632
|
+
return () => clearTimeout(timer);
|
|
1633
|
+
}, [toast]);
|
|
1634
|
+
|
|
1635
|
+
const unseenCount = messages.reduce((count, message) => (Number.isFinite(message?.at_ms) && message.at_ms > lastSeenMs ? count + 1 : count), 0);
|
|
1636
|
+
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
1637
|
+
const usageText = usage === null ? null : `${Math.round((usage.used_24h_bytes ?? 0) / 1024)} KiB/24h · ${usage.messages ?? 0} 条`;
|
|
1638
|
+
const machineLabel = (message) => {
|
|
1639
|
+
const id = typeof message?.machine_id === 'string' ? message.machine_id : null;
|
|
1640
|
+
if (id === null) return '?';
|
|
1641
|
+
const known = Array.isArray(roster?.machines)
|
|
1642
|
+
? roster.machines.find((machine) => machine.machine_id === id) ?? null
|
|
1643
|
+
: null;
|
|
1644
|
+
const mine = id === localMachineId;
|
|
1645
|
+
const name = known?.name ?? message?.machine_name ?? (mine ? localMachineName : null);
|
|
1646
|
+
const detail = mine ? '\u672c\u673a' : (known?.os ?? null);
|
|
1647
|
+
// A machine with no name yet is shown by a short id, never by a full UUID, and the detail says which of the
|
|
1648
|
+
// two it is -- "unknown" next to a UUID is what made the panel look like it was leaking identifiers.
|
|
1649
|
+
const shown = name ?? (id.length > 12 ? `${id.slice(0, 8)}…` : id);
|
|
1650
|
+
return detail === null ? shown : `${shown} · ${detail}`;
|
|
1651
|
+
};
|
|
1652
|
+
const timeLabel = (message) => (Number.isFinite(message?.at_ms) ? new Date(message.at_ms).toLocaleTimeString() : '');
|
|
1653
|
+
|
|
1654
|
+
return h('span', { className: 'w2mChat' },
|
|
1655
|
+
h('button', {
|
|
1656
|
+
type: 'button',
|
|
1657
|
+
className: 'w2mChat_trigger',
|
|
1658
|
+
title: '编队聊天(本会话广播)',
|
|
1659
|
+
'aria-expanded': open,
|
|
1660
|
+
disabled,
|
|
1661
|
+
onClick: () => setOpen((value) => !value),
|
|
1662
|
+
}, '💬', h('span', { className: 'w2mChat_label' }, '编队聊天'), unseenCount > 0 && !open ? h('span', { className: 'w2mChat_unseen' }, String(unseenCount)) : null),
|
|
1663
|
+
open
|
|
1664
|
+
? h('div', { className: 'w2mChat_panel' + (chatFull || viewMode === true ? ' w2mChat_panelFull' : '') + (viewMode === true ? ' w2mChat_view' : ''), role: 'dialog', 'aria-label': '编队聊天' },
|
|
1665
|
+
h('div', { className: 'w2mChat_head' },
|
|
1666
|
+
h('select', {
|
|
1667
|
+
className: 'w2mChat_to',
|
|
1668
|
+
value: toRecipient,
|
|
1669
|
+
title: '收件人',
|
|
1670
|
+
onChange: (event) => setToRecipient(event.target.value),
|
|
1671
|
+
}, [
|
|
1672
|
+
h('option', { key: '__all__', value: 'all' }, '全部'),
|
|
1673
|
+
...(Array.isArray(roster?.machines) ? roster.machines.filter((machine) => machine.online === true && typeof machine.machine_id === 'string').map((machine) => h('option', { key: machine.machine_id, value: machine.machine_id }, machine.name ?? machine.machine_id)) : []),
|
|
1674
|
+
]),
|
|
1675
|
+
h('span', null, '编队聊天'),
|
|
1676
|
+
usageText === null ? null : h('span', { className: 'w2mChat_usage' }, usageText),
|
|
1677
|
+
h('button', { type: 'button', className: 'w2mChat_close', title: '关闭', onClick: () => setOpen(false) }, '×')),
|
|
1678
|
+
tasks.length === 0 ? null : h('div', { className: 'w2mChat_board' }, [
|
|
1679
|
+
h('div', { key: 'title', className: 'w2mChat_boardTitle' }, '任务看板'),
|
|
1680
|
+
h('div', { key: 'counts', className: 'w2mChat_boardCounts' }, (() => { const b = { ready: 0, active: 0, waiting: 0, blocked: 0 }; for (const task of tasks) { const status = String(task?.status ?? ''); if (status === 'done' || status === 'completed') continue; if (status === 'failed' || status === 'cancelled' || status === 'refused') b.blocked += 1; else if (status === 'waiting' || (Array.isArray(task?.waiting_on) && task.waiting_on.length > 0)) b.waiting += 1; else if (status === 'active' || status === 'queued' || status === 'offered' || status === 'running') b.active += 1; else b.ready += 1; } return '准备 ' + b.ready + ' · 进行 ' + b.active + ' · 等待 ' + b.waiting + ' · 阻塞 ' + b.blocked; })()),
|
|
1681
|
+
...tasks.slice(0, 6).map((task) => h('div', { key: String(task?.task_id ?? Math.random()), className: 'w2mChat_boardRow' },
|
|
1682
|
+
`${String(task?.task_id ?? '').slice(0, 8)} ${task?.mode ?? '?'} ${task?.status ?? '?'}`)),
|
|
1683
|
+
]),
|
|
1684
|
+
h('div', { className: 'w2mChat_list', onMouseDownCapture: (event) => { if (event.button === 0) event.stopPropagation(); }, onCopy: (event) => event.stopPropagation() },
|
|
1685
|
+
error !== null ? h('div', { className: 'w2mChat_error' }, error) : null,
|
|
1686
|
+
messages.length === 0
|
|
1687
|
+
? (error === null ? h('div', { className: 'w2mChat_empty' }, '本会话还没有消息') : null)
|
|
1688
|
+
: groupThreads(messages).flatMap((group, groupIndex) => [
|
|
1689
|
+
group.threadId === null ? null : h('div', { key: 'thread:' + group.threadId + ':' + groupIndex, className: 'w2mChat_threadHead' }, '#' + group.threadId),
|
|
1690
|
+
...group.messages.map((message) => {
|
|
1691
|
+
var kind = messageKind(message, localMachineId);
|
|
1692
|
+
return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind, onClick: () => { const selection = typeof window.getSelection === 'function' ? String(window.getSelection() ?? '') : ''; if (selection !== '') return; setReplyTo({ id: message?.message_id ?? null, threadId: message?.thread_id ?? null, text: typeof message?.text === 'string' ? message.text : '' }); } },
|
|
1693
|
+
h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : '', h('button', { key: 'copy', type: 'button', className: 'w2mChat_copy', title: '复制这条消息', onClick: (event) => { event.stopPropagation(); copyText(String(message?.text ?? '')); } }, '⧉'), message?.thread_id ? h('span', { className: 'w2mChat_thread' }, '#' + message.thread_id) : null),
|
|
1694
|
+
h('div', { className: 'w2mChat_text' }, message?.text ?? ''));
|
|
1695
|
+
}),
|
|
1696
|
+
].filter((node) => node !== null))),
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
chatFull ? h('div', { className: 'w2mChat_emojiRow' }, CHAT_EMOJIS.map((emoji) => h('button', { key: emoji, type: 'button', className: 'w2mChat_emoji', onClick: () => setText((prev) => prev + emoji) }, emoji))) : null,
|
|
1700
|
+
h('div', { className: 'w2mChat_compose' },
|
|
1701
|
+
replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
|
|
1702
|
+
h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
|
|
1703
|
+
h('button', { key: 'cancel', type: 'button', className: 'w2mChat_replyCancel', title: '取消回复', onClick: () => setReplyTo(null) }, '×'),
|
|
1704
|
+
]),
|
|
1705
|
+
h('textarea', {
|
|
1706
|
+
className: 'w2mChat_input',
|
|
1707
|
+
value: text,
|
|
1708
|
+
rows: 2,
|
|
1709
|
+
placeholder: '说点什么,发给本会话的机器…',
|
|
1710
|
+
disabled: busy,
|
|
1711
|
+
onChange: (event) => setText(event.target.value),
|
|
1712
|
+
onKeyDown: (event) => {
|
|
1713
|
+
if (event.key === 'Enter' && event.shiftKey !== true) {
|
|
1714
|
+
event.preventDefault();
|
|
1715
|
+
send();
|
|
1716
|
+
}
|
|
1717
|
+
},
|
|
1718
|
+
}),
|
|
1719
|
+
h('button', { type: 'button', className: 'w2mChat_shotButton', title: '截图并复制(Ctrl+Shift+P)', disabled: shotBusy, onClick: captureScreenshot }, shotBusy ? '截图…' : '📷'),
|
|
1720
|
+
h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
|
|
1721
|
+
: null);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* v1.4.2: the Node-code-execution capability switch.
|
|
1727
|
+
*
|
|
1728
|
+
* Three explicit decisions, no fourth: deny, temporary (24 h, expiring), permanent. The host owns the file
|
|
1729
|
+
* and the agent reads it before every task, so a choice here takes effect on the next dispatch; the control
|
|
1730
|
+
* never claims the choice succeeded until the host's own answer says so.
|
|
1731
|
+
*/
|
|
1732
|
+
function ExecCapability({ t }) {
|
|
1733
|
+
const [open, setOpen] = React.useState(false);
|
|
1734
|
+
const [capability, setCapability] = React.useState(null);
|
|
1735
|
+
const [error, setError] = React.useState(null);
|
|
1736
|
+
const [busy, setBusy] = React.useState(false);
|
|
1737
|
+
const anchorRef = React.useRef(null);
|
|
1738
|
+
const menuRef = React.useRef(null);
|
|
1739
|
+
const [placement, setPlacement] = React.useState(null);
|
|
1740
|
+
|
|
1741
|
+
React.useEffect(() => {
|
|
1742
|
+
const controller = new AbortController();
|
|
1743
|
+
readCapability(controller.signal).then((result) => {
|
|
1744
|
+
if (controller.signal.aborted) return;
|
|
1745
|
+
if (result.ok) { setCapability(result.capacity); setError(null); }
|
|
1746
|
+
else setError(result.routeMissing ? t('capRouteMissing') : result.error);
|
|
1747
|
+
});
|
|
1748
|
+
return () => controller.abort();
|
|
1749
|
+
}, []);
|
|
1750
|
+
|
|
1751
|
+
React.useEffect(() => {
|
|
1752
|
+
if (!open) return undefined;
|
|
1753
|
+
const reposition = () => {
|
|
1754
|
+
const anchor = anchorRef.current;
|
|
1755
|
+
if (anchor === null) return;
|
|
1756
|
+
const rect = anchor.getBoundingClientRect();
|
|
1757
|
+
const width = Math.min(280, Math.max(240, Math.round(rect.width) + 120));
|
|
1758
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
|
|
1759
|
+
const top = Math.max(8, rect.top - 220);
|
|
1760
|
+
setPlacement({ left, width, top });
|
|
1761
|
+
};
|
|
1762
|
+
const onKeyDown = (event) => { if (event.key === 'Escape') { setOpen(false); anchorRef.current?.focus?.(); } };
|
|
1763
|
+
const onPointerDown = (event) => {
|
|
1764
|
+
if (anchorRef.current?.contains(event.target) === true) return;
|
|
1765
|
+
if (menuRef.current?.contains(event.target) === true) return;
|
|
1766
|
+
setOpen(false);
|
|
1767
|
+
};
|
|
1768
|
+
reposition();
|
|
1769
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
1770
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
1771
|
+
return () => {
|
|
1772
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
1773
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
1774
|
+
};
|
|
1775
|
+
}, [open]);
|
|
1776
|
+
|
|
1777
|
+
const active = capability?.effective === true;
|
|
1778
|
+
const stateName = active ? (capability.state === 'temporary' ? t('capTemporary') : t('capPermanent')) : t('capDenied');
|
|
1779
|
+
const hours = capability?.remaining_ms === null || capability?.remaining_ms === undefined
|
|
1780
|
+
? null
|
|
1781
|
+
: Math.max(0, Math.round(capability.remaining_ms / 3_600_000));
|
|
1782
|
+
const title = capability === null
|
|
1783
|
+
? t('capMenuLabel')
|
|
1784
|
+
: active ? t('capWarning') : t('capDenied');
|
|
1785
|
+
|
|
1786
|
+
const choose = (state, ttlMs) => {
|
|
1787
|
+
setBusy(true);
|
|
1788
|
+
writeCapability(state, ttlMs).then((result) => {
|
|
1789
|
+
setBusy(false);
|
|
1790
|
+
if (result.ok) { setCapability(result.capacity); setError(null); setOpen(false); return; }
|
|
1791
|
+
setError(result.routeMissing ? t('capRouteMissing') : result.error);
|
|
1792
|
+
});
|
|
1793
|
+
};
|
|
1794
|
+
|
|
1795
|
+
const menu = open && placement !== null
|
|
1796
|
+
? h('div', {
|
|
1797
|
+
className: 'w2mCap_menu',
|
|
1798
|
+
role: 'menu',
|
|
1799
|
+
'aria-label': t('capMenuLabel'),
|
|
1800
|
+
ref: menuRef,
|
|
1801
|
+
style: { left: placement.left + 'px', top: placement.top + 'px', width: placement.width + 'px' },
|
|
1802
|
+
}, [
|
|
1803
|
+
h('div', { key: 'warn', className: 'w2mCap_note', role: 'note' }, t('capWarning')),
|
|
1804
|
+
h('button', { key: 'deny', type: 'button', className: 'w2mCap_option', 'data-active': active ? 'false' : 'true', onClick: () => choose('denied') }, t('capDenied')),
|
|
1805
|
+
h('button', { key: 'temp', type: 'button', className: 'w2mCap_option', 'data-active': capability?.state === 'temporary' ? 'true' : 'false', onClick: () => choose('temporary', 24 * 60 * 60 * 1000) }, t('capTemporary')),
|
|
1806
|
+
h('button', { key: 'perm', type: 'button', className: 'w2mCap_option', 'data-active': capability?.state === 'permanent' ? 'true' : 'false', onClick: () => choose('permanent') }, t('capPermanent')),
|
|
1807
|
+
error === null ? null : h('div', { key: 'err', className: 'w2mCap_note', role: 'status' }, error),
|
|
1808
|
+
])
|
|
1809
|
+
: null;
|
|
1810
|
+
|
|
1811
|
+
return h(React.Fragment, null, [
|
|
1812
|
+
h('button', {
|
|
1813
|
+
key: 'trigger',
|
|
1814
|
+
type: 'button',
|
|
1815
|
+
ref: anchorRef,
|
|
1816
|
+
className: 'w2mCap_trigger',
|
|
1817
|
+
'data-on': active ? 'true' : 'false',
|
|
1818
|
+
'aria-label': t('capMenuLabel'),
|
|
1819
|
+
'aria-haspopup': 'menu',
|
|
1820
|
+
'aria-expanded': open,
|
|
1821
|
+
'aria-busy': busy,
|
|
1822
|
+
title,
|
|
1823
|
+
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
1824
|
+
}, [
|
|
1825
|
+
h('span', { key: 'dot', className: 'w2mCap_dot', 'data-on': active ? 'true' : 'false', 'aria-hidden': 'true' }),
|
|
1826
|
+
h('span', { key: 'label' }, t('capLabel', { state: stateName }) + (hours === null ? '' : ' · ' + t('capRemaining', { hours }))),
|
|
1827
|
+
]),
|
|
1828
|
+
menu,
|
|
1829
|
+
]);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* Client plugin body: install the stylesheet, register the dictionaries, take the seat.
|
|
1834
|
+
*
|
|
1835
|
+
* @param ctx - the client root context.
|
|
1836
|
+
*/
|
|
1837
|
+
function apply(ctx) {
|
|
1838
|
+
try {
|
|
1839
|
+
mount(ctx);
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
// The audit that decides whether the app boots reports the entry's name and the word
|
|
1842
|
+
// "failed" and nothing else, so this is where the cause has to be said out loud. The
|
|
1843
|
+
// renderer console at error level is the channel the desktop crash log already carries
|
|
1844
|
+
// (`--- renderer console (error level, oldest first) ---`), which makes this the one place a
|
|
1845
|
+
// browser half can put its own diagnosis next to the failure it caused.
|
|
1846
|
+
console.error(
|
|
1847
|
+
'@twinsearth/w2m-dsh-plugin: the client entry failed to activate, so this page will not boot: ' +
|
|
1848
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
1849
|
+
error,
|
|
1850
|
+
);
|
|
1851
|
+
// Deliberately rethrown. A swallowed failure would boot a page that looks healthy with no
|
|
1852
|
+
// target control in it — the silent-half-arrival failure this package has already shipped
|
|
1853
|
+
// once (v0.5.4) and refuses to ship again.
|
|
1854
|
+
throw error;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* The body of {@link apply}, wrapped so no failure can escape without a diagnostic.
|
|
1860
|
+
*
|
|
1861
|
+
* `slots.inject` (not a bare `register`) is what the slot's declaration requires of an entry
|
|
1862
|
+
* from a package that does not own the seat: the parent declared `conversation.input.left`, and
|
|
1863
|
+
* an entry may only register into a key it has announced. The returned `inject` factory is
|
|
1864
|
+
* called per render position with that session's id, and its keys become this component's props
|
|
1865
|
+
* — which is how the control knows which session's selection it is showing.
|
|
1866
|
+
*
|
|
1867
|
+
* The stylesheet is installed here rather than at module scope, so its lifetime is the plugin's:
|
|
1868
|
+
* `ctx.effect` disposes it on unload, and nothing a bundle factory does at materialization can
|
|
1869
|
+
* outlive the entry that materialized it.
|
|
1870
|
+
*
|
|
1871
|
+
* @param ctx - the client root context.
|
|
1872
|
+
*/
|
|
1873
|
+
function mount(ctx) {
|
|
1874
|
+
// `ctx.slots` is legal to read because {@link inject} declares it — and declaring it is also
|
|
1875
|
+
// what makes this entry wait for the seat instead of racing it. The shape check is for callers
|
|
1876
|
+
// that are not a Cordis fiber at all (`test/client-bundle.test.mjs`): a missing or foreign
|
|
1877
|
+
// registry is a control that cannot be mounted, and a control that cannot be mounted must not
|
|
1878
|
+
// be allowed to take the whole page down with it.
|
|
1879
|
+
const slots = ctx?.slots;
|
|
1880
|
+
if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') {
|
|
1881
|
+
console.error(
|
|
1882
|
+
'@twinsearth/w2m-dsh-plugin: the client half found no slot registry on this context, so the ' +
|
|
1883
|
+
'dispatch-target control is not mounted; the rest of the page is unaffected',
|
|
1884
|
+
);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
const styleDisposer = injectStyle(typeof document === 'undefined' ? null : document);
|
|
1888
|
+
if (styleDisposer !== null && typeof ctx.effect === 'function') {
|
|
1889
|
+
ctx.effect(() => styleDisposer, 'w2m: composer target control styles');
|
|
1890
|
+
}
|
|
1891
|
+
// Dictionaries as early as the composition allows: a namespace registered after the UI has
|
|
1892
|
+
// mounted is still picked up, and the service is optional, so nothing here may block.
|
|
1893
|
+
const locale = readOptionalService(ctx, 'locale');
|
|
1894
|
+
registerDictionaries(ctx, locale);
|
|
1895
|
+
const t = makeTranslator(() => readOptionalService(ctx, 'locale'));
|
|
1896
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
1897
|
+
name: 'conversation.input.left',
|
|
1898
|
+
id: 'w2m-target',
|
|
1899
|
+
// After the shipped permission control and the plan control, which sit earlier in the
|
|
1900
|
+
// composer row: the target is a per-session choice like them, and the row reads left to
|
|
1901
|
+
// right as "attach, permissions, plan, where it runs".
|
|
1902
|
+
order: 40,
|
|
1903
|
+
label: 'dispatch target',
|
|
1904
|
+
inject: (sessionId) => ({
|
|
1905
|
+
sessionId,
|
|
1906
|
+
t,
|
|
1907
|
+
}),
|
|
1908
|
+
}, TargetSelect)); // v1.4.2: the Node-code-execution switch, one seat to the right of the chat panel.
|
|
1909
|
+
// v1.7.5-beta.1: the GPW folder panel is a floating button, so it is its own slot entry rather than a
|
|
1910
|
+
// row inside the composer; the slot fixes its mount lifetime, the CSS fixes its corner.
|
|
1911
|
+
try {
|
|
1912
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
1913
|
+
name: 'conversation.input.left',
|
|
1914
|
+
id: 'w2m-gpw',
|
|
1915
|
+
order: 43,
|
|
1916
|
+
label: 'GPW folders',
|
|
1917
|
+
inject: (sessionId) => ({ t, sessionId }),
|
|
1918
|
+
}, GpwLauncher));
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
console.error('@twinsearth/w2m-dsh-plugin: the GPW folder panel was not mounted; the rest of the page is unaffected', error);
|
|
1921
|
+
}
|
|
1922
|
+
// v1.9.3: the left-sidebar GPW entry (always visible, no close affordance).
|
|
1923
|
+
// A missing seat must not take the page down: this is an extra path to the same panel.
|
|
1924
|
+
try {
|
|
1925
|
+
slots.inject('sidebar.panellist', () => slots.register({
|
|
1926
|
+
name: 'sidebar.panellist',
|
|
1927
|
+
id: 'w2m-gpw',
|
|
1928
|
+
order: 4,
|
|
1929
|
+
label: () => 'GPW 文件夹',
|
|
1930
|
+
inject: () => ({}),
|
|
1931
|
+
}, GpwSidebarIcon));
|
|
1932
|
+
} catch (error) {
|
|
1933
|
+
console.error('@twinsearth/w2m-dsh-plugin: the sidebar GPW entry was not mounted; the composer and launcher still work', error);
|
|
1934
|
+
}
|
|
1935
|
+
// v1.9.6: the page the sidebar row switches to; the key must match the panellist id.
|
|
1798
1936
|
try {
|
|
1799
|
-
slots.inject('
|
|
1800
|
-
name: '
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
label: 'GPW folders',
|
|
1804
|
-
inject: (sessionId) => ({ t, sessionId }),
|
|
1805
|
-
}, GpwLauncher));
|
|
1806
|
-
} catch (error) {
|
|
1807
|
-
console.error('@twinsearth/w2m-dsh-plugin: the GPW folder panel was not mounted; the rest of the page is unaffected', error);
|
|
1808
|
-
}
|
|
1809
|
-
// v1.9.3: the left-sidebar GPW entry (always visible, no close affordance).
|
|
1810
|
-
// A missing seat must not take the page down: this is an extra path to the same panel.
|
|
1811
|
-
try {
|
|
1812
|
-
slots.inject('sidebar.footer.action', () => slots.register({
|
|
1813
|
-
name: 'sidebar.footer.action',
|
|
1814
|
-
id: 'w2m-gpw-sidebar',
|
|
1815
|
-
label: 'GPW folders',
|
|
1816
|
-
inject: () => ({}),
|
|
1817
|
-
}, GpwSidebarFooter));
|
|
1818
|
-
} catch (error) {
|
|
1819
|
-
console.error('@twinsearth/w2m-dsh-plugin: the sidebar GPW entry was not mounted; the composer and launcher still work', error);
|
|
1820
|
-
}
|
|
1821
|
-
// v1.9.3: check-update + auto-update on the plugin settings page.
|
|
1822
|
-
try {
|
|
1823
|
-
slots.inject('settings.section', () => slots.register({
|
|
1824
|
-
name: 'settings.section',
|
|
1825
|
-
id: 'w2m-update',
|
|
1826
|
-
order: 90,
|
|
1827
|
-
label: () => 'W2M 插件更新',
|
|
1828
|
-
inject: () => ({}),
|
|
1829
|
-
}, UpdateSettingsSection));
|
|
1830
|
-
} catch (error) {
|
|
1831
|
-
console.error('@twinsearth/w2m-dsh-plugin: the update settings section was not mounted; the composer button still works', error);
|
|
1832
|
-
}
|
|
1833
|
-
// v1.7.9: the inline pending prompt, mounted in the same composer row so an interception
|
|
1834
|
-
// asks allow/deny immediately instead of waiting silently in the panel.
|
|
1835
|
-
try {
|
|
1836
|
-
slots.inject('conversation.input.left', () => slots.register({
|
|
1837
|
-
name: 'conversation.input.left',
|
|
1838
|
-
id: 'w2m-gpw-prompt',
|
|
1839
|
-
order: 44,
|
|
1840
|
-
label: 'GPW pending prompt',
|
|
1841
|
-
inject: () => ({ t }),
|
|
1842
|
-
}, GpwPrompt));
|
|
1843
|
-
} catch (error) {
|
|
1844
|
-
console.error('@twinsearth/w2m-dsh-plugin: the GPW approval prompt was not mounted; the panel still works', error);
|
|
1845
|
-
}
|
|
1846
|
-
try {
|
|
1847
|
-
slots.inject('conversation.input.left', () => slots.register({
|
|
1848
|
-
name: 'conversation.input.left',
|
|
1849
|
-
id: 'w2m-capability',
|
|
1850
|
-
order: 42,
|
|
1851
|
-
label: 'node code execution',
|
|
1937
|
+
slots.inject('main', () => slots.register({
|
|
1938
|
+
name: 'main',
|
|
1939
|
+
key: 'w2m-gpw',
|
|
1940
|
+
label: () => 'GPW 文件夹策略',
|
|
1852
1941
|
inject: () => ({ t }),
|
|
1853
|
-
},
|
|
1942
|
+
}, GpwMainPage));
|
|
1854
1943
|
} catch (error) {
|
|
1855
|
-
console.error('@twinsearth/w2m-dsh-plugin: the
|
|
1944
|
+
console.error('@twinsearth/w2m-dsh-plugin: the GPW main page was not mounted', error);
|
|
1856
1945
|
}
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1946
|
+
// v1.9.3: check-update + auto-update on the plugin settings page.
|
|
1947
|
+
try {
|
|
1948
|
+
slots.inject('settings.section', () => slots.register({
|
|
1949
|
+
name: 'settings.section',
|
|
1950
|
+
id: 'w2m-gpw',
|
|
1951
|
+
order: 12,
|
|
1952
|
+
label: () => 'GPW 文件夹',
|
|
1953
|
+
inject: () => ({ t }),
|
|
1954
|
+
}, GpwSettingsPage));
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
console.error('@twinsearth/w2m-dsh-plugin: the GPW settings tab was not mounted', error);
|
|
1957
|
+
}
|
|
1958
|
+
// v1.9.4: the update controls belong on the plugin's own page (same page as Uninstall),
|
|
1959
|
+
// not only in the global settings list.
|
|
1960
|
+
try {
|
|
1961
|
+
slots.inject('plugins.bundle.config', () => slots.register({
|
|
1962
|
+
name: 'plugins.bundle.config',
|
|
1963
|
+
key: '@twinsearth/w2m-dsh-plugin',
|
|
1964
|
+
label: () => 'W2M 插件更新',
|
|
1965
|
+
inject: () => ({}),
|
|
1966
|
+
}, UpdateSettingsSection));
|
|
1967
|
+
} catch (error) {
|
|
1968
|
+
console.error('@twinsearth/w2m-dsh-plugin: the update settings section was not mounted; the composer button still works', error);
|
|
1969
|
+
}
|
|
1970
|
+
// v1.7.9: the inline pending prompt, mounted in the same composer row so an interception
|
|
1971
|
+
// asks allow/deny immediately instead of waiting silently in the panel.
|
|
1972
|
+
try {
|
|
1973
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
1974
|
+
name: 'conversation.input.left',
|
|
1975
|
+
id: 'w2m-gpw-prompt',
|
|
1976
|
+
order: 44,
|
|
1977
|
+
label: 'GPW pending prompt',
|
|
1978
|
+
inject: () => ({ t }),
|
|
1979
|
+
}, GpwPrompt));
|
|
1980
|
+
} catch (error) {
|
|
1981
|
+
console.error('@twinsearth/w2m-dsh-plugin: the GPW approval prompt was not mounted; the panel still works', error);
|
|
1982
|
+
}
|
|
1983
|
+
try {
|
|
1984
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
1985
|
+
name: 'conversation.input.left',
|
|
1986
|
+
id: 'w2m-capability',
|
|
1987
|
+
order: 42,
|
|
1988
|
+
label: 'node code execution',
|
|
1989
|
+
inject: () => ({ t }),
|
|
1990
|
+
}, ExecCapability));
|
|
1991
|
+
} catch (error) {
|
|
1992
|
+
console.error('@twinsearth/w2m-dsh-plugin: the capability switch was not mounted; the rest of the page is unaffected', error);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
// v1.9.6: fleet chat as a real conversation View tab (after Chat and Rules), rendering the
|
|
1998
|
+
// full chat page in the main workspace.
|
|
1863
1999
|
try {
|
|
1864
|
-
slots.inject('conversation.
|
|
1865
|
-
name: 'conversation.
|
|
2000
|
+
slots.inject('conversation.view', () => slots.register({
|
|
2001
|
+
name: 'conversation.view',
|
|
1866
2002
|
id: 'w2m-chat',
|
|
1867
|
-
order:
|
|
1868
|
-
label:
|
|
1869
|
-
inject: (sessionId) => ({ sessionId }),
|
|
2003
|
+
order: 2,
|
|
2004
|
+
label: () => '编队群组',
|
|
2005
|
+
inject: (sessionId) => ({ sessionId, viewMode: true }),
|
|
1870
2006
|
}, FleetChat));
|
|
1871
2007
|
} catch (error) {
|
|
1872
|
-
console.error(
|
|
1873
|
-
'@twinsearth/w2m-dsh-plugin: the fleet chat panel was not mounted; the rest of the page is unaffected',
|
|
1874
|
-
error,
|
|
1875
|
-
);
|
|
2008
|
+
console.error('@twinsearth/w2m-dsh-plugin: the fleet-chat view tab was not mounted', error);
|
|
1876
2009
|
}
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2010
|
+
try {
|
|
2011
|
+
slots.inject('shell.bottom', () => slots.register({
|
|
2012
|
+
name: 'shell.bottom',
|
|
2013
|
+
id: 'w2m-fleet-status',
|
|
2014
|
+
label: () => 'W2M 编队状态',
|
|
2015
|
+
inject: () => ({}),
|
|
2016
|
+
}, FleetStatusBar));
|
|
2017
|
+
} catch (error) {
|
|
2018
|
+
console.error('@twinsearth/w2m-dsh-plugin: the fleet status bar was not mounted', error);
|
|
2019
|
+
}
|
|
2020
|
+
// The fleet-chat panel: the same session-scoped seat, one slot after the target control.
|
|
2021
|
+
// Wrapped in its own try/catch on purpose — the target control is the primary half of this
|
|
2022
|
+
// bundle, and a chat panel that cannot mount must not take the page (or the app) down with it.
|
|
2023
|
+
try {
|
|
2024
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
2025
|
+
name: 'conversation.input.left',
|
|
2026
|
+
id: 'w2m-chat',
|
|
2027
|
+
order: 41,
|
|
2028
|
+
label: 'fleet chat',
|
|
2029
|
+
inject: (sessionId) => ({ sessionId }),
|
|
2030
|
+
}, FleetChat));
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
console.error(
|
|
2033
|
+
'@twinsearth/w2m-dsh-plugin: the fleet chat panel was not mounted; the rest of the page is unaffected',
|
|
2034
|
+
error,
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/**
|
|
2040
|
+
* Cordis services this entry depends on, read by the fiber that mounts this bundle.
|
|
2041
|
+
*
|
|
2042
|
+
* `slots` is the seat: the control is registered into `conversation.input.left` through the
|
|
2043
|
+
* slot registry, and a fiber that cannot see `slots` has nowhere to register. Declaring it is
|
|
2044
|
+
* not bookkeeping. Cordis resolves `ctx.<name>` against this list, so reading an undeclared
|
|
2045
|
+
* service **throws** (`cannot get property "slots" without inject`) even when the service is
|
|
2046
|
+
* provided — measured in production: this bundle shipped with `inject = []` while reading
|
|
2047
|
+
* `ctx.locale` and `ctx.slots`, the first read threw, and the app refused to boot.
|
|
2048
|
+
*
|
|
2049
|
+
* `locale` is deliberately absent, and that is the other half of the same rule: **every**
|
|
2050
|
+
* declared name is a required dependency, so an entry that declares a service the composition
|
|
2051
|
+
* does not provide never reaches `active` either — the audit then reports it as
|
|
2052
|
+
* `pending (waiting for service: locale)`, which is just as fatal. Optional carriers are read
|
|
2053
|
+
* with {@link readOptionalService} instead, and waited for by a child fiber when waiting is
|
|
2054
|
+
* useful ({@link registerDictionaries}). Keep those two decisions in step with the reads in
|
|
2055
|
+
* {@link mount}: `test/client-bundle.test.mjs` mounts this bundle behind a context that throws
|
|
2056
|
+
* on any undeclared service read, exactly as Cordis does.
|
|
2057
|
+
*/
|
|
2058
|
+
const inject = ['slots'];
|
|
2059
|
+
|
|
2060
|
+
exports.apply = apply;
|
|
2061
|
+
exports.inject = inject;
|
|
2062
|
+
exports.TargetSelect = TargetSelect;
|
|
2063
|
+
exports.GpwLauncher = GpwLauncher;
|
|
2064
|
+
exports.GpwPrompt = GpwPrompt;
|
|
2065
|
+
exports.FleetChat = FleetChat;
|
|
2066
|
+
exports.injectStyle = injectStyle;
|
|
2067
|
+
exports.makeTranslator = makeTranslator;
|
|
2068
|
+
return module.exports;
|
|
2069
|
+
},
|
|
2070
|
+
});
|