@twinsearth/w2m-dsh-plugin 1.7.1 → 1.7.3
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 +24 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.7.2/CHAT-CLIPBOARD-SHORTCUT.json +36 -0
- package/docs/acceptance/1.7.3/TOAST-AND-TOTALS.json +29 -0
- package/lib/client.js +56 -53
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/plugin/fleet-target.mjs +32 -12
- package/src/plugin/tools.mjs +1 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.7.
|
|
8
|
+
"version": "1.7.3"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.7.
|
|
15
|
+
"version": "1.7.3",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.7.
|
|
29
|
+
"version": "1.7.3"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.7.3] — 2026-10-25
|
|
11
|
+
|
|
12
|
+
**截图提示改为顶部浮层(5 秒自动消失、可关闭、不挤压输入框);全服计数标注来源 relay 并单列本编队。**
|
|
13
|
+
|
|
14
|
+
- 截图成功/失败提示不再插在输入区里:改为聊天面板顶部浮层(绝对定位,不占布局;连续截图只替换同一行,
|
|
15
|
+
不会越堆越多),**5 秒自动消失**,右侧 × 可立即关闭。
|
|
16
|
+
- 全服统计行改为「主 relay(地址)全服:X/Y 个编队 · A/B 台在线」,并新增一行「本编队:n/m 台在线」;
|
|
17
|
+
两者语义不同,不再混在一起看。
|
|
18
|
+
- relay 角色判定改为按**配置的主 relay**(不再按响应最快,备用 relay 偶然更快时会被误标成 primary)。
|
|
19
|
+
- 证据:`docs/acceptance/1.7.3/TOAST-AND-TOTALS.json`(fleet task `01M5ANYMVMSGGQV`;宿主截图 141,768 字节)。
|
|
20
|
+
## [1.7.2] — 2026-10-25
|
|
21
|
+
|
|
22
|
+
**按实测反馈调整编队聊天截图/复制:只复制不预览、快捷键 Ctrl+Shift+P、强制可选中。**
|
|
23
|
+
|
|
24
|
+
- 截图按钮不再在面板里生成缩略图/下载行:抓到画面后**只写入剪贴板**并给一行提示,聊天区域保持干净。
|
|
25
|
+
- 快捷键从 Ctrl+P 改为 **Ctrl+Shift+P(macOS 也支持 Cmd+Shift+P)**;普通 Ctrl+P 不再被劫持,
|
|
26
|
+
浏览器/应用的打印快捷键恢复原样。
|
|
27
|
+
- 选中&复制:`user-select:text!important` 应用到整个面板的所有子节点(不再只覆盖几个类),
|
|
28
|
+
并在消息列表上阻止宿主吞掉 mousedown/copy 事件,保证拖选与 Ctrl+C 生效;单条消息的 ⧉ 复制按钮
|
|
29
|
+
仍走「浏览器剪贴板 → 宿主剪贴板」回退链。
|
|
30
|
+
- 全服统计只认**配置的主 relay**:主 relay 不可达时不再把备用 relay 的数字(1/1 编队、2/5 在线)当"全服"显示,
|
|
31
|
+
而是显示「主 relay 不可达 · 全服统计不可用」。修复了在线编队/在线机器数量"错误"的观感来源。
|
|
32
|
+
- 证据:`docs/acceptance/1.7.2/CHAT-CLIPBOARD-SHORTCUT.json`(fleet task `01M5ANYMVMR3IOB`;
|
|
33
|
+
宿主截图实测 159,561 字节并复制成功)。
|
|
10
34
|
## [1.7.1] — 2026-10-25
|
|
11
35
|
|
|
12
36
|
**修复桌面壳下聊天截图/复制失败(permission denied)与插件更新"no supported installer available"。**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.7.2",
|
|
3
|
+
"case": "CHAT-CLIPBOARD-SHORTCUT",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-24T18:47:27.925Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "user feedback on v1.7.1: the screenshot must copy to the clipboard only (no thumbnail crowding the chat), the shortcut must be Ctrl+Shift+P (plain Ctrl+P printed the page), and the transcript must actually be selectable and copyable inside the DSH host",
|
|
11
|
+
"task_id": "01M5ANYMVMR3IOB",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"host_capture_smoke": {
|
|
15
|
+
"platform": "win32",
|
|
16
|
+
"ok": true,
|
|
17
|
+
"bytes": 159561,
|
|
18
|
+
"copied": true,
|
|
19
|
+
"note": "host-side capture copied the real screen to the Windows clipboard"
|
|
20
|
+
},
|
|
21
|
+
"assertions": {
|
|
22
|
+
"fleet_chat_capture": "3/3 pass",
|
|
23
|
+
"client_bundle": "26/26 pass",
|
|
24
|
+
"ui_contract": [
|
|
25
|
+
"screenshot renders no preview row; feedback is a toast only",
|
|
26
|
+
"shortcut is Ctrl+Shift+P / Cmd+Shift+P and plain Ctrl+P keeps the print dialog",
|
|
27
|
+
"user-select:text!important is applied to the whole panel (not just one node)",
|
|
28
|
+
"the list stops the host from swallowing mousedown/copy so selection survives"
|
|
29
|
+
],
|
|
30
|
+
"count_source": "whole-server totals come from the configured primary relay only; when it is unreachable the panel says unavailable instead of showing the backup relay numbers"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"fixes": [
|
|
34
|
+
"fleet-target: server stats chosen by configured rabbitUrl, not by first reachable relay; buildUiState exposes server_url/server_reachable; client renders unavailable"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.7.3",
|
|
3
|
+
"case": "TOAST-AND-TOTALS",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-24T19:25:35.677Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "screenshot feedback must not squeeze the compose box or stay forever: a top overlay that dismisses after 5s and can be closed; whole-server totals must name the relay they came from and not impersonate our fleet",
|
|
11
|
+
"task_id": "01M5ANYMVMSGGQV",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"host_capture_smoke": {
|
|
15
|
+
"platform": "win32",
|
|
16
|
+
"ok": true,
|
|
17
|
+
"bytes": 141768,
|
|
18
|
+
"copied": true
|
|
19
|
+
},
|
|
20
|
+
"assertions": {
|
|
21
|
+
"fleet_chat_capture": "3/3 pass",
|
|
22
|
+
"client_bundle": "26/26 pass",
|
|
23
|
+
"fleet_target": "76/76 pass",
|
|
24
|
+
"toast": "absolute overlay inside the panel (no layout shift), 5s timer, close button",
|
|
25
|
+
"totals": "labelled \"primary relay (<url>)\"; separate \"this fleet: n/m online\" line",
|
|
26
|
+
"roles": "primary role follows the configured rabbitUrl, not the fastest response"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -115,6 +115,9 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
115
115
|
relayUnfold: '收起折叠的 relay',
|
|
116
116
|
relayTemporary: '临时 Relay 节点(回退配置)',
|
|
117
117
|
globalSummary: '全服:{fleetsOnline}/{fleets} 个编队 · {online}/{total} 台在线',
|
|
118
|
+
globalSummaryWithSource: '主 relay({source})全服:{fleetsOnline}/{fleets} 个编队 · {online}/{total} 台在线',
|
|
119
|
+
fleetSummary: '本编队:{online}/{total} 台在线',
|
|
120
|
+
globalUnreachable: '主 relay 不可达({url})· 全服统计不可用',
|
|
118
121
|
updateButton: '检查插件更新',
|
|
119
122
|
updateChecking: '正在检查 npm…',
|
|
120
123
|
updating: '正在更新…',
|
|
@@ -153,6 +156,9 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
153
156
|
relayUnfold: 'collapse the folded relays',
|
|
154
157
|
relayTemporary: 'temporary relay nodes (fallback config)',
|
|
155
158
|
globalSummary: 'fleets {fleetsOnline}/{fleets} · machines {online}/{total} online',
|
|
159
|
+
globalSummaryWithSource: 'primary relay ({source}) · fleets {fleetsOnline}/{fleets} · machines {online}/{total}',
|
|
160
|
+
fleetSummary: 'this fleet: {online}/{total} online',
|
|
161
|
+
globalUnreachable: 'primary relay unreachable ({url}) · server totals unavailable',
|
|
156
162
|
updateButton: 'check for plugin update',
|
|
157
163
|
updateChecking: 'checking npm…',
|
|
158
164
|
updating: 'updating…',
|
|
@@ -395,14 +401,13 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
395
401
|
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
396
402
|
'.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}',
|
|
397
403
|
// v1.6.10: the transcript is selectable text; screenshots get a preview and a copy affordance.
|
|
398
|
-
'.w2mChat_panel,.
|
|
404
|
+
'.w2mChat_panel,.w2mChat_panel *{user-select:text!important;-webkit-user-select:text!important;cursor:text}',
|
|
405
|
+
'.w2mChat_copy{cursor:pointer!important}',
|
|
399
406
|
'.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}',
|
|
400
407
|
'.w2mChat_copy:hover{color:var(--dsw-alias-label-primary)}',
|
|
401
408
|
'.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}',
|
|
402
|
-
'.
|
|
403
|
-
'.
|
|
404
|
-
'.w2mChat_shot{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
405
|
-
'.w2mChat_toast{padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
409
|
+
'.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)}',
|
|
410
|
+
'.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}',
|
|
406
411
|
'.w2mChat_text{white-space:pre-wrap;word-break:break-word}',
|
|
407
412
|
'.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}',
|
|
408
413
|
'.w2mChat_compose{display:flex;gap:8px;padding:8px 10px;border-top:.5px solid var(--dsw-alias-border-l2);align-items:flex-end}',
|
|
@@ -697,12 +702,20 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
697
702
|
h('span', { key: 'url', className: cls('relayUrl') }, String(item.url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '')),
|
|
698
703
|
h('span', { key: 'rtt', className: cls('relayRtt') }, item.rtt_ms === null || item.rtt_ms === undefined ? '—' : `${item.rtt_ms} ms`),
|
|
699
704
|
])),
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
705
|
+
// v1.7.3: name the relay the totals came from; "whole server" and "this fleet" are different
|
|
706
|
+
// numbers, and the panel used to leave the reader to guess which one it was showing.
|
|
707
|
+
relayFold.server === null || relayFold.server === undefined
|
|
708
|
+
? (relayFold.server_reachable === false
|
|
709
|
+
? h('div', { key: 'globalUnreachable', className: cls('relayHint') }, t('globalUnreachable', { url: String(relayFold.server_url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '') || '?' }))
|
|
710
|
+
: null)
|
|
711
|
+
: h('div', { key: 'global', className: cls('relayHint') }, t('globalSummaryWithSource', {
|
|
712
|
+
source: String(relayFold.server_url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '') || '?',
|
|
713
|
+
fleetsOnline: relayFold.server.fleets_online ?? '—',
|
|
714
|
+
fleets: relayFold.server.fleets_total ?? '—',
|
|
715
|
+
online: relayFold.server.devices_online ?? '—',
|
|
716
|
+
total: relayFold.server.devices_total ?? '—',
|
|
717
|
+
})),
|
|
718
|
+
h('div', { key: 'fleet', className: cls('relayHint') }, t('fleetSummary', { online: state?.fleet?.online ?? '—', total: state?.fleet?.total ?? '—' })),
|
|
706
719
|
h(PluginUpdate, { key: 'update', t }),
|
|
707
720
|
relayFold.hidden_count > 0 && relayOpen === false
|
|
708
721
|
? h('div', { key: 'hint', className: cls('relayHint') }, t('relayFold', { count: relayFold.hidden_count }))
|
|
@@ -825,11 +838,11 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
825
838
|
const [error, setError] = React.useState(null);
|
|
826
839
|
const [busy, setBusy] = React.useState(false);
|
|
827
840
|
const [localMachineId, setLocalMachineId] = React.useState(null);
|
|
828
|
-
// v1.
|
|
829
|
-
// clipboard; the relay's chat payload stays text, so an image cannot be broadcast as a message.
|
|
830
|
-
const [shot, setShot] = React.useState(null);
|
|
841
|
+
// v1.7.2: screenshot state is clipboard-only (no preview); `toast` is the only visible feedback.
|
|
831
842
|
const [shotBusy, setShotBusy] = React.useState(false);
|
|
832
843
|
const [toast, setToast] = React.useState(null);
|
|
844
|
+
// v1.6.10: screenshot support (button + Ctrl+P). The blob is previewed and copied to the
|
|
845
|
+
// clipboard; the relay's chat payload stays text, so an image cannot be broadcast as a message.
|
|
833
846
|
|
|
834
847
|
const load = React.useCallback(async (signal) => {
|
|
835
848
|
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
@@ -970,13 +983,20 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
970
983
|
// v1.7.1: screenshot with a host fallback. The renderer path (getDisplayMedia) is tried first;
|
|
971
984
|
// when the host forbids it -- the DSH desktop shell does -- the panel captures on the host and
|
|
972
985
|
// receives JPEG base64, so the button and Ctrl+P work in both places.
|
|
986
|
+
// v1.7.2: capture and COPY only -- no thumbnail in the panel (the chat got too cramped), and no
|
|
987
|
+
// print-dialog shortcut stealing. The renderer path is tried first; the DSH desktop shell denies
|
|
988
|
+
// getDisplayMedia, so the host route captures and copies on the host side instead.
|
|
973
989
|
const captureScreenshot = React.useCallback(async () => {
|
|
974
990
|
if (shotBusy) return undefined;
|
|
975
991
|
setShotBusy(true); setToast(null);
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
992
|
+
const copyImage = async (blob) => {
|
|
993
|
+
try {
|
|
994
|
+
if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
|
|
995
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
} catch { /* host fallback below */ }
|
|
999
|
+
return false;
|
|
980
1000
|
};
|
|
981
1001
|
try {
|
|
982
1002
|
if (typeof navigator?.mediaDevices?.getDisplayMedia === 'function') {
|
|
@@ -992,15 +1012,8 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
992
1012
|
canvas.height = video.videoHeight || 720;
|
|
993
1013
|
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
994
1014
|
const blob = await new Promise((resolve) => { canvas.toBlob(resolve, 'image/png'); });
|
|
995
|
-
if (blob !== null) {
|
|
996
|
-
|
|
997
|
-
try {
|
|
998
|
-
if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
|
|
999
|
-
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
1000
|
-
copied = true;
|
|
1001
|
-
}
|
|
1002
|
-
} catch { /* host copies below */ }
|
|
1003
|
-
showBlob(blob, copied, '浏览器');
|
|
1015
|
+
if (blob !== null && await copyImage(blob) === true) {
|
|
1016
|
+
setToast('截图已复制到剪贴板');
|
|
1004
1017
|
return undefined;
|
|
1005
1018
|
}
|
|
1006
1019
|
} catch { /* the host fallback below is the whole point */ }
|
|
@@ -1008,21 +1021,9 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1008
1021
|
}
|
|
1009
1022
|
const response = await fetch(CAPTURE_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ copy: true }) });
|
|
1010
1023
|
const body = await response.json().catch(() => null);
|
|
1011
|
-
if (response.ok !== true || body?.ok !== true
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
const binary = atob(body.data_base64);
|
|
1015
|
-
const bytes = new Uint8Array(binary.length);
|
|
1016
|
-
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
1017
|
-
const blob = new Blob([bytes], { type: body.mime ?? 'image/jpeg' });
|
|
1018
|
-
let copied = body.copied === true;
|
|
1019
|
-
try {
|
|
1020
|
-
if (!copied && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
|
|
1021
|
-
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
1022
|
-
copied = true;
|
|
1023
|
-
}
|
|
1024
|
-
} catch { /* host already tried */ }
|
|
1025
|
-
showBlob(blob, copied, '宿主');
|
|
1024
|
+
if (response.ok !== true || body?.ok !== true) throw new Error(body?.error ?? ('HTTP ' + response.status));
|
|
1025
|
+
if (body.copied === true) setToast('截图已复制到剪贴板(宿主)');
|
|
1026
|
+
else setToast('截图已生成,但剪贴板不可用');
|
|
1026
1027
|
} catch (error) {
|
|
1027
1028
|
setToast('截图失败:' + (error instanceof Error ? error.message : String(error)));
|
|
1028
1029
|
} finally {
|
|
@@ -1031,14 +1032,14 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1031
1032
|
return undefined;
|
|
1032
1033
|
}, [shotBusy]);
|
|
1033
1034
|
|
|
1034
|
-
// v1.
|
|
1035
|
-
//
|
|
1035
|
+
// v1.7.2: Ctrl+Shift+P (Cmd+Shift+P) opens the capture while the panel is open. Plain Ctrl+P is left
|
|
1036
|
+
// alone: hijacking the browser/app print shortcut surprised testers and printed the page instead.
|
|
1036
1037
|
React.useEffect(() => {
|
|
1037
1038
|
if (!open) return undefined;
|
|
1038
1039
|
const onKeyDown = (event) => {
|
|
1039
1040
|
if (event.defaultPrevented) return;
|
|
1040
1041
|
const modifier = event.ctrlKey === true || event.metaKey === true;
|
|
1041
|
-
if (modifier && event.shiftKey
|
|
1042
|
+
if (modifier && event.shiftKey === true && event.altKey !== true && String(event.key).toLowerCase() === 'p') {
|
|
1042
1043
|
event.preventDefault();
|
|
1043
1044
|
captureScreenshot();
|
|
1044
1045
|
}
|
|
@@ -1047,6 +1048,14 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1047
1048
|
return () => document.removeEventListener('keydown', onKeyDown, true);
|
|
1048
1049
|
}, [open, captureScreenshot]);
|
|
1049
1050
|
|
|
1051
|
+
// v1.7.3: screenshot feedback is ephemeral: it fades after 5s and can be closed. It must not
|
|
1052
|
+
// sit in the compose row (that squeezed the input) and must not accumulate with more captures.
|
|
1053
|
+
React.useEffect(() => {
|
|
1054
|
+
if (toast === null) return undefined;
|
|
1055
|
+
const timer = setTimeout(() => setToast(null), 5000);
|
|
1056
|
+
return () => clearTimeout(timer);
|
|
1057
|
+
}, [toast]);
|
|
1058
|
+
|
|
1050
1059
|
const unseenCount = messages.reduce((count, message) => (Number.isFinite(message?.at_ms) && message.at_ms > lastSeenMs ? count + 1 : count), 0);
|
|
1051
1060
|
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
1052
1061
|
const usageText = usage === null ? null : `${Math.round((usage.used_24h_bytes ?? 0) / 1024)} KiB/24h · ${usage.messages ?? 0} 条`;
|
|
@@ -1096,7 +1105,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1096
1105
|
...tasks.slice(0, 6).map((task) => h('div', { key: String(task?.task_id ?? Math.random()), className: 'w2mChat_boardRow' },
|
|
1097
1106
|
`${String(task?.task_id ?? '').slice(0, 8)} ${task?.mode ?? '?'} ${task?.status ?? '?'}`)),
|
|
1098
1107
|
]),
|
|
1099
|
-
h('div', { className: 'w2mChat_list' },
|
|
1108
|
+
h('div', { className: 'w2mChat_list', onMouseDownCapture: (event) => { if (event.button === 0) event.stopPropagation(); }, onCopy: (event) => event.stopPropagation() },
|
|
1100
1109
|
error !== null ? h('div', { className: 'w2mChat_error' }, error) : null,
|
|
1101
1110
|
messages.length === 0
|
|
1102
1111
|
? (error === null ? h('div', { className: 'w2mChat_empty' }, '本会话还没有消息') : null)
|
|
@@ -1112,12 +1121,6 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1112
1121
|
|
|
1113
1122
|
|
|
1114
1123
|
h('div', { className: 'w2mChat_compose' },
|
|
1115
|
-
shot === null ? null : h('div', { key: 'shot', className: 'w2mChat_shotPreview' }, [
|
|
1116
|
-
h('img', { key: 'img', src: shot.url, alt: '截图预览' }),
|
|
1117
|
-
h('a', { key: 'dl', href: shot.url, download: 'w2m-screenshot-' + shot.at + '.png', className: 'w2mChat_shot' }, '下载'),
|
|
1118
|
-
h('button', { key: 'copy', type: 'button', className: 'w2mChat_copy', onClick: () => { try { navigator.clipboard?.write?.([new ClipboardItem({ 'image/png': shot.blob })]).then(() => setToast('已复制'), () => setToast('复制失败')); } catch { setToast('复制失败'); } } }, '复制'),
|
|
1119
|
-
]),
|
|
1120
|
-
toast === null ? null : h('div', { key: 'toast', className: 'w2mChat_toast' }, toast),
|
|
1121
1124
|
replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
|
|
1122
1125
|
h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
|
|
1123
1126
|
h('button', { key: 'cancel', type: 'button', className: 'w2mChat_replyCancel', title: '取消回复', onClick: () => setReplyTo(null) }, '×'),
|
|
@@ -1136,7 +1139,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1136
1139
|
}
|
|
1137
1140
|
},
|
|
1138
1141
|
}),
|
|
1139
|
-
h('button', { type: 'button', className: 'w2mChat_shotButton', title: '
|
|
1142
|
+
h('button', { type: 'button', className: 'w2mChat_shotButton', title: '截图并复制(Ctrl+Shift+P)', disabled: shotBusy, onClick: captureScreenshot }, shotBusy ? '截图…' : '📷'),
|
|
1140
1143
|
h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
|
|
1141
1144
|
: null);
|
|
1142
1145
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.3",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -585,6 +585,9 @@ export function buildUiState(deps) {
|
|
|
585
585
|
temporary: relays.temporary === true,
|
|
586
586
|
// v1.6.2: whole-relay overview (fleets + online machines) for the panel header line.
|
|
587
587
|
server: relays.server ?? null,
|
|
588
|
+
// v1.7.2: only the configured primary's totals; null means "unavailable", never the backup's.
|
|
589
|
+
server_url: relays.server_url ?? null,
|
|
590
|
+
server_reachable: relays.server_reachable !== false,
|
|
588
591
|
},
|
|
589
592
|
};
|
|
590
593
|
}
|
|
@@ -798,19 +801,36 @@ function mountTargetRoutes(connection, deps, status, logger) {
|
|
|
798
801
|
return null;
|
|
799
802
|
}
|
|
800
803
|
const probed = await probeRelays(urls);
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
804
|
+
// v1.7.3: the role follows the CONFIGURED primary relay, not whichever relay answered fastest.
|
|
805
|
+
// Ranking by latency let the backup masquerade as primary whenever it was quicker -- the panel then
|
|
806
|
+
// explained one relay while showing another relay's whole-server numbers.
|
|
807
|
+
const normalize = (value) => (typeof value === 'string' ? value.trim().replace(/\/+$/, '') : null);
|
|
808
|
+
const configuredPrimary = normalize(deps.cfg()?.rabbitUrl) ?? normalize(urls[0]);
|
|
809
|
+
const ordered = [...probed];
|
|
810
|
+
const primaryIndex = ordered.findIndex((item) => normalize(item.url) === configuredPrimary);
|
|
811
|
+
if (primaryIndex > 0) ordered.unshift(...ordered.splice(primaryIndex, 1));
|
|
812
|
+
const items = ordered.map((item) => ({
|
|
813
|
+
...item,
|
|
814
|
+
role: item.ok !== true ? 'offline' : (normalize(item.url) === configuredPrimary ? 'primary' : 'standby'),
|
|
815
|
+
}));
|
|
809
816
|
const folded = foldRelayItems(items, 3);
|
|
810
|
-
// v1.
|
|
811
|
-
|
|
812
|
-
//
|
|
813
|
-
|
|
817
|
+
// v1.7.2: whole-server totals come from the CONFIGURED primary relay only. v1.6.2 used "the first
|
|
818
|
+
// relay that answered", so when the primary was unreachable the panel showed the BACKUP relay's
|
|
819
|
+
// numbers (1/1 fleets, 2/5 machines) under the "whole server" label -- visibly wrong. An
|
|
820
|
+
// unreachable primary reports `server: null` + `server_reachable: false`, and the panel says the
|
|
821
|
+
// totals are unavailable instead of presenting another relay's fleet as the server's.
|
|
822
|
+
const primaryItem = configuredPrimary === null
|
|
823
|
+
? items.find((item) => item.ok === true) ?? null
|
|
824
|
+
: items.find((item) => normalize(item.url) === configuredPrimary) ?? null;
|
|
825
|
+
const serverReachable = primaryItem?.ok === true;
|
|
826
|
+
const primaryStats = serverReachable ? primaryItem.stats : null;
|
|
827
|
+
const withMark = {
|
|
828
|
+
...folded,
|
|
829
|
+
temporary: deps.cfg()?.relayTemporary === true,
|
|
830
|
+
server: primaryStats,
|
|
831
|
+
server_url: configuredPrimary ?? primaryItem?.url ?? null,
|
|
832
|
+
server_reachable: serverReachable,
|
|
833
|
+
};
|
|
814
834
|
relayCache = { at: now, value: withMark };
|
|
815
835
|
return withMark;
|
|
816
836
|
};
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -283,7 +283,7 @@ const MAX_STAGES = 16;
|
|
|
283
283
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
284
284
|
* overrides it at runtime for tests and packagers.
|
|
285
285
|
*/
|
|
286
|
-
const PLUGIN_VERSION = '1.7.
|
|
286
|
+
const PLUGIN_VERSION = '1.7.3';
|
|
287
287
|
|
|
288
288
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
289
289
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|