@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.42
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/README.md +1 -1
- package/README_zh.md +1 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/index.js +57 -9
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- package/lib/types/origin-claim.d.ts +2 -0
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +513 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1272 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
package/nexus-stage/src/main.ts
CHANGED
|
@@ -1,513 +1,513 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The A2A workbench boot: fetches /__dsh_a2a/state on a poll, feeds the
|
|
3
|
-
* merged workbench (the 2D planning board as the stage + directory/ledger/
|
|
4
|
-
* activity/plan rail, 0.6.2) and keeps the fault discipline
|
|
5
|
-
* (a fetch failure must never masquerade as an empty fleet — the badge is
|
|
6
|
-
* the honesty surface).
|
|
7
|
-
* @module nexus-stage/main
|
|
8
|
-
*/
|
|
9
|
-
import { createFaultReporter } from './fault'
|
|
10
|
-
import { createWorkbench, type StateBody } from './workbench'
|
|
11
|
-
import { createPlanningView } from './planning-view'
|
|
12
|
-
import { rosterFrames, rosterNameOf } from './planning-roster'
|
|
13
|
-
import { createSaveLoop, type LampState } from './layout-wire'
|
|
14
|
-
import { createCanvasWire } from './canvas-wire'
|
|
15
|
-
import { actionTeam, type CanvasAction } from './canvas-ops'
|
|
16
|
-
import './workbench.css'
|
|
17
|
-
|
|
18
|
-
const app = document.getElementById('app')!
|
|
19
|
-
|
|
20
|
-
// gh#9 §10: the host injects the control-plane key into the shell when
|
|
21
|
-
// apiKey is configured — stamp it on every /__dsh_a2a write so the GUI
|
|
22
|
-
// survives a keyed host (same-origin disclosure only, see the serve site).
|
|
23
|
-
// gh#40: a REMOTE face (LAN-bound test host, tunnel) serves the shell
|
|
24
|
-
// WITHOUT the injected key by design — the credential must not leak to any
|
|
25
|
-
// network fetcher. The operator still needs the panel there, so the key
|
|
26
|
-
// can be typed once: sessionStorage is browser-local (never transmitted in
|
|
27
|
-
// the page), stamped on the same X-API-Key header thereafter.
|
|
28
|
-
const storedKey = (() => { try { return window.sessionStorage.getItem('dsh_a2a_key') ?? '' } catch { return '' } })()
|
|
29
|
-
const CONTROL_KEY = (window as unknown as { __DSH_A2A_KEY__?: string }).__DSH_A2A_KEY__ ?? storedKey
|
|
30
|
-
const controlFetch = (path: string, init: RequestInit = {}): Promise<Response> =>
|
|
31
|
-
fetch(path, { ...init, headers: { ...(init.headers as Record<string, string> | undefined ?? {}), ...(CONTROL_KEY !== '' ? { 'X-API-Key': CONTROL_KEY } : {}) } })
|
|
32
|
-
|
|
33
|
-
// gh#40: key-entry affordance — mounts BEFORE the panel when the shell came
|
|
34
|
-
// keyless (remote face) and the control plane answers 401. One input, one
|
|
35
|
-
// submit; the stored key survives until the tab closes.
|
|
36
|
-
if (CONTROL_KEY === '') {
|
|
37
|
-
void controlFetch('/__dsh_a2a/owner-settings').then(res => {
|
|
38
|
-
if (res.status !== 401) return
|
|
39
|
-
const gate = document.createElement('div')
|
|
40
|
-
gate.className = 'nexus-keygate'
|
|
41
|
-
gate.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#fff;z-index:9999;font:14px/1.5 system-ui,sans-serif;color:#000'
|
|
42
|
-
const label = document.createElement('div')
|
|
43
|
-
label.textContent = '此面板经远程面打开(密钥不随页面下发)——输入控制密钥'
|
|
44
|
-
const input = document.createElement('input')
|
|
45
|
-
input.type = 'password'
|
|
46
|
-
input.placeholder = 'X-API-Key'
|
|
47
|
-
input.setAttribute('aria-label', '控制密钥')
|
|
48
|
-
const submit = document.createElement('button')
|
|
49
|
-
submit.type = 'button'
|
|
50
|
-
submit.textContent = '进入面板'
|
|
51
|
-
const err = document.createElement('div')
|
|
52
|
-
err.style.color = '#b00'
|
|
53
|
-
const tryKey = (): void => {
|
|
54
|
-
const v = input.value.trim()
|
|
55
|
-
if (v === '') return
|
|
56
|
-
void fetch('/__dsh_a2a/owner-settings', { headers: { 'X-API-Key': v } }).then(r => {
|
|
57
|
-
if (r.status === 401) { err.textContent = '密钥不对,再试一次'; return }
|
|
58
|
-
try { window.sessionStorage.setItem('dsh_a2a_key', v) } catch { /* blocked site data: keep in-page */ }
|
|
59
|
-
window.location.reload()
|
|
60
|
-
})
|
|
61
|
-
}
|
|
62
|
-
submit.addEventListener('click', tryKey)
|
|
63
|
-
input.addEventListener('keydown', ev => { if (ev.key === 'Enter') tryKey() })
|
|
64
|
-
gate.append(label, input, submit, err)
|
|
65
|
-
document.body.appendChild(gate)
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const faultBadge = document.createElement('div')
|
|
70
|
-
faultBadge.className = 'nexus-fault'
|
|
71
|
-
app.appendChild(faultBadge)
|
|
72
|
-
const { fault, clear: clearFault } = createFaultReporter(
|
|
73
|
-
{
|
|
74
|
-
show(message) {
|
|
75
|
-
faultBadge.textContent = message
|
|
76
|
-
faultBadge.style.display = 'block'
|
|
77
|
-
},
|
|
78
|
-
hide() {
|
|
79
|
-
faultBadge.style.display = 'none'
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
message => console.error(message),
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
// ─── Spatial planning board (restored 0.6.1; wiring from the pre-0.6.0 stage) ───
|
|
86
|
-
// 0.6.13: board node picks forward to the workbench chat drawer. The bench
|
|
87
|
-
// is created later (it embeds the board), so the dep is a late-bound slot.
|
|
88
|
-
let openNodeChat: ((id: string) => void) | undefined
|
|
89
|
-
// Per-device viewport memory: browser-local, one slot per screen bucket.
|
|
90
|
-
// Storage can be absent or throw (private windows, blocked site data) —
|
|
91
|
-
// every access is guarded and the board falls back to the host/fit path.
|
|
92
|
-
const VIEWPORT_KEY = 'dsh-a2a.viewport.'
|
|
93
|
-
const viewportStore = {
|
|
94
|
-
get(bucket: string): unknown {
|
|
95
|
-
try {
|
|
96
|
-
const raw = window.localStorage.getItem(VIEWPORT_KEY + bucket)
|
|
97
|
-
return raw === null ? undefined : JSON.parse(raw)
|
|
98
|
-
} catch {
|
|
99
|
-
return undefined
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
set(bucket: string, vp: { x: number; y: number; scale: number }): void {
|
|
103
|
-
try {
|
|
104
|
-
window.localStorage.setItem(VIEWPORT_KEY + bucket, JSON.stringify({ x: vp.x, y: vp.y, scale: vp.scale }))
|
|
105
|
-
} catch { /* presentation memory only */ }
|
|
106
|
-
},
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const planning = createPlanningView({
|
|
110
|
-
viewportStore,
|
|
111
|
-
onDirty: () => saveLoop.markDirty(),
|
|
112
|
-
onLampClick: () => saveLoop.retry(),
|
|
113
|
-
onNodePick: (id) => { openNodeChat?.(id) },
|
|
114
|
-
onCanvasAction: async (a) => {
|
|
115
|
-
const ok = await runCanvasAction(a)
|
|
116
|
-
// Settled: converge on truth either way — success absorbs the host's
|
|
117
|
-
// rich state; failure re-pulls so the rollback matches reality.
|
|
118
|
-
void poll()
|
|
119
|
-
return ok
|
|
120
|
-
},
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
const saveLoop = createSaveLoop({
|
|
124
|
-
snapshot: () => planning.snapshotDoc(),
|
|
125
|
-
send: (doc, opts) => controlFetch('/__dsh_a2a/canvas-layout', {
|
|
126
|
-
method: 'POST',
|
|
127
|
-
headers: { 'Content-Type': 'application/json' },
|
|
128
|
-
body: JSON.stringify({ action: 'save', layout: doc }),
|
|
129
|
-
cache: 'no-store',
|
|
130
|
-
keepalive: opts.keepalive,
|
|
131
|
-
}).then(async (r) => await r.json() as { ok: boolean; layout?: unknown; error?: string }),
|
|
132
|
-
schedule: (fn, ms) => { const t = setTimeout(fn, ms); return () => { clearTimeout(t) } },
|
|
133
|
-
now: Date.now,
|
|
134
|
-
onLamp: (state) => {
|
|
135
|
-
lampState = state
|
|
136
|
-
const now = new Date()
|
|
137
|
-
const hhmm = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0')
|
|
138
|
-
planning.setLamp(state, hhmm)
|
|
139
|
-
},
|
|
140
|
-
onAdopt: (normalized) => { planning.adoptExternalLayout(normalized) },
|
|
141
|
-
onHttpError: (message) => fault(message),
|
|
142
|
-
})
|
|
143
|
-
window.addEventListener('pagehide', () => saveLoop.flush())
|
|
144
|
-
|
|
145
|
-
const canvasWire = createCanvasWire({
|
|
146
|
-
send: async (body) => {
|
|
147
|
-
const r = await controlFetch('/__dsh_a2a/canvas', {
|
|
148
|
-
method: 'POST',
|
|
149
|
-
headers: { 'Content-Type': 'application/json' },
|
|
150
|
-
body: JSON.stringify(body),
|
|
151
|
-
cache: 'no-store',
|
|
152
|
-
})
|
|
153
|
-
return { status: r.status, body: await r.json() as { ok?: boolean; error?: string } }
|
|
154
|
-
},
|
|
155
|
-
onNotice: (kind, text) => planning.notice(kind, text),
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
/** One relayed op on a REMOTE peer's team. THIS host signs the envelope
|
|
159
|
-
* and forwards; the peer's link gate authorizes the write. */
|
|
160
|
-
async function fleetOp(peer: string, op: 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<boolean> {
|
|
161
|
-
const r = await controlFetch('/__dsh_a2a/fleet-canvas-op', {
|
|
162
|
-
method: 'POST',
|
|
163
|
-
headers: { 'Content-Type': 'application/json' },
|
|
164
|
-
body: JSON.stringify({ peer, op, name, ...(id !== undefined ? { id } : {}) }),
|
|
165
|
-
cache: 'no-store',
|
|
166
|
-
}).catch(() => undefined)
|
|
167
|
-
const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
|
|
168
|
-
if (body?.ok === true && op === 'remove' && body.removed === false) planning.notice('info', `属主那边已没有「${name}」,无需解散`)
|
|
169
|
-
if (body?.ok === true) return true
|
|
170
|
-
// Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
|
|
171
|
-
planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
|
|
172
|
-
return false
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function rosterAction(a: CanvasAction, name: string): Promise<boolean> {
|
|
176
|
-
const post = async (body: Record<string, unknown>): Promise<boolean> => {
|
|
177
|
-
const reply = await controlFetch('/__dsh_a2a/roster-op', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ...body }) }).catch(() => undefined)
|
|
178
|
-
const result = reply !== undefined ? await reply.json().catch(() => undefined) as { ok?: boolean; error?: string } | undefined : undefined
|
|
179
|
-
if (reply?.ok && result?.ok === true) return true
|
|
180
|
-
planning.notice('error', `roster 操作失败:${String(result?.error ?? (reply ? `HTTP ${reply.status}` : '无响应'))}`)
|
|
181
|
-
return false
|
|
182
|
-
}
|
|
183
|
-
if (a.type === 'create-team') return post({ op: 'create', ids: a.ids })
|
|
184
|
-
if (a.type === 'remove-team') return post({ op: 'dissolve' })
|
|
185
|
-
if (a.type === 'add-member' || a.type === 'remove-member') {
|
|
186
|
-
for (const id of a.ids) if (!(await post({ op: a.type, id }))) return false
|
|
187
|
-
return true
|
|
188
|
-
}
|
|
189
|
-
planning.notice('error', 'roster 不支持该操作')
|
|
190
|
-
return false
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function runCanvasAction(a: CanvasAction): Promise<boolean> {
|
|
194
|
-
// gh#41: a remote-frame drop forks to the fleet relay; so does dissolving
|
|
195
|
-
// a mirror frame (single owner + mirror: the owner's store is the truth).
|
|
196
|
-
if ((a.type === 'add-member' || a.type === 'remove-member') && a.fleetPeer !== undefined) {
|
|
197
|
-
const peer = a.fleetPeer
|
|
198
|
-
return (async () => {
|
|
199
|
-
for (const id of a.ids) if (!(await fleetOp(peer, a.type, a.team, id))) return false
|
|
200
|
-
return true
|
|
201
|
-
})()
|
|
202
|
-
}
|
|
203
|
-
if (a.type === 'remove-team' && a.fleetPeer !== undefined) return fleetOp(a.fleetPeer, 'remove', a.name)
|
|
204
|
-
// Only a trusted registry roster frame or an explicit create choice may trigger a local roster write.
|
|
205
|
-
if (a.roster === true) {
|
|
206
|
-
const roster = rosterNameOf(actionTeam(a))
|
|
207
|
-
if (roster !== undefined) return rosterAction(a, roster)
|
|
208
|
-
planning.notice('error', '无效的 roster 框名'); return Promise.resolve(false)
|
|
209
|
-
}
|
|
210
|
-
switch (a.type) {
|
|
211
|
-
case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
|
|
212
|
-
case 'add-member': return canvasWire.addMembers(a.team, a.ids)
|
|
213
|
-
case 'remove-member': return canvasWire.removeMembers(a.team, a.ids)
|
|
214
|
-
case 'remove-team': return canvasWire.removeTeam(a.name)
|
|
215
|
-
case 'reorder': return canvasWire.runRosterOps(a.team, a.ops)
|
|
216
|
-
case 'join-network':
|
|
217
|
-
case 'leave-network': return postNetworkJoin(a.type === 'join-network' ? 'join' : 'leave', a.id)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Network join/leave for one session (the panel-slim migration: this
|
|
222
|
-
* affordance lives on the planning view's node list). */
|
|
223
|
-
async function postNetworkJoin(action: 'join' | 'leave', id: string): Promise<boolean> {
|
|
224
|
-
const r = await controlFetch(`/__dsh_a2a/${action}`, {
|
|
225
|
-
method: 'POST',
|
|
226
|
-
headers: { 'Content-Type': 'application/json' },
|
|
227
|
-
body: JSON.stringify({ id }),
|
|
228
|
-
cache: 'no-store',
|
|
229
|
-
}).catch(() => undefined)
|
|
230
|
-
return r !== undefined && r.ok
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
let lampState: LampState = 'idle'
|
|
234
|
-
// The nodes face reports effective scoped assemblies; a mounted registration alone does not prove visibility.
|
|
235
|
-
let nodePromptMissing = new Set<string>()
|
|
236
|
-
let nodePromptCheckedAt = 0
|
|
237
|
-
let canvasFace = false
|
|
238
|
-
let lastLayoutJson: string | undefined
|
|
239
|
-
/** True once any layout answer (saved doc or an explicit null) has been
|
|
240
|
-
* seen; the board build waits for that answer on the very first poll. */
|
|
241
|
-
let layoutSeen = false
|
|
242
|
-
|
|
243
|
-
/** Fetch the persisted layout; throws on transport failure so the poll can
|
|
244
|
-
* hold the board build instead of silently building on ring seats. */
|
|
245
|
-
async function fetchLayout(): Promise<unknown> {
|
|
246
|
-
const res = await controlFetch('/__dsh_a2a/canvas-layout', { cache: 'no-store' })
|
|
247
|
-
if (!res.ok) throw new Error(`/__dsh_a2a/canvas-layout ${res.status}`)
|
|
248
|
-
return ((await res.json()) as { layout?: unknown }).layout ?? null
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ─── The workbench ───
|
|
252
|
-
const bench = createWorkbench({
|
|
253
|
-
now: () => Date.now(),
|
|
254
|
-
// gh#34: the owner three-key panel rides the control face.
|
|
255
|
-
async ownerSettingsRead() {
|
|
256
|
-
try {
|
|
257
|
-
const res = await controlFetch('/__dsh_a2a/owner-settings')
|
|
258
|
-
return await res.json()
|
|
259
|
-
} catch {
|
|
260
|
-
return { ok: false, error: 'unreachable' }
|
|
261
|
-
}
|
|
262
|
-
},
|
|
263
|
-
async ownerSettingsWrite(changes) {
|
|
264
|
-
try {
|
|
265
|
-
const res = await controlFetch('/__dsh_a2a/owner-settings', {
|
|
266
|
-
method: 'POST',
|
|
267
|
-
headers: { 'content-type': 'application/json' },
|
|
268
|
-
body: JSON.stringify(changes),
|
|
269
|
-
})
|
|
270
|
-
return await res.json()
|
|
271
|
-
} catch {
|
|
272
|
-
return { ok: false, error: 'unreachable' }
|
|
273
|
-
}
|
|
274
|
-
},
|
|
275
|
-
async abandon(taskId, reason) {
|
|
276
|
-
try {
|
|
277
|
-
const res = await controlFetch('/__dsh_a2a/tasks/abandon', {
|
|
278
|
-
method: 'POST',
|
|
279
|
-
headers: { 'content-type': 'application/json' },
|
|
280
|
-
body: JSON.stringify({ task_id: taskId, reason }),
|
|
281
|
-
})
|
|
282
|
-
return res.ok
|
|
283
|
-
} catch {
|
|
284
|
-
return false
|
|
285
|
-
}
|
|
286
|
-
},
|
|
287
|
-
copy(text) {
|
|
288
|
-
void navigator.clipboard?.writeText(text).catch(() => {})
|
|
289
|
-
},
|
|
290
|
-
// 0.6.10: the ↗ jump affordances (0.6.7 L1 broadcast / 0.6.8 L2 deep
|
|
291
|
-
// link) are REMOVED by ruling — 禁止任何跳转,全部使用响应式布局.
|
|
292
|
-
// The only reach primitive is the inline 快派 form: it works for local
|
|
293
|
-
// session teams, canvas teams and remote rows alike, and never leaves
|
|
294
|
-
// this page.
|
|
295
|
-
async cliHomeOptions(name, id) {
|
|
296
|
-
try {
|
|
297
|
-
const res = await controlFetch('/__dsh_a2a/canvas', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cli-home-options', name, id }) })
|
|
298
|
-
const body = await res.json() as { ok: boolean; homes?: string[]; unreadable?: number; held?: boolean; error?: string }
|
|
299
|
-
return { ...body, ok: res.ok && body.ok === true }
|
|
300
|
-
} catch (error) { return { ok: false, error: String(error) } }
|
|
301
|
-
},
|
|
302
|
-
async canvasOp(action, name, id, home) {
|
|
303
|
-
// Same control route the planning board's wire uses; the inspector's
|
|
304
|
-
// member writes are one id at a time, so no serial fan-out needed.
|
|
305
|
-
try {
|
|
306
|
-
const res = await controlFetch('/__dsh_a2a/canvas', {
|
|
307
|
-
method: 'POST',
|
|
308
|
-
headers: { 'content-type': 'application/json' },
|
|
309
|
-
body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }) }),
|
|
310
|
-
})
|
|
311
|
-
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
|
|
312
|
-
if (res.ok && body.ok !== false) return true
|
|
313
|
-
console.warn(`canvas ${action} ${name}: ${body.error ?? `HTTP ${res.status}`}`)
|
|
314
|
-
return false
|
|
315
|
-
} catch {
|
|
316
|
-
return false
|
|
317
|
-
}
|
|
318
|
-
},
|
|
319
|
-
async dispatchQuick(team, message) {
|
|
320
|
-
// Quick dispatch (0.6.9): host-face POST; the trunk answers with the
|
|
321
|
-
// canonical route shape ({ok, task_id, task_status, team, ...} on
|
|
322
|
-
// delivery) or {ok:false, error}.
|
|
323
|
-
try {
|
|
324
|
-
const res = await controlFetch('/__dsh_a2a/dispatch', {
|
|
325
|
-
method: 'POST',
|
|
326
|
-
headers: { 'content-type': 'application/json' },
|
|
327
|
-
body: JSON.stringify({ team, message }),
|
|
328
|
-
})
|
|
329
|
-
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; task_id?: string; reply?: string; error?: string }
|
|
330
|
-
return {
|
|
331
|
-
ok: res.ok && body.ok === true,
|
|
332
|
-
task_id: body.task_id,
|
|
333
|
-
reply: body.reply,
|
|
334
|
-
error: body.error ?? `HTTP ${res.status}`,
|
|
335
|
-
}
|
|
336
|
-
} catch (error) {
|
|
337
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
338
|
-
}
|
|
339
|
-
},
|
|
340
|
-
async sessionLog(id) {
|
|
341
|
-
// Inline transcript (0.6.11): read-only tail of a local session's own
|
|
342
|
-
// log from the host control face. Caps live server-side.
|
|
343
|
-
try {
|
|
344
|
-
const res = await controlFetch(`/__dsh_a2a/session-log?id=${encodeURIComponent(id)}`)
|
|
345
|
-
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; messages?: Array<{ role: string; text: string }>; error?: string }
|
|
346
|
-
// 0.6.16: error stays undefined on success — an unconditional
|
|
347
|
-
// `body.error ?? HTTP ${status}` painted "HTTP 200" on listing-shaped
|
|
348
|
-
// successes that lacked a messages field.
|
|
349
|
-
return { ok: res.ok && body.ok === true, messages: body.messages, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
|
|
350
|
-
} catch (error) {
|
|
351
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
352
|
-
}
|
|
353
|
-
},
|
|
354
|
-
async sessionLogList() {
|
|
355
|
-
// 0.6.12: host-scope readable-log listing — archived and never-joined
|
|
356
|
-
// sessions included; the network directory is not the reading gate.
|
|
357
|
-
try {
|
|
358
|
-
const res = await controlFetch('/__dsh_a2a/session-log')
|
|
359
|
-
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }
|
|
360
|
-
// count: how many logs the host holds — the list face caps at 50, so the
|
|
361
|
-
// directory can say it is showing 50 of N instead of silently truncating.
|
|
362
|
-
return { ok: res.ok && body.ok === true, sessions: body.sessions, count: typeof body.count === 'number' ? body.count : undefined, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
|
|
363
|
-
} catch (error) {
|
|
364
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
365
|
-
}
|
|
366
|
-
},
|
|
367
|
-
async remoteSessionLog(query) {
|
|
368
|
-
// 0.6.15: proxy read of a remote host's /a2a/log — by tracked peer url or
|
|
369
|
-
// by team handle (directory-resolved). The proxy is the only reader; the
|
|
370
|
-
// browser never dials the peer directly.
|
|
371
|
-
try {
|
|
372
|
-
const params = new URLSearchParams()
|
|
373
|
-
if (query.team !== undefined) params.set('team', query.team)
|
|
374
|
-
if (query.peer !== undefined) params.set('peer', query.peer)
|
|
375
|
-
if (query.id !== undefined) params.set('id', query.id)
|
|
376
|
-
if (query.limit !== undefined) params.set('limit', String(query.limit))
|
|
377
|
-
const res = await controlFetch(`/__dsh_a2a/remote-session-log?${params.toString()}`)
|
|
378
|
-
const body = (await res.json().catch(() => ({}))) as {
|
|
379
|
-
ok?: boolean
|
|
380
|
-
messages?: Array<{ role: string; text: string }>
|
|
381
|
-
sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>
|
|
382
|
-
error?: string
|
|
383
|
-
via?: string
|
|
384
|
-
}
|
|
385
|
-
return { ok: res.ok && body.ok === true, messages: body.messages, sessions: body.sessions, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`), via: body.via }
|
|
386
|
-
} catch (error) {
|
|
387
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
388
|
-
}
|
|
389
|
-
},
|
|
390
|
-
board: {
|
|
391
|
-
root: planning.root,
|
|
392
|
-
activate: () => planning.activate(),
|
|
393
|
-
deactivate: () => planning.deactivate(),
|
|
394
|
-
available: () => canvasFace,
|
|
395
|
-
},
|
|
396
|
-
})
|
|
397
|
-
app.appendChild(bench.root)
|
|
398
|
-
// 0.6.13: board node picks now resolve against the live bench state.
|
|
399
|
-
openNodeChat = (id) => { bench.openNodeChat(id) }
|
|
400
|
-
|
|
401
|
-
// Legacy entry compatibility: the old stage link ?mode=plan converges on the
|
|
402
|
-
// board stage — in the merged shell canvas mode is already the default while
|
|
403
|
-
// the host serves the canvas face, so this only guards the first poll.
|
|
404
|
-
if (new URLSearchParams(window.location.search).get('mode') === 'plan') {
|
|
405
|
-
const tryBoard = window.setInterval(() => {
|
|
406
|
-
if (canvasFace) {
|
|
407
|
-
bench.selectTab('board')
|
|
408
|
-
window.clearInterval(tryBoard)
|
|
409
|
-
}
|
|
410
|
-
}, 300)
|
|
411
|
-
window.setTimeout(() => window.clearInterval(tryBoard), 30_000)
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
async function poll(): Promise<void> {
|
|
415
|
-
try {
|
|
416
|
-
const res = await controlFetch('/__dsh_a2a/state', { cache: 'no-store' })
|
|
417
|
-
if (!res.ok) throw new Error(`/__dsh_a2a/state ${res.status}`)
|
|
418
|
-
const body = (await res.json()) as StateBody
|
|
419
|
-
if (Date.now() - nodePromptCheckedAt >= 15_000) {
|
|
420
|
-
nodePromptCheckedAt = Date.now()
|
|
421
|
-
try {
|
|
422
|
-
const nodes = await controlFetch('/__dsh_a2a/nodes', { cache: 'no-store' })
|
|
423
|
-
if (!nodes.ok) throw new Error(`nodes ${nodes.status}`)
|
|
424
|
-
const view = await nodes.json() as { bindings?: Array<{ session: string; mounted: unknown; effective: { core: boolean; mutable: boolean } | null }> }
|
|
425
|
-
nodePromptMissing = new Set((view.bindings ?? [])
|
|
426
|
-
.filter(b => b.mounted !== null && b.effective !== null && (b.effective.core === false || b.effective.mutable === false))
|
|
427
|
-
.map(b => b.session))
|
|
428
|
-
} catch { nodePromptMissing.clear() } // unavailable is unknown, not confirmed missing
|
|
429
|
-
}
|
|
430
|
-
const shown = { ...body, sessions: (body.sessions ?? []).map(s => ({ ...s, ...(nodePromptMissing.has(s.id) ? { nodePromptMissing: true } : {}) })) }
|
|
431
|
-
clearFault()
|
|
432
|
-
// canvasFace first: bench.update reads board.available() to pick the
|
|
433
|
-
// mode, so assigning it afterwards held the very first paint in data
|
|
434
|
-
// mode (board display:none, 0px wide) until the next 3s poll — and the
|
|
435
|
-
// board's first reconcile measured a zero-width view.
|
|
436
|
-
canvasFace = body.canvas !== undefined
|
|
437
|
-
bench.update(shown)
|
|
438
|
-
|
|
439
|
-
// ── The layout answer precedes the first reconcile on purpose ──
|
|
440
|
-
// The board's first build reads `layoutDoc` for saved positions and
|
|
441
|
-
// frame rects; every later pass keeps live geometry ("live rect wins").
|
|
442
|
-
// A layout landing AFTER the build therefore never displaces the ring
|
|
443
|
-
// seats — the refresh-reset-to-middle bug. Until any answer (doc or
|
|
444
|
-
// null) has been seen, a failed GET holds the board one poll rather
|
|
445
|
-
// than building on seats; after that, failures leave the last doc in
|
|
446
|
-
// place and the poll continues.
|
|
447
|
-
if (canvasFace) {
|
|
448
|
-
try {
|
|
449
|
-
const layout = await fetchLayout()
|
|
450
|
-
const layoutJson = JSON.stringify(layout ?? null)
|
|
451
|
-
if (layoutJson !== lastLayoutJson) {
|
|
452
|
-
lastLayoutJson = layoutJson
|
|
453
|
-
// Adopt poll-sourced layouts only while nothing is unsaved/in
|
|
454
|
-
// flight — otherwise a slow GET would revert positions the user
|
|
455
|
-
// is still dragging.
|
|
456
|
-
if ((lampState === 'idle' || lampState === 'saved') && !saveLoop.isBusy()) {
|
|
457
|
-
planning.adoptExternalLayout(layout)
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
layoutSeen = true
|
|
461
|
-
} catch (err) {
|
|
462
|
-
if (!layoutSeen) {
|
|
463
|
-
fault(`canvas layout 获取失败:${String(err)}`)
|
|
464
|
-
return
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
} else {
|
|
468
|
-
layoutSeen = true
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// ── Planning board shares this poll: same state payload. ──
|
|
472
|
-
// SessionLite needs `joined: boolean` (workbench rows carry `joined?`):
|
|
473
|
-
// tri-state → boolean without losing the unjoined node-list population.
|
|
474
|
-
const allSessions = (body.sessions ?? []).map(s => ({ ...s, joined: s.joined === true }))
|
|
475
|
-
const teams = [...(body.canvas?.teams ?? []), ...rosterFrames(body.registry?.teams ?? [], body.zone ?? '')]
|
|
476
|
-
const peers = (body.peers ?? []).filter(p => p !== null && typeof p === 'object' && typeof (p as { url?: unknown }).url === 'string')
|
|
477
|
-
const inFlight = (body.inFlight ?? []).filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { peer?: unknown }).peer === 'string' && Number.isFinite((r as { startedAt?: unknown }).startedAt))
|
|
478
|
-
const remoteTeams = (body.remote ?? [])
|
|
479
|
-
.filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { via?: unknown }).via === 'string')
|
|
480
|
-
.map(r => ({
|
|
481
|
-
team: r.team,
|
|
482
|
-
name: r.name,
|
|
483
|
-
via: r.via,
|
|
484
|
-
workspace: r.workspace,
|
|
485
|
-
...(r.unreachable === true ? { unreachable: true } : {}),
|
|
486
|
-
...(typeof r.origin === 'string' && r.origin !== '' ? { origin: r.origin } : {}),
|
|
487
|
-
...(Array.isArray(r.teams) && r.teams.length > 0 ? { teams: r.teams.filter((t): t is string => typeof t === 'string' && t !== '') } : {}),
|
|
488
|
-
}))
|
|
489
|
-
// gh#41: the peers' published canvas org rides the board (remote frames).
|
|
490
|
-
const peerOrg = (body.peerOrg as unknown[] ?? [])
|
|
491
|
-
.filter((r): r is { url?: unknown; org?: unknown; unreachable?: unknown } => r !== null && typeof r === 'object')
|
|
492
|
-
.filter(r => typeof r.url === 'string' && Array.isArray(r.org))
|
|
493
|
-
.map(r => ({
|
|
494
|
-
url: r.url as string,
|
|
495
|
-
...(r.unreachable === true ? { unreachable: true } : {}),
|
|
496
|
-
org: (r.org as unknown[])
|
|
497
|
-
.filter((e): e is { name?: unknown; team?: unknown; members?: unknown } => e !== null && typeof e === 'object')
|
|
498
|
-
.filter(e => typeof e.team === 'string' && e.team !== '')
|
|
499
|
-
.map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: e.team as string, members: Array.isArray(e.members) ? e.members.filter((m): m is string => typeof m === 'string') : [] })),
|
|
500
|
-
}))
|
|
501
|
-
planning.reconcile({ sessions: allSessions, teams, peerCount: peers.length, peers, inFlight, remoteTeams, peerOrg })
|
|
502
|
-
} catch (err) {
|
|
503
|
-
fault(`state 轮询失败:${String(err)}`)
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
void poll()
|
|
508
|
-
window.setInterval(() => {
|
|
509
|
-
if (!document.hidden) void poll()
|
|
510
|
-
}, 3000)
|
|
511
|
-
document.addEventListener('visibilitychange', () => {
|
|
512
|
-
if (!document.hidden) void poll()
|
|
513
|
-
})
|
|
1
|
+
/**
|
|
2
|
+
* The A2A workbench boot: fetches /__dsh_a2a/state on a poll, feeds the
|
|
3
|
+
* merged workbench (the 2D planning board as the stage + directory/ledger/
|
|
4
|
+
* activity/plan rail, 0.6.2) and keeps the fault discipline
|
|
5
|
+
* (a fetch failure must never masquerade as an empty fleet — the badge is
|
|
6
|
+
* the honesty surface).
|
|
7
|
+
* @module nexus-stage/main
|
|
8
|
+
*/
|
|
9
|
+
import { createFaultReporter } from './fault'
|
|
10
|
+
import { createWorkbench, type StateBody } from './workbench'
|
|
11
|
+
import { createPlanningView } from './planning-view'
|
|
12
|
+
import { rosterFrames, rosterNameOf } from './planning-roster'
|
|
13
|
+
import { createSaveLoop, type LampState } from './layout-wire'
|
|
14
|
+
import { createCanvasWire } from './canvas-wire'
|
|
15
|
+
import { actionTeam, type CanvasAction } from './canvas-ops'
|
|
16
|
+
import './workbench.css'
|
|
17
|
+
|
|
18
|
+
const app = document.getElementById('app')!
|
|
19
|
+
|
|
20
|
+
// gh#9 §10: the host injects the control-plane key into the shell when
|
|
21
|
+
// apiKey is configured — stamp it on every /__dsh_a2a write so the GUI
|
|
22
|
+
// survives a keyed host (same-origin disclosure only, see the serve site).
|
|
23
|
+
// gh#40: a REMOTE face (LAN-bound test host, tunnel) serves the shell
|
|
24
|
+
// WITHOUT the injected key by design — the credential must not leak to any
|
|
25
|
+
// network fetcher. The operator still needs the panel there, so the key
|
|
26
|
+
// can be typed once: sessionStorage is browser-local (never transmitted in
|
|
27
|
+
// the page), stamped on the same X-API-Key header thereafter.
|
|
28
|
+
const storedKey = (() => { try { return window.sessionStorage.getItem('dsh_a2a_key') ?? '' } catch { return '' } })()
|
|
29
|
+
const CONTROL_KEY = (window as unknown as { __DSH_A2A_KEY__?: string }).__DSH_A2A_KEY__ ?? storedKey
|
|
30
|
+
const controlFetch = (path: string, init: RequestInit = {}): Promise<Response> =>
|
|
31
|
+
fetch(path, { ...init, headers: { ...(init.headers as Record<string, string> | undefined ?? {}), ...(CONTROL_KEY !== '' ? { 'X-API-Key': CONTROL_KEY } : {}) } })
|
|
32
|
+
|
|
33
|
+
// gh#40: key-entry affordance — mounts BEFORE the panel when the shell came
|
|
34
|
+
// keyless (remote face) and the control plane answers 401. One input, one
|
|
35
|
+
// submit; the stored key survives until the tab closes.
|
|
36
|
+
if (CONTROL_KEY === '') {
|
|
37
|
+
void controlFetch('/__dsh_a2a/owner-settings').then(res => {
|
|
38
|
+
if (res.status !== 401) return
|
|
39
|
+
const gate = document.createElement('div')
|
|
40
|
+
gate.className = 'nexus-keygate'
|
|
41
|
+
gate.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#fff;z-index:9999;font:14px/1.5 system-ui,sans-serif;color:#000'
|
|
42
|
+
const label = document.createElement('div')
|
|
43
|
+
label.textContent = '此面板经远程面打开(密钥不随页面下发)——输入控制密钥'
|
|
44
|
+
const input = document.createElement('input')
|
|
45
|
+
input.type = 'password'
|
|
46
|
+
input.placeholder = 'X-API-Key'
|
|
47
|
+
input.setAttribute('aria-label', '控制密钥')
|
|
48
|
+
const submit = document.createElement('button')
|
|
49
|
+
submit.type = 'button'
|
|
50
|
+
submit.textContent = '进入面板'
|
|
51
|
+
const err = document.createElement('div')
|
|
52
|
+
err.style.color = '#b00'
|
|
53
|
+
const tryKey = (): void => {
|
|
54
|
+
const v = input.value.trim()
|
|
55
|
+
if (v === '') return
|
|
56
|
+
void fetch('/__dsh_a2a/owner-settings', { headers: { 'X-API-Key': v } }).then(r => {
|
|
57
|
+
if (r.status === 401) { err.textContent = '密钥不对,再试一次'; return }
|
|
58
|
+
try { window.sessionStorage.setItem('dsh_a2a_key', v) } catch { /* blocked site data: keep in-page */ }
|
|
59
|
+
window.location.reload()
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
submit.addEventListener('click', tryKey)
|
|
63
|
+
input.addEventListener('keydown', ev => { if (ev.key === 'Enter') tryKey() })
|
|
64
|
+
gate.append(label, input, submit, err)
|
|
65
|
+
document.body.appendChild(gate)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const faultBadge = document.createElement('div')
|
|
70
|
+
faultBadge.className = 'nexus-fault'
|
|
71
|
+
app.appendChild(faultBadge)
|
|
72
|
+
const { fault, clear: clearFault } = createFaultReporter(
|
|
73
|
+
{
|
|
74
|
+
show(message) {
|
|
75
|
+
faultBadge.textContent = message
|
|
76
|
+
faultBadge.style.display = 'block'
|
|
77
|
+
},
|
|
78
|
+
hide() {
|
|
79
|
+
faultBadge.style.display = 'none'
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
message => console.error(message),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// ─── Spatial planning board (restored 0.6.1; wiring from the pre-0.6.0 stage) ───
|
|
86
|
+
// 0.6.13: board node picks forward to the workbench chat drawer. The bench
|
|
87
|
+
// is created later (it embeds the board), so the dep is a late-bound slot.
|
|
88
|
+
let openNodeChat: ((id: string) => void) | undefined
|
|
89
|
+
// Per-device viewport memory: browser-local, one slot per screen bucket.
|
|
90
|
+
// Storage can be absent or throw (private windows, blocked site data) —
|
|
91
|
+
// every access is guarded and the board falls back to the host/fit path.
|
|
92
|
+
const VIEWPORT_KEY = 'dsh-a2a.viewport.'
|
|
93
|
+
const viewportStore = {
|
|
94
|
+
get(bucket: string): unknown {
|
|
95
|
+
try {
|
|
96
|
+
const raw = window.localStorage.getItem(VIEWPORT_KEY + bucket)
|
|
97
|
+
return raw === null ? undefined : JSON.parse(raw)
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
set(bucket: string, vp: { x: number; y: number; scale: number }): void {
|
|
103
|
+
try {
|
|
104
|
+
window.localStorage.setItem(VIEWPORT_KEY + bucket, JSON.stringify({ x: vp.x, y: vp.y, scale: vp.scale }))
|
|
105
|
+
} catch { /* presentation memory only */ }
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const planning = createPlanningView({
|
|
110
|
+
viewportStore,
|
|
111
|
+
onDirty: () => saveLoop.markDirty(),
|
|
112
|
+
onLampClick: () => saveLoop.retry(),
|
|
113
|
+
onNodePick: (id) => { openNodeChat?.(id) },
|
|
114
|
+
onCanvasAction: async (a) => {
|
|
115
|
+
const ok = await runCanvasAction(a)
|
|
116
|
+
// Settled: converge on truth either way — success absorbs the host's
|
|
117
|
+
// rich state; failure re-pulls so the rollback matches reality.
|
|
118
|
+
void poll()
|
|
119
|
+
return ok
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const saveLoop = createSaveLoop({
|
|
124
|
+
snapshot: () => planning.snapshotDoc(),
|
|
125
|
+
send: (doc, opts) => controlFetch('/__dsh_a2a/canvas-layout', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
128
|
+
body: JSON.stringify({ action: 'save', layout: doc }),
|
|
129
|
+
cache: 'no-store',
|
|
130
|
+
keepalive: opts.keepalive,
|
|
131
|
+
}).then(async (r) => await r.json() as { ok: boolean; layout?: unknown; error?: string }),
|
|
132
|
+
schedule: (fn, ms) => { const t = setTimeout(fn, ms); return () => { clearTimeout(t) } },
|
|
133
|
+
now: Date.now,
|
|
134
|
+
onLamp: (state) => {
|
|
135
|
+
lampState = state
|
|
136
|
+
const now = new Date()
|
|
137
|
+
const hhmm = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0')
|
|
138
|
+
planning.setLamp(state, hhmm)
|
|
139
|
+
},
|
|
140
|
+
onAdopt: (normalized) => { planning.adoptExternalLayout(normalized) },
|
|
141
|
+
onHttpError: (message) => fault(message),
|
|
142
|
+
})
|
|
143
|
+
window.addEventListener('pagehide', () => saveLoop.flush())
|
|
144
|
+
|
|
145
|
+
const canvasWire = createCanvasWire({
|
|
146
|
+
send: async (body) => {
|
|
147
|
+
const r = await controlFetch('/__dsh_a2a/canvas', {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
cache: 'no-store',
|
|
152
|
+
})
|
|
153
|
+
return { status: r.status, body: await r.json() as { ok?: boolean; error?: string } }
|
|
154
|
+
},
|
|
155
|
+
onNotice: (kind, text) => planning.notice(kind, text),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
/** One relayed op on a REMOTE peer's team. THIS host signs the envelope
|
|
159
|
+
* and forwards; the peer's link gate authorizes the write. */
|
|
160
|
+
async function fleetOp(peer: string, op: 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<boolean> {
|
|
161
|
+
const r = await controlFetch('/__dsh_a2a/fleet-canvas-op', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ peer, op, name, ...(id !== undefined ? { id } : {}) }),
|
|
165
|
+
cache: 'no-store',
|
|
166
|
+
}).catch(() => undefined)
|
|
167
|
+
const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
|
|
168
|
+
if (body?.ok === true && op === 'remove' && body.removed === false) planning.notice('info', `属主那边已没有「${name}」,无需解散`)
|
|
169
|
+
if (body?.ok === true) return true
|
|
170
|
+
// Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
|
|
171
|
+
planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function rosterAction(a: CanvasAction, name: string): Promise<boolean> {
|
|
176
|
+
const post = async (body: Record<string, unknown>): Promise<boolean> => {
|
|
177
|
+
const reply = await controlFetch('/__dsh_a2a/roster-op', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ...body }) }).catch(() => undefined)
|
|
178
|
+
const result = reply !== undefined ? await reply.json().catch(() => undefined) as { ok?: boolean; error?: string } | undefined : undefined
|
|
179
|
+
if (reply?.ok && result?.ok === true) return true
|
|
180
|
+
planning.notice('error', `roster 操作失败:${String(result?.error ?? (reply ? `HTTP ${reply.status}` : '无响应'))}`)
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
if (a.type === 'create-team') return post({ op: 'create', ids: a.ids })
|
|
184
|
+
if (a.type === 'remove-team') return post({ op: 'dissolve' })
|
|
185
|
+
if (a.type === 'add-member' || a.type === 'remove-member') {
|
|
186
|
+
for (const id of a.ids) if (!(await post({ op: a.type, id }))) return false
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
planning.notice('error', 'roster 不支持该操作')
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function runCanvasAction(a: CanvasAction): Promise<boolean> {
|
|
194
|
+
// gh#41: a remote-frame drop forks to the fleet relay; so does dissolving
|
|
195
|
+
// a mirror frame (single owner + mirror: the owner's store is the truth).
|
|
196
|
+
if ((a.type === 'add-member' || a.type === 'remove-member') && a.fleetPeer !== undefined) {
|
|
197
|
+
const peer = a.fleetPeer
|
|
198
|
+
return (async () => {
|
|
199
|
+
for (const id of a.ids) if (!(await fleetOp(peer, a.type, a.team, id))) return false
|
|
200
|
+
return true
|
|
201
|
+
})()
|
|
202
|
+
}
|
|
203
|
+
if (a.type === 'remove-team' && a.fleetPeer !== undefined) return fleetOp(a.fleetPeer, 'remove', a.name)
|
|
204
|
+
// Only a trusted registry roster frame or an explicit create choice may trigger a local roster write.
|
|
205
|
+
if (a.roster === true) {
|
|
206
|
+
const roster = rosterNameOf(actionTeam(a))
|
|
207
|
+
if (roster !== undefined) return rosterAction(a, roster)
|
|
208
|
+
planning.notice('error', '无效的 roster 框名'); return Promise.resolve(false)
|
|
209
|
+
}
|
|
210
|
+
switch (a.type) {
|
|
211
|
+
case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
|
|
212
|
+
case 'add-member': return canvasWire.addMembers(a.team, a.ids)
|
|
213
|
+
case 'remove-member': return canvasWire.removeMembers(a.team, a.ids)
|
|
214
|
+
case 'remove-team': return canvasWire.removeTeam(a.name)
|
|
215
|
+
case 'reorder': return canvasWire.runRosterOps(a.team, a.ops)
|
|
216
|
+
case 'join-network':
|
|
217
|
+
case 'leave-network': return postNetworkJoin(a.type === 'join-network' ? 'join' : 'leave', a.id)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Network join/leave for one session (the panel-slim migration: this
|
|
222
|
+
* affordance lives on the planning view's node list). */
|
|
223
|
+
async function postNetworkJoin(action: 'join' | 'leave', id: string): Promise<boolean> {
|
|
224
|
+
const r = await controlFetch(`/__dsh_a2a/${action}`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'Content-Type': 'application/json' },
|
|
227
|
+
body: JSON.stringify({ id }),
|
|
228
|
+
cache: 'no-store',
|
|
229
|
+
}).catch(() => undefined)
|
|
230
|
+
return r !== undefined && r.ok
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let lampState: LampState = 'idle'
|
|
234
|
+
// The nodes face reports effective scoped assemblies; a mounted registration alone does not prove visibility.
|
|
235
|
+
let nodePromptMissing = new Set<string>()
|
|
236
|
+
let nodePromptCheckedAt = 0
|
|
237
|
+
let canvasFace = false
|
|
238
|
+
let lastLayoutJson: string | undefined
|
|
239
|
+
/** True once any layout answer (saved doc or an explicit null) has been
|
|
240
|
+
* seen; the board build waits for that answer on the very first poll. */
|
|
241
|
+
let layoutSeen = false
|
|
242
|
+
|
|
243
|
+
/** Fetch the persisted layout; throws on transport failure so the poll can
|
|
244
|
+
* hold the board build instead of silently building on ring seats. */
|
|
245
|
+
async function fetchLayout(): Promise<unknown> {
|
|
246
|
+
const res = await controlFetch('/__dsh_a2a/canvas-layout', { cache: 'no-store' })
|
|
247
|
+
if (!res.ok) throw new Error(`/__dsh_a2a/canvas-layout ${res.status}`)
|
|
248
|
+
return ((await res.json()) as { layout?: unknown }).layout ?? null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── The workbench ───
|
|
252
|
+
const bench = createWorkbench({
|
|
253
|
+
now: () => Date.now(),
|
|
254
|
+
// gh#34: the owner three-key panel rides the control face.
|
|
255
|
+
async ownerSettingsRead() {
|
|
256
|
+
try {
|
|
257
|
+
const res = await controlFetch('/__dsh_a2a/owner-settings')
|
|
258
|
+
return await res.json()
|
|
259
|
+
} catch {
|
|
260
|
+
return { ok: false, error: 'unreachable' }
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
async ownerSettingsWrite(changes) {
|
|
264
|
+
try {
|
|
265
|
+
const res = await controlFetch('/__dsh_a2a/owner-settings', {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: { 'content-type': 'application/json' },
|
|
268
|
+
body: JSON.stringify(changes),
|
|
269
|
+
})
|
|
270
|
+
return await res.json()
|
|
271
|
+
} catch {
|
|
272
|
+
return { ok: false, error: 'unreachable' }
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
async abandon(taskId, reason) {
|
|
276
|
+
try {
|
|
277
|
+
const res = await controlFetch('/__dsh_a2a/tasks/abandon', {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: { 'content-type': 'application/json' },
|
|
280
|
+
body: JSON.stringify({ task_id: taskId, reason }),
|
|
281
|
+
})
|
|
282
|
+
return res.ok
|
|
283
|
+
} catch {
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
copy(text) {
|
|
288
|
+
void navigator.clipboard?.writeText(text).catch(() => {})
|
|
289
|
+
},
|
|
290
|
+
// 0.6.10: the ↗ jump affordances (0.6.7 L1 broadcast / 0.6.8 L2 deep
|
|
291
|
+
// link) are REMOVED by ruling — 禁止任何跳转,全部使用响应式布局.
|
|
292
|
+
// The only reach primitive is the inline 快派 form: it works for local
|
|
293
|
+
// session teams, canvas teams and remote rows alike, and never leaves
|
|
294
|
+
// this page.
|
|
295
|
+
async cliHomeOptions(name, id) {
|
|
296
|
+
try {
|
|
297
|
+
const res = await controlFetch('/__dsh_a2a/canvas', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cli-home-options', name, id }) })
|
|
298
|
+
const body = await res.json() as { ok: boolean; homes?: string[]; unreadable?: number; held?: boolean; error?: string }
|
|
299
|
+
return { ...body, ok: res.ok && body.ok === true }
|
|
300
|
+
} catch (error) { return { ok: false, error: String(error) } }
|
|
301
|
+
},
|
|
302
|
+
async canvasOp(action, name, id, home) {
|
|
303
|
+
// Same control route the planning board's wire uses; the inspector's
|
|
304
|
+
// member writes are one id at a time, so no serial fan-out needed.
|
|
305
|
+
try {
|
|
306
|
+
const res = await controlFetch('/__dsh_a2a/canvas', {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { 'content-type': 'application/json' },
|
|
309
|
+
body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }) }),
|
|
310
|
+
})
|
|
311
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
|
|
312
|
+
if (res.ok && body.ok !== false) return true
|
|
313
|
+
console.warn(`canvas ${action} ${name}: ${body.error ?? `HTTP ${res.status}`}`)
|
|
314
|
+
return false
|
|
315
|
+
} catch {
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
async dispatchQuick(team, message) {
|
|
320
|
+
// Quick dispatch (0.6.9): host-face POST; the trunk answers with the
|
|
321
|
+
// canonical route shape ({ok, task_id, task_status, team, ...} on
|
|
322
|
+
// delivery) or {ok:false, error}.
|
|
323
|
+
try {
|
|
324
|
+
const res = await controlFetch('/__dsh_a2a/dispatch', {
|
|
325
|
+
method: 'POST',
|
|
326
|
+
headers: { 'content-type': 'application/json' },
|
|
327
|
+
body: JSON.stringify({ team, message }),
|
|
328
|
+
})
|
|
329
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; task_id?: string; reply?: string; error?: string }
|
|
330
|
+
return {
|
|
331
|
+
ok: res.ok && body.ok === true,
|
|
332
|
+
task_id: body.task_id,
|
|
333
|
+
reply: body.reply,
|
|
334
|
+
error: body.error ?? `HTTP ${res.status}`,
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
async sessionLog(id) {
|
|
341
|
+
// Inline transcript (0.6.11): read-only tail of a local session's own
|
|
342
|
+
// log from the host control face. Caps live server-side.
|
|
343
|
+
try {
|
|
344
|
+
const res = await controlFetch(`/__dsh_a2a/session-log?id=${encodeURIComponent(id)}`)
|
|
345
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; messages?: Array<{ role: string; text: string }>; error?: string }
|
|
346
|
+
// 0.6.16: error stays undefined on success — an unconditional
|
|
347
|
+
// `body.error ?? HTTP ${status}` painted "HTTP 200" on listing-shaped
|
|
348
|
+
// successes that lacked a messages field.
|
|
349
|
+
return { ok: res.ok && body.ok === true, messages: body.messages, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
|
|
350
|
+
} catch (error) {
|
|
351
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
async sessionLogList() {
|
|
355
|
+
// 0.6.12: host-scope readable-log listing — archived and never-joined
|
|
356
|
+
// sessions included; the network directory is not the reading gate.
|
|
357
|
+
try {
|
|
358
|
+
const res = await controlFetch('/__dsh_a2a/session-log')
|
|
359
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }
|
|
360
|
+
// count: how many logs the host holds — the list face caps at 50, so the
|
|
361
|
+
// directory can say it is showing 50 of N instead of silently truncating.
|
|
362
|
+
return { ok: res.ok && body.ok === true, sessions: body.sessions, count: typeof body.count === 'number' ? body.count : undefined, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
async remoteSessionLog(query) {
|
|
368
|
+
// 0.6.15: proxy read of a remote host's /a2a/log — by tracked peer url or
|
|
369
|
+
// by team handle (directory-resolved). The proxy is the only reader; the
|
|
370
|
+
// browser never dials the peer directly.
|
|
371
|
+
try {
|
|
372
|
+
const params = new URLSearchParams()
|
|
373
|
+
if (query.team !== undefined) params.set('team', query.team)
|
|
374
|
+
if (query.peer !== undefined) params.set('peer', query.peer)
|
|
375
|
+
if (query.id !== undefined) params.set('id', query.id)
|
|
376
|
+
if (query.limit !== undefined) params.set('limit', String(query.limit))
|
|
377
|
+
const res = await controlFetch(`/__dsh_a2a/remote-session-log?${params.toString()}`)
|
|
378
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
379
|
+
ok?: boolean
|
|
380
|
+
messages?: Array<{ role: string; text: string }>
|
|
381
|
+
sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>
|
|
382
|
+
error?: string
|
|
383
|
+
via?: string
|
|
384
|
+
}
|
|
385
|
+
return { ok: res.ok && body.ok === true, messages: body.messages, sessions: body.sessions, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`), via: body.via }
|
|
386
|
+
} catch (error) {
|
|
387
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
board: {
|
|
391
|
+
root: planning.root,
|
|
392
|
+
activate: () => planning.activate(),
|
|
393
|
+
deactivate: () => planning.deactivate(),
|
|
394
|
+
available: () => canvasFace,
|
|
395
|
+
},
|
|
396
|
+
})
|
|
397
|
+
app.appendChild(bench.root)
|
|
398
|
+
// 0.6.13: board node picks now resolve against the live bench state.
|
|
399
|
+
openNodeChat = (id) => { bench.openNodeChat(id) }
|
|
400
|
+
|
|
401
|
+
// Legacy entry compatibility: the old stage link ?mode=plan converges on the
|
|
402
|
+
// board stage — in the merged shell canvas mode is already the default while
|
|
403
|
+
// the host serves the canvas face, so this only guards the first poll.
|
|
404
|
+
if (new URLSearchParams(window.location.search).get('mode') === 'plan') {
|
|
405
|
+
const tryBoard = window.setInterval(() => {
|
|
406
|
+
if (canvasFace) {
|
|
407
|
+
bench.selectTab('board')
|
|
408
|
+
window.clearInterval(tryBoard)
|
|
409
|
+
}
|
|
410
|
+
}, 300)
|
|
411
|
+
window.setTimeout(() => window.clearInterval(tryBoard), 30_000)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function poll(): Promise<void> {
|
|
415
|
+
try {
|
|
416
|
+
const res = await controlFetch('/__dsh_a2a/state', { cache: 'no-store' })
|
|
417
|
+
if (!res.ok) throw new Error(`/__dsh_a2a/state ${res.status}`)
|
|
418
|
+
const body = (await res.json()) as StateBody
|
|
419
|
+
if (Date.now() - nodePromptCheckedAt >= 15_000) {
|
|
420
|
+
nodePromptCheckedAt = Date.now()
|
|
421
|
+
try {
|
|
422
|
+
const nodes = await controlFetch('/__dsh_a2a/nodes', { cache: 'no-store' })
|
|
423
|
+
if (!nodes.ok) throw new Error(`nodes ${nodes.status}`)
|
|
424
|
+
const view = await nodes.json() as { bindings?: Array<{ session: string; mounted: unknown; effective: { core: boolean; mutable: boolean } | null }> }
|
|
425
|
+
nodePromptMissing = new Set((view.bindings ?? [])
|
|
426
|
+
.filter(b => b.mounted !== null && b.effective !== null && (b.effective.core === false || b.effective.mutable === false))
|
|
427
|
+
.map(b => b.session))
|
|
428
|
+
} catch { nodePromptMissing.clear() } // unavailable is unknown, not confirmed missing
|
|
429
|
+
}
|
|
430
|
+
const shown = { ...body, sessions: (body.sessions ?? []).map(s => ({ ...s, ...(nodePromptMissing.has(s.id) ? { nodePromptMissing: true } : {}) })) }
|
|
431
|
+
clearFault()
|
|
432
|
+
// canvasFace first: bench.update reads board.available() to pick the
|
|
433
|
+
// mode, so assigning it afterwards held the very first paint in data
|
|
434
|
+
// mode (board display:none, 0px wide) until the next 3s poll — and the
|
|
435
|
+
// board's first reconcile measured a zero-width view.
|
|
436
|
+
canvasFace = body.canvas !== undefined
|
|
437
|
+
bench.update(shown)
|
|
438
|
+
|
|
439
|
+
// ── The layout answer precedes the first reconcile on purpose ──
|
|
440
|
+
// The board's first build reads `layoutDoc` for saved positions and
|
|
441
|
+
// frame rects; every later pass keeps live geometry ("live rect wins").
|
|
442
|
+
// A layout landing AFTER the build therefore never displaces the ring
|
|
443
|
+
// seats — the refresh-reset-to-middle bug. Until any answer (doc or
|
|
444
|
+
// null) has been seen, a failed GET holds the board one poll rather
|
|
445
|
+
// than building on seats; after that, failures leave the last doc in
|
|
446
|
+
// place and the poll continues.
|
|
447
|
+
if (canvasFace) {
|
|
448
|
+
try {
|
|
449
|
+
const layout = await fetchLayout()
|
|
450
|
+
const layoutJson = JSON.stringify(layout ?? null)
|
|
451
|
+
if (layoutJson !== lastLayoutJson) {
|
|
452
|
+
lastLayoutJson = layoutJson
|
|
453
|
+
// Adopt poll-sourced layouts only while nothing is unsaved/in
|
|
454
|
+
// flight — otherwise a slow GET would revert positions the user
|
|
455
|
+
// is still dragging.
|
|
456
|
+
if ((lampState === 'idle' || lampState === 'saved') && !saveLoop.isBusy()) {
|
|
457
|
+
planning.adoptExternalLayout(layout)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
layoutSeen = true
|
|
461
|
+
} catch (err) {
|
|
462
|
+
if (!layoutSeen) {
|
|
463
|
+
fault(`canvas layout 获取失败:${String(err)}`)
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
layoutSeen = true
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── Planning board shares this poll: same state payload. ──
|
|
472
|
+
// SessionLite needs `joined: boolean` (workbench rows carry `joined?`):
|
|
473
|
+
// tri-state → boolean without losing the unjoined node-list population.
|
|
474
|
+
const allSessions = (body.sessions ?? []).map(s => ({ ...s, joined: s.joined === true }))
|
|
475
|
+
const teams = [...(body.canvas?.teams ?? []), ...rosterFrames(body.registry?.teams ?? [], body.zone ?? '')]
|
|
476
|
+
const peers = (body.peers ?? []).filter(p => p !== null && typeof p === 'object' && typeof (p as { url?: unknown }).url === 'string')
|
|
477
|
+
const inFlight = (body.inFlight ?? []).filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { peer?: unknown }).peer === 'string' && Number.isFinite((r as { startedAt?: unknown }).startedAt))
|
|
478
|
+
const remoteTeams = (body.remote ?? [])
|
|
479
|
+
.filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { via?: unknown }).via === 'string')
|
|
480
|
+
.map(r => ({
|
|
481
|
+
team: r.team,
|
|
482
|
+
name: r.name,
|
|
483
|
+
via: r.via,
|
|
484
|
+
workspace: r.workspace,
|
|
485
|
+
...(r.unreachable === true ? { unreachable: true } : {}),
|
|
486
|
+
...(typeof r.origin === 'string' && r.origin !== '' ? { origin: r.origin } : {}),
|
|
487
|
+
...(Array.isArray(r.teams) && r.teams.length > 0 ? { teams: r.teams.filter((t): t is string => typeof t === 'string' && t !== '') } : {}),
|
|
488
|
+
}))
|
|
489
|
+
// gh#41: the peers' published canvas org rides the board (remote frames).
|
|
490
|
+
const peerOrg = (body.peerOrg as unknown[] ?? [])
|
|
491
|
+
.filter((r): r is { url?: unknown; org?: unknown; unreachable?: unknown } => r !== null && typeof r === 'object')
|
|
492
|
+
.filter(r => typeof r.url === 'string' && Array.isArray(r.org))
|
|
493
|
+
.map(r => ({
|
|
494
|
+
url: r.url as string,
|
|
495
|
+
...(r.unreachable === true ? { unreachable: true } : {}),
|
|
496
|
+
org: (r.org as unknown[])
|
|
497
|
+
.filter((e): e is { name?: unknown; team?: unknown; members?: unknown } => e !== null && typeof e === 'object')
|
|
498
|
+
.filter(e => typeof e.team === 'string' && e.team !== '')
|
|
499
|
+
.map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: e.team as string, members: Array.isArray(e.members) ? e.members.filter((m): m is string => typeof m === 'string') : [] })),
|
|
500
|
+
}))
|
|
501
|
+
planning.reconcile({ sessions: allSessions, teams, peerCount: peers.length, peers, inFlight, remoteTeams, peerOrg })
|
|
502
|
+
} catch (err) {
|
|
503
|
+
fault(`state 轮询失败:${String(err)}`)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
void poll()
|
|
508
|
+
window.setInterval(() => {
|
|
509
|
+
if (!document.hidden) void poll()
|
|
510
|
+
}, 3000)
|
|
511
|
+
document.addEventListener('visibilitychange', () => {
|
|
512
|
+
if (!document.hidden) void poll()
|
|
513
|
+
})
|