@nelsonlongxiang/dsh-open-a2a-net 0.6.24 → 0.6.26
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 +15 -11
- package/README_zh.md +3 -2
- package/assets/nexusDist/assets/index-DszoLz8i.js +5 -0
- package/assets/nexusDist/assets/index-uQf7NwjF.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +300 -0
- package/lib/card.js +17 -0
- package/lib/cli-deposit.js +141 -0
- package/lib/exposure-store.js +82 -0
- package/lib/index.js +904 -57
- package/lib/owner-settings.js +100 -0
- package/lib/peer-store.js +65 -9
- package/lib/task-ledger.js +44 -3
- package/lib/types/cli-deposit.d.ts +53 -0
- package/lib/types/exposure-store.d.ts +45 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/owner-settings.d.ts +19 -0
- package/lib/types/peer-store.d.ts +22 -3
- package/lib/types/task-ledger.d.ts +18 -0
- package/lib/types/types.d.ts +10 -0
- package/nexus-stage/src/canvas-ops.ts +2 -2
- package/nexus-stage/src/federation.ts +6 -0
- package/nexus-stage/src/main.ts +97 -4
- package/nexus-stage/src/planning-menus.ts +16 -4
- package/nexus-stage/src/planning-types.ts +3 -0
- package/nexus-stage/src/planning-view.ts +208 -18
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-chat.ts +670 -0
- package/nexus-stage/src/wb-directory.ts +222 -0
- package/nexus-stage/src/wb-ledger.ts +266 -0
- package/nexus-stage/src/wb-rail.ts +147 -0
- package/nexus-stage/src/workbench.css +190 -84
- package/nexus-stage/src/workbench.ts +385 -721
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +18 -22
- package/assets/nexusDist/assets/index-034h1cNc.js +0 -3
- package/assets/nexusDist/assets/index-PG9EieGX.css +0 -1
- package/lib/types/client.d.ts +0 -73
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* state, and the active mode are view state kept across updates.
|
|
19
19
|
* @module nexus-stage/workbench
|
|
20
20
|
*/
|
|
21
|
+
import { createChatDrawer, type ChatTarget } from './wb-chat'
|
|
22
|
+
import { createRailTabs } from './wb-rail'
|
|
23
|
+
import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
|
|
24
|
+
import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
|
|
21
25
|
|
|
22
26
|
/** One pending ledger row from /state `tasks`. */
|
|
23
27
|
export interface PendingRow {
|
|
@@ -48,6 +52,35 @@ export interface DeadRow {
|
|
|
48
52
|
readonly reason?: string
|
|
49
53
|
/** First line of the dispatched message (see {@link PendingRow.brief}). */
|
|
50
54
|
readonly brief?: string
|
|
55
|
+
/** gh#31: which side of the wire this row served (absent on old hosts). */
|
|
56
|
+
readonly direction?: 'inbound' | 'outbound'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** gh#31: one archived row from /state `archived` (newest first) — the
|
|
60
|
+
* per-task outcome for a task that left the ledger. Fields past `summary`
|
|
61
|
+
* are absent on hosts that predate them. */
|
|
62
|
+
export interface ArchivedRow {
|
|
63
|
+
readonly taskId: string
|
|
64
|
+
readonly team: string
|
|
65
|
+
readonly resolvedAt: number
|
|
66
|
+
readonly outcome: string
|
|
67
|
+
readonly summary?: string
|
|
68
|
+
readonly peer?: string
|
|
69
|
+
readonly startedAt?: number
|
|
70
|
+
/** How the row left the book: a receipt, a content echo, or the caller
|
|
71
|
+
* (abandon / cancel — both archive with outcome 'abandoned'). */
|
|
72
|
+
readonly endedBy?: 'receipt' | 'echo' | 'abandoned' | 'cancelled'
|
|
73
|
+
/** A receipt arrived after the caller had already ended the row. */
|
|
74
|
+
readonly late?: boolean
|
|
75
|
+
readonly direction?: 'inbound' | 'outbound'
|
|
76
|
+
readonly brief?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** gh#41: one tracked peer's published canvas organization (full-card
|
|
80
|
+
* canvasOrg) — the board renders REMOTE team frames from these rows. */
|
|
81
|
+
export interface PeerOrgRow {
|
|
82
|
+
readonly url: string
|
|
83
|
+
readonly org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }>
|
|
51
84
|
}
|
|
52
85
|
|
|
53
86
|
/** One session row from /state `sessions` (live or cold joined). */
|
|
@@ -112,15 +145,31 @@ export interface StateBody {
|
|
|
112
145
|
readonly version?: string
|
|
113
146
|
readonly sessions?: readonly SessionRow[]
|
|
114
147
|
readonly canvas?: { teams?: readonly CanvasTeamRow[] }
|
|
115
|
-
|
|
148
|
+
/** gh#20 W-A/W-C: the peer row carries the link badge source, the host
|
|
149
|
+
* rail's label/version/last-contact facts (absent on older hosts). */
|
|
150
|
+
readonly peers?: ReadonlyArray<{
|
|
151
|
+
url: string
|
|
152
|
+
score?: number
|
|
153
|
+
link?: 'linked' | 'stranger'
|
|
154
|
+
verified?: boolean
|
|
155
|
+
name?: string
|
|
156
|
+
version?: string
|
|
157
|
+
lastContactAt?: number
|
|
158
|
+
/** gh#24: whether the last contact attempt succeeded. */
|
|
159
|
+
lastContactOk?: boolean
|
|
160
|
+
}>
|
|
116
161
|
readonly remote?: readonly RemoteRow[]
|
|
117
162
|
readonly activity?: readonly ActivityRow[]
|
|
118
163
|
readonly inFlight?: ReadonlyArray<{ team: string; peer: string; startedAt: number }>
|
|
119
164
|
readonly tasks?: readonly PendingRow[]
|
|
120
165
|
readonly tasksStaleCount?: number
|
|
121
166
|
readonly tasksDead?: readonly DeadRow[]
|
|
167
|
+
/** gh#31: per-task outcomes for rows that left the ledger (bounded). */
|
|
168
|
+
readonly archived?: readonly ArchivedRow[]
|
|
169
|
+
/** gh#41 fleet planning: each tracked peer's published canvas org. */
|
|
170
|
+
readonly peerOrg?: readonly PeerOrgRow[]
|
|
122
171
|
readonly archivedCount?: number
|
|
123
|
-
/** Settled-archive split by verdict ('completed' | '
|
|
172
|
+
/** Settled-archive split by verdict (the receipt outcome vocabulary — 'completed' | 'rejected' | 'failed' | … | 'abandoned' — or 'unclassified'). */
|
|
124
173
|
readonly archivedSplit?: Readonly<Record<string, number>>
|
|
125
174
|
readonly idempotency?: {
|
|
126
175
|
window?: number
|
|
@@ -140,6 +189,15 @@ export interface StateBody {
|
|
|
140
189
|
/** What the workbench can DO — the seams tests drive. */
|
|
141
190
|
export interface WorkbenchDeps {
|
|
142
191
|
now(): number
|
|
192
|
+
/** gh#41 fleet planning: each tracked peer's published canvas org. */
|
|
193
|
+
peerOrg?: readonly PeerOrgRow[]
|
|
194
|
+
/** gh#34: read the owner three-key panel state (GET /__dsh_a2a/owner-settings). */
|
|
195
|
+
ownerSettingsRead?(): Promise<{ ok: boolean; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean; announceRestartPending?: boolean; error?: string }>
|
|
196
|
+
/** gh#34: write owner panel changes; true on success (POST the same face). */
|
|
197
|
+
ownerSettingsWrite?(changes: { teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean }): Promise<{ ok: boolean; restartNeeded?: boolean; error?: string }>
|
|
198
|
+
/** gh#41 fleet planning: regulate a REMOTE peer's canvas team (this host
|
|
199
|
+
* signs the envelope and forwards; the peer's link gate authorizes). */
|
|
200
|
+
fleetCanvasOp?(peer: string, op: 'create' | 'add-member' | 'remove-member', name: string, id?: string): Promise<{ ok: boolean; members?: string[]; error?: string }>
|
|
143
201
|
/** Abandon one dead-lettered task via the control route. True on success. */
|
|
144
202
|
abandon(taskId: string, reason: string): Promise<boolean>
|
|
145
203
|
/** Canvas team member change via the control route (0.6.6): `id` is a
|
|
@@ -155,7 +213,7 @@ export interface WorkbenchDeps {
|
|
|
155
213
|
/** 0.6.12: list every local session holding a log artifact (newest first) —
|
|
156
214
|
* the reading face is host-scoped, so archived and never-joined sessions
|
|
157
215
|
* appear here even when the network directory shows no local rows. */
|
|
158
|
-
sessionLogList?(): Promise<{ ok: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string }>
|
|
216
|
+
sessionLogList?(): Promise<{ ok: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }>
|
|
159
217
|
/** 0.6.15: remote transcript via the local proxy — ?team=<zone/id8>
|
|
160
218
|
* resolves the publishing peer through the directory, ?peer=<base-url>
|
|
161
219
|
* reads one tracked peer directly. Without id the peer answers its
|
|
@@ -199,30 +257,6 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string):
|
|
|
199
257
|
return node
|
|
200
258
|
}
|
|
201
259
|
|
|
202
|
-
/** One labelled counter in a pane's stat strip; `title` carries the definition
|
|
203
|
-
* (native tooltip — the definition lives with the number it explains). */
|
|
204
|
-
function statChip(label: string, value: number, hint: string, tone?: 'warn' | 'danger'): HTMLElement {
|
|
205
|
-
const chip = el('span', 'wb-stat')
|
|
206
|
-
if (tone !== undefined) chip.classList.add(`wb-stat-${tone}`)
|
|
207
|
-
chip.setAttribute('title', hint)
|
|
208
|
-
chip.appendChild(el('span', 'wb-stat-k')).textContent = label
|
|
209
|
-
chip.appendChild(el('span', 'wb-stat-v')).textContent = String(value)
|
|
210
|
-
return chip
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** One proportional bar: label · track · count. Cheap density for a histogram. */
|
|
214
|
-
function barRow(parent: HTMLElement, label: string, count: number, max: number): void {
|
|
215
|
-
const row = el('div', 'wb-bar-row')
|
|
216
|
-
row.appendChild(el('span', 'wb-bar-k')).textContent = label
|
|
217
|
-
const track = el('span', 'wb-bar-track')
|
|
218
|
-
const fill = el('span', 'wb-bar-fill')
|
|
219
|
-
fill.style.width = `${max > 0 ? Math.max(count > 0 ? 2 : 0, Math.round((count / max) * 100)) : 0}%`
|
|
220
|
-
track.appendChild(fill)
|
|
221
|
-
row.appendChild(track)
|
|
222
|
-
row.appendChild(el('span', 'wb-bar-v')).textContent = String(count)
|
|
223
|
-
parent.appendChild(row)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
260
|
function textRow(parent: HTMLElement, label: string, value: string): void {
|
|
227
261
|
const row = el('div', 'wb-kv')
|
|
228
262
|
row.appendChild(el('span', 'wb-kv-k')).textContent = label
|
|
@@ -234,40 +268,9 @@ type Selection =
|
|
|
234
268
|
| { kind: 'canvas'; row: CanvasTeamRow }
|
|
235
269
|
| undefined
|
|
236
270
|
|
|
237
|
-
/**
|
|
238
|
-
|
|
239
|
-
* read through the /a2a/log proxy. Reading, not jumping. */
|
|
240
|
-
type ChatTarget =
|
|
241
|
-
| { kind: 'session'; id: string; title: string; sub: string; team?: string; live?: boolean }
|
|
242
|
-
| { kind: 'log'; id: string; title: string; sub: string }
|
|
243
|
-
| { kind: 'peer'; id: string; title: string; sub: string; url?: string; picked?: string }
|
|
244
|
-
| { kind: 'remote'; team: string; title: string; sub: string; row?: RemoteRow }
|
|
245
|
-
|
|
246
|
-
/** The four rail sections. */
|
|
247
|
-
export type WorkbenchSection = 'directory' | 'ledger' | 'activity' | 'plan'
|
|
248
|
-
|
|
249
|
-
/** Directory category filter (0.6.6): one chip per directory section group. */
|
|
250
|
-
type DirFilter = 'all' | 'local' | 'canvas' | 'peers' | 'remote'
|
|
271
|
+
/** The rail sections. */
|
|
272
|
+
export type WorkbenchSection = 'directory' | 'hosts' | 'ledger' | 'activity' | 'plan'
|
|
251
273
|
|
|
252
|
-
const DIRECTORY_FILTERS: ReadonlyArray<{ key: DirFilter; label: string; hint: string }> = [
|
|
253
|
-
{ key: 'all', label: '全部', hint: '不过滤:目录全部条目' },
|
|
254
|
-
{ key: 'local', label: '本机', hint: '本机会话节点(现场 + 冷启动)' },
|
|
255
|
-
{ key: 'canvas', label: '队', hint: '画布队(人类手工分配的组队)' },
|
|
256
|
-
{ key: 'peers', label: 'Peers', hint: '对等节点(peer 池)' },
|
|
257
|
-
{ key: 'remote', label: '远端', hint: '远端会话队(来自对端卡片)' },
|
|
258
|
-
]
|
|
259
|
-
|
|
260
|
-
/** Lowercased haystack for the unified search: name/team/label/id/url/teams. */
|
|
261
|
-
function rowHaystack(...parts: ReadonlyArray<string | readonly string[] | undefined>): string {
|
|
262
|
-
const flat: string[] = []
|
|
263
|
-
for (const part of parts) {
|
|
264
|
-
if (part === undefined) continue
|
|
265
|
-
if (typeof part === 'string') {
|
|
266
|
-
if (part !== '') flat.push(part)
|
|
267
|
-
} else for (const entry of part) if (entry !== '') flat.push(entry)
|
|
268
|
-
}
|
|
269
|
-
return flat.join(' ').toLowerCase()
|
|
270
|
-
}
|
|
271
274
|
|
|
272
275
|
/** Legacy deep-link targets: 'board' focuses the stage, sections open a rail section. */
|
|
273
276
|
export type WorkbenchTab = 'board' | WorkbenchSection
|
|
@@ -289,8 +292,21 @@ export interface Workbench {
|
|
|
289
292
|
openNodeChat(id: string): void
|
|
290
293
|
}
|
|
291
294
|
|
|
295
|
+
/** Bare host:port of a peer url (workbench-local twin of federation's
|
|
296
|
+
* helper — this module keeps a zero-import posture). */
|
|
297
|
+
function peerHostOf(url: string): string {
|
|
298
|
+
try {
|
|
299
|
+
return new URL(url).host
|
|
300
|
+
} catch {
|
|
301
|
+
return url
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Rail tabs (2026-10-23): 主机 moved up next to 目录 — host health was the
|
|
306
|
+
* last accordion section and sat below the fold. */
|
|
292
307
|
const SECTION_DEFS: ReadonlyArray<{ key: WorkbenchSection; label: string }> = [
|
|
293
308
|
{ key: 'directory', label: '目录' },
|
|
309
|
+
{ key: 'hosts', label: '主机' },
|
|
294
310
|
{ key: 'ledger', label: '台账' },
|
|
295
311
|
{ key: 'activity', label: '活动' },
|
|
296
312
|
{ key: 'plan', label: '规划' },
|
|
@@ -301,7 +317,6 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
301
317
|
let mode: WorkbenchMode = 'data'
|
|
302
318
|
let selection: Selection
|
|
303
319
|
let last: StateBody | null = null
|
|
304
|
-
const collapsed = new Set<WorkbenchSection>(['activity', 'plan'])
|
|
305
320
|
// Directory view state (survives polls): unified search + category filter.
|
|
306
321
|
let searchText = ''
|
|
307
322
|
let dirFilter: DirFilter = 'all'
|
|
@@ -311,27 +326,14 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
311
326
|
// Quick-dispatch form (0.6.9), cached per target routing team the same
|
|
312
327
|
// draftForm way: a textarea's draft must survive the 3s poll remount.
|
|
313
328
|
let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
|
|
314
|
-
// Chat drawer (0.6.13): the picked node + its own dispatch form cache
|
|
315
|
-
// (separate from the inspector's so both faces can hold different teams).
|
|
316
|
-
let chatTarget: ChatTarget | null = null
|
|
317
|
-
/** 0.6.17: one-shot composer focus on drawer open (consumed by renderChat). */
|
|
318
|
-
let chatFocusPending = false
|
|
319
|
-
let chatForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
|
|
320
|
-
// 0.6.11: inline transcript cache — one fetch per session id, refreshed on
|
|
321
|
-
// demand. renderInspector rebuilds every poll, so the cache keeps the
|
|
322
|
-
// transcript block from refetching on each 3s tick.
|
|
323
|
-
const transcriptCache = new Map<string, { ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string } | undefined>()
|
|
324
|
-
/** 0.6.17 chat-UI convention: reading position per transcript key — the
|
|
325
|
-
* 3s poll rebuilds the body, the scroll restores instead of jumping top. */
|
|
326
|
-
const transcriptScroll = new Map<string, number>()
|
|
327
|
-
/** Owner signal (2026-10-21): transcripts default to a collapsed detail —
|
|
328
|
-
* the seats' forensics face, not the owner's. Expansion persists per key. */
|
|
329
|
-
const expandedTranscripts = new Set<string>()
|
|
330
|
-
/** 0.6.15: peer-host session listings, cached per peer url. */
|
|
331
|
-
const peerListCache = new Map<string, { ok: boolean; sessions?: Array<{ id: string; sizeBytes: number }>; error?: string } | undefined>()
|
|
332
329
|
// 0.6.12: readable local logs (host-scope list, newest first). Refreshed
|
|
333
330
|
// opportunistically per poll; rows render in the directory's 本机日志 section.
|
|
334
331
|
let logRows: Array<{ id: string; mtimeMs: number; sizeBytes: number }> = []
|
|
332
|
+
/** How many logs the host holds (the list face caps at 50); from the listing's count. */
|
|
333
|
+
let logTotal: number | undefined
|
|
334
|
+
/** Learned 本机日志 titles: the first user line of a transcript, captured
|
|
335
|
+
* when the chat drawer loads it (the list face carries no titles). */
|
|
336
|
+
const logTitles = loadLogTitles()
|
|
335
337
|
let logRowsBusy = false
|
|
336
338
|
|
|
337
339
|
const root = el('div', 'wb')
|
|
@@ -351,6 +353,16 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
351
353
|
const versionBadge = el('span', 'wb-badge')
|
|
352
354
|
title.appendChild(versionBadge)
|
|
353
355
|
header.appendChild(title)
|
|
356
|
+
// gh#34 owner three-key panel: the header gear opens the inspector panel
|
|
357
|
+
// (teamJoinAllowlist / teamScopeInfo / announce) — 远程建联三件不再翻配置文件。
|
|
358
|
+
const ownerBtn = el('button', 'wb-icon-btn wb-owner-btn')
|
|
359
|
+
ownerBtn.type = 'button'
|
|
360
|
+
ownerBtn.dataset.testid = 'wb-owner-btn'
|
|
361
|
+
ownerBtn.setAttribute('aria-label', '主人设置(三键面板)')
|
|
362
|
+
ownerBtn.title = '主人设置:入网白名单 / 可见性 / 发布'
|
|
363
|
+
ownerBtn.textContent = '⚙'
|
|
364
|
+
ownerBtn.addEventListener('click', () => { ownerPanelOpen = !ownerPanelOpen; renderOwnerPanel() })
|
|
365
|
+
header.appendChild(ownerBtn)
|
|
354
366
|
const headerStats = el('div', 'wb-header-stats')
|
|
355
367
|
header.appendChild(headerStats)
|
|
356
368
|
const railToggle = el('button', 'wb-icon-btn wb-rail-toggle')
|
|
@@ -367,32 +379,11 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
367
379
|
header.appendChild(railToggle)
|
|
368
380
|
|
|
369
381
|
const body = el('div', 'wb-body')
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
const sectionCounts = new Map<WorkbenchSection, HTMLElement>()
|
|
376
|
-
for (const def of SECTION_DEFS) {
|
|
377
|
-
const sec = el('section', 'wb-sec')
|
|
378
|
-
sec.dataset.sec = def.key
|
|
379
|
-
const head = el('button', 'wb-sec-head')
|
|
380
|
-
head.type = 'button'
|
|
381
|
-
head.appendChild(el('span', 'wb-sec-label')).textContent = def.label
|
|
382
|
-
const count = el('span', 'wb-sec-count')
|
|
383
|
-
head.appendChild(count)
|
|
384
|
-
head.addEventListener('click', () => {
|
|
385
|
-
if (collapsed.has(def.key)) collapsed.delete(def.key)
|
|
386
|
-
else collapsed.add(def.key)
|
|
387
|
-
syncSections()
|
|
388
|
-
})
|
|
389
|
-
sec.appendChild(head)
|
|
390
|
-
const secBody = el('div', 'wb-sec-body')
|
|
391
|
-
sec.appendChild(secBody)
|
|
392
|
-
rail.appendChild(sec)
|
|
393
|
-
sectionBodies.set(def.key, secBody)
|
|
394
|
-
sectionCounts.set(def.key, count)
|
|
395
|
-
}
|
|
382
|
+
// Rail tabs (2026-10-23, wb-rail): one panel per pane, each with its own
|
|
383
|
+
// scroll. Panes are created once and re-parented between tab panels
|
|
384
|
+
// (canvas mode) and the stage stack (data mode).
|
|
385
|
+
const railTabs = createRailTabs(SECTION_DEFS)
|
|
386
|
+
const rail = railTabs.root
|
|
396
387
|
|
|
397
388
|
const directoryBody = el('div', 'wb-directory')
|
|
398
389
|
// Directory toolbar: unified search + category filter chips (0.6.6). Built
|
|
@@ -423,16 +414,19 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
423
414
|
filterRow.appendChild(chip)
|
|
424
415
|
}
|
|
425
416
|
directoryTools.appendChild(filterRow)
|
|
426
|
-
|
|
427
|
-
|
|
417
|
+
railTabs.panel('directory').appendChild(directoryTools)
|
|
418
|
+
railTabs.panel('directory').appendChild(directoryBody)
|
|
428
419
|
|
|
429
420
|
const ledgerPane = el('section', 'wb-pane')
|
|
430
421
|
const activityPane = el('section', 'wb-pane')
|
|
431
422
|
const planPane = el('section', 'wb-pane')
|
|
423
|
+
// gh#20 W-C: the host rail pane (one row per tracked peer).
|
|
424
|
+
const hostsPane = el('section', 'wb-pane')
|
|
432
425
|
const dataPanes: ReadonlyArray<[WorkbenchSection, HTMLElement]> = [
|
|
433
426
|
['ledger', ledgerPane],
|
|
434
427
|
['activity', activityPane],
|
|
435
428
|
['plan', planPane],
|
|
429
|
+
['hosts', hostsPane],
|
|
436
430
|
]
|
|
437
431
|
|
|
438
432
|
const stage = el('main', 'wb-stage')
|
|
@@ -456,64 +450,35 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
456
450
|
const inspectorBody = el('div', 'wb-inspector-body')
|
|
457
451
|
inspector.appendChild(inspectorBody)
|
|
458
452
|
|
|
459
|
-
// ── Node chat drawer (0.6.13) ──
|
|
460
|
-
// Clicking a node — on the planning board or a directory
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
chatClose.textContent = '✕'
|
|
472
|
-
chatClose.addEventListener('click', () => closeChat())
|
|
473
|
-
chatHead.appendChild(chatDot)
|
|
474
|
-
chatHead.appendChild(chatTitle)
|
|
475
|
-
chatHead.appendChild(chatSub)
|
|
476
|
-
chatHead.appendChild(chatClose)
|
|
477
|
-
const chatBody = el('div', 'wb-chat-body')
|
|
478
|
-
chat.appendChild(chatHead)
|
|
479
|
-
chat.appendChild(chatBody)
|
|
480
|
-
// 0.6.17 chat-UI convention: Escape closes the drawer. Document-scoped
|
|
481
|
-
// (a reading drawer often holds no focusable element), but it never
|
|
482
|
-
// steals Escape from another surface's text field or from the planning
|
|
483
|
-
// board's own deselect gesture.
|
|
484
|
-
document.addEventListener('keydown', ev => {
|
|
485
|
-
if (ev.key !== 'Escape' || !chat.classList.contains('is-open')) return
|
|
486
|
-
const target = ev.target
|
|
487
|
-
if (!(target instanceof HTMLElement)) return
|
|
488
|
-
const insideChat = chat.contains(target)
|
|
489
|
-
if (!insideChat) {
|
|
490
|
-
const typingElsewhere = target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement || target.isContentEditable
|
|
491
|
-
const onBoard = target.closest('.wb-stage-board') !== null
|
|
492
|
-
if (typingElsewhere || onBoard) return
|
|
493
|
-
}
|
|
494
|
-
closeChat()
|
|
453
|
+
// ── Node chat drawer (0.6.13; rewritten 2026-10-23 as wb-chat) ──
|
|
454
|
+
// Clicking a node — on the planning board or a directory row — opens the
|
|
455
|
+
// conversation as a right-side chat: thread + composer + receipt tracking.
|
|
456
|
+
const chat = createChatDrawer({
|
|
457
|
+
now: deps.now,
|
|
458
|
+
sessionLog: deps.sessionLog,
|
|
459
|
+
remoteSessionLog: deps.remoteSessionLog,
|
|
460
|
+
dispatchQuick: deps.dispatchQuick,
|
|
461
|
+
onTranscript: (target, messages) => {
|
|
462
|
+
if (target.kind !== 'log' && target.kind !== 'session') return
|
|
463
|
+
if (learnLogTitle(logTitles, target.id, messages)) renderSidebar()
|
|
464
|
+
},
|
|
495
465
|
})
|
|
496
466
|
|
|
497
467
|
stage.appendChild(dataStack)
|
|
498
468
|
body.appendChild(rail)
|
|
499
469
|
body.appendChild(stage)
|
|
500
470
|
body.appendChild(inspector)
|
|
501
|
-
body.appendChild(chat)
|
|
471
|
+
body.appendChild(chat.root)
|
|
502
472
|
root.appendChild(header)
|
|
503
473
|
root.appendChild(body)
|
|
504
474
|
|
|
505
|
-
function syncSections(): void {
|
|
506
|
-
for (const def of SECTION_DEFS) {
|
|
507
|
-
const sec = sectionBodies.get(def.key)!.parentElement!
|
|
508
|
-
sec.classList.toggle('is-collapsed', collapsed.has(def.key))
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
475
|
/** Move the data panes to their mode home: rail sections while the board
|
|
513
476
|
* owns the stage, the stage stack when the board is out. */
|
|
514
477
|
function placePanes(m: WorkbenchMode): void {
|
|
515
478
|
for (const [key, pane] of dataPanes) {
|
|
516
|
-
const home = m === 'canvas' ?
|
|
479
|
+
const home = m === 'canvas' ? railTabs.panel(key) : dataStack
|
|
480
|
+
// Data mode: the pane lives on the stage, so its tab steps aside.
|
|
481
|
+
railTabs.setHidden(key, m !== 'canvas')
|
|
517
482
|
if (pane.parentElement !== home) home.appendChild(pane)
|
|
518
483
|
}
|
|
519
484
|
}
|
|
@@ -554,284 +519,127 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
554
519
|
|
|
555
520
|
// ── Directory section ──
|
|
556
521
|
function renderSidebar(): void {
|
|
557
|
-
const box = clear(directoryBody)
|
|
558
522
|
// Filter chips reflect the live view state (built once, toggled here).
|
|
559
523
|
for (let i = 0; i < filterRow.children.length; i++) {
|
|
560
524
|
const node = filterRow.children[i] as HTMLElement
|
|
561
525
|
node.classList.toggle('is-active', node.dataset.filter === dirFilter)
|
|
562
526
|
}
|
|
563
527
|
if (last === null) {
|
|
564
|
-
|
|
565
|
-
|
|
528
|
+
directoryBody.replaceChildren(el('div', 'wb-muted'))
|
|
529
|
+
directoryBody.firstElementChild!.textContent = '载入中…'
|
|
530
|
+
railTabs.setBadge('directory', '')
|
|
566
531
|
return
|
|
567
532
|
}
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
item.appendChild(chip)
|
|
581
|
-
}
|
|
582
|
-
const section = (label: string, count: number): HTMLElement => {
|
|
583
|
-
const head = el('div', 'wb-side-head')
|
|
584
|
-
head.textContent = `${label} · ${count}`
|
|
585
|
-
box.appendChild(head)
|
|
586
|
-
return box
|
|
587
|
-
}
|
|
588
|
-
const sessions = state.sessions ?? []
|
|
589
|
-
const live = sessions.filter(row => row.live === true && admit('local', rowHaystack(displayName(row), row.team, row.label, row.id, row.canvasTeams)))
|
|
590
|
-
const cold = sessions.filter(row => row.live !== true && admit('local', rowHaystack(displayName(row), row.team, row.label, row.id, row.canvasTeams)))
|
|
591
|
-
sectionCounts.get('directory')!.textContent = `${live.length}/${cold.length}${logRows.length > 0 ? ` · 档 ${logRows.length}` : ''}`
|
|
592
|
-
if (live.length > 0) {
|
|
593
|
-
section('本机 · 现场', live.length)
|
|
594
|
-
for (const row of live) {
|
|
595
|
-
const item = el('button', 'wb-item')
|
|
596
|
-
item.type = 'button'
|
|
597
|
-
item.appendChild(el('span', 'wb-dot wb-dot-live')).textContent = '●'
|
|
598
|
-
item.appendChild(el('span', 'wb-item-label')).textContent = displayName(row)
|
|
599
|
-
badges(item, row.canvasTeams)
|
|
600
|
-
item.addEventListener('click', () => openChat({ kind: 'session', id: row.id, title: displayName(row), sub: row.team, team: row.team, live: row.live }))
|
|
601
|
-
box.appendChild(item)
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
if (cold.length > 0) {
|
|
605
|
-
section('本机 · 冷启动', cold.length)
|
|
606
|
-
for (const row of cold) {
|
|
607
|
-
const item = el('button', 'wb-item')
|
|
608
|
-
item.type = 'button'
|
|
609
|
-
item.appendChild(el('span', 'wb-dot wb-dot-cold')).textContent = '○'
|
|
610
|
-
item.appendChild(el('span', 'wb-item-label')).textContent = displayName(row)
|
|
611
|
-
badges(item, row.canvasTeams)
|
|
612
|
-
item.addEventListener('click', () => openChat({ kind: 'session', id: row.id, title: displayName(row), sub: row.team, team: row.team, live: row.live }))
|
|
613
|
-
box.appendChild(item)
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
const canvases = (state.canvas?.teams ?? []).filter(row =>
|
|
617
|
-
admit('canvas', rowHaystack(row.name, row.team, row.members.map(member => member.team ?? member.id))))
|
|
618
|
-
if (canvases.length > 0) {
|
|
619
|
-
section('Canvas 队', canvases.length)
|
|
620
|
-
for (const row of canvases) {
|
|
621
|
-
const item = el('button', 'wb-item')
|
|
622
|
-
item.type = 'button'
|
|
623
|
-
item.appendChild(el('span', 'wb-dot wb-dot-canvas')).textContent = '▤'
|
|
624
|
-
const remote = row.members.filter(member => member.remote === true).length
|
|
625
|
-
item.appendChild(el('span', 'wb-item-label')).textContent =
|
|
626
|
-
`${row.name} · ${row.members.length}${remote > 0 ? `(远端 ${remote})` : ''}`
|
|
627
|
-
item.addEventListener('click', () => select({ kind: 'canvas', row }))
|
|
628
|
-
box.appendChild(item)
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
const peers = (state.peers ?? []).filter(row => admit('peers', rowHaystack(row.url)))
|
|
632
|
-
if (peers.length > 0) {
|
|
633
|
-
section('Peers', peers.length)
|
|
634
|
-
for (const row of peers) {
|
|
635
|
-
const item = el('button', 'wb-item')
|
|
636
|
-
item.type = 'button'
|
|
637
|
-
item.appendChild(el('span', 'wb-dot wb-dot-peer')).textContent = '⇄'
|
|
638
|
-
item.appendChild(el('span', 'wb-item-label')).textContent =
|
|
639
|
-
row.url.replace(/^https?:\/\//, '') + (row.score !== undefined ? ` · ${row.score}` : '')
|
|
640
|
-
item.addEventListener('click', () => {
|
|
641
|
-
const host = row.url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
|
|
642
|
-
openChat({ kind: 'peer', id: `peer-${host}`, title: row.url, sub: host, url: row.url })
|
|
643
|
-
})
|
|
644
|
-
box.appendChild(item)
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
const remote = (state.remote ?? []).filter(row => admit('remote', rowHaystack(displayName(row), row.team, row.origin, row.workspace, row.canvasTeams)))
|
|
648
|
-
if (remote.length > 0) {
|
|
649
|
-
section('远端节点', remote.length)
|
|
650
|
-
for (const row of remote) {
|
|
651
|
-
const item = el('button', 'wb-item')
|
|
652
|
-
item.type = 'button'
|
|
653
|
-
item.appendChild(el('span', 'wb-dot wb-dot-remote')).textContent = '◇'
|
|
654
|
-
item.appendChild(el('span', 'wb-item-label')).textContent = displayName(row)
|
|
655
|
-
badges(item, row.canvasTeams)
|
|
656
|
-
// 0.6.15: remote rows open the chat drawer — the transcript now
|
|
657
|
-
// reads through the proxy instead of the where-the-records-live note.
|
|
658
|
-
item.addEventListener('click', () => openChat({ kind: 'remote', team: row.team, title: displayName(row), sub: row.team, row }))
|
|
659
|
-
box.appendChild(item)
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
// 0.6.12: 本机日志 — every local session artifact EXCEPT ids already shown
|
|
663
|
-
// as network rows (those open the same transcript from their own cards).
|
|
664
|
-
// This is the reachability fix for hosts whose cold rows are all archived:
|
|
665
|
-
// reading is host-scoped, the network directory is not the reading gate.
|
|
666
|
-
const networkIds = new Set(sessions.map(row => row.id))
|
|
667
|
-
const fmtSize = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
|
|
668
|
-
const logs = logRows.filter(row => !networkIds.has(row.id) && admit('local', rowHaystack(row.id)))
|
|
669
|
-
if (logs.length > 0) {
|
|
670
|
-
section('本机日志', logs.length)
|
|
671
|
-
for (const row of logs) {
|
|
672
|
-
const item = el('button', 'wb-item')
|
|
673
|
-
item.type = 'button'
|
|
674
|
-
item.appendChild(el('span', 'wb-dot')).textContent = '❐'
|
|
675
|
-
const label = el('span', 'wb-item-label')
|
|
676
|
-
label.textContent = `…${row.id.slice(-12)} · ${fmtSize(row.sizeBytes)}`
|
|
677
|
-
item.title = `${row.id}\n${fmtSize(row.sizeBytes)} · ${new Date(row.mtimeMs).toLocaleString()}`
|
|
678
|
-
item.appendChild(label)
|
|
679
|
-
item.addEventListener('click', () => openChat({ kind: 'log', id: row.id, title: `…${row.id.slice(-12)}`, sub: '档案' }))
|
|
680
|
-
box.appendChild(item)
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
if (filtering && live.length + cold.length + canvases.length + peers.length + remote.length + logs.length === 0) {
|
|
684
|
-
box.appendChild(el('div', 'wb-muted')).textContent = '无匹配条目'
|
|
685
|
-
}
|
|
533
|
+
const badge = renderDirectory(directoryBody, {
|
|
534
|
+
state: last,
|
|
535
|
+
logs: logRows,
|
|
536
|
+
logTotal,
|
|
537
|
+
titles: logTitles,
|
|
538
|
+
search: searchText,
|
|
539
|
+
filter: dirFilter,
|
|
540
|
+
now: deps.now(),
|
|
541
|
+
openChat,
|
|
542
|
+
selectCanvas: (row) => select({ kind: 'canvas', row }),
|
|
543
|
+
})
|
|
544
|
+
railTabs.setBadge('directory', badge)
|
|
686
545
|
}
|
|
687
546
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
*
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
for (const row of rows) {
|
|
703
|
-
const age = now - row.startedAt
|
|
704
|
-
const index = AGE_BUCKETS.findIndex(bucket => age < bucket.maxMs)
|
|
705
|
-
counts[index === -1 ? counts.length - 1 : index]! += 1
|
|
547
|
+
/**
|
|
548
|
+
* gh#20 W-C: the host rail — one row per tracked peer host. Each row is a
|
|
549
|
+
* HOST card: operator-facing label (the peer's published card name, host
|
|
550
|
+
* fallback), the W-A tri-state badge (link class + verified mark), a
|
|
551
|
+
* health lamp driven by the last-contact recency (green ≤60s, amber
|
|
552
|
+
* ≤5min, gray older/never — tooltip carries the exact timestamp), and the
|
|
553
|
+
* peer's published version chip. Clicking drills down: the peer chat
|
|
554
|
+
* drawer (remote log entry) rides the existing face.
|
|
555
|
+
*/
|
|
556
|
+
function renderHosts(): void {
|
|
557
|
+
const pane = clear(hostsPane)
|
|
558
|
+
if (last === null) {
|
|
559
|
+
railTabs.setBadge('hosts', '')
|
|
560
|
+
return
|
|
706
561
|
}
|
|
707
|
-
return counts
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/** Top teams by pending-row count, most loaded first (bounded to 5). */
|
|
711
|
-
function topTeams(rows: readonly PendingRow[]): Array<[string, number]> {
|
|
712
|
-
const counts = new Map<string, number>()
|
|
713
|
-
for (const row of rows) counts.set(row.team, (counts.get(row.team) ?? 0) + 1)
|
|
714
|
-
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function renderLedger(): void {
|
|
718
|
-
const pane = clear(ledgerPane)
|
|
719
|
-
if (last === null) return
|
|
720
562
|
const now = deps.now()
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
stats.appendChild(statChip('待回执', pending.length,
|
|
730
|
-
'等待回执的任务(pending)。其中「陈旧」= 超过 1 小时仍未回执,或本就无回执债(fire-and-forget)'))
|
|
731
|
-
stats.appendChild(statChip('陈旧', stale,
|
|
732
|
-
'陈旧尾:≥1h 无回执的旧账 + receiptExpected:false 的免回执行。它们到 24h TTL 自动转死信,不算可催办债务',
|
|
733
|
-
stale > 0 ? 'warn' : undefined))
|
|
734
|
-
stats.appendChild(statChip('死信', dead.length,
|
|
735
|
-
'超过 24h TTL 仍未结算,或目标早已离开。需清账(abandon)才能移出',
|
|
736
|
-
dead.length > 0 ? 'danger' : undefined))
|
|
737
|
-
stats.appendChild(statChip('已归档', archived,
|
|
738
|
-
'已结算并留档的任务(回执已关联)。用于审计,不再占待回执名额'))
|
|
739
|
-
pane.appendChild(stats)
|
|
740
|
-
|
|
741
|
-
// Archive verdict split: how those settled rows actually ended.
|
|
742
|
-
const split = last.archivedSplit
|
|
743
|
-
if (archived > 0 && split !== undefined) {
|
|
744
|
-
const entries = Object.entries(split).sort((a, b) => b[1] - a[1])
|
|
745
|
-
if (entries.length > 0) {
|
|
746
|
-
const line = el('div', 'wb-pane-head')
|
|
747
|
-
line.textContent = `归档结局 · ${entries.map(([key, count]) => `${key} ${count}`).join(' · ')}`
|
|
748
|
-
pane.appendChild(line)
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// Forensics (moved off the header): the plumbing counters a seat reads
|
|
753
|
-
// when something looks off — muted, never the owner's first line.
|
|
754
|
-
const idem = last.idempotency
|
|
755
|
-
const reconcileState = last.reconcile?.state
|
|
756
|
-
if (idem !== undefined || reconcileState !== undefined) {
|
|
757
|
-
const parts: string[] = []
|
|
758
|
-
if (idem !== undefined) parts.push(`幂等 claims ${idem.claimsFresh ?? 0} · replays ${idem.replays ?? 0} · conflicts ${idem.conflicts ?? 0}`)
|
|
759
|
-
if (reconcileState !== undefined) parts.push(`reconcile ${reconcileState}`)
|
|
760
|
-
const line = el('div', 'wb-pane-head wb-forensics')
|
|
761
|
-
line.textContent = `取证 · ${parts.join(' · ')}`
|
|
762
|
-
pane.appendChild(line)
|
|
563
|
+
const peers = [...(last.peers ?? [])].sort((a, b) => {
|
|
564
|
+
const rank = (row: { link?: string }): number => row.link === 'linked' ? 0 : 1
|
|
565
|
+
return rank(a) - rank(b) || (b.score ?? 0) - (a.score ?? 0)
|
|
566
|
+
})
|
|
567
|
+
railTabs.setBadge('hosts', peers.length > 0 ? `${peers.filter(row => row.link === 'linked').length}/${peers.length}` : '')
|
|
568
|
+
if (peers.length === 0) {
|
|
569
|
+
pane.appendChild(el('div', 'wb-nodelist-empty')).textContent = '暂无 tracked peer——配 peers 种子或等 referral 到达'
|
|
570
|
+
return
|
|
763
571
|
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
572
|
+
for (const row of peers) {
|
|
573
|
+
const host = peerHostOf(row.url)
|
|
574
|
+
const lineEl = el('div', 'wb-host-row')
|
|
575
|
+
// Health lamp (gh#24 semantics): recency alone lied — a FAILED fresh
|
|
576
|
+
// contact glowed green. Tiers by age: ≤60s live / ≤5min warm /
|
|
577
|
+
// beyond cold, overridden by the OUTCOME: a recent failure (≤5min)
|
|
578
|
+
// is red regardless of the live/warm band, and a never-contacted
|
|
579
|
+
// peer is the hollow unknown state.
|
|
580
|
+
const lamp = el('span', 'wb-host-lamp')
|
|
581
|
+
const last = row.lastContactAt
|
|
582
|
+
const age = last === undefined ? undefined : now - last
|
|
583
|
+
const ok = row.lastContactOk
|
|
584
|
+
if (age === undefined) lamp.classList.add('is-unknown')
|
|
585
|
+
else if (ok === false && age <= 5 * 60_000) lamp.classList.add('is-fail')
|
|
586
|
+
else if (age <= 60_000) lamp.classList.add('is-live')
|
|
587
|
+
else if (age <= 5 * 60_000) lamp.classList.add('is-warm')
|
|
588
|
+
else lamp.classList.add('is-cold')
|
|
589
|
+
lamp.title = last === undefined
|
|
590
|
+
? '尚无接触记录'
|
|
591
|
+
: `最近接触 ${ageText(last, now)}${ok === false ? '(失败)' : ok === true ? '(成功)' : ''}`
|
|
592
|
+
lineEl.appendChild(lamp)
|
|
593
|
+
// Label: the peer's published name, host fallback.
|
|
594
|
+
lineEl.appendChild(el('span', 'wb-host-name')).textContent = row.name !== undefined && row.name !== '' ? row.name : host
|
|
595
|
+
// W-A badge: link class + verified mark.
|
|
596
|
+
const badge = el('span', `wb-host-badge ${row.link === 'linked' ? 'is-linked' : 'is-stranger'}`)
|
|
597
|
+
badge.textContent = `${row.link === 'linked' ? 'linked' : 'stranger'} ${row.verified === true ? '✓' : '✗'}`
|
|
598
|
+
// The tooltip follows the verified mark (gh#24): a linked ✗ must not
|
|
599
|
+
// claim a valid signature.
|
|
600
|
+
badge.title = row.link === 'linked'
|
|
601
|
+
? `已建立运营者链接(互种或 canvas 引用);${row.verified === true ? '验签有效' : '尚未持有验签通过的卡片'}`
|
|
602
|
+
: '无运营者关系(验签 ≠ 关系)'
|
|
603
|
+
lineEl.appendChild(badge)
|
|
604
|
+
// Version chip from the warm card.
|
|
605
|
+
if (row.version !== undefined && row.version !== '') {
|
|
606
|
+
lineEl.appendChild(el('span', 'wb-host-ver')).textContent = row.version
|
|
772
607
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
608
|
+
lineEl.appendChild(el('span', 'wb-host-url mono')).textContent = host
|
|
609
|
+
lineEl.setAttribute('role', 'button')
|
|
610
|
+
lineEl.tabIndex = 0
|
|
611
|
+
lineEl.title = `${row.url} · score ${String(row.score ?? 0)}`
|
|
612
|
+
const drill = (): void => { openChat({ kind: 'peer', id: `peer-${host}`, title: row.name !== undefined && row.name !== '' ? row.name : host, sub: host, url: row.url }) }
|
|
613
|
+
lineEl.addEventListener('click', drill)
|
|
614
|
+
lineEl.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); drill() } })
|
|
615
|
+
pane.appendChild(lineEl)
|
|
778
616
|
}
|
|
617
|
+
}
|
|
779
618
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
brief.textContent = briefText
|
|
791
|
-
brief.title = briefText
|
|
792
|
-
line.appendChild(brief)
|
|
793
|
-
const badges: string[] = []
|
|
794
|
-
if (row.settleable === true) badges.push('可结算')
|
|
795
|
-
if (row.receiptExpected === false) badges.push('无回执债')
|
|
796
|
-
if (row.direction === 'inbound') badges.push('入站')
|
|
797
|
-
const meta = el('span', 'wb-row-meta-line')
|
|
798
|
-
meta.textContent = `${row.taskId} · ${row.team} · ${ageText(row.startedAt, now)}${badges.length > 0 ? ` · ${badges.join(' ')}` : ''}`
|
|
799
|
-
line.appendChild(meta)
|
|
800
|
-
table.appendChild(line)
|
|
801
|
-
}
|
|
802
|
-
pane.appendChild(table)
|
|
803
|
-
if (dead.length > 0) {
|
|
804
|
-
const deadHead = el('div', 'wb-pane-subhead')
|
|
805
|
-
deadHead.textContent = '死信(可清账)'
|
|
806
|
-
pane.appendChild(deadHead)
|
|
807
|
-
const deadTable = el('div', 'wb-table wb-table-dead')
|
|
808
|
-
for (const row of dead) {
|
|
809
|
-
// Same results-first shape, always exception-colored: dead letters
|
|
810
|
-
// ARE the alarm.
|
|
811
|
-
const line = el('div', 'wb-row wb-row-result wb-row-alert')
|
|
812
|
-
const deadBriefText = row.brief ?? '(无摘要)'
|
|
813
|
-
const deadBrief = el('span', 'wb-row-head')
|
|
814
|
-
deadBrief.textContent = deadBriefText
|
|
815
|
-
deadBrief.title = deadBriefText
|
|
816
|
-
line.appendChild(deadBrief)
|
|
817
|
-
const meta = el('span', 'wb-row-meta-line')
|
|
818
|
-
meta.textContent = `${row.taskId} · ${row.team} · ${ageText(row.deadAt, now)}${row.reason !== undefined && row.reason !== '' ? ` · ${row.reason}` : ''}`
|
|
819
|
-
line.appendChild(meta)
|
|
820
|
-
const btn = el('button', 'wb-btn')
|
|
821
|
-
btn.type = 'button'
|
|
822
|
-
btn.textContent = '清账'
|
|
823
|
-
btn.addEventListener('click', () => {
|
|
824
|
-
btn.disabled = true
|
|
825
|
-
void deps.abandon(row.taskId, 'workbench dead-letter cleanup').then(ok => {
|
|
826
|
-
btn.disabled = false
|
|
827
|
-
btn.textContent = ok ? '已清' : '失败'
|
|
828
|
-
})
|
|
829
|
-
})
|
|
830
|
-
line.appendChild(btn)
|
|
831
|
-
deadTable.appendChild(line)
|
|
832
|
-
}
|
|
833
|
-
pane.appendChild(deadTable)
|
|
619
|
+
// ── Ledger pane (wb-ledger) ──
|
|
620
|
+
function renderLedger(): void {
|
|
621
|
+
if (last === null) { ledgerPane.replaceChildren(); railTabs.setBadge('ledger', ''); return }
|
|
622
|
+
const state = last
|
|
623
|
+
const resolve = (team: string): ChatTarget | undefined => {
|
|
624
|
+
const session = (state.sessions ?? []).find(row => row.team === team)
|
|
625
|
+
if (session !== undefined) return { kind: 'session', id: session.id, title: displayName(session), sub: session.team, team: session.team, live: session.live }
|
|
626
|
+
const remote = (state.remote ?? []).find(row => row.team === team)
|
|
627
|
+
if (remote !== undefined) return { kind: 'remote', team: remote.team, title: displayName(remote), sub: remote.team, row: remote }
|
|
628
|
+
return undefined
|
|
834
629
|
}
|
|
630
|
+
const out = paintLedger(ledgerPane, {
|
|
631
|
+
state,
|
|
632
|
+
now: deps.now(),
|
|
633
|
+
abandon: (taskId, reason) => deps.abandon(taskId, reason),
|
|
634
|
+
canOpenTeam: (team) => resolve(team) !== undefined,
|
|
635
|
+
openTeam: (team) => {
|
|
636
|
+
const target = resolve(team)
|
|
637
|
+
if (target === undefined) return false
|
|
638
|
+
openChat(target)
|
|
639
|
+
return true
|
|
640
|
+
},
|
|
641
|
+
})
|
|
642
|
+
railTabs.setBadge('ledger', out.badge, out.tone)
|
|
835
643
|
}
|
|
836
644
|
|
|
837
645
|
// ── Activity pane ──
|
|
@@ -840,7 +648,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
840
648
|
if (last === null) return
|
|
841
649
|
const now = deps.now()
|
|
842
650
|
const flights = last.inFlight ?? []
|
|
843
|
-
|
|
651
|
+
railTabs.setBadge('activity', flights.length > 0 ? String(flights.length) : '', flights.length > 0 ? 'accent' : undefined)
|
|
844
652
|
const head = el('div', 'wb-pane-head')
|
|
845
653
|
head.textContent = `在途 ${flights.length} · 活动 ${last.activity?.length ?? 0}`
|
|
846
654
|
pane.appendChild(head)
|
|
@@ -881,23 +689,24 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
881
689
|
function planActions(state: StateBody): Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> {
|
|
882
690
|
const now = deps.now()
|
|
883
691
|
const actions: Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> = []
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
692
|
+
// One definition with the 台账 tab: fire-forget expiries are bookkeeping,
|
|
693
|
+
// not dead letters (review L1), and only OUR stale sends are nudgeable.
|
|
694
|
+
const facts = ledgerFacts(state, now)
|
|
695
|
+
if (facts.deadLetters.length > 0) {
|
|
696
|
+
actions.push({ severity: 'high', text: `清账 ${facts.deadLetters.length} 条死信(目标早已离开或死信化,无回执可期)` })
|
|
887
697
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
}
|
|
698
|
+
// (The old `age > 1h && settleable === true` never fired against a real
|
|
699
|
+
// host — the server's settleable means receipt-expected AND age < 1h.)
|
|
700
|
+
for (const row of facts.stale) {
|
|
701
|
+
// Quote the original ask: a nudge that only repeats a task id makes
|
|
702
|
+
// the target re-derive WHAT was owed, and the target is exactly the
|
|
703
|
+
// session that already failed to answer once.
|
|
704
|
+
const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
|
|
705
|
+
actions.push({
|
|
706
|
+
severity: 'normal',
|
|
707
|
+
text: `催办 ${row.taskId} → ${row.team}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
|
|
708
|
+
brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
|
|
709
|
+
})
|
|
901
710
|
}
|
|
902
711
|
for (const row of state.sessions ?? []) {
|
|
903
712
|
if (row.live !== true) {
|
|
@@ -920,7 +729,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
920
729
|
const pane = clear(planPane)
|
|
921
730
|
if (last === null) return
|
|
922
731
|
const actions = planActions(last)
|
|
923
|
-
|
|
732
|
+
railTabs.setBadge('plan', actions.length > 0 ? String(actions.length) : '', actions.some(a => a.severity === 'high') ? 'danger' : undefined)
|
|
924
733
|
const head = el('div', 'wb-pane-head')
|
|
925
734
|
head.textContent = actions.length === 0 ? '网络健康——无待办行动' : `待办行动 ${actions.length}`
|
|
926
735
|
pane.appendChild(head)
|
|
@@ -989,8 +798,125 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
989
798
|
}
|
|
990
799
|
|
|
991
800
|
// ── Inspector (overlay card over the stage; bottom sheet on narrow) ──
|
|
801
|
+
// gh#34 owner panel state + render (the gear toggles; the inspector hosts).
|
|
802
|
+
let ownerPanelOpen = false
|
|
803
|
+
let ownerState: { teamJoinAllowlist: string[]; teamScopeInfo: boolean; announce: boolean; announceRestartPending: boolean } | null = null
|
|
804
|
+
function renderOwnerPanel(): void {
|
|
805
|
+
renderInspector()
|
|
806
|
+
if (!ownerPanelOpen) return
|
|
807
|
+
void deps.ownerSettingsRead?.().then(result => {
|
|
808
|
+
if (!ownerPanelOpen) return
|
|
809
|
+
if (result.ok !== true) { ownerState = null; renderInspector(); return }
|
|
810
|
+
ownerState = {
|
|
811
|
+
teamJoinAllowlist: result.teamJoinAllowlist ?? [],
|
|
812
|
+
teamScopeInfo: result.teamScopeInfo !== false,
|
|
813
|
+
announce: result.announce === true,
|
|
814
|
+
announceRestartPending: result.announceRestartPending === true,
|
|
815
|
+
}
|
|
816
|
+
renderInspector()
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
function renderOwnerPanelInto(box: HTMLElement): void {
|
|
820
|
+
const card = el('div', 'wb-card')
|
|
821
|
+
const head = el('div', 'wb-inspector-head')
|
|
822
|
+
head.appendChild(el('span', 'wb-inspector-title')).textContent = '主人设置'
|
|
823
|
+
const closeBtn = el('button', 'wb-icon-btn')
|
|
824
|
+
closeBtn.type = 'button'
|
|
825
|
+
closeBtn.setAttribute('aria-label', '关闭主人设置')
|
|
826
|
+
closeBtn.textContent = '✕'
|
|
827
|
+
closeBtn.addEventListener('click', () => { ownerPanelOpen = false; renderInspector() })
|
|
828
|
+
head.appendChild(closeBtn)
|
|
829
|
+
card.appendChild(head)
|
|
830
|
+
if (deps.ownerSettingsRead === undefined) {
|
|
831
|
+
const note = el('div', 'wb-hint')
|
|
832
|
+
note.textContent = '此宿主未提供主人设置面(旧版本)——升级后可用。'
|
|
833
|
+
card.appendChild(note)
|
|
834
|
+
box.appendChild(card)
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
if (ownerState === null) {
|
|
838
|
+
const note = el('div', 'wb-hint')
|
|
839
|
+
note.textContent = '读取中…'
|
|
840
|
+
card.appendChild(note)
|
|
841
|
+
box.appendChild(card)
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
const st = ownerState
|
|
845
|
+
// ① 入网白名单(精确团队名增删)
|
|
846
|
+
const alHead = el('div', 'wb-side-head')
|
|
847
|
+
alHead.textContent = '入网白名单(精确团队名)'
|
|
848
|
+
card.appendChild(alHead)
|
|
849
|
+
for (const name of st.teamJoinAllowlist) {
|
|
850
|
+
const row = el('div', 'wb-member-row')
|
|
851
|
+
row.appendChild(el('span', 'wb-member-label')).textContent = name
|
|
852
|
+
const del = el('button', 'wb-member-remove')
|
|
853
|
+
del.type = 'button'
|
|
854
|
+
del.textContent = '移除'
|
|
855
|
+
del.addEventListener('click', () => {
|
|
856
|
+
void deps.ownerSettingsWrite?.({ teamJoinAllowlist: st.teamJoinAllowlist.filter(n => n !== name) }).then(() => renderOwnerPanel())
|
|
857
|
+
})
|
|
858
|
+
row.appendChild(del)
|
|
859
|
+
card.appendChild(row)
|
|
860
|
+
}
|
|
861
|
+
const addForm = el('div', 'wb-member-add')
|
|
862
|
+
const input = el('input', 'wb-member-manual')
|
|
863
|
+
input.type = 'text'
|
|
864
|
+
input.placeholder = 'dsh/团队名(精确,无通配)'
|
|
865
|
+
input.setAttribute('aria-label', '新增白名单团队名')
|
|
866
|
+
const addBtn = el('button', 'wb-member-submit')
|
|
867
|
+
addBtn.type = 'button'
|
|
868
|
+
addBtn.textContent = '加入'
|
|
869
|
+
const doAdd = (): void => {
|
|
870
|
+
const v = input.value.trim()
|
|
871
|
+
if (v === '' || st.teamJoinAllowlist.includes(v)) return
|
|
872
|
+
void deps.ownerSettingsWrite?.({ teamJoinAllowlist: [...st.teamJoinAllowlist, v] }).then(() => renderOwnerPanel())
|
|
873
|
+
}
|
|
874
|
+
addBtn.addEventListener('click', doAdd)
|
|
875
|
+
input.addEventListener('keydown', ev => { if (ev.key === 'Enter') doAdd() })
|
|
876
|
+
addForm.append(input, addBtn)
|
|
877
|
+
card.appendChild(addForm)
|
|
878
|
+
// ② 可见性开关 + 精度固化文案
|
|
879
|
+
const tsHead = el('div', 'wb-side-head')
|
|
880
|
+
tsHead.textContent = '目录可见性(teamScopeInfo)'
|
|
881
|
+
card.appendChild(tsHead)
|
|
882
|
+
const tsRow = el('div', 'wb-member-row')
|
|
883
|
+
const tsBtn = el('button', 'wb-member-submit')
|
|
884
|
+
tsBtn.type = 'button'
|
|
885
|
+
tsBtn.textContent = st.teamScopeInfo ? '已开:仅显示已加入团队(点击放开为全舰队可见)' : '已关:全舰队可见(点击收窄为已加入团队)'
|
|
886
|
+
tsBtn.addEventListener('click', () => {
|
|
887
|
+
void deps.ownerSettingsWrite?.({ teamScopeInfo: !st.teamScopeInfo }).then(() => renderOwnerPanel())
|
|
888
|
+
})
|
|
889
|
+
tsRow.appendChild(tsBtn)
|
|
890
|
+
card.appendChild(tsRow)
|
|
891
|
+
const tsNote = el('div', 'wb-hint')
|
|
892
|
+
tsNote.textContent = '精度说明:teamScopeInfo 只管「目录显示范围」。关上它不会解锁任何通信准入——会话入网(S3)与目标级路由(F5)规则原样生效。'
|
|
893
|
+
card.appendChild(tsNote)
|
|
894
|
+
// ③ 发布开关(重启生效)
|
|
895
|
+
const anHead = el('div', 'wb-side-head')
|
|
896
|
+
anHead.textContent = '节点发布(announce)'
|
|
897
|
+
card.appendChild(anHead)
|
|
898
|
+
const anRow = el('div', 'wb-member-row')
|
|
899
|
+
const anBtn = el('button', 'wb-member-submit')
|
|
900
|
+
anBtn.type = 'button'
|
|
901
|
+
anBtn.textContent = st.announce ? '已开:本节点发布签名卡(点击关闭)' : '已关:本节点不发布(点击开启——重启宿主后生效)'
|
|
902
|
+
anBtn.addEventListener('click', () => {
|
|
903
|
+
void deps.ownerSettingsWrite?.({ announce: !st.announce }).then(() => renderOwnerPanel())
|
|
904
|
+
})
|
|
905
|
+
anRow.appendChild(anBtn)
|
|
906
|
+
card.appendChild(anRow)
|
|
907
|
+
if (st.announceRestartPending) {
|
|
908
|
+
const anNote = el('div', 'wb-hint')
|
|
909
|
+
anNote.textContent = '有 announce 变更待生效:重启宿主后按新配置发布。'
|
|
910
|
+
card.appendChild(anNote)
|
|
911
|
+
}
|
|
912
|
+
box.appendChild(card)
|
|
913
|
+
}
|
|
914
|
+
|
|
992
915
|
function renderInspector(): void {
|
|
993
916
|
const box = clear(inspectorBody)
|
|
917
|
+
// gh#34: the owner panel owns the inspector when open (selection cards
|
|
918
|
+
// yield; the gear closes it back).
|
|
919
|
+
if (ownerPanelOpen) { inspector.classList.add('is-open'); renderOwnerPanelInto(box); return }
|
|
994
920
|
inspector.classList.toggle('is-open', selection !== undefined)
|
|
995
921
|
if (selection === undefined) return
|
|
996
922
|
const sel = selection
|
|
@@ -1075,6 +1001,14 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
1075
1001
|
}
|
|
1076
1002
|
let remoteLabeled = false
|
|
1077
1003
|
for (const remote of last?.remote ?? []) {
|
|
1004
|
+
// gh#37: only zone/id8 HANDLES are addable members — a bare zone row
|
|
1005
|
+
// (the peer's own team card, e.g. 'dsh') fails the backend shape
|
|
1006
|
+
// gate and the old picker offered it anyway: the owner clicked 远端,
|
|
1007
|
+
// got refused, and read the feature as broken. Rows without a '/'
|
|
1008
|
+
// are skipped; when the directory carries no handles at all the
|
|
1009
|
+
// hint explains why (unlinked peers serve the minimal card, which
|
|
1010
|
+
// carries no session teams) instead of offering dead options.
|
|
1011
|
+
if (!remote.team.includes('/')) continue
|
|
1078
1012
|
if (memberIds.has(remote.team)) continue
|
|
1079
1013
|
if (!remoteLabeled) {
|
|
1080
1014
|
pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
|
|
@@ -1082,11 +1016,14 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
1082
1016
|
}
|
|
1083
1017
|
pick.appendChild(new Option(`${displayName(remote)} — 远端`, remote.team))
|
|
1084
1018
|
}
|
|
1019
|
+
if (!remoteLabeled) {
|
|
1020
|
+
pick.appendChild(new Option('(暂无可拉入的远端会话队——见下方说明)', '', true, false))
|
|
1021
|
+
}
|
|
1085
1022
|
if (keep !== '' && Array.from(pick.options).some(option => option.value === keep)) pick.value = keep
|
|
1086
1023
|
card.appendChild(canvasForm.form)
|
|
1087
1024
|
const hint = el('div', 'wb-hint')
|
|
1088
1025
|
hint.textContent =
|
|
1089
|
-
'成员由人类在此手工分配(agent
|
|
1026
|
+
'成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。远端下拉只列 zone/id8 会话句柄——若为空,说明目录里还没有已链接对端的会话队(未链接的对端只给最小卡);此时可在输入框手输句柄(如 dsh/xxxxxxxx)先把人拉进来,句柄本身即链接手势。'
|
|
1090
1027
|
card.appendChild(hint)
|
|
1091
1028
|
// Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
|
|
1092
1029
|
// first, then the remote members in order — the seam's own priority.
|
|
@@ -1156,289 +1093,11 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
1156
1093
|
return wrap
|
|
1157
1094
|
}
|
|
1158
1095
|
|
|
1159
|
-
// ── Inline transcript (0.6.11, remote 0.6.15) ──
|
|
1160
|
-
// The tail of a session's conversation log, read-only, on this page —
|
|
1161
|
-
// local sessions read the host log face; remote sessions read through
|
|
1162
|
-
// the proxy. Cached per cache key across the 3s poll remounts; the
|
|
1163
|
-
// refresh button forces one re-read. Reading — not jumping — is the
|
|
1164
|
-
// interaction.
|
|
1165
|
-
type TranscriptState = { ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string } | undefined
|
|
1166
|
-
|
|
1167
|
-
function transcriptBlock(cacheKey: string, load: () => Promise<TranscriptState>): HTMLElement {
|
|
1168
|
-
const block = el('div', 'wb-transcript')
|
|
1169
|
-
const head = el('div', 'wb-side-head')
|
|
1170
|
-
// Owner signal (2026-10-21): log reading is the SEAT's forensics face —
|
|
1171
|
-
// it defaults to a collapsed detail (展开 once per transcript; the
|
|
1172
|
-
// expansion persists across the 3s poll remounts). The owner reads
|
|
1173
|
-
// results in the ledger; seats click 展开 when they need the record.
|
|
1174
|
-
const expand = el('button', 'wb-transcript-refresh')
|
|
1175
|
-
expand.type = 'button'
|
|
1176
|
-
const paintExpand = (): void => {
|
|
1177
|
-
expand.textContent = expandedTranscripts.has(cacheKey) ? '收起取证' : '展开取证'
|
|
1178
|
-
}
|
|
1179
|
-
expand.addEventListener('click', () => {
|
|
1180
|
-
if (expandedTranscripts.has(cacheKey)) expandedTranscripts.delete(cacheKey)
|
|
1181
|
-
else expandedTranscripts.add(cacheKey)
|
|
1182
|
-
paintExpand()
|
|
1183
|
-
body.classList.toggle('is-folded', !expandedTranscripts.has(cacheKey))
|
|
1184
|
-
if (expandedTranscripts.has(cacheKey)) settle()
|
|
1185
|
-
})
|
|
1186
|
-
paintExpand()
|
|
1187
|
-
head.appendChild(expand)
|
|
1188
|
-
head.appendChild(el('span', 'wb-transcript-title')).textContent = '最近对话'
|
|
1189
|
-
const refresh = el('button', 'wb-transcript-refresh')
|
|
1190
|
-
refresh.type = 'button'
|
|
1191
|
-
refresh.textContent = '刷新'
|
|
1192
|
-
head.appendChild(refresh)
|
|
1193
|
-
block.appendChild(head)
|
|
1194
|
-
const body = el('div', 'wb-transcript-body')
|
|
1195
|
-
if (!expandedTranscripts.has(cacheKey)) body.classList.add('is-folded')
|
|
1196
|
-
block.appendChild(body)
|
|
1197
|
-
// 0.6.17 chat-UI conventions: the reading position survives the 3s poll
|
|
1198
|
-
// remount (scroll captured on scroll, restored after every paint), and a
|
|
1199
|
-
// FIRST successful load lands on the newest turn (bottom) like every
|
|
1200
|
-
// chat surface. Refresh explicitly re-lands on the bottom.
|
|
1201
|
-
let landed = transcriptScroll.has(cacheKey)
|
|
1202
|
-
let landOnBottom = !landed
|
|
1203
|
-
const settle = (): void => {
|
|
1204
|
-
if (landOnBottom) {
|
|
1205
|
-
body.scrollTop = body.scrollHeight
|
|
1206
|
-
landOnBottom = false
|
|
1207
|
-
landed = true
|
|
1208
|
-
} else if (transcriptScroll.has(cacheKey)) {
|
|
1209
|
-
body.scrollTop = transcriptScroll.get(cacheKey) ?? 0
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
body.addEventListener('scroll', () => {
|
|
1213
|
-
if (body.scrollTop > 0 || landed) transcriptScroll.set(cacheKey, body.scrollTop)
|
|
1214
|
-
})
|
|
1215
|
-
const paint = (state: TranscriptState): void => {
|
|
1216
|
-
body.replaceChildren()
|
|
1217
|
-
if (state === undefined) {
|
|
1218
|
-
body.appendChild(el('div', 'wb-muted')).textContent = '读取中…'
|
|
1219
|
-
return
|
|
1220
|
-
}
|
|
1221
|
-
if (state.ok !== true || state.messages === undefined) {
|
|
1222
|
-
body.appendChild(el('div', 'wb-muted')).textContent = `无法读取(${state.error ?? '未知错误'})`
|
|
1223
|
-
return
|
|
1224
|
-
}
|
|
1225
|
-
if (state.messages.length === 0) {
|
|
1226
|
-
body.appendChild(el('div', 'wb-muted')).textContent = '暂无对话记录'
|
|
1227
|
-
return
|
|
1228
|
-
}
|
|
1229
|
-
for (const message of state.messages) {
|
|
1230
|
-
const row = el('div', `wb-transcript-row wb-transcript-${message.role === 'user' ? 'user' : 'assistant'}`)
|
|
1231
|
-
row.appendChild(el('span', 'wb-transcript-role')).textContent = message.role === 'user' ? '问' : '答'
|
|
1232
|
-
row.appendChild(el('span', 'wb-transcript-text')).textContent = message.text
|
|
1233
|
-
body.appendChild(row)
|
|
1234
|
-
}
|
|
1235
|
-
// Scroll writes only stick on ATTACHED elements: the synchronous
|
|
1236
|
-
// cache-hit path paints before the block joins the DOM, so the
|
|
1237
|
-
// settle must run on the next frame, after append + layout.
|
|
1238
|
-
requestAnimationFrame(() => {
|
|
1239
|
-
if (body.isConnected) settle()
|
|
1240
|
-
})
|
|
1241
|
-
}
|
|
1242
|
-
const run = (force: boolean): void => {
|
|
1243
|
-
if (force) landOnBottom = true
|
|
1244
|
-
if (!force && transcriptCache.has(cacheKey)) {
|
|
1245
|
-
paint(transcriptCache.get(cacheKey))
|
|
1246
|
-
return
|
|
1247
|
-
}
|
|
1248
|
-
transcriptCache.set(cacheKey, undefined)
|
|
1249
|
-
paint(undefined)
|
|
1250
|
-
void load().then(result => {
|
|
1251
|
-
transcriptCache.set(cacheKey, result)
|
|
1252
|
-
// The poll may have remounted the drawer since; only the live
|
|
1253
|
-
// body repaints, the cache carries the result to the next mount.
|
|
1254
|
-
if (body.isConnected) paint(result)
|
|
1255
|
-
})
|
|
1256
|
-
}
|
|
1257
|
-
refresh.addEventListener('click', () => run(true))
|
|
1258
|
-
run(false)
|
|
1259
|
-
return block
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
function localTranscript(id: string): HTMLElement {
|
|
1263
|
-
return transcriptBlock(`local:${id}`, async () => await deps.sessionLog?.(id) ?? { ok: false, error: 'sessionLog unavailable' })
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
function remoteTranscript(cacheKey: string, query: { team?: string; peer?: string; id?: string }): HTMLElement {
|
|
1267
|
-
return transcriptBlock(cacheKey, async () => await deps.remoteSessionLog?.(query) ?? { ok: false, error: 'remoteSessionLog unavailable' })
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
/** Peer-host session listing (0.6.15): the list face of /a2a/log through
|
|
1271
|
-
* the proxy — recent readable sessions on that peer, click to drill. */
|
|
1272
|
-
function peerLogList(url: string): HTMLElement {
|
|
1273
|
-
const block = el('div', 'wb-transcript')
|
|
1274
|
-
const head = el('div', 'wb-side-head')
|
|
1275
|
-
head.textContent = '对端最近会话'
|
|
1276
|
-
block.appendChild(head)
|
|
1277
|
-
const body = el('div', 'wb-transcript-body')
|
|
1278
|
-
block.appendChild(body)
|
|
1279
|
-
const cacheKey = `peerlist:${url}`
|
|
1280
|
-
const paint = (state: { ok: boolean; sessions?: Array<{ id: string; sizeBytes: number }>; error?: string } | undefined): void => {
|
|
1281
|
-
body.replaceChildren()
|
|
1282
|
-
if (state === undefined) {
|
|
1283
|
-
body.appendChild(el('div', 'wb-muted')).textContent = '读取中…'
|
|
1284
|
-
return
|
|
1285
|
-
}
|
|
1286
|
-
if (state.ok !== true || state.sessions === undefined) {
|
|
1287
|
-
body.appendChild(el('div', 'wb-muted')).textContent = `无法读取(${state.error ?? '未知错误'})`
|
|
1288
|
-
return
|
|
1289
|
-
}
|
|
1290
|
-
if (state.sessions.length === 0) {
|
|
1291
|
-
body.appendChild(el('div', 'wb-muted')).textContent = '对端没有可读会话'
|
|
1292
|
-
return
|
|
1293
|
-
}
|
|
1294
|
-
const fmtSize = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
|
|
1295
|
-
for (const row of state.sessions) {
|
|
1296
|
-
const item = el('button', 'wb-member-row')
|
|
1297
|
-
item.type = 'button'
|
|
1298
|
-
item.appendChild(el('span', 'wb-chat-dot is-cold')).textContent = '❐'
|
|
1299
|
-
item.appendChild(el('span', 'wb-item-label')).textContent = `…${row.id.slice(-12)} · ${fmtSize(row.sizeBytes)}`
|
|
1300
|
-
item.addEventListener('click', () => {
|
|
1301
|
-
if (chatTarget?.kind === 'peer') chatTarget.picked = row.id
|
|
1302
|
-
renderChat()
|
|
1303
|
-
})
|
|
1304
|
-
body.appendChild(item)
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
if (peerListCache.has(cacheKey)) paint(peerListCache.get(cacheKey))
|
|
1308
|
-
else {
|
|
1309
|
-
paint(undefined)
|
|
1310
|
-
void deps.remoteSessionLog?.({ peer: url }).then(result => {
|
|
1311
|
-
peerListCache.set(cacheKey, result)
|
|
1312
|
-
if (body.isConnected) paint(result)
|
|
1313
|
-
})
|
|
1314
|
-
}
|
|
1315
|
-
return block
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
// ── Node chat drawer (0.6.13) ──
|
|
1319
|
-
// The right-side reading face. The transcript block is cached per id, so
|
|
1320
|
-
// re-rendering across the 3s poll is cheap; the dispatch form has its own
|
|
1321
|
-
// cache slot so the inspector's team and the chat's can coexist.
|
|
1322
|
-
function closeChat(): void {
|
|
1323
|
-
chatTarget = null
|
|
1324
|
-
chatForm = null
|
|
1325
|
-
chat.classList.remove('is-open')
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
1096
|
function openChat(target: ChatTarget): void {
|
|
1329
|
-
|
|
1330
|
-
// Keep the chat form only when it belongs to the same session team.
|
|
1331
|
-
const team = target.kind === 'session' || target.kind === 'remote' ? target.team : undefined
|
|
1332
|
-
if (chatForm !== null && chatForm.team !== team) chatForm = null
|
|
1333
|
-
// 0.6.17: focus the composer ONCE per open — never on poll remounts,
|
|
1334
|
-
// which would steal focus every 3s (the focus bug the owner hit).
|
|
1335
|
-
chatFocusPending = true
|
|
1336
|
-
renderChat()
|
|
1337
|
-
chat.classList.add('is-open')
|
|
1097
|
+
chat.open(target)
|
|
1338
1098
|
root.classList.remove('is-rail-open')
|
|
1339
1099
|
}
|
|
1340
1100
|
|
|
1341
|
-
/** 0.6.17 focus law: the 3s poll rebuilds the drawer, and MOVING a
|
|
1342
|
-
* focused node (re-append) blurs it in every browser — the composer
|
|
1343
|
-
* would lose the caret mid-typing. Capture focus + selection around the
|
|
1344
|
-
* face render and restore it on the same node after (cached nodes come
|
|
1345
|
-
* back; throwaway nodes just lose focus as before). */
|
|
1346
|
-
function renderChat(): void {
|
|
1347
|
-
const activeBefore = document.activeElement
|
|
1348
|
-
const focusWasInside = activeBefore instanceof HTMLElement && chatBody.contains(activeBefore)
|
|
1349
|
-
const selection = activeBefore instanceof HTMLTextAreaElement || activeBefore instanceof HTMLInputElement
|
|
1350
|
-
? { start: activeBefore.selectionStart, end: activeBefore.selectionEnd }
|
|
1351
|
-
: undefined
|
|
1352
|
-
renderChatFace()
|
|
1353
|
-
if (!focusWasInside || !(activeBefore instanceof HTMLElement) || !chatBody.contains(activeBefore)) return
|
|
1354
|
-
activeBefore.focus({ preventScroll: true })
|
|
1355
|
-
if (selection !== undefined && activeBefore instanceof HTMLTextAreaElement) {
|
|
1356
|
-
activeBefore.setSelectionRange(selection.start, selection.end)
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
function renderChatFace(): void {
|
|
1361
|
-
const t = chatTarget
|
|
1362
|
-
if (t === null) return
|
|
1363
|
-
chatDot.className = `wb-chat-dot ${t.kind === 'peer' ? 'is-peer' : t.kind === 'log' || t.kind === 'remote' || t.live === false ? 'is-cold' : 'is-live'}`
|
|
1364
|
-
chatTitle.textContent = t.title
|
|
1365
|
-
chatSub.textContent = t.sub
|
|
1366
|
-
clear(chatBody)
|
|
1367
|
-
if (t.kind === 'peer') {
|
|
1368
|
-
// 0.6.15: the peer host is readable through the proxy — list its
|
|
1369
|
-
// recent sessions, then drill into one. Without the proxy dep the
|
|
1370
|
-
// honest where-the-records-live note stays.
|
|
1371
|
-
const url = t.url ?? (t.title.startsWith('http') ? t.title : '')
|
|
1372
|
-
if (url === '' || deps.remoteSessionLog === undefined) {
|
|
1373
|
-
const note = el('div', 'wb-chat-note')
|
|
1374
|
-
note.textContent = `对端主机节点(${t.sub})—— 会话记录存放在对端机器上,本页只读本机会话日志。`
|
|
1375
|
-
chatBody.appendChild(note)
|
|
1376
|
-
return
|
|
1377
|
-
}
|
|
1378
|
-
const note = el('div', 'wb-chat-note')
|
|
1379
|
-
note.textContent = '对端主机节点 —— 以下会话列表经本机代理从对端 /a2a/log 读取。'
|
|
1380
|
-
chatBody.appendChild(note)
|
|
1381
|
-
if (t.picked !== undefined) {
|
|
1382
|
-
const back = el('button', 'wb-transcript-refresh')
|
|
1383
|
-
back.type = 'button'
|
|
1384
|
-
back.textContent = '← 返回会话列表'
|
|
1385
|
-
back.addEventListener('click', () => {
|
|
1386
|
-
if (chatTarget?.kind === 'peer') chatTarget.picked = undefined
|
|
1387
|
-
renderChat()
|
|
1388
|
-
})
|
|
1389
|
-
chatBody.appendChild(back)
|
|
1390
|
-
chatBody.appendChild(remoteTranscript(`peer:${url}:${t.picked}`, { peer: url, id: t.picked }))
|
|
1391
|
-
} else {
|
|
1392
|
-
chatBody.appendChild(peerLogList(url))
|
|
1393
|
-
}
|
|
1394
|
-
return
|
|
1395
|
-
}
|
|
1396
|
-
if (t.kind === 'remote') {
|
|
1397
|
-
// Card metadata the old inspector face carried (origin/via/workspace),
|
|
1398
|
-
// kept readable in the drawer head area.
|
|
1399
|
-
const meta = el('div')
|
|
1400
|
-
if (t.row?.origin !== undefined) textRow(meta, '来源', t.row.origin)
|
|
1401
|
-
if (t.row?.via !== undefined) textRow(meta, '经', t.row.via)
|
|
1402
|
-
if (t.row?.workspace !== undefined) textRow(meta, '工作区', t.row.workspace)
|
|
1403
|
-
if (meta.childElementCount > 0) chatBody.appendChild(meta)
|
|
1404
|
-
if (deps.remoteSessionLog !== undefined) {
|
|
1405
|
-
const note = el('div', 'wb-chat-note')
|
|
1406
|
-
note.textContent = '远端会话节点 —— 经本机代理从对端 /a2a/log 读取最近对话。'
|
|
1407
|
-
chatBody.appendChild(note)
|
|
1408
|
-
chatBody.appendChild(remoteTranscript(`remote:${t.team}`, { team: t.team }))
|
|
1409
|
-
} else {
|
|
1410
|
-
const note = el('div', 'wb-chat-note')
|
|
1411
|
-
note.textContent = '远端节点的对话记录在对端机器上;本页的代理读取面未启用。'
|
|
1412
|
-
chatBody.appendChild(note)
|
|
1413
|
-
}
|
|
1414
|
-
} else {
|
|
1415
|
-
if (t.kind === 'log') {
|
|
1416
|
-
const note = el('div', 'wb-chat-note')
|
|
1417
|
-
note.textContent = '只读日志 —— 此会话当前不在网络目录(可能已归档或未入网)。'
|
|
1418
|
-
chatBody.appendChild(note)
|
|
1419
|
-
}
|
|
1420
|
-
if (deps.sessionLog !== undefined) chatBody.appendChild(localTranscript(t.id))
|
|
1421
|
-
}
|
|
1422
|
-
const dispatchTeam = t.kind === 'session' || t.kind === 'remote' ? t.team : undefined
|
|
1423
|
-
if (dispatchTeam !== undefined && dispatchTeam !== '' && deps.dispatchQuick !== undefined) {
|
|
1424
|
-
const wrap = el('div', 'wb-dispatch')
|
|
1425
|
-
const head = el('div', 'wb-side-head')
|
|
1426
|
-
head.textContent = '发送'
|
|
1427
|
-
wrap.appendChild(head)
|
|
1428
|
-
if (chatForm === null) chatForm = { team: dispatchTeam, ...buildDispatchForm(dispatchTeam) }
|
|
1429
|
-
wrap.appendChild(chatForm.form)
|
|
1430
|
-
chatBody.appendChild(wrap)
|
|
1431
|
-
if (chatFocusPending) {
|
|
1432
|
-
chatFocusPending = false
|
|
1433
|
-
// preventScroll: focusing must not fight the transcript's own
|
|
1434
|
-
// landing scroll (bottom on first read).
|
|
1435
|
-
chatForm.text.focus({ preventScroll: true })
|
|
1436
|
-
}
|
|
1437
|
-
} else {
|
|
1438
|
-
chatFocusPending = false
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
1101
|
/** Board picks (0.6.13): resolve a node id against the latest state. */
|
|
1443
1102
|
function openNodeChat(id: string): void {
|
|
1444
1103
|
if (last === null) return
|
|
@@ -1467,10 +1126,15 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
1467
1126
|
const box = clear(headerStats)
|
|
1468
1127
|
if (last === null) return
|
|
1469
1128
|
const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string }> = []
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1129
|
+
// Same definitions as the 台账 tab (wb-ledger ledgerFacts): fire-forget
|
|
1130
|
+
// expiries are not dead letters, receiptless rows are never stale.
|
|
1131
|
+
const facts = ledgerFacts(last, deps.now())
|
|
1132
|
+
const dead = facts.deadLetters.length
|
|
1133
|
+
if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账' })
|
|
1134
|
+
const stale = facts.stale.length
|
|
1135
|
+
if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)' })
|
|
1136
|
+
const owed = facts.owedOverdue.length
|
|
1137
|
+
if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时' })
|
|
1474
1138
|
const conflicts = last.idempotency?.conflicts ?? 0
|
|
1475
1139
|
if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查' })
|
|
1476
1140
|
const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
|
|
@@ -1495,6 +1159,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
1495
1159
|
logRowsBusy = true
|
|
1496
1160
|
deps.sessionLogList().then(result => {
|
|
1497
1161
|
logRows = result.ok && result.sessions !== undefined ? result.sessions : []
|
|
1162
|
+
logTotal = result.ok ? result.count : undefined
|
|
1498
1163
|
}).catch(() => {}).finally(() => {
|
|
1499
1164
|
logRowsBusy = false
|
|
1500
1165
|
renderSidebar()
|
|
@@ -1503,17 +1168,17 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
1503
1168
|
renderHeader()
|
|
1504
1169
|
renderSidebar()
|
|
1505
1170
|
renderDraftTargets()
|
|
1171
|
+
renderHosts()
|
|
1506
1172
|
renderLedger()
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1509
|
-
|
|
1173
|
+
// The open chat drawer refreshes in place (head + receipt statuses); its
|
|
1174
|
+
// thread and composer are never rebuilt by the poll.
|
|
1175
|
+
chat.update(state)
|
|
1510
1176
|
renderActivity()
|
|
1511
1177
|
renderPlan()
|
|
1512
1178
|
renderInspector()
|
|
1513
1179
|
// Mode is availability-derived every poll: canvas face down → data mode,
|
|
1514
1180
|
// the board deactivates and the panes return to the stage stack.
|
|
1515
1181
|
syncMode()
|
|
1516
|
-
syncSections()
|
|
1517
1182
|
}
|
|
1518
1183
|
|
|
1519
1184
|
function selectTab(key: WorkbenchTab): void {
|
|
@@ -1521,8 +1186,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
1521
1186
|
syncMode()
|
|
1522
1187
|
return
|
|
1523
1188
|
}
|
|
1524
|
-
|
|
1525
|
-
syncSections()
|
|
1189
|
+
railTabs.select(key)
|
|
1526
1190
|
}
|
|
1527
1191
|
|
|
1528
1192
|
update(null)
|