@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README_zh.md +1 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/index.js +57 -9
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- package/lib/types/origin-claim.d.ts +2 -0
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +513 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1272 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
|
@@ -1,237 +1,237 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The 目录 tab (2026-10-23 rewrite): unified search + category chips over
|
|
3
|
-
* four groups — 本机会话 (live first, then cold), 画布队, 远端节点, 本机日志.
|
|
4
|
-
* Peer hosts moved to the 主机 tab (the directory listed them twice).
|
|
5
|
-
*
|
|
6
|
-
* 本机日志 rows used to read `…arket-review · 48 KB`: the id's last 12
|
|
7
|
-
* characters and a size. They now group by day (今天 / 昨天 / 更早), carry a
|
|
8
|
-
* relative time, and show a learned title — the first user line of the
|
|
9
|
-
* transcript, captured when the log is opened in the chat drawer (the list
|
|
10
|
-
* face itself returns only id / mtime / size). When the host caps the list,
|
|
11
|
-
* the group says how many it is showing out of how many.
|
|
12
|
-
* Rows are attacker-shaped data: textContent only.
|
|
13
|
-
* @module nexus-stage/wb-directory
|
|
14
|
-
*/
|
|
15
|
-
import type { CanvasTeamRow, StateBody } from './workbench'
|
|
16
|
-
import type { ChatTarget } from './wb-chat'
|
|
17
|
-
import { displayName } from './stage-name'
|
|
18
|
-
import { makeSessionDraggable } from './session-drag'
|
|
19
|
-
|
|
20
|
-
export type DirFilter = 'all' | 'local' | 'canvas' | 'remote' | 'logs'
|
|
21
|
-
|
|
22
|
-
export const DIRECTORY_FILTERS: ReadonlyArray<{ key: DirFilter; label: string; hint: string }> = [
|
|
23
|
-
{ key: 'all', label: '全部', hint: '不过滤:目录全部条目' },
|
|
24
|
-
{ key: 'local', label: '本机', hint: '本机会话节点(现场 + 冷启动)' },
|
|
25
|
-
{ key: 'canvas', label: '队', hint: '画布队(人类手工分配的组队)' },
|
|
26
|
-
{ key: 'remote', label: '远端', hint: '远端会话队(来自对端卡片)' },
|
|
27
|
-
{ key: 'logs', label: '日志', hint: '本机日志:不在网络目录里的会话档案' },
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
export interface LogRow { readonly id: string; readonly mtimeMs: number; readonly sizeBytes: number }
|
|
31
|
-
|
|
32
|
-
export interface DirectoryContext {
|
|
33
|
-
readonly state: StateBody
|
|
34
|
-
readonly logs: ReadonlyArray<LogRow>
|
|
35
|
-
/** How many logs the host holds (the list face is capped); undefined when unknown. */
|
|
36
|
-
readonly logTotal?: number
|
|
37
|
-
/** Learned log titles by session id. */
|
|
38
|
-
readonly titles: ReadonlyMap<string, string>
|
|
39
|
-
/** Lowercased search text ('' = none). */
|
|
40
|
-
readonly search: string
|
|
41
|
-
readonly filter: DirFilter
|
|
42
|
-
readonly now: number
|
|
43
|
-
openChat(target: ChatTarget): void
|
|
44
|
-
selectCanvas(row: CanvasTeamRow): void
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
|
|
48
|
-
const node = document.createElement(tag)
|
|
49
|
-
if (className !== undefined) node.className = className
|
|
50
|
-
return node
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Lowercased haystack for the unified search. */
|
|
54
|
-
function haystack(...parts: ReadonlyArray<string | readonly string[] | undefined>): string {
|
|
55
|
-
const flat: string[] = []
|
|
56
|
-
for (const p of parts) {
|
|
57
|
-
if (p === undefined) continue
|
|
58
|
-
if (typeof p === 'string') flat.push(p)
|
|
59
|
-
else flat.push(...p)
|
|
60
|
-
}
|
|
61
|
-
return flat.join(' ').toLowerCase()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function ago(ms: number, now: number): string {
|
|
65
|
-
const s = Math.max(0, Math.round((now - ms) / 1000))
|
|
66
|
-
if (s < 60) return '刚刚'
|
|
67
|
-
const m = Math.round(s / 60)
|
|
68
|
-
if (m < 60) return `${m} 分钟前`
|
|
69
|
-
const h = Math.round(m / 60)
|
|
70
|
-
if (h < 48) return `${h} 小时前`
|
|
71
|
-
return `${Math.round(h / 24)} 天前`
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function fmtSize(n: number): string {
|
|
75
|
-
return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Local-calendar day bucket relative to `now`. */
|
|
79
|
-
function dayBucket(ms: number, now: number): '今天' | '昨天' | '更早' {
|
|
80
|
-
const startOfToday = new Date(now)
|
|
81
|
-
startOfToday.setHours(0, 0, 0, 0)
|
|
82
|
-
const t0 = startOfToday.getTime()
|
|
83
|
-
if (ms >= t0) return '今天'
|
|
84
|
-
if (ms >= t0 - 86_400_000) return '昨天'
|
|
85
|
-
return '更早'
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Render the directory into `box`; returns the tab badge text. */
|
|
89
|
-
export function renderDirectory(box: HTMLElement, ctx: DirectoryContext): string {
|
|
90
|
-
box.replaceChildren()
|
|
91
|
-
const { state, search, filter } = ctx
|
|
92
|
-
const filtering = search !== '' || filter !== 'all'
|
|
93
|
-
const admit = (cat: DirFilter, hay: string): boolean => (filter === 'all' || filter === cat) && (search === '' || hay.includes(search))
|
|
94
|
-
|
|
95
|
-
const group = (label: string, count: number, extra?: string): HTMLElement => {
|
|
96
|
-
const g = el('div', 'wb-dir-group')
|
|
97
|
-
const head = el('div', 'wb-dir-head')
|
|
98
|
-
head.appendChild(el('span', 'wb-dir-head-label')).textContent = `${label} · ${count}`
|
|
99
|
-
if (extra !== undefined) head.appendChild(el('span', 'wb-dir-head-note')).textContent = extra
|
|
100
|
-
g.appendChild(head)
|
|
101
|
-
box.appendChild(g)
|
|
102
|
-
return g
|
|
103
|
-
}
|
|
104
|
-
const row = (parent: HTMLElement, dotClass: string, glyph: string, label: string, sub: string, onClick: () => void, tip?: string): HTMLButtonElement => {
|
|
105
|
-
const item = el('button', 'wb-item')
|
|
106
|
-
item.type = 'button'
|
|
107
|
-
item.appendChild(el('span', `wb-dot ${dotClass}`)).textContent = glyph
|
|
108
|
-
const text = el('span', 'wb-item-text')
|
|
109
|
-
text.appendChild(el('span', 'wb-item-label')).textContent = label
|
|
110
|
-
if (sub !== '') text.appendChild(el('span', 'wb-item-sub')).textContent = sub
|
|
111
|
-
item.appendChild(text)
|
|
112
|
-
if (tip !== undefined) item.title = tip
|
|
113
|
-
item.addEventListener('click', onClick)
|
|
114
|
-
parent.appendChild(item)
|
|
115
|
-
return item
|
|
116
|
-
}
|
|
117
|
-
const badges = (item: HTMLElement, teams: readonly string[] | undefined): void => {
|
|
118
|
-
if (teams === undefined || teams.length === 0) return
|
|
119
|
-
const chip = el('span', 'wb-item-badge')
|
|
120
|
-
const names = teams.map(t => t.slice(t.lastIndexOf('/') + 1))
|
|
121
|
-
chip.textContent = `▤ ${names.slice(0, 2).join(' ')}${names.length > 2 ? ` +${names.length - 2}` : ''}`
|
|
122
|
-
chip.title = `画布队:${teams.join(' · ')}`
|
|
123
|
-
item.appendChild(chip)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ── 本机会话: live first, then cold ──
|
|
127
|
-
const sessions = (state.sessions ?? []).filter(s => admit('local', haystack(displayName(s), s.team, s.label, s.id, s.canvasTeams)))
|
|
128
|
-
const live = sessions.filter(s => s.live === true)
|
|
129
|
-
const cold = sessions.filter(s => s.live !== true)
|
|
130
|
-
if (sessions.length > 0) {
|
|
131
|
-
const g = group('本机会话', sessions.length, cold.length > 0 ? `${live.length} 在线 · ${cold.length} 冷` : undefined)
|
|
132
|
-
for (const s of [...live, ...cold]) {
|
|
133
|
-
const isLive = s.live === true
|
|
134
|
-
const missing = s.nodePromptMissing === true
|
|
135
|
-
const item = row(g, isLive ? 'wb-dot-live' : 'wb-dot-cold', isLive ? '●' : '○', displayName(s), isLive ? s.team : `冷 · ${s.team}`,
|
|
136
|
-
() => ctx.openChat({ kind: 'session', id: s.id, title: displayName(s), sub: s.team, team: s.team, live: s.live }), s.id)
|
|
137
|
-
if (missing) {
|
|
138
|
-
item.classList.add('is-node-prompt-missing')
|
|
139
|
-
const note = el('span', 'wb-node-warning')
|
|
140
|
-
note.textContent = '本会话的组装不含节点段'
|
|
141
|
-
item.querySelector('.wb-item-text')?.appendChild(note)
|
|
142
|
-
}
|
|
143
|
-
badges(item, s.canvasTeams)
|
|
144
|
-
makeSessionDraggable(item, s.id)
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ── 画布队 ──
|
|
149
|
-
const canvases = (state.canvas?.teams ?? []).filter(t => admit('canvas', haystack(t.name, t.team, t.members.map(m => m.team ?? m.id))))
|
|
150
|
-
if (canvases.length > 0) {
|
|
151
|
-
const g = group('画布队', canvases.length)
|
|
152
|
-
for (const t of canvases) {
|
|
153
|
-
const remote = t.members.filter(m => m.remote === true).length
|
|
154
|
-
row(g, 'wb-dot-canvas', '▤', t.name, `${t.members.length} 成员${remote > 0 ? ` · ${remote} 远端` : ''}`, () => ctx.selectCanvas(t), t.team)
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ── 远端节点 ──
|
|
159
|
-
const remote = (state.remote ?? []).filter(r => admit('remote', haystack(displayName(r), r.team, r.origin, r.workspace, r.canvasTeams)))
|
|
160
|
-
if (remote.length > 0) {
|
|
161
|
-
const g = group('远端节点', remote.length)
|
|
162
|
-
for (const r of remote) {
|
|
163
|
-
const item = row(g, 'wb-dot-remote', '◇', displayName(r), r.origin !== undefined && r.origin !== '' ? r.origin : r.team,
|
|
164
|
-
() => ctx.openChat({ kind: 'remote', team: r.team, title: displayName(r), sub: r.team, row: r }), r.team)
|
|
165
|
-
if (r.unreachable === true) {
|
|
166
|
-
item.classList.add('is-unreachable')
|
|
167
|
-
const note = el('span', 'wb-unreachable-note')
|
|
168
|
-
note.textContent = '对端未应答(显示的是最后已知状态)'
|
|
169
|
-
item.querySelector('.wb-item-text')?.appendChild(note)
|
|
170
|
-
}
|
|
171
|
-
badges(item, r.canvasTeams)
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ── 本机日志: every local log artifact not already shown as a network row ──
|
|
176
|
-
// (0.6.12 reachability: reading is host-scoped, the network directory is
|
|
177
|
-
// not the reading gate), newest first, grouped by day.
|
|
178
|
-
const networkIds = new Set((state.sessions ?? []).map(s => s.id))
|
|
179
|
-
const logs = ctx.logs.filter(l => !networkIds.has(l.id) && admit('logs', haystack(l.id, ctx.titles.get(l.id))))
|
|
180
|
-
if (logs.length > 0) {
|
|
181
|
-
const capped = ctx.logTotal !== undefined && ctx.logTotal > ctx.logs.length
|
|
182
|
-
const g = group('本机日志', logs.length, capped ? `显示 ${ctx.logs.length} / 共 ${ctx.logTotal}` : undefined)
|
|
183
|
-
let bucket = ''
|
|
184
|
-
for (const l of [...logs].sort((a, b) => b.mtimeMs - a.mtimeMs)) {
|
|
185
|
-
const b = dayBucket(l.mtimeMs, ctx.now)
|
|
186
|
-
if (b !== bucket) {
|
|
187
|
-
bucket = b
|
|
188
|
-
g.appendChild(el('div', 'wb-dir-day')).textContent = b
|
|
189
|
-
}
|
|
190
|
-
const title = ctx.titles.get(l.id)
|
|
191
|
-
const item = row(g, 'wb-dot-log', '❐', title ?? `会话 …${l.id.slice(-12)}`, `${ago(l.mtimeMs, ctx.now)} · ${fmtSize(l.sizeBytes)}`,
|
|
192
|
-
() => ctx.openChat({ kind: 'log', id: l.id, title: title ?? `…${l.id.slice(-12)}`, sub: '本机日志' }),
|
|
193
|
-
`${l.id}\n${new Date(l.mtimeMs).toLocaleString()} · ${fmtSize(l.sizeBytes)}`)
|
|
194
|
-
if (title === undefined) item.classList.add('is-untitled')
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (sessions.length + canvases.length + remote.length + logs.length === 0) {
|
|
199
|
-
box.appendChild(el('div', 'wb-dir-empty')).textContent = filtering ? '无匹配条目' : '目录为空'
|
|
200
|
-
}
|
|
201
|
-
const allSessions = state.sessions ?? []
|
|
202
|
-
const liveAll = allSessions.filter(s => s.live === true).length
|
|
203
|
-
return `${liveAll}/${allSessions.length}`
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ── learned log titles (per-viewer convenience, browser-local) ──
|
|
207
|
-
const TITLE_KEY = 'dsh-a2a.logtitles'
|
|
208
|
-
const TITLE_CAP = 300
|
|
209
|
-
const TITLE_MAX = 60
|
|
210
|
-
|
|
211
|
-
/** Load the learned titles; storage can be absent or throw — an empty map then. */
|
|
212
|
-
export function loadLogTitles(): Map<string, string> {
|
|
213
|
-
try {
|
|
214
|
-
const raw = window.localStorage.getItem(TITLE_KEY)
|
|
215
|
-
const parsed = raw === null ? null : JSON.parse(raw) as unknown
|
|
216
|
-
if (parsed !== null && typeof parsed === 'object') {
|
|
217
|
-
return new Map(Object.entries(parsed as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === 'string'))
|
|
218
|
-
}
|
|
219
|
-
} catch { /* fall through */ }
|
|
220
|
-
return new Map()
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Learn a title from a transcript (first user line, else first line); true when it changed. */
|
|
224
|
-
export function learnLogTitle(titles: Map<string, string>, id: string, messages: ReadonlyArray<{ role: string; text: string }>): boolean {
|
|
225
|
-
const source = messages.find(m => m.role === 'user' && m.text.trim() !== '') ?? messages.find(m => m.text.trim() !== '')
|
|
226
|
-
if (source === undefined) return false
|
|
227
|
-
const line = source.text.trim().split('\n')[0]!.trim()
|
|
228
|
-
const title = line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX - 1)}…` : line
|
|
229
|
-
if (titles.get(id) === title) return false
|
|
230
|
-
titles.delete(id)
|
|
231
|
-
titles.set(id, title)
|
|
232
|
-
while (titles.size > TITLE_CAP) titles.delete(titles.keys().next().value as string)
|
|
233
|
-
try {
|
|
234
|
-
window.localStorage.setItem(TITLE_KEY, JSON.stringify(Object.fromEntries(titles)))
|
|
235
|
-
} catch { /* per-viewer convenience only */ }
|
|
236
|
-
return true
|
|
237
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The 目录 tab (2026-10-23 rewrite): unified search + category chips over
|
|
3
|
+
* four groups — 本机会话 (live first, then cold), 画布队, 远端节点, 本机日志.
|
|
4
|
+
* Peer hosts moved to the 主机 tab (the directory listed them twice).
|
|
5
|
+
*
|
|
6
|
+
* 本机日志 rows used to read `…arket-review · 48 KB`: the id's last 12
|
|
7
|
+
* characters and a size. They now group by day (今天 / 昨天 / 更早), carry a
|
|
8
|
+
* relative time, and show a learned title — the first user line of the
|
|
9
|
+
* transcript, captured when the log is opened in the chat drawer (the list
|
|
10
|
+
* face itself returns only id / mtime / size). When the host caps the list,
|
|
11
|
+
* the group says how many it is showing out of how many.
|
|
12
|
+
* Rows are attacker-shaped data: textContent only.
|
|
13
|
+
* @module nexus-stage/wb-directory
|
|
14
|
+
*/
|
|
15
|
+
import type { CanvasTeamRow, StateBody } from './workbench'
|
|
16
|
+
import type { ChatTarget } from './wb-chat'
|
|
17
|
+
import { displayName } from './stage-name'
|
|
18
|
+
import { makeSessionDraggable } from './session-drag'
|
|
19
|
+
|
|
20
|
+
export type DirFilter = 'all' | 'local' | 'canvas' | 'remote' | 'logs'
|
|
21
|
+
|
|
22
|
+
export const DIRECTORY_FILTERS: ReadonlyArray<{ key: DirFilter; label: string; hint: string }> = [
|
|
23
|
+
{ key: 'all', label: '全部', hint: '不过滤:目录全部条目' },
|
|
24
|
+
{ key: 'local', label: '本机', hint: '本机会话节点(现场 + 冷启动)' },
|
|
25
|
+
{ key: 'canvas', label: '队', hint: '画布队(人类手工分配的组队)' },
|
|
26
|
+
{ key: 'remote', label: '远端', hint: '远端会话队(来自对端卡片)' },
|
|
27
|
+
{ key: 'logs', label: '日志', hint: '本机日志:不在网络目录里的会话档案' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export interface LogRow { readonly id: string; readonly mtimeMs: number; readonly sizeBytes: number }
|
|
31
|
+
|
|
32
|
+
export interface DirectoryContext {
|
|
33
|
+
readonly state: StateBody
|
|
34
|
+
readonly logs: ReadonlyArray<LogRow>
|
|
35
|
+
/** How many logs the host holds (the list face is capped); undefined when unknown. */
|
|
36
|
+
readonly logTotal?: number
|
|
37
|
+
/** Learned log titles by session id. */
|
|
38
|
+
readonly titles: ReadonlyMap<string, string>
|
|
39
|
+
/** Lowercased search text ('' = none). */
|
|
40
|
+
readonly search: string
|
|
41
|
+
readonly filter: DirFilter
|
|
42
|
+
readonly now: number
|
|
43
|
+
openChat(target: ChatTarget): void
|
|
44
|
+
selectCanvas(row: CanvasTeamRow): void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
|
|
48
|
+
const node = document.createElement(tag)
|
|
49
|
+
if (className !== undefined) node.className = className
|
|
50
|
+
return node
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Lowercased haystack for the unified search. */
|
|
54
|
+
function haystack(...parts: ReadonlyArray<string | readonly string[] | undefined>): string {
|
|
55
|
+
const flat: string[] = []
|
|
56
|
+
for (const p of parts) {
|
|
57
|
+
if (p === undefined) continue
|
|
58
|
+
if (typeof p === 'string') flat.push(p)
|
|
59
|
+
else flat.push(...p)
|
|
60
|
+
}
|
|
61
|
+
return flat.join(' ').toLowerCase()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ago(ms: number, now: number): string {
|
|
65
|
+
const s = Math.max(0, Math.round((now - ms) / 1000))
|
|
66
|
+
if (s < 60) return '刚刚'
|
|
67
|
+
const m = Math.round(s / 60)
|
|
68
|
+
if (m < 60) return `${m} 分钟前`
|
|
69
|
+
const h = Math.round(m / 60)
|
|
70
|
+
if (h < 48) return `${h} 小时前`
|
|
71
|
+
return `${Math.round(h / 24)} 天前`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fmtSize(n: number): string {
|
|
75
|
+
return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Local-calendar day bucket relative to `now`. */
|
|
79
|
+
function dayBucket(ms: number, now: number): '今天' | '昨天' | '更早' {
|
|
80
|
+
const startOfToday = new Date(now)
|
|
81
|
+
startOfToday.setHours(0, 0, 0, 0)
|
|
82
|
+
const t0 = startOfToday.getTime()
|
|
83
|
+
if (ms >= t0) return '今天'
|
|
84
|
+
if (ms >= t0 - 86_400_000) return '昨天'
|
|
85
|
+
return '更早'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Render the directory into `box`; returns the tab badge text. */
|
|
89
|
+
export function renderDirectory(box: HTMLElement, ctx: DirectoryContext): string {
|
|
90
|
+
box.replaceChildren()
|
|
91
|
+
const { state, search, filter } = ctx
|
|
92
|
+
const filtering = search !== '' || filter !== 'all'
|
|
93
|
+
const admit = (cat: DirFilter, hay: string): boolean => (filter === 'all' || filter === cat) && (search === '' || hay.includes(search))
|
|
94
|
+
|
|
95
|
+
const group = (label: string, count: number, extra?: string): HTMLElement => {
|
|
96
|
+
const g = el('div', 'wb-dir-group')
|
|
97
|
+
const head = el('div', 'wb-dir-head')
|
|
98
|
+
head.appendChild(el('span', 'wb-dir-head-label')).textContent = `${label} · ${count}`
|
|
99
|
+
if (extra !== undefined) head.appendChild(el('span', 'wb-dir-head-note')).textContent = extra
|
|
100
|
+
g.appendChild(head)
|
|
101
|
+
box.appendChild(g)
|
|
102
|
+
return g
|
|
103
|
+
}
|
|
104
|
+
const row = (parent: HTMLElement, dotClass: string, glyph: string, label: string, sub: string, onClick: () => void, tip?: string): HTMLButtonElement => {
|
|
105
|
+
const item = el('button', 'wb-item')
|
|
106
|
+
item.type = 'button'
|
|
107
|
+
item.appendChild(el('span', `wb-dot ${dotClass}`)).textContent = glyph
|
|
108
|
+
const text = el('span', 'wb-item-text')
|
|
109
|
+
text.appendChild(el('span', 'wb-item-label')).textContent = label
|
|
110
|
+
if (sub !== '') text.appendChild(el('span', 'wb-item-sub')).textContent = sub
|
|
111
|
+
item.appendChild(text)
|
|
112
|
+
if (tip !== undefined) item.title = tip
|
|
113
|
+
item.addEventListener('click', onClick)
|
|
114
|
+
parent.appendChild(item)
|
|
115
|
+
return item
|
|
116
|
+
}
|
|
117
|
+
const badges = (item: HTMLElement, teams: readonly string[] | undefined): void => {
|
|
118
|
+
if (teams === undefined || teams.length === 0) return
|
|
119
|
+
const chip = el('span', 'wb-item-badge')
|
|
120
|
+
const names = teams.map(t => t.slice(t.lastIndexOf('/') + 1))
|
|
121
|
+
chip.textContent = `▤ ${names.slice(0, 2).join(' ')}${names.length > 2 ? ` +${names.length - 2}` : ''}`
|
|
122
|
+
chip.title = `画布队:${teams.join(' · ')}`
|
|
123
|
+
item.appendChild(chip)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 本机会话: live first, then cold ──
|
|
127
|
+
const sessions = (state.sessions ?? []).filter(s => admit('local', haystack(displayName(s), s.team, s.label, s.id, s.canvasTeams)))
|
|
128
|
+
const live = sessions.filter(s => s.live === true)
|
|
129
|
+
const cold = sessions.filter(s => s.live !== true)
|
|
130
|
+
if (sessions.length > 0) {
|
|
131
|
+
const g = group('本机会话', sessions.length, cold.length > 0 ? `${live.length} 在线 · ${cold.length} 冷` : undefined)
|
|
132
|
+
for (const s of [...live, ...cold]) {
|
|
133
|
+
const isLive = s.live === true
|
|
134
|
+
const missing = s.nodePromptMissing === true
|
|
135
|
+
const item = row(g, isLive ? 'wb-dot-live' : 'wb-dot-cold', isLive ? '●' : '○', displayName(s), isLive ? s.team : `冷 · ${s.team}`,
|
|
136
|
+
() => ctx.openChat({ kind: 'session', id: s.id, title: displayName(s), sub: s.team, team: s.team, live: s.live }), s.id)
|
|
137
|
+
if (missing) {
|
|
138
|
+
item.classList.add('is-node-prompt-missing')
|
|
139
|
+
const note = el('span', 'wb-node-warning')
|
|
140
|
+
note.textContent = '本会话的组装不含节点段'
|
|
141
|
+
item.querySelector('.wb-item-text')?.appendChild(note)
|
|
142
|
+
}
|
|
143
|
+
badges(item, s.canvasTeams)
|
|
144
|
+
makeSessionDraggable(item, s.id)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── 画布队 ──
|
|
149
|
+
const canvases = (state.canvas?.teams ?? []).filter(t => admit('canvas', haystack(t.name, t.team, t.members.map(m => m.team ?? m.id))))
|
|
150
|
+
if (canvases.length > 0) {
|
|
151
|
+
const g = group('画布队', canvases.length)
|
|
152
|
+
for (const t of canvases) {
|
|
153
|
+
const remote = t.members.filter(m => m.remote === true).length
|
|
154
|
+
row(g, 'wb-dot-canvas', '▤', t.name, `${t.members.length} 成员${remote > 0 ? ` · ${remote} 远端` : ''}`, () => ctx.selectCanvas(t), t.team)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── 远端节点 ──
|
|
159
|
+
const remote = (state.remote ?? []).filter(r => admit('remote', haystack(displayName(r), r.team, r.origin, r.workspace, r.canvasTeams)))
|
|
160
|
+
if (remote.length > 0) {
|
|
161
|
+
const g = group('远端节点', remote.length)
|
|
162
|
+
for (const r of remote) {
|
|
163
|
+
const item = row(g, 'wb-dot-remote', '◇', displayName(r), r.origin !== undefined && r.origin !== '' ? r.origin : r.team,
|
|
164
|
+
() => ctx.openChat({ kind: 'remote', team: r.team, title: displayName(r), sub: r.team, row: r }), r.team)
|
|
165
|
+
if (r.unreachable === true) {
|
|
166
|
+
item.classList.add('is-unreachable')
|
|
167
|
+
const note = el('span', 'wb-unreachable-note')
|
|
168
|
+
note.textContent = '对端未应答(显示的是最后已知状态)'
|
|
169
|
+
item.querySelector('.wb-item-text')?.appendChild(note)
|
|
170
|
+
}
|
|
171
|
+
badges(item, r.canvasTeams)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── 本机日志: every local log artifact not already shown as a network row ──
|
|
176
|
+
// (0.6.12 reachability: reading is host-scoped, the network directory is
|
|
177
|
+
// not the reading gate), newest first, grouped by day.
|
|
178
|
+
const networkIds = new Set((state.sessions ?? []).map(s => s.id))
|
|
179
|
+
const logs = ctx.logs.filter(l => !networkIds.has(l.id) && admit('logs', haystack(l.id, ctx.titles.get(l.id))))
|
|
180
|
+
if (logs.length > 0) {
|
|
181
|
+
const capped = ctx.logTotal !== undefined && ctx.logTotal > ctx.logs.length
|
|
182
|
+
const g = group('本机日志', logs.length, capped ? `显示 ${ctx.logs.length} / 共 ${ctx.logTotal}` : undefined)
|
|
183
|
+
let bucket = ''
|
|
184
|
+
for (const l of [...logs].sort((a, b) => b.mtimeMs - a.mtimeMs)) {
|
|
185
|
+
const b = dayBucket(l.mtimeMs, ctx.now)
|
|
186
|
+
if (b !== bucket) {
|
|
187
|
+
bucket = b
|
|
188
|
+
g.appendChild(el('div', 'wb-dir-day')).textContent = b
|
|
189
|
+
}
|
|
190
|
+
const title = ctx.titles.get(l.id)
|
|
191
|
+
const item = row(g, 'wb-dot-log', '❐', title ?? `会话 …${l.id.slice(-12)}`, `${ago(l.mtimeMs, ctx.now)} · ${fmtSize(l.sizeBytes)}`,
|
|
192
|
+
() => ctx.openChat({ kind: 'log', id: l.id, title: title ?? `…${l.id.slice(-12)}`, sub: '本机日志' }),
|
|
193
|
+
`${l.id}\n${new Date(l.mtimeMs).toLocaleString()} · ${fmtSize(l.sizeBytes)}`)
|
|
194
|
+
if (title === undefined) item.classList.add('is-untitled')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (sessions.length + canvases.length + remote.length + logs.length === 0) {
|
|
199
|
+
box.appendChild(el('div', 'wb-dir-empty')).textContent = filtering ? '无匹配条目' : '目录为空'
|
|
200
|
+
}
|
|
201
|
+
const allSessions = state.sessions ?? []
|
|
202
|
+
const liveAll = allSessions.filter(s => s.live === true).length
|
|
203
|
+
return `${liveAll}/${allSessions.length}`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── learned log titles (per-viewer convenience, browser-local) ──
|
|
207
|
+
const TITLE_KEY = 'dsh-a2a.logtitles'
|
|
208
|
+
const TITLE_CAP = 300
|
|
209
|
+
const TITLE_MAX = 60
|
|
210
|
+
|
|
211
|
+
/** Load the learned titles; storage can be absent or throw — an empty map then. */
|
|
212
|
+
export function loadLogTitles(): Map<string, string> {
|
|
213
|
+
try {
|
|
214
|
+
const raw = window.localStorage.getItem(TITLE_KEY)
|
|
215
|
+
const parsed = raw === null ? null : JSON.parse(raw) as unknown
|
|
216
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
217
|
+
return new Map(Object.entries(parsed as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === 'string'))
|
|
218
|
+
}
|
|
219
|
+
} catch { /* fall through */ }
|
|
220
|
+
return new Map()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Learn a title from a transcript (first user line, else first line); true when it changed. */
|
|
224
|
+
export function learnLogTitle(titles: Map<string, string>, id: string, messages: ReadonlyArray<{ role: string; text: string }>): boolean {
|
|
225
|
+
const source = messages.find(m => m.role === 'user' && m.text.trim() !== '') ?? messages.find(m => m.text.trim() !== '')
|
|
226
|
+
if (source === undefined) return false
|
|
227
|
+
const line = source.text.trim().split('\n')[0]!.trim()
|
|
228
|
+
const title = line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX - 1)}…` : line
|
|
229
|
+
if (titles.get(id) === title) return false
|
|
230
|
+
titles.delete(id)
|
|
231
|
+
titles.set(id, title)
|
|
232
|
+
while (titles.size > TITLE_CAP) titles.delete(titles.keys().next().value as string)
|
|
233
|
+
try {
|
|
234
|
+
window.localStorage.setItem(TITLE_KEY, JSON.stringify(Object.fromEntries(titles)))
|
|
235
|
+
} catch { /* per-viewer convenience only */ }
|
|
236
|
+
return true
|
|
237
|
+
}
|