@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24
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 +243 -121
- package/README_zh.md +144 -84
- package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
- package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
- package/assets/nexusDist/index.html +26 -0
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
- package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
- package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
- package/assets/stageDist/index.html +13 -0
- package/lib/a2a-client.js +190 -13
- package/lib/canvas-store.js +218 -0
- package/lib/card.js +25 -0
- package/lib/client/A2aControl.js +45 -137
- package/lib/client/locales.js +16 -40
- package/lib/client.js +164 -467
- package/lib/client.js.map +1 -1
- package/lib/group-store.js +6 -0
- package/lib/idempotency-store.js +291 -0
- package/lib/index.js +4217 -259
- package/lib/joined-store.js +11 -0
- package/lib/layout-store.js +155 -0
- package/lib/peer-store.js +98 -13
- package/lib/receipt-ladder.js +35 -0
- package/lib/receipt.js +102 -0
- package/lib/self-suppress.js +62 -0
- package/lib/session-transcript.js +150 -0
- package/lib/skill/SKILL.md +77 -0
- package/lib/skill/references/discovery.md +38 -0
- package/lib/skill/references/membership.md +50 -0
- package/lib/skill/references/pitfalls.md +42 -0
- package/lib/skill/references/routing.md +43 -0
- package/lib/skill-registration.js +52 -0
- package/lib/stage-mount.js +32 -0
- package/lib/task-ledger.js +540 -0
- package/lib/team-store.js +122 -0
- package/lib/teams-bridge.js +18 -0
- package/lib/transport-caps.js +31 -0
- package/lib/types/a2a-client.d.ts +72 -1
- package/lib/types/canvas-store.d.ts +102 -0
- package/lib/types/client/A2aControl.d.ts +18 -28
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/group-store.d.ts +6 -0
- package/lib/types/idempotency-store.d.ts +164 -0
- package/lib/types/index.d.ts +210 -6
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/layout-store.d.ts +63 -0
- package/lib/types/peer-store.d.ts +45 -2
- package/lib/types/receipt-ladder.d.ts +25 -0
- package/lib/types/receipt.d.ts +51 -0
- package/lib/types/self-suppress.d.ts +41 -0
- package/lib/types/session-transcript.d.ts +37 -0
- package/lib/types/skill-registration.d.ts +3 -0
- package/lib/types/stage-mount.d.ts +29 -0
- package/lib/types/task-ledger.d.ts +308 -0
- package/lib/types/team-store.d.ts +55 -0
- package/lib/types/teams-bridge.d.ts +112 -0
- package/lib/types/transport-caps.d.ts +29 -0
- package/lib/types/types.d.ts +43 -0
- package/nexus-stage/package-lock.json +2262 -0
- package/nexus-stage/package.json +21 -0
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
- package/nexus-stage/scripts/mockhost.py +253 -0
- package/nexus-stage/src/canvas-ops.ts +175 -0
- package/nexus-stage/src/canvas-wire.ts +156 -0
- package/nexus-stage/src/edges.ts +72 -0
- package/nexus-stage/src/fault.ts +56 -0
- package/nexus-stage/src/federation.ts +234 -0
- package/nexus-stage/src/layout-doc.ts +143 -0
- package/nexus-stage/src/layout-wire.ts +158 -0
- package/nexus-stage/src/main.ts +361 -0
- package/nexus-stage/src/planning-menus.ts +352 -0
- package/nexus-stage/src/planning-nodelist.ts +294 -0
- package/nexus-stage/src/planning-types.ts +111 -0
- package/nexus-stage/src/planning-view.ts +1313 -0
- package/nexus-stage/src/planning.css +280 -0
- package/nexus-stage/src/seat.ts +35 -0
- package/nexus-stage/src/stage-name.ts +13 -0
- package/nexus-stage/src/tokens.ts +32 -0
- package/nexus-stage/src/viewport.ts +83 -0
- package/nexus-stage/src/workbench.css +267 -0
- package/nexus-stage/src/workbench.ts +1530 -0
- package/nexus-stage/src/world.ts +509 -0
- package/nexus-stage/tsconfig.json +25 -0
- package/nexus-stage/vite.config.ts +16 -0
- package/package.json +83 -22
|
@@ -0,0 +1,1530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A2A workbench: one DOM client UI for human AI researchers and agents
|
|
3
|
+
* to OPERATE the network. Since 0.6.2 the 2D spatial planning board and the
|
|
4
|
+
* operational panels are MERGED into one responsive shell — the board is the
|
|
5
|
+
* stage (canvas mode, default while the host serves the canvas face) and the
|
|
6
|
+
* directory/ledger/activity/plan panels ride a collapsible side rail; when
|
|
7
|
+
* the canvas face is down the panels become the main stack (data mode).
|
|
8
|
+
* Narrow screens turn the rail into a drawer and the inspector into a
|
|
9
|
+
* bottom sheet (CSS media queries only — the DOM contract is stable).
|
|
10
|
+
*
|
|
11
|
+
* Discipline (inherited from the planning view it absorbed):
|
|
12
|
+
* - Pure DOM, no WebGL, no three, no framework: every row is a textContent
|
|
13
|
+
* write, so attacker-shaped labels stay text.
|
|
14
|
+
* - The workbench never fabricates capability: dead-letter rows act through
|
|
15
|
+
* the real abandon seam (deps), dispatch only DRAFTS a pasteable brief.
|
|
16
|
+
* - update() re-renders data regions wholesale but never touches the
|
|
17
|
+
* dispatch textarea (user input survives polls); rail collapse, section
|
|
18
|
+
* state, and the active mode are view state kept across updates.
|
|
19
|
+
* @module nexus-stage/workbench
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** One pending ledger row from /state `tasks`. */
|
|
23
|
+
export interface PendingRow {
|
|
24
|
+
readonly taskId: string
|
|
25
|
+
readonly team: string
|
|
26
|
+
readonly peer?: string
|
|
27
|
+
readonly startedAt: number
|
|
28
|
+
readonly status: string
|
|
29
|
+
readonly direction?: string
|
|
30
|
+
readonly receiptExpected?: boolean
|
|
31
|
+
readonly settleable?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* First line of the dispatched message. A row without it is unreadable:
|
|
34
|
+
* `direct-8f3a1c2b · dsh · 3h` says nothing about whether to nudge,
|
|
35
|
+
* abandon, or go read the receipt — the operator's whole question is
|
|
36
|
+
* WHAT is owed. Absent on rows persisted before the field existed.
|
|
37
|
+
*/
|
|
38
|
+
readonly brief?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One dead-lettered row from /state `tasksDead`. */
|
|
42
|
+
export interface DeadRow {
|
|
43
|
+
readonly taskId: string
|
|
44
|
+
readonly team: string
|
|
45
|
+
readonly peer?: string
|
|
46
|
+
readonly startedAt: number
|
|
47
|
+
readonly deadAt: number
|
|
48
|
+
readonly reason?: string
|
|
49
|
+
/** First line of the dispatched message (see {@link PendingRow.brief}). */
|
|
50
|
+
readonly brief?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One session row from /state `sessions` (live or cold joined). */
|
|
54
|
+
export interface SessionRow {
|
|
55
|
+
readonly id: string
|
|
56
|
+
readonly label: string
|
|
57
|
+
readonly team: string
|
|
58
|
+
readonly name?: string
|
|
59
|
+
readonly description?: string
|
|
60
|
+
readonly workspace?: string
|
|
61
|
+
readonly joined?: boolean
|
|
62
|
+
readonly live?: boolean
|
|
63
|
+
readonly teams?: readonly string[]
|
|
64
|
+
/** Canvas teams this session is a member of (0.6.6 roster annotation). */
|
|
65
|
+
readonly canvasTeams?: readonly string[]
|
|
66
|
+
/** @deprecated 0.6.6 — display-only taxonomy superseded by canvas teams. */
|
|
67
|
+
readonly group?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One remote team row from /state `remote`. */
|
|
71
|
+
export interface RemoteRow {
|
|
72
|
+
readonly team: string
|
|
73
|
+
readonly name?: string
|
|
74
|
+
readonly origin?: string
|
|
75
|
+
readonly via?: string
|
|
76
|
+
readonly workspace?: string
|
|
77
|
+
readonly legacy?: boolean
|
|
78
|
+
readonly teams?: readonly string[]
|
|
79
|
+
/** Canvas teams on this host that carry this remote handle as a member. */
|
|
80
|
+
readonly canvasTeams?: readonly string[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One canvas member: a local bare session id or a zone-qualified remote
|
|
84
|
+
* handle (`zone/id8`) — remote members are routed by the directory, never
|
|
85
|
+
* woken locally (0.6.6 unified team model). */
|
|
86
|
+
export interface CanvasMemberRow {
|
|
87
|
+
readonly id: string
|
|
88
|
+
readonly team?: string
|
|
89
|
+
readonly joined?: boolean
|
|
90
|
+
readonly live?: boolean
|
|
91
|
+
readonly remote?: boolean
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One canvas team from /state `canvas.teams`. */
|
|
95
|
+
export interface CanvasTeamRow {
|
|
96
|
+
readonly name: string
|
|
97
|
+
readonly team: string
|
|
98
|
+
readonly members: readonly CanvasMemberRow[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One activity-ring entry. */
|
|
102
|
+
export interface ActivityRow {
|
|
103
|
+
readonly ts: number
|
|
104
|
+
readonly dir: 'in' | 'out'
|
|
105
|
+
readonly team: string
|
|
106
|
+
readonly peer: string
|
|
107
|
+
readonly ok: boolean
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The /state subset the workbench consumes (additive fields ignored). */
|
|
111
|
+
export interface StateBody {
|
|
112
|
+
readonly version?: string
|
|
113
|
+
readonly sessions?: readonly SessionRow[]
|
|
114
|
+
readonly canvas?: { teams?: readonly CanvasTeamRow[] }
|
|
115
|
+
readonly peers?: ReadonlyArray<{ url: string; score?: number }>
|
|
116
|
+
readonly remote?: readonly RemoteRow[]
|
|
117
|
+
readonly activity?: readonly ActivityRow[]
|
|
118
|
+
readonly inFlight?: ReadonlyArray<{ team: string; peer: string; startedAt: number }>
|
|
119
|
+
readonly tasks?: readonly PendingRow[]
|
|
120
|
+
readonly tasksStaleCount?: number
|
|
121
|
+
readonly tasksDead?: readonly DeadRow[]
|
|
122
|
+
readonly archivedCount?: number
|
|
123
|
+
/** Settled-archive split by verdict ('completed' | 'declined' | … | 'abandoned' | 'unclassified'). */
|
|
124
|
+
readonly archivedSplit?: Readonly<Record<string, number>>
|
|
125
|
+
readonly idempotency?: {
|
|
126
|
+
window?: number
|
|
127
|
+
pending?: number
|
|
128
|
+
settled?: number
|
|
129
|
+
claimsFresh?: number
|
|
130
|
+
replays?: number
|
|
131
|
+
conflicts?: number
|
|
132
|
+
}
|
|
133
|
+
readonly reconcile?: {
|
|
134
|
+
state?: string
|
|
135
|
+
woken?: number
|
|
136
|
+
rows?: ReadonlyArray<{ id: string; error: string; attempts: number; needsRepair?: boolean }>
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What the workbench can DO — the seams tests drive. */
|
|
141
|
+
export interface WorkbenchDeps {
|
|
142
|
+
now(): number
|
|
143
|
+
/** Abandon one dead-lettered task via the control route. True on success. */
|
|
144
|
+
abandon(taskId: string, reason: string): Promise<boolean>
|
|
145
|
+
/** Canvas team member change via the control route (0.6.6): `id` is a
|
|
146
|
+
* local session id or a zone-qualified remote handle. True on success. */
|
|
147
|
+
canvasOp(action: 'add-member' | 'remove-member', name: string, id: string): Promise<boolean>
|
|
148
|
+
/** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
|
|
149
|
+
* one message to a routing team, async delivery, receipt lands in the
|
|
150
|
+
* ledger. Optional — without it the 快派 form hides. */
|
|
151
|
+
dispatchQuick?(team: string, message: string): Promise<{ ok: boolean; task_id?: string; reply?: string; error?: string }>
|
|
152
|
+
/** Inline transcript (0.6.11): the tail of a LOCAL session's own
|
|
153
|
+
* conversation log, read-only. Optional — without it the block hides. */
|
|
154
|
+
sessionLog?(id: string): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string }>
|
|
155
|
+
/** 0.6.12: list every local session holding a log artifact (newest first) —
|
|
156
|
+
* the reading face is host-scoped, so archived and never-joined sessions
|
|
157
|
+
* 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 }>
|
|
159
|
+
/** 0.6.15: remote transcript via the local proxy — ?team=<zone/id8>
|
|
160
|
+
* resolves the publishing peer through the directory, ?peer=<base-url>
|
|
161
|
+
* reads one tracked peer directly. Without id the peer answers its
|
|
162
|
+
* readable-session listing. Optional — without it remote/peer chat
|
|
163
|
+
* shows the honest where-the-records-live note. */
|
|
164
|
+
remoteSessionLog?(query: { team?: string; peer?: string; id?: string; limit?: number }): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string; via?: string }>
|
|
165
|
+
/** Copy text to the clipboard; drafts are pasteable task briefs. */
|
|
166
|
+
copy(text: string): void
|
|
167
|
+
// 0.6.10: openLocal/openRemote (0.6.7/0.6.8 jump affordances) removed by
|
|
168
|
+
// ruling — 禁止任何跳转,全部使用响应式布局. dispatchQuick is the only
|
|
169
|
+
// reach primitive and it stays on this page.
|
|
170
|
+
/** The spatial planning board. Optional: without it the shell stays in
|
|
171
|
+
* data mode permanently. `available()` mirrors the host's canvas face
|
|
172
|
+
* (state.canvas !== undefined) and gates canvas mode. */
|
|
173
|
+
board?: {
|
|
174
|
+
readonly root: HTMLElement
|
|
175
|
+
activate(): void
|
|
176
|
+
deactivate(): void
|
|
177
|
+
available(): boolean
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Display name for one node: session title, then team, then label. */
|
|
182
|
+
function displayName(node: { name?: string; team?: string; label?: string }): string {
|
|
183
|
+
return node.name ?? node.team ?? node.label ?? ''
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function ageText(ms: number, now: number): string {
|
|
187
|
+
const s = Math.max(0, Math.round((now - ms) / 1000))
|
|
188
|
+
if (s < 90) return `${s}s`
|
|
189
|
+
const m = Math.round(s / 60)
|
|
190
|
+
if (m < 90) return `${m}m`
|
|
191
|
+
const h = Math.round(m / 60)
|
|
192
|
+
if (h < 48) return `${h}h`
|
|
193
|
+
return `${Math.round(h / 24)}d`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
|
|
197
|
+
const node = document.createElement(tag)
|
|
198
|
+
if (className !== undefined) node.className = className
|
|
199
|
+
return node
|
|
200
|
+
}
|
|
201
|
+
|
|
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
|
+
function textRow(parent: HTMLElement, label: string, value: string): void {
|
|
227
|
+
const row = el('div', 'wb-kv')
|
|
228
|
+
row.appendChild(el('span', 'wb-kv-k')).textContent = label
|
|
229
|
+
row.appendChild(el('span', 'wb-kv-v')).textContent = value
|
|
230
|
+
parent.appendChild(row)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type Selection =
|
|
234
|
+
| { kind: 'canvas'; row: CanvasTeamRow }
|
|
235
|
+
| undefined
|
|
236
|
+
|
|
237
|
+
/** Chat drawer target (0.6.13, remote 0.6.15): a node picked on the board or in the
|
|
238
|
+
* directory — session/log get the local transcript tail, remote rows and peers
|
|
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'
|
|
251
|
+
|
|
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
|
+
|
|
272
|
+
/** Legacy deep-link targets: 'board' focuses the stage, sections open a rail section. */
|
|
273
|
+
export type WorkbenchTab = 'board' | WorkbenchSection
|
|
274
|
+
|
|
275
|
+
/** Which face fills the stage. */
|
|
276
|
+
export type WorkbenchMode = 'canvas' | 'data'
|
|
277
|
+
|
|
278
|
+
export interface Workbench {
|
|
279
|
+
readonly root: HTMLElement
|
|
280
|
+
/** Feed one /state poll (or null while the first fetch is out). */
|
|
281
|
+
update(state: StateBody | null): void
|
|
282
|
+
/** Current stage mode: the board while the canvas face is up, else data. */
|
|
283
|
+
readonly mode: () => WorkbenchMode
|
|
284
|
+
/** Focus a target: 'board' converges on canvas mode (no-op while the
|
|
285
|
+
* canvas face is down); a section un-collapses its rail panel. */
|
|
286
|
+
selectTab(key: WorkbenchTab): void
|
|
287
|
+
/** A node was picked on the planning board (0.6.13): resolve it against
|
|
288
|
+
* the latest state and open the right-side chat drawer for it. */
|
|
289
|
+
openNodeChat(id: string): void
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const SECTION_DEFS: ReadonlyArray<{ key: WorkbenchSection; label: string }> = [
|
|
293
|
+
{ key: 'directory', label: '目录' },
|
|
294
|
+
{ key: 'ledger', label: '台账' },
|
|
295
|
+
{ key: 'activity', label: '活动' },
|
|
296
|
+
{ key: 'plan', label: '规划' },
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
300
|
+
const board = deps.board
|
|
301
|
+
let mode: WorkbenchMode = 'data'
|
|
302
|
+
let selection: Selection
|
|
303
|
+
let last: StateBody | null = null
|
|
304
|
+
const collapsed = new Set<WorkbenchSection>(['activity', 'plan'])
|
|
305
|
+
// Directory view state (survives polls): unified search + category filter.
|
|
306
|
+
let searchText = ''
|
|
307
|
+
let dirFilter: DirFilter = 'all'
|
|
308
|
+
// Canvas add-member form cache (static DOM re-mounted across polls; keyed
|
|
309
|
+
// by team name — same trick as draftForm so inputs survive the 3s poll).
|
|
310
|
+
let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
|
|
311
|
+
// Quick-dispatch form (0.6.9), cached per target routing team the same
|
|
312
|
+
// draftForm way: a textarea's draft must survive the 3s poll remount.
|
|
313
|
+
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
|
+
// 0.6.12: readable local logs (host-scope list, newest first). Refreshed
|
|
333
|
+
// opportunistically per poll; rows render in the directory's 本机日志 section.
|
|
334
|
+
let logRows: Array<{ id: string; mtimeMs: number; sizeBytes: number }> = []
|
|
335
|
+
let logRowsBusy = false
|
|
336
|
+
|
|
337
|
+
const root = el('div', 'wb')
|
|
338
|
+
root.setAttribute('data-testid', 'wb-root')
|
|
339
|
+
|
|
340
|
+
// ── Static skeleton: built once, update() only rewrites data regions. ──
|
|
341
|
+
const header = el('header', 'wb-header')
|
|
342
|
+
const menuBtn = el('button', 'wb-icon-btn wb-menu')
|
|
343
|
+
menuBtn.type = 'button'
|
|
344
|
+
menuBtn.dataset.testid = 'wb-menu'
|
|
345
|
+
menuBtn.setAttribute('aria-label', '打开目录抽屉')
|
|
346
|
+
menuBtn.textContent = '☰'
|
|
347
|
+
menuBtn.addEventListener('click', () => root.classList.toggle('is-rail-open'))
|
|
348
|
+
header.appendChild(menuBtn)
|
|
349
|
+
const title = el('div', 'wb-title')
|
|
350
|
+
title.appendChild(el('span', 'wb-title-main')).textContent = 'A2A 工作台'
|
|
351
|
+
const versionBadge = el('span', 'wb-badge')
|
|
352
|
+
title.appendChild(versionBadge)
|
|
353
|
+
header.appendChild(title)
|
|
354
|
+
const headerStats = el('div', 'wb-header-stats')
|
|
355
|
+
header.appendChild(headerStats)
|
|
356
|
+
const railToggle = el('button', 'wb-icon-btn wb-rail-toggle')
|
|
357
|
+
railToggle.type = 'button'
|
|
358
|
+
railToggle.dataset.testid = 'wb-rail-toggle'
|
|
359
|
+
railToggle.setAttribute('aria-label', '折叠/展开侧栏')
|
|
360
|
+
railToggle.textContent = '◧'
|
|
361
|
+
railToggle.addEventListener('click', () => {
|
|
362
|
+
root.classList.toggle('is-rail-collapsed')
|
|
363
|
+
// The stage width changed while the board fills it → re-sync its view
|
|
364
|
+
// size (idempotent; focus side effect is harmless in a shell toggle).
|
|
365
|
+
if (mode === 'canvas' && board !== undefined) board.activate()
|
|
366
|
+
})
|
|
367
|
+
header.appendChild(railToggle)
|
|
368
|
+
|
|
369
|
+
const body = el('div', 'wb-body')
|
|
370
|
+
const rail = el('aside', 'wb-rail')
|
|
371
|
+
|
|
372
|
+
// Rail sections (accordion). Panes are created once and re-parented
|
|
373
|
+
// between section bodies (canvas mode) and the stage stack (data mode).
|
|
374
|
+
const sectionBodies = new Map<WorkbenchSection, HTMLElement>()
|
|
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
|
+
}
|
|
396
|
+
|
|
397
|
+
const directoryBody = el('div', 'wb-directory')
|
|
398
|
+
// Directory toolbar: unified search + category filter chips (0.6.6). Built
|
|
399
|
+
// once above the re-rendered list so the input keeps focus and its text
|
|
400
|
+
// across polls; filtering is pure frontend over /state.
|
|
401
|
+
const directoryTools = el('div', 'wb-dir-tools')
|
|
402
|
+
const searchInput = document.createElement('input')
|
|
403
|
+
searchInput.className = 'wb-search'
|
|
404
|
+
searchInput.type = 'search'
|
|
405
|
+
searchInput.placeholder = '搜索:名称 / 队 / 会话 / 节点'
|
|
406
|
+
searchInput.setAttribute('aria-label', '统一搜索目录')
|
|
407
|
+
searchInput.addEventListener('input', () => {
|
|
408
|
+
searchText = searchInput.value.trim().toLowerCase()
|
|
409
|
+
renderSidebar()
|
|
410
|
+
})
|
|
411
|
+
directoryTools.appendChild(searchInput)
|
|
412
|
+
const filterRow = el('div', 'wb-filters')
|
|
413
|
+
for (const def of DIRECTORY_FILTERS) {
|
|
414
|
+
const chip = el('button', 'wb-filter')
|
|
415
|
+
chip.type = 'button'
|
|
416
|
+
chip.textContent = def.label
|
|
417
|
+
chip.dataset.filter = def.key
|
|
418
|
+
chip.title = def.hint
|
|
419
|
+
chip.addEventListener('click', () => {
|
|
420
|
+
dirFilter = def.key
|
|
421
|
+
renderSidebar()
|
|
422
|
+
})
|
|
423
|
+
filterRow.appendChild(chip)
|
|
424
|
+
}
|
|
425
|
+
directoryTools.appendChild(filterRow)
|
|
426
|
+
sectionBodies.get('directory')!.appendChild(directoryTools)
|
|
427
|
+
sectionBodies.get('directory')!.appendChild(directoryBody)
|
|
428
|
+
|
|
429
|
+
const ledgerPane = el('section', 'wb-pane')
|
|
430
|
+
const activityPane = el('section', 'wb-pane')
|
|
431
|
+
const planPane = el('section', 'wb-pane')
|
|
432
|
+
const dataPanes: ReadonlyArray<[WorkbenchSection, HTMLElement]> = [
|
|
433
|
+
['ledger', ledgerPane],
|
|
434
|
+
['activity', activityPane],
|
|
435
|
+
['plan', planPane],
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
const stage = el('main', 'wb-stage')
|
|
439
|
+
const boardPane = el('section', 'wb-stage-board')
|
|
440
|
+
if (board !== undefined) {
|
|
441
|
+
boardPane.appendChild(board.root)
|
|
442
|
+
stage.appendChild(boardPane)
|
|
443
|
+
}
|
|
444
|
+
const dataStack = el('div', 'wb-data')
|
|
445
|
+
|
|
446
|
+
const inspector = el('aside', 'wb-inspector')
|
|
447
|
+
const inspectorHead = el('div', 'wb-inspector-head')
|
|
448
|
+
inspectorHead.appendChild(el('span', 'wb-inspector-title')).textContent = '详情'
|
|
449
|
+
const inspectorClose = el('button', 'wb-icon-btn')
|
|
450
|
+
inspectorClose.type = 'button'
|
|
451
|
+
inspectorClose.setAttribute('aria-label', '关闭详情')
|
|
452
|
+
inspectorClose.textContent = '✕'
|
|
453
|
+
inspectorClose.addEventListener('click', () => select(undefined))
|
|
454
|
+
inspectorHead.appendChild(inspectorClose)
|
|
455
|
+
inspector.appendChild(inspectorHead)
|
|
456
|
+
const inspectorBody = el('div', 'wb-inspector-body')
|
|
457
|
+
inspector.appendChild(inspectorBody)
|
|
458
|
+
|
|
459
|
+
// ── Node chat drawer (0.6.13) ──
|
|
460
|
+
// Clicking a node — on the planning board or a directory session/log/peer
|
|
461
|
+
// row — opens the conversation reading face as a right-side chat UI. The
|
|
462
|
+
// transcript tail is local-only; peers get the honest cross-host note.
|
|
463
|
+
const chat = el('aside', 'wb-chat')
|
|
464
|
+
const chatHead = el('div', 'wb-chat-head')
|
|
465
|
+
const chatDot = el('span', 'wb-chat-dot')
|
|
466
|
+
const chatTitle = el('span', 'wb-chat-title')
|
|
467
|
+
const chatSub = el('span', 'wb-chat-sub')
|
|
468
|
+
const chatClose = el('button', 'wb-icon-btn')
|
|
469
|
+
chatClose.type = 'button'
|
|
470
|
+
chatClose.setAttribute('aria-label', '关闭对话')
|
|
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()
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
stage.appendChild(dataStack)
|
|
498
|
+
body.appendChild(rail)
|
|
499
|
+
body.appendChild(stage)
|
|
500
|
+
body.appendChild(inspector)
|
|
501
|
+
body.appendChild(chat)
|
|
502
|
+
root.appendChild(header)
|
|
503
|
+
root.appendChild(body)
|
|
504
|
+
|
|
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
|
+
/** Move the data panes to their mode home: rail sections while the board
|
|
513
|
+
* owns the stage, the stage stack when the board is out. */
|
|
514
|
+
function placePanes(m: WorkbenchMode): void {
|
|
515
|
+
for (const [key, pane] of dataPanes) {
|
|
516
|
+
const home = m === 'canvas' ? sectionBodies.get(key)! : dataStack
|
|
517
|
+
if (pane.parentElement !== home) home.appendChild(pane)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function syncMode(): void {
|
|
522
|
+
const canvasUp = board !== undefined && board.available()
|
|
523
|
+
const next: WorkbenchMode = canvasUp ? 'canvas' : 'data'
|
|
524
|
+
root.dataset.mode = next
|
|
525
|
+
boardPane.classList.toggle('is-hidden', next !== 'canvas')
|
|
526
|
+
dataStack.classList.toggle('is-hidden', next === 'canvas')
|
|
527
|
+
placePanes(next)
|
|
528
|
+
if (next !== mode) {
|
|
529
|
+
if (board !== undefined) {
|
|
530
|
+
if (next === 'canvas') board.activate()
|
|
531
|
+
else board.deactivate()
|
|
532
|
+
}
|
|
533
|
+
mode = next
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function clear(node: HTMLElement): HTMLElement {
|
|
538
|
+
while (node.firstChild !== null) node.removeChild(node.firstChild)
|
|
539
|
+
return node
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function select(next: Selection): void {
|
|
543
|
+
selection = next
|
|
544
|
+
// Canvas add-member form is cached per team name (draftForm trick):
|
|
545
|
+
// drop it whenever the selection moves off its team.
|
|
546
|
+
if (next?.kind !== 'canvas' || canvasForm?.name !== next.row.name) canvasForm = null
|
|
547
|
+
// Same for the quick-dispatch form: it is cached per target team
|
|
548
|
+
// (0.6.15: remote dispatch moved to the chat drawer's own form).
|
|
549
|
+
if (next?.kind !== 'canvas') dispatchForm = null
|
|
550
|
+
renderInspector()
|
|
551
|
+
// Narrow screens: picking from the drawer closes it so the stage shows.
|
|
552
|
+
root.classList.remove('is-rail-open')
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── Directory section ──
|
|
556
|
+
function renderSidebar(): void {
|
|
557
|
+
const box = clear(directoryBody)
|
|
558
|
+
// Filter chips reflect the live view state (built once, toggled here).
|
|
559
|
+
for (let i = 0; i < filterRow.children.length; i++) {
|
|
560
|
+
const node = filterRow.children[i] as HTMLElement
|
|
561
|
+
node.classList.toggle('is-active', node.dataset.filter === dirFilter)
|
|
562
|
+
}
|
|
563
|
+
if (last === null) {
|
|
564
|
+
box.appendChild(el('div', 'wb-muted')).textContent = '载入中…'
|
|
565
|
+
sectionCounts.get('directory')!.textContent = ''
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
const state = last
|
|
569
|
+
const filtering = searchText !== '' || dirFilter !== 'all'
|
|
570
|
+
/** One directory row factory with the shared search+filter gate. */
|
|
571
|
+
const admit = (cat: DirFilter, haystack: string): boolean =>
|
|
572
|
+
(dirFilter === 'all' || dirFilter === cat) && (searchText === '' || haystack.includes(searchText))
|
|
573
|
+
/** Canvas-team badge for roster annotation (▤ + short name). */
|
|
574
|
+
const badges = (item: HTMLElement, teams: readonly string[] | undefined): void => {
|
|
575
|
+
if (teams === undefined || teams.length === 0) return
|
|
576
|
+
const chip = el('span', 'wb-item-badge')
|
|
577
|
+
const names = teams.map(t => t.slice(t.lastIndexOf('/') + 1))
|
|
578
|
+
chip.textContent = `▤ ${names.slice(0, 3).join(' ')}${names.length > 3 ? ` +${names.length - 3}` : ''}`
|
|
579
|
+
chip.title = `画布队:${teams.join(' · ')}`
|
|
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
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── Ledger pane ──
|
|
689
|
+
/** Age buckets for debt: what the ledger is actually made of, at a glance.
|
|
690
|
+
* Boundaries mirror the C-ruling split (1h = settleable→conversational). */
|
|
691
|
+
const AGE_BUCKETS: ReadonlyArray<{ label: string; maxMs: number }> = [
|
|
692
|
+
{ label: '<10m', maxMs: 10 * 60_000 },
|
|
693
|
+
{ label: '10m-1h', maxMs: 60 * 60_000 },
|
|
694
|
+
{ label: '1-6h', maxMs: 6 * 60 * 60_000 },
|
|
695
|
+
{ label: '6-24h', maxMs: 24 * 60 * 60_000 },
|
|
696
|
+
{ label: '>24h', maxMs: Number.POSITIVE_INFINITY },
|
|
697
|
+
]
|
|
698
|
+
|
|
699
|
+
/** Group pending rows by age bucket; returns buckets in age order with counts. */
|
|
700
|
+
function ageHistogram(rows: readonly PendingRow[], now: number): number[] {
|
|
701
|
+
const counts = AGE_BUCKETS.map(() => 0)
|
|
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
|
|
706
|
+
}
|
|
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
|
+
const now = deps.now()
|
|
721
|
+
const pending = last.tasks ?? []
|
|
722
|
+
const dead = last.tasksDead ?? []
|
|
723
|
+
const stale = last.tasksStaleCount ?? 0
|
|
724
|
+
const archived = last.archivedCount ?? 0
|
|
725
|
+
sectionCounts.get('ledger')!.textContent = `待 ${pending.length} · 死 ${dead.length}`
|
|
726
|
+
|
|
727
|
+
// Stat strip: the four headline numbers, each carrying its definition.
|
|
728
|
+
const stats = el('div', 'wb-stats')
|
|
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)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (pending.length > 0) {
|
|
766
|
+
// Age histogram + team load: WHERE the debt sits and how old it is.
|
|
767
|
+
const counts = ageHistogram(pending, now)
|
|
768
|
+
const max = Math.max(...counts, 1)
|
|
769
|
+
const hist = el('div', 'wb-hist')
|
|
770
|
+
for (let i = 0; i < AGE_BUCKETS.length; i += 1) {
|
|
771
|
+
barRow(hist, AGE_BUCKETS[i]!.label, counts[i]!, max)
|
|
772
|
+
}
|
|
773
|
+
pane.appendChild(hist)
|
|
774
|
+
const teams = topTeams(pending)
|
|
775
|
+
const teamLine = el('div', 'wb-pane-head')
|
|
776
|
+
teamLine.textContent = `债务队 Top${teams.length} · ${teams.map(([team, count]) => `${team} ${count}`).join(' · ')}`
|
|
777
|
+
pane.appendChild(teamLine)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const table = el('div', 'wb-table')
|
|
781
|
+
for (const row of pending) {
|
|
782
|
+
// Owner signal (2026-10-21): the ledger is a RESULTS face — the brief
|
|
783
|
+
// is the primary copy, ids/teams/ages are muted forensics underneath.
|
|
784
|
+
// Exception-driven: only overdue (>1h) rows raise their voice; normal
|
|
785
|
+
// rows stay quiet so the debt that needs eyes stands out.
|
|
786
|
+
const overdue = now - row.startedAt > 60 * 60_000
|
|
787
|
+
const line = el('div', `wb-row wb-row-result${overdue ? ' wb-row-alert' : ''}`)
|
|
788
|
+
const briefText = row.brief ?? '(无摘要——派发未带 brief)'
|
|
789
|
+
const brief = el('span', 'wb-row-head')
|
|
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)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ── Activity pane ──
|
|
838
|
+
function renderActivity(): void {
|
|
839
|
+
const pane = clear(activityPane)
|
|
840
|
+
if (last === null) return
|
|
841
|
+
const now = deps.now()
|
|
842
|
+
const flights = last.inFlight ?? []
|
|
843
|
+
sectionCounts.get('activity')!.textContent = `${flights.length}+${last.activity?.length ?? 0}`
|
|
844
|
+
const head = el('div', 'wb-pane-head')
|
|
845
|
+
head.textContent = `在途 ${flights.length} · 活动 ${last.activity?.length ?? 0}`
|
|
846
|
+
pane.appendChild(head)
|
|
847
|
+
if (flights.length > 0) {
|
|
848
|
+
const sub = el('div', 'wb-pane-subhead')
|
|
849
|
+
sub.textContent = '在途路由'
|
|
850
|
+
pane.appendChild(sub)
|
|
851
|
+
for (const row of flights) {
|
|
852
|
+
const line = el('div', 'wb-row')
|
|
853
|
+
line.appendChild(el('span', 'wb-row-dir')).textContent = '→'
|
|
854
|
+
line.appendChild(el('span', 'wb-row-team')).textContent = row.team
|
|
855
|
+
line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.startedAt, now)}`
|
|
856
|
+
pane.appendChild(line)
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const ringHead = el('div', 'wb-pane-subhead')
|
|
860
|
+
ringHead.textContent = '活动环(最新在前)'
|
|
861
|
+
pane.appendChild(ringHead)
|
|
862
|
+
const ring = last.activity ?? []
|
|
863
|
+
if (ring.length === 0) {
|
|
864
|
+
pane.appendChild(el('div', 'wb-muted')).textContent = '暂无活动'
|
|
865
|
+
return
|
|
866
|
+
}
|
|
867
|
+
for (const row of ring) {
|
|
868
|
+
const line = el('div', 'wb-row')
|
|
869
|
+
const dir = el('span', 'wb-row-dir')
|
|
870
|
+
dir.textContent = row.dir === 'out' ? '→' : '←'
|
|
871
|
+
if (row.ok !== true) dir.classList.add('is-fail')
|
|
872
|
+
line.appendChild(dir)
|
|
873
|
+
line.appendChild(el('span', 'wb-row-team')).textContent = row.team
|
|
874
|
+
line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.ts, now)}`
|
|
875
|
+
line.appendChild(el('span', 'wb-row-badges')).textContent = row.ok === true ? '' : '失败'
|
|
876
|
+
pane.appendChild(line)
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ── Plan pane: derived actions + dispatch drafting ──
|
|
881
|
+
function planActions(state: StateBody): Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> {
|
|
882
|
+
const now = deps.now()
|
|
883
|
+
const actions: Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> = []
|
|
884
|
+
const dead = state.tasksDead ?? []
|
|
885
|
+
if (dead.length > 0) {
|
|
886
|
+
actions.push({ severity: 'high', text: `清账 ${dead.length} 条死信(目标早已离开或死信化,无回执可期)` })
|
|
887
|
+
}
|
|
888
|
+
for (const row of state.tasks ?? []) {
|
|
889
|
+
const age = now - row.startedAt
|
|
890
|
+
if (age > 60 * 60_000 && row.settleable === true) {
|
|
891
|
+
// Quote the original ask: a nudge that only repeats a task id makes
|
|
892
|
+
// the target re-derive WHAT was owed, and the target is exactly the
|
|
893
|
+
// session that already failed to answer once.
|
|
894
|
+
const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
|
|
895
|
+
actions.push({
|
|
896
|
+
severity: 'normal',
|
|
897
|
+
text: `催办 ${row.taskId} → ${row.team}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
|
|
898
|
+
brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
|
|
899
|
+
})
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
for (const row of state.sessions ?? []) {
|
|
903
|
+
if (row.live !== true) {
|
|
904
|
+
actions.push({ severity: 'normal', text: `冷会话 ${displayName(row)}:在 GUI 打开该会话即可唤醒加入面` })
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
for (const row of state.reconcile?.rows ?? []) {
|
|
908
|
+
if (row.needsRepair === true) {
|
|
909
|
+
actions.push({ severity: 'high', text: `reconcile 需修复:${row.id} ${row.error}` })
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const conflicts = state.idempotency?.conflicts ?? 0
|
|
913
|
+
if (conflicts > 0) {
|
|
914
|
+
actions.push({ severity: 'high', text: `幂等冲突 ${conflicts} 例:人工核查 UNIQUE-ization 流量` })
|
|
915
|
+
}
|
|
916
|
+
return actions
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function renderPlan(): void {
|
|
920
|
+
const pane = clear(planPane)
|
|
921
|
+
if (last === null) return
|
|
922
|
+
const actions = planActions(last)
|
|
923
|
+
sectionCounts.get('plan')!.textContent = `${actions.length}`
|
|
924
|
+
const head = el('div', 'wb-pane-head')
|
|
925
|
+
head.textContent = actions.length === 0 ? '网络健康——无待办行动' : `待办行动 ${actions.length}`
|
|
926
|
+
pane.appendChild(head)
|
|
927
|
+
for (const action of actions) {
|
|
928
|
+
const line = el('div', `wb-action${action.severity === 'high' ? ' wb-action-high' : ''}`)
|
|
929
|
+
line.appendChild(el('span', 'wb-action-text')).textContent = action.text
|
|
930
|
+
if (action.brief !== undefined) {
|
|
931
|
+
const btn = el('button', 'wb-btn')
|
|
932
|
+
btn.type = 'button'
|
|
933
|
+
btn.textContent = '复制催办'
|
|
934
|
+
btn.addEventListener('click', () => deps.copy(action.brief ?? ''))
|
|
935
|
+
line.appendChild(btn)
|
|
936
|
+
}
|
|
937
|
+
pane.appendChild(line)
|
|
938
|
+
}
|
|
939
|
+
// Dispatch drafting — static inner form so textarea survives updates.
|
|
940
|
+
const draftHead = el('div', 'wb-pane-subhead')
|
|
941
|
+
draftHead.textContent = '派单起草(复制后在任一会话粘贴执行)'
|
|
942
|
+
pane.appendChild(draftHead)
|
|
943
|
+
pane.appendChild(draftForm)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Dispatch draft form: created ONCE; renderPlan re-attaches the same node.
|
|
947
|
+
const draftForm = el('div', 'wb-draft')
|
|
948
|
+
const draftTarget = document.createElement('select')
|
|
949
|
+
draftTarget.className = 'wb-select'
|
|
950
|
+
const draftInput = document.createElement('textarea')
|
|
951
|
+
draftInput.className = 'wb-textarea'
|
|
952
|
+
draftInput.rows = 3
|
|
953
|
+
draftInput.placeholder = '任务正文:目标、验收、期限…'
|
|
954
|
+
const draftBtn = el('button', 'wb-btn wb-btn-primary')
|
|
955
|
+
draftBtn.type = 'button'
|
|
956
|
+
draftBtn.textContent = '复制任务书'
|
|
957
|
+
draftBtn.addEventListener('click', () => {
|
|
958
|
+
const target = draftTarget.value
|
|
959
|
+
const message = draftInput.value.trim()
|
|
960
|
+
if (target === '' || message === '') return
|
|
961
|
+
deps.copy(`a2a_route 派单 → ${target}:\n${message}`)
|
|
962
|
+
})
|
|
963
|
+
draftForm.appendChild(draftTarget)
|
|
964
|
+
draftForm.appendChild(draftInput)
|
|
965
|
+
draftForm.appendChild(draftBtn)
|
|
966
|
+
|
|
967
|
+
function renderDraftTargets(): void {
|
|
968
|
+
const previous = draftTarget.value
|
|
969
|
+
clear(draftTarget)
|
|
970
|
+
const empty = document.createElement('option')
|
|
971
|
+
empty.value = ''
|
|
972
|
+
empty.textContent = '选择目标队…'
|
|
973
|
+
draftTarget.appendChild(empty)
|
|
974
|
+
if (last === null) return
|
|
975
|
+
const state = last
|
|
976
|
+
const targets = new Map<string, string>()
|
|
977
|
+
for (const row of state.sessions ?? []) {
|
|
978
|
+
if (row.team !== undefined && row.team !== '') targets.set(row.team, `${displayName(row)}(会话队)`)
|
|
979
|
+
}
|
|
980
|
+
for (const row of state.canvas?.teams ?? []) targets.set(row.team, `${row.name}(canvas 队)`)
|
|
981
|
+
for (const row of state.remote ?? []) targets.set(row.team, `${displayName(row)}(远端)`)
|
|
982
|
+
for (const [team, label] of targets) {
|
|
983
|
+
const opt = document.createElement('option')
|
|
984
|
+
opt.value = team
|
|
985
|
+
opt.textContent = `${label} · ${team}`
|
|
986
|
+
draftTarget.appendChild(opt)
|
|
987
|
+
}
|
|
988
|
+
if (previous !== '' && targets.has(previous)) draftTarget.value = previous
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// ── Inspector (overlay card over the stage; bottom sheet on narrow) ──
|
|
992
|
+
function renderInspector(): void {
|
|
993
|
+
const box = clear(inspectorBody)
|
|
994
|
+
inspector.classList.toggle('is-open', selection !== undefined)
|
|
995
|
+
if (selection === undefined) return
|
|
996
|
+
const sel = selection
|
|
997
|
+
const card = el('div', 'wb-card')
|
|
998
|
+
// 0.6.13: session/log/peer faces moved to the chat drawer; 0.6.15 took
|
|
999
|
+
// the remote rows with them (their metadata rides the chat head) — the
|
|
1000
|
+
// inspector keeps the canvas team card.
|
|
1001
|
+
if (sel.kind === 'canvas') {
|
|
1002
|
+
// Re-resolve the team from the freshest state: membership changes here
|
|
1003
|
+
// must be visible on the next render even though the click-time row is
|
|
1004
|
+
// a stale snapshot.
|
|
1005
|
+
const fresh = (last?.canvas?.teams ?? []).find(t => t.name === sel.row.name) ?? sel.row
|
|
1006
|
+
textRow(card, 'Canvas 队', fresh.name)
|
|
1007
|
+
textRow(card, '路由队', fresh.team)
|
|
1008
|
+
const head = el('div', 'wb-side-head')
|
|
1009
|
+
head.textContent = `成员 · ${fresh.members.length}`
|
|
1010
|
+
card.appendChild(head)
|
|
1011
|
+
if (fresh.members.length === 0) {
|
|
1012
|
+
const empty = el('div', 'wb-muted')
|
|
1013
|
+
empty.textContent = '空队——用下方表单添加成员'
|
|
1014
|
+
card.appendChild(empty)
|
|
1015
|
+
}
|
|
1016
|
+
for (const member of fresh.members) {
|
|
1017
|
+
const row = el('div', 'wb-member-row')
|
|
1018
|
+
const dot = member.remote === true ? '◇ 远端' : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
|
|
1019
|
+
row.appendChild(el('span', 'wb-member-dot')).textContent = dot
|
|
1020
|
+
const label = el('span', 'wb-member-label')
|
|
1021
|
+
const session = (last?.sessions ?? []).find(s => s.id === member.id)
|
|
1022
|
+
label.textContent = member.remote === true ? member.id : (session !== undefined ? displayName(session) : member.id)
|
|
1023
|
+
label.title = member.team ?? member.id
|
|
1024
|
+
row.appendChild(label)
|
|
1025
|
+
const remove = el('button', 'wb-member-remove')
|
|
1026
|
+
remove.type = 'button'
|
|
1027
|
+
remove.textContent = '移除'
|
|
1028
|
+
remove.addEventListener('click', async () => {
|
|
1029
|
+
remove.disabled = true
|
|
1030
|
+
if (!(await deps.canvasOp('remove-member', fresh.name, member.id))) remove.disabled = false
|
|
1031
|
+
})
|
|
1032
|
+
row.appendChild(remove)
|
|
1033
|
+
card.appendChild(row)
|
|
1034
|
+
}
|
|
1035
|
+
// Add-member form: static DOM re-mounted per poll (draftForm trick) —
|
|
1036
|
+
// rebuilt only when the inspected team changes.
|
|
1037
|
+
if (canvasForm?.name !== fresh.name) {
|
|
1038
|
+
const form = el('form', 'wb-member-add')
|
|
1039
|
+
const pick = document.createElement('select')
|
|
1040
|
+
pick.className = 'wb-member-pick'
|
|
1041
|
+
pick.setAttribute('aria-label', '选择成员')
|
|
1042
|
+
const manual = document.createElement('input')
|
|
1043
|
+
manual.type = 'text'
|
|
1044
|
+
manual.className = 'wb-member-manual'
|
|
1045
|
+
manual.placeholder = '或输入远端句柄 zone/id8'
|
|
1046
|
+
manual.setAttribute('aria-label', '远端成员句柄')
|
|
1047
|
+
const submit = el('button', 'wb-member-submit')
|
|
1048
|
+
submit.type = 'submit'
|
|
1049
|
+
submit.textContent = '添加'
|
|
1050
|
+
form.appendChild(pick)
|
|
1051
|
+
form.appendChild(manual)
|
|
1052
|
+
form.appendChild(submit)
|
|
1053
|
+
form.addEventListener('submit', async ev => {
|
|
1054
|
+
ev.preventDefault()
|
|
1055
|
+
const handle = manual.value.trim()
|
|
1056
|
+
const id = handle !== '' ? handle : pick.value
|
|
1057
|
+
if (id === '') return
|
|
1058
|
+
submit.disabled = true
|
|
1059
|
+
const ok = await deps.canvasOp('add-member', fresh.name, id)
|
|
1060
|
+
submit.disabled = false
|
|
1061
|
+
if (ok) manual.value = ''
|
|
1062
|
+
})
|
|
1063
|
+
canvasForm = { name: fresh.name, form, pick, manual }
|
|
1064
|
+
}
|
|
1065
|
+
// Refresh the picker's options from the freshest state, preserving the
|
|
1066
|
+
// current choice (sessions/remote rows churn across polls).
|
|
1067
|
+
const pick = canvasForm.pick
|
|
1068
|
+
const keep = pick.value
|
|
1069
|
+
clear(pick)
|
|
1070
|
+
pick.appendChild(new Option('本机会话(现场/冷启动)…', '', true, true))
|
|
1071
|
+
const memberIds = new Set(fresh.members.map(member => member.id))
|
|
1072
|
+
for (const session of last?.sessions ?? []) {
|
|
1073
|
+
if (memberIds.has(session.id)) continue
|
|
1074
|
+
pick.appendChild(new Option(`${displayName(session)} — 本机`, session.id))
|
|
1075
|
+
}
|
|
1076
|
+
let remoteLabeled = false
|
|
1077
|
+
for (const remote of last?.remote ?? []) {
|
|
1078
|
+
if (memberIds.has(remote.team)) continue
|
|
1079
|
+
if (!remoteLabeled) {
|
|
1080
|
+
pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
|
|
1081
|
+
remoteLabeled = true
|
|
1082
|
+
}
|
|
1083
|
+
pick.appendChild(new Option(`${displayName(remote)} — 远端`, remote.team))
|
|
1084
|
+
}
|
|
1085
|
+
if (keep !== '' && Array.from(pick.options).some(option => option.value === keep)) pick.value = keep
|
|
1086
|
+
card.appendChild(canvasForm.form)
|
|
1087
|
+
const hint = el('div', 'wb-hint')
|
|
1088
|
+
hint.textContent =
|
|
1089
|
+
'成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。'
|
|
1090
|
+
card.appendChild(hint)
|
|
1091
|
+
// Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
|
|
1092
|
+
// first, then the remote members in order — the seam's own priority.
|
|
1093
|
+
if (deps.dispatchQuick !== undefined) card.appendChild(dispatchCard(fresh.team))
|
|
1094
|
+
}
|
|
1095
|
+
box.appendChild(card)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ── Quick dispatch (0.6.9) ──
|
|
1099
|
+
// One human-typed message to a routing team, no session opened. The form
|
|
1100
|
+
// is cached per target team (draftForm trick) so the textarea draft and
|
|
1101
|
+
// the result note survive the 3s poll remount.
|
|
1102
|
+
function buildDispatchForm(target: string): { form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } {
|
|
1103
|
+
const form = el('form', 'wb-dispatch-form')
|
|
1104
|
+
const text = document.createElement('textarea')
|
|
1105
|
+
text.className = 'wb-dispatch-text'
|
|
1106
|
+
text.placeholder = '一句话任务…(≤4000 字符,异步送达,回执进台账)'
|
|
1107
|
+
text.setAttribute('aria-label', '快派消息')
|
|
1108
|
+
const submit = el('button', 'wb-dispatch-submit')
|
|
1109
|
+
submit.type = 'submit'
|
|
1110
|
+
submit.textContent = '发送'
|
|
1111
|
+
const note = el('div', 'wb-dispatch-note')
|
|
1112
|
+
form.appendChild(text)
|
|
1113
|
+
form.appendChild(submit)
|
|
1114
|
+
form.appendChild(note)
|
|
1115
|
+
// 0.6.17 chat-UI conventions (deepseek-harness composer): Enter sends,
|
|
1116
|
+
// Shift+Enter is a soft newline, IME composition is never hijacked.
|
|
1117
|
+
text.addEventListener('keydown', ev => {
|
|
1118
|
+
if (ev.key !== 'Enter' || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) return
|
|
1119
|
+
if (ev.isComposing) return
|
|
1120
|
+
ev.preventDefault()
|
|
1121
|
+
if (!submit.disabled) form.requestSubmit()
|
|
1122
|
+
})
|
|
1123
|
+
form.addEventListener('submit', async ev => {
|
|
1124
|
+
ev.preventDefault()
|
|
1125
|
+
const message = text.value.trim()
|
|
1126
|
+
if (message === '' || submit.disabled) return
|
|
1127
|
+
submit.disabled = true
|
|
1128
|
+
note.textContent = '派发中…'
|
|
1129
|
+
let outcome: { ok: boolean; task_id?: string; reply?: string; error?: string }
|
|
1130
|
+
try {
|
|
1131
|
+
outcome = await (deps.dispatchQuick?.(target, message) ?? Promise.resolve({ ok: false, error: 'dispatch unavailable' }))
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
1134
|
+
}
|
|
1135
|
+
submit.disabled = false
|
|
1136
|
+
if (outcome.ok === true) {
|
|
1137
|
+
// Sent: clear the composer like every chat surface and hand the
|
|
1138
|
+
// caret back — the submit round-trip must not strand focus.
|
|
1139
|
+
text.value = ''
|
|
1140
|
+
}
|
|
1141
|
+
text.focus({ preventScroll: true })
|
|
1142
|
+
note.textContent = outcome.ok === true
|
|
1143
|
+
? `已派发${outcome.task_id !== undefined ? ` · ${outcome.task_id}` : ''} · 回执将入台账(待回执)`
|
|
1144
|
+
: `失败:${outcome.error ?? '未知原因'}`
|
|
1145
|
+
})
|
|
1146
|
+
return { form, text, note }
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function dispatchCard(target: string): HTMLElement {
|
|
1150
|
+
const wrap = el('div', 'wb-dispatch')
|
|
1151
|
+
const head = el('div', 'wb-side-head')
|
|
1152
|
+
head.textContent = '快派'
|
|
1153
|
+
wrap.appendChild(head)
|
|
1154
|
+
if (dispatchForm?.team !== target) dispatchForm = { team: target, ...buildDispatchForm(target) }
|
|
1155
|
+
wrap.appendChild(dispatchForm.form)
|
|
1156
|
+
return wrap
|
|
1157
|
+
}
|
|
1158
|
+
|
|
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
|
+
function openChat(target: ChatTarget): void {
|
|
1329
|
+
chatTarget = target
|
|
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')
|
|
1338
|
+
root.classList.remove('is-rail-open')
|
|
1339
|
+
}
|
|
1340
|
+
|
|
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
|
+
/** Board picks (0.6.13): resolve a node id against the latest state. */
|
|
1443
|
+
function openNodeChat(id: string): void {
|
|
1444
|
+
if (last === null) return
|
|
1445
|
+
const session = (last.sessions ?? []).find(row => row.id === id)
|
|
1446
|
+
if (session !== undefined) {
|
|
1447
|
+
openChat({ kind: 'session', id, title: displayName(session), sub: session.team, team: session.team, live: session.live })
|
|
1448
|
+
return
|
|
1449
|
+
}
|
|
1450
|
+
if (id.startsWith('peer-')) {
|
|
1451
|
+
const host = id.slice('peer-'.length)
|
|
1452
|
+
const peer = (last.peers ?? []).find(row => row.url.includes(host))
|
|
1453
|
+
openChat({ kind: 'peer', id, title: peer !== undefined ? peer.url : host, sub: host, url: peer?.url })
|
|
1454
|
+
return
|
|
1455
|
+
}
|
|
1456
|
+
const log = logRows.find(row => row.id === id)
|
|
1457
|
+
if (log !== undefined) openChat({ kind: 'log', id, title: `…${id.slice(-12)}`, sub: '档案' })
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// ── Header ──
|
|
1461
|
+
// Results first (owner signal, 0.6.24): the header carries the verdict —
|
|
1462
|
+
// exceptions only, one pill each; a healthy host says so quietly. The
|
|
1463
|
+
// forensic counters that used to sit here (idempotency claims/replays,
|
|
1464
|
+
// reconcile state) read on the ledger's 取证 line instead.
|
|
1465
|
+
function renderHeader(): void {
|
|
1466
|
+
versionBadge.textContent = last?.version !== undefined ? `v${last.version}` : 'v?'
|
|
1467
|
+
const box = clear(headerStats)
|
|
1468
|
+
if (last === null) return
|
|
1469
|
+
const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string }> = []
|
|
1470
|
+
const dead = last.tasksDead?.length ?? 0
|
|
1471
|
+
if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务,需在台账清账' })
|
|
1472
|
+
const stale = last.tasksStaleCount ?? 0
|
|
1473
|
+
if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '≥1h 无回执的旧账(含免回执行),到 24h TTL 自动转死信' })
|
|
1474
|
+
const conflicts = last.idempotency?.conflicts ?? 0
|
|
1475
|
+
if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查' })
|
|
1476
|
+
const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
|
|
1477
|
+
if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划' })
|
|
1478
|
+
if (alerts.length === 0) {
|
|
1479
|
+
box.appendChild(el('span', 'wb-header-ok')).textContent = '● 无异常'
|
|
1480
|
+
return
|
|
1481
|
+
}
|
|
1482
|
+
for (const a of alerts) {
|
|
1483
|
+
const pill = el('span', `wb-alert wb-alert-${a.tone}`)
|
|
1484
|
+
pill.textContent = a.text
|
|
1485
|
+
pill.title = a.title
|
|
1486
|
+
box.appendChild(pill)
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function update(state: StateBody | null): void {
|
|
1491
|
+
last = state
|
|
1492
|
+
// 0.6.12: refresh the readable-log list opportunistically (single flight;
|
|
1493
|
+
// the sidebar re-renders when rows land, mid-poll remount is idempotent).
|
|
1494
|
+
if (deps.sessionLogList !== undefined && !logRowsBusy) {
|
|
1495
|
+
logRowsBusy = true
|
|
1496
|
+
deps.sessionLogList().then(result => {
|
|
1497
|
+
logRows = result.ok && result.sessions !== undefined ? result.sessions : []
|
|
1498
|
+
}).catch(() => {}).finally(() => {
|
|
1499
|
+
logRowsBusy = false
|
|
1500
|
+
renderSidebar()
|
|
1501
|
+
})
|
|
1502
|
+
}
|
|
1503
|
+
renderHeader()
|
|
1504
|
+
renderSidebar()
|
|
1505
|
+
renderDraftTargets()
|
|
1506
|
+
renderLedger()
|
|
1507
|
+
// 0.6.13: an open chat drawer re-renders against the fresh state (title
|
|
1508
|
+
// freshness + the transcript block's per-id cache answers instantly).
|
|
1509
|
+
if (chatTarget !== null) renderChat()
|
|
1510
|
+
renderActivity()
|
|
1511
|
+
renderPlan()
|
|
1512
|
+
renderInspector()
|
|
1513
|
+
// Mode is availability-derived every poll: canvas face down → data mode,
|
|
1514
|
+
// the board deactivates and the panes return to the stage stack.
|
|
1515
|
+
syncMode()
|
|
1516
|
+
syncSections()
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function selectTab(key: WorkbenchTab): void {
|
|
1520
|
+
if (key === 'board') {
|
|
1521
|
+
syncMode()
|
|
1522
|
+
return
|
|
1523
|
+
}
|
|
1524
|
+
collapsed.delete(key)
|
|
1525
|
+
syncSections()
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
update(null)
|
|
1529
|
+
return { root, update, mode: () => mode, selectTab, openNodeChat }
|
|
1530
|
+
}
|