dsh-multi-session 0.0.0-stage → 0.3.0
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/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +158 -2
- package/client.js +1972 -0
- package/cordis.patch.yml +3 -0
- package/icon.png +0 -0
- package/icon.svg +6 -0
- package/index.js +767 -0
- package/package.json +68 -4
package/client.js
ADDED
|
@@ -0,0 +1,1972 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-multi-session — Client 半边。
|
|
3
|
+
*
|
|
4
|
+
* Phase 1:在会话内注册一个「多会话」视图,把所有会话按工作区分组列出来,
|
|
5
|
+
* 一眼看到谁在跑、谁刚跑完还没看、谁闲着;点一下直接跳过去。
|
|
6
|
+
* 针对的痛点:开了多个会话在跑,跑完了不知道,还得一个个点开找。
|
|
7
|
+
*
|
|
8
|
+
* ── 数据来源(2026-10-30 实测,别再猜)────────────────────────────────
|
|
9
|
+
* props.useSessions(selector) →
|
|
10
|
+
* { ids: SessionId[], byId: Record<SessionId, SessionMeta>,
|
|
11
|
+
* phase: 'ready'|..., projectionsBySession: Record<SessionId, {values,state,error}> }
|
|
12
|
+
*
|
|
13
|
+
* SessionMeta = { id, title, displayTitle, running: boolean,
|
|
14
|
+
* retainedBy, blank, updatedAt: number(ms), formatStatus,
|
|
15
|
+
* projectionValues, cwd }
|
|
16
|
+
* ← running / cwd / updatedAt / displayTitle 都在这儿,这是主数据源。
|
|
17
|
+
*
|
|
18
|
+
* projectionsBySession[id].values = { title, goal, tokenUsage, contextPressure,
|
|
19
|
+
* inbox, sessionStats, turnOutline[], agentPreset, userQuestions, todos,
|
|
20
|
+
* modelSelection, subagentCatalog[], workingDirectory, ... }
|
|
21
|
+
* ← 轮数、子代理数、token、模型从这儿取,只当补充。
|
|
22
|
+
*
|
|
23
|
+
* props.useWorkspaces(selector) →
|
|
24
|
+
* { items: [{ workspaceId, path, title, sessionIds[], createdAt, updatedAt }],
|
|
25
|
+
* archivedSessionIds: SessionId[], pinnedSessionIds: SessionId[],
|
|
26
|
+
* state, phase, error }
|
|
27
|
+
* ← 只有分组信息和归档名单,没有运行态。
|
|
28
|
+
*
|
|
29
|
+
* 两个 hook 都是实时推送快照:不轮询、不自建库、不落盘。
|
|
30
|
+
*
|
|
31
|
+
* ── 完成提醒 ──────────────────────────────────────────────────────
|
|
32
|
+
* running 由 true → false 的瞬间标记「刚完成」(纯内存态,刷新即清空;
|
|
33
|
+
* Phase 2 再挪到 Host 侧做跨端持久化 + 系统通知)。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
window.__ModuleLoader__.load({
|
|
37
|
+
id: 'dsh-multi-session',
|
|
38
|
+
factory(require) {
|
|
39
|
+
const React = require('react')
|
|
40
|
+
const h = React.createElement
|
|
41
|
+
|
|
42
|
+
const TAG = '[dsh-multi-session]'
|
|
43
|
+
const ROUTE = '/plugins/dsh-multi-session/probe'
|
|
44
|
+
const VIEW_ID = 'multi-session'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 开发期诊断层开关:Host 在 index.html 注入 window.__DSH_MULTI_SESSION_DEV__。
|
|
48
|
+
* 正式安装(未开 dev)时为 false,不注册诊断浮层,也不往 /probe 上报。
|
|
49
|
+
*/
|
|
50
|
+
const DIAG = (() => {
|
|
51
|
+
try {
|
|
52
|
+
return typeof window !== 'undefined' && window.__DSH_MULTI_SESSION_DEV__ === true
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
})()
|
|
57
|
+
|
|
58
|
+
/** 诊断代号:每次改动递增,用来区分浏览器里跑的是哪一代 bundle。 */
|
|
59
|
+
const BUILD = 'panel-v51'
|
|
60
|
+
|
|
61
|
+
/** apply() 的 ctx,用于点击跳转。 */
|
|
62
|
+
let rootCtx
|
|
63
|
+
|
|
64
|
+
/** 选择器必须是模块级稳定引用,否则快照订阅每帧重订(轻则抖,重则死循环)。 */
|
|
65
|
+
const identity = (state) => state
|
|
66
|
+
|
|
67
|
+
const EMPTY = Object.freeze([])
|
|
68
|
+
|
|
69
|
+
/** 用户当前正在看的会话 id,由 ActiveSessionProbe 在会话头部写入。 */
|
|
70
|
+
let activeSessionId = ''
|
|
71
|
+
|
|
72
|
+
/** Phase 2 探针只跑一次。 */
|
|
73
|
+
let retainProbed = false
|
|
74
|
+
let digestProbed = false
|
|
75
|
+
let promptProbed = false
|
|
76
|
+
|
|
77
|
+
/* ------------------------------------------------------------------ *
|
|
78
|
+
* 完成检测
|
|
79
|
+
* ------------------------------------------------------------------ */
|
|
80
|
+
|
|
81
|
+
const previousRunning = new Map()
|
|
82
|
+
const finishedAt = new Map()
|
|
83
|
+
/** 首帧只当基线:不能把历史会话全标成"刚完成"。 */
|
|
84
|
+
let primed = false
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 「哪些会话完成了、我还没看」必须活过页面刷新。
|
|
88
|
+
* 只放内存的话,开发期一次热重载就把待查看列表清空,等于这个功能白做。
|
|
89
|
+
*/
|
|
90
|
+
const FINISHED_STORE = 'dsh-multi-session:finished:v1'
|
|
91
|
+
const FINISHED_STORE_MAX = 200
|
|
92
|
+
/** 上次看到每个会话时的 updatedAt,用来补标"关页面期间完成"的会话。 */
|
|
93
|
+
const SEEN_STORE = 'dsh-multi-session:seen:v1'
|
|
94
|
+
const SEEN_STORE_MAX = 400
|
|
95
|
+
const lastSeen = new Map()
|
|
96
|
+
/** 冷启动补标最多补这么多个,避免几百个子代理会话把浮标刷爆。 */
|
|
97
|
+
const CATCHUP_MAX = 10
|
|
98
|
+
let lastSeenWriteAt = 0
|
|
99
|
+
|
|
100
|
+
function loadFinished() {
|
|
101
|
+
try {
|
|
102
|
+
const raw = window.localStorage.getItem(FINISHED_STORE)
|
|
103
|
+
if (raw === null || raw === '') return
|
|
104
|
+
const parsed = JSON.parse(raw)
|
|
105
|
+
if (parsed === null || typeof parsed !== 'object') return
|
|
106
|
+
for (const key of Object.keys(parsed)) {
|
|
107
|
+
const value = Number(parsed[key])
|
|
108
|
+
if (Number.isFinite(value) && value > 0) finishedAt.set(key, value)
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// localStorage 不可用(隐私模式等)就退化成纯内存,不影响主功能。
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function saveFinished() {
|
|
116
|
+
try {
|
|
117
|
+
const entries = Array.from(finishedAt.entries())
|
|
118
|
+
const kept = entries.length > FINISHED_STORE_MAX ? entries.slice(-FINISHED_STORE_MAX) : entries
|
|
119
|
+
const plain = {}
|
|
120
|
+
for (const [key, value] of kept) plain[key] = value
|
|
121
|
+
window.localStorage.setItem(FINISHED_STORE, JSON.stringify(plain))
|
|
122
|
+
} catch {
|
|
123
|
+
// 同上:存不下就只保留内存态。
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
loadFinished()
|
|
128
|
+
|
|
129
|
+
function loadSeen() {
|
|
130
|
+
try {
|
|
131
|
+
const raw = window.localStorage.getItem(SEEN_STORE)
|
|
132
|
+
if (raw === null || raw === '') return
|
|
133
|
+
const parsed = JSON.parse(raw)
|
|
134
|
+
if (parsed === null || typeof parsed !== 'object') return
|
|
135
|
+
for (const key of Object.keys(parsed)) {
|
|
136
|
+
const value = Number(parsed[key])
|
|
137
|
+
if (Number.isFinite(value) && value > 0) lastSeen.set(key, value)
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// 同上,读不到就退化成"没有基线",不会误标。
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function saveSeen(force) {
|
|
145
|
+
const now = Date.now()
|
|
146
|
+
if (!force && now - lastSeenWriteAt < 30000) return
|
|
147
|
+
lastSeenWriteAt = now
|
|
148
|
+
try {
|
|
149
|
+
let entries = Array.from(lastSeen.entries())
|
|
150
|
+
if (entries.length > SEEN_STORE_MAX) {
|
|
151
|
+
// 超量时保留"最近更新"的那批,而不是插入顺序的尾巴——
|
|
152
|
+
// 否则先遍历到的会话基线会被丢掉,冷启动补标对它们永远失效。
|
|
153
|
+
entries.sort((left, right) => right[1] - left[1])
|
|
154
|
+
entries = entries.slice(0, SEEN_STORE_MAX)
|
|
155
|
+
}
|
|
156
|
+
const plain = {}
|
|
157
|
+
for (const [key, value] of entries) plain[key] = value
|
|
158
|
+
window.localStorage.setItem(SEEN_STORE, JSON.stringify(plain))
|
|
159
|
+
} catch {
|
|
160
|
+
// 存不下不影响主功能。
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
loadSeen()
|
|
165
|
+
|
|
166
|
+
/** 钉住的会话:退出面板再进来要还在,否则每次都得重新挑三个。 */
|
|
167
|
+
const PINNED_STORE = 'dsh-multi-session:pinned:v1'
|
|
168
|
+
|
|
169
|
+
function loadPinned() {
|
|
170
|
+
try {
|
|
171
|
+
const raw = window.localStorage.getItem(PINNED_STORE)
|
|
172
|
+
if (raw === null || raw === '') return []
|
|
173
|
+
const parsed = JSON.parse(raw)
|
|
174
|
+
if (!Array.isArray(parsed)) return []
|
|
175
|
+
const ids = []
|
|
176
|
+
for (const value of parsed) {
|
|
177
|
+
if (typeof value === 'string' && value !== '' && ids.indexOf(value) < 0) ids.push(value)
|
|
178
|
+
}
|
|
179
|
+
return ids.slice(0, 3)
|
|
180
|
+
} catch {
|
|
181
|
+
// 读不到就当没钉过,不影响主功能。
|
|
182
|
+
return []
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function savePinned(ids) {
|
|
187
|
+
try {
|
|
188
|
+
window.localStorage.setItem(PINNED_STORE, JSON.stringify(Array.isArray(ids) ? ids : []))
|
|
189
|
+
} catch {
|
|
190
|
+
// 存不下就只在本次会话里生效。
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 冷启动补标:页面关着的那段时间里跑完的会话,光靠"看着 running 变 false"是发现不了的。
|
|
196
|
+
* 用上次记住的 updatedAt 对比——时间往前走了、现在不在跑、又不是当前会话,就当作"刚完成"。
|
|
197
|
+
*/
|
|
198
|
+
function catchUpFinished(entries) {
|
|
199
|
+
const candidates = []
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
if (entry.blank || entry.running) continue
|
|
202
|
+
const seen = lastSeen.get(entry.id)
|
|
203
|
+
if (seen === undefined) continue
|
|
204
|
+
if (!(entry.updatedAt > seen)) continue
|
|
205
|
+
if (finishedAt.has(entry.id)) continue
|
|
206
|
+
candidates.push(entry)
|
|
207
|
+
}
|
|
208
|
+
if (candidates.length === 0) return false
|
|
209
|
+
candidates.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
210
|
+
for (const entry of candidates.slice(0, CATCHUP_MAX)) {
|
|
211
|
+
finishedAt.set(entry.id, entry.updatedAt)
|
|
212
|
+
}
|
|
213
|
+
saveFinished()
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function detectFinished(entries) {
|
|
218
|
+
let changed = false
|
|
219
|
+
const coldStart = !primed
|
|
220
|
+
// 必须先补标再刷新基线:catchUpFinished 依赖"旧 lastSeen vs 新 updatedAt"的差,
|
|
221
|
+
// 如果先更新 lastSeen,差值就被抹平,补标永远为空。
|
|
222
|
+
if (coldStart) {
|
|
223
|
+
primed = true
|
|
224
|
+
if (catchUpFinished(entries)) changed = true
|
|
225
|
+
}
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
const previous = previousRunning.get(entry.id)
|
|
228
|
+
previousRunning.set(entry.id, entry.running)
|
|
229
|
+
if (entry.updatedAt > 0) lastSeen.set(entry.id, entry.updatedAt)
|
|
230
|
+
if (coldStart) continue
|
|
231
|
+
if (previous === true && entry.running === false && !finishedAt.has(entry.id)) {
|
|
232
|
+
finishedAt.set(entry.id, Date.now())
|
|
233
|
+
changed = true
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
saveSeen(coldStart)
|
|
237
|
+
if (changed) saveFinished()
|
|
238
|
+
return changed
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* ------------------------------------------------------------------ *
|
|
242
|
+
* 快照解析
|
|
243
|
+
* ------------------------------------------------------------------ */
|
|
244
|
+
|
|
245
|
+
function basename(path) {
|
|
246
|
+
if (!path) return ''
|
|
247
|
+
const parts = String(path).split(/[\\/]+/).filter(Boolean)
|
|
248
|
+
return parts.length > 0 ? parts[parts.length - 1] : String(path)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readNumber(value) {
|
|
252
|
+
return typeof value === 'number' && isFinite(value) ? value : 0
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function tokenTotal(usage) {
|
|
256
|
+
if (!usage || typeof usage !== 'object') return 0
|
|
257
|
+
return (
|
|
258
|
+
readNumber(usage.uncachedInputTokens) +
|
|
259
|
+
readNumber(usage.outputTokens) +
|
|
260
|
+
readNumber(usage.cacheReadTokens) +
|
|
261
|
+
readNumber(usage.cacheWriteTokens)
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 模型名。实测形状(2026-10-30):
|
|
267
|
+
* { lastUsed: { provider, model, reasoningEffort }, next: { provider, model, reasoningEffort } }
|
|
268
|
+
* 优先取 next(即将用于下一轮的),退回 lastUsed,再退回扁平字段。
|
|
269
|
+
*/
|
|
270
|
+
function modelLabel(selection) {
|
|
271
|
+
if (!selection) return ''
|
|
272
|
+
if (typeof selection === 'string') return selection
|
|
273
|
+
if (typeof selection !== 'object') return ''
|
|
274
|
+
const layers = [selection.next, selection.lastUsed, selection]
|
|
275
|
+
for (const layer of layers) {
|
|
276
|
+
if (!layer || typeof layer !== 'object') continue
|
|
277
|
+
for (const key of ['model', 'modelId', 'id', 'name']) {
|
|
278
|
+
const candidate = layer[key]
|
|
279
|
+
if (typeof candidate === 'string' && candidate) return candidate
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return ''
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 工作区索引:sessionId → { workspaceId, title, path },外加归档名单。 */
|
|
286
|
+
function readWorkspaces(workspaces) {
|
|
287
|
+
const bySession = new Map()
|
|
288
|
+
const byPath = new Map()
|
|
289
|
+
const groups = []
|
|
290
|
+
const archived = new Set()
|
|
291
|
+
if (!workspaces || typeof workspaces !== 'object') {
|
|
292
|
+
return { bySession, byPath, groups, archived }
|
|
293
|
+
}
|
|
294
|
+
const items = Array.isArray(workspaces.items) ? workspaces.items : EMPTY
|
|
295
|
+
for (const item of items) {
|
|
296
|
+
if (!item || typeof item !== 'object') continue
|
|
297
|
+
const group = {
|
|
298
|
+
workspaceId: item.workspaceId,
|
|
299
|
+
path: typeof item.path === 'string' ? item.path : '',
|
|
300
|
+
title: typeof item.title === 'string' && item.title ? item.title : '',
|
|
301
|
+
sessionIds: Array.isArray(item.sessionIds) ? item.sessionIds : EMPTY,
|
|
302
|
+
}
|
|
303
|
+
groups.push(group)
|
|
304
|
+
for (const sessionId of group.sessionIds) bySession.set(sessionId, group)
|
|
305
|
+
// 子代理会话(parentId 非空)通常不进任何 workspace 的 sessionIds,
|
|
306
|
+
// 但它们带着真实 cwd;按 cwd 归回工作区,比单开一个"其他"桶更符合直觉。
|
|
307
|
+
if (group.path) {
|
|
308
|
+
const key = group.path.toLowerCase()
|
|
309
|
+
if (!byPath.has(key)) byPath.set(key, group)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (Array.isArray(workspaces.archivedSessionIds)) {
|
|
313
|
+
for (const sessionId of workspaces.archivedSessionIds) archived.add(sessionId)
|
|
314
|
+
}
|
|
315
|
+
return { bySession, byPath, groups, archived }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 会话条目。主数据来自 useSessions().byId,投影值来自 projectionsBySession。
|
|
320
|
+
* 不遍历 byId 全表——以 ids 为准,避免把残留键也画出来。
|
|
321
|
+
*/
|
|
322
|
+
function readEntries(sessions, workspaceIndex) {
|
|
323
|
+
if (!sessions || typeof sessions !== 'object') return EMPTY
|
|
324
|
+
const byId = sessions.byId
|
|
325
|
+
if (!byId || typeof byId !== 'object') return EMPTY
|
|
326
|
+
const ids = Array.isArray(sessions.ids) ? sessions.ids : Object.keys(byId)
|
|
327
|
+
const projections =
|
|
328
|
+
sessions.projectionsBySession && typeof sessions.projectionsBySession === 'object'
|
|
329
|
+
? sessions.projectionsBySession
|
|
330
|
+
: {}
|
|
331
|
+
|
|
332
|
+
const entries = []
|
|
333
|
+
for (const sessionId of ids) {
|
|
334
|
+
const meta = byId[sessionId]
|
|
335
|
+
if (!meta || typeof meta !== 'object') continue
|
|
336
|
+
const projection = projections[sessionId]
|
|
337
|
+
const values =
|
|
338
|
+
projection && projection.values && typeof projection.values === 'object'
|
|
339
|
+
? projection.values
|
|
340
|
+
: meta.projectionValues && typeof meta.projectionValues === 'object'
|
|
341
|
+
? meta.projectionValues
|
|
342
|
+
: null
|
|
343
|
+
|
|
344
|
+
const cwd = typeof meta.cwd === 'string' ? meta.cwd : values && values.workingDirectory
|
|
345
|
+
const cwdText = typeof cwd === 'string' ? cwd : ''
|
|
346
|
+
const group =
|
|
347
|
+
workspaceIndex.bySession.get(sessionId) ||
|
|
348
|
+
(cwdText ? workspaceIndex.byPath.get(cwdText.toLowerCase()) : undefined)
|
|
349
|
+
const turnOutline = values && Array.isArray(values.turnOutline) ? values.turnOutline : EMPTY
|
|
350
|
+
const subagents = values && Array.isArray(values.subagentCatalog) ? values.subagentCatalog : EMPTY
|
|
351
|
+
|
|
352
|
+
entries.push({
|
|
353
|
+
id: typeof meta.id === 'string' ? meta.id : sessionId,
|
|
354
|
+
title:
|
|
355
|
+
(typeof meta.displayTitle === 'string' && meta.displayTitle) ||
|
|
356
|
+
(typeof meta.title === 'string' && meta.title) ||
|
|
357
|
+
(values && typeof values.title === 'string' && values.title) ||
|
|
358
|
+
'(无标题)',
|
|
359
|
+
running: meta.running === true,
|
|
360
|
+
updatedAt: readNumber(meta.updatedAt),
|
|
361
|
+
cwd: cwdText,
|
|
362
|
+
blank: meta.blank === true,
|
|
363
|
+
parentId: typeof meta.parentId === 'string' ? meta.parentId : '',
|
|
364
|
+
origin: typeof meta.origin === 'string' ? meta.origin : '',
|
|
365
|
+
archived: workspaceIndex.archived.has(sessionId),
|
|
366
|
+
groupId: group ? group.workspaceId : '',
|
|
367
|
+
groupTitle: group ? group.title || basename(group.path) || '工作区' : '',
|
|
368
|
+
groupPath: group ? group.path : '',
|
|
369
|
+
turns: turnOutline.length,
|
|
370
|
+
subagents: subagents.length,
|
|
371
|
+
tokens: tokenTotal(values && values.tokenUsage),
|
|
372
|
+
model: modelLabel(values && values.modelSelection),
|
|
373
|
+
finishedAt: finishedAt.get(sessionId) || 0,
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
return entries
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ------------------------------------------------------------------ *
|
|
380
|
+
* 交互
|
|
381
|
+
* ------------------------------------------------------------------ */
|
|
382
|
+
|
|
383
|
+
function getService(name) {
|
|
384
|
+
if (!rootCtx) return undefined
|
|
385
|
+
try {
|
|
386
|
+
return typeof rootCtx.get === 'function' ? rootCtx.get(name) : rootCtx[name]
|
|
387
|
+
} catch (cause) {
|
|
388
|
+
return undefined
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 跳转到某个会话。SessionTarget 的公开形状没有文档,两种写法都试。
|
|
394
|
+
* 失败只告警,不打断面板。
|
|
395
|
+
*/
|
|
396
|
+
function openSession(sessionId) {
|
|
397
|
+
const ui = getService('uiWorkspace')
|
|
398
|
+
if (!ui || typeof ui.openSession !== 'function') {
|
|
399
|
+
console.warn(`${TAG} uiWorkspace.openSession unavailable`)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
// SessionTarget = SessionId | SubagentAddress:普通会话就是裸 id 字符串,
|
|
403
|
+
// 传 { sessionId } 这种对象既不是合法 id 也不是地址,会被当成"没有目标"而落到新建会话。
|
|
404
|
+
// 子代理会话要换成它自己的地址对象才认得。
|
|
405
|
+
let target = sessionId
|
|
406
|
+
try {
|
|
407
|
+
const sessions = getService('sessions')
|
|
408
|
+
if (sessions && typeof sessions.subagentAddress === 'function') {
|
|
409
|
+
const address = sessions.subagentAddress(sessionId)
|
|
410
|
+
if (address !== undefined && address !== null) target = address
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
// 拿不到地址就按普通会话处理。
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
ui.openSession(target)
|
|
417
|
+
} catch (cause) {
|
|
418
|
+
try {
|
|
419
|
+
ui.openSession(sessionId)
|
|
420
|
+
} catch (fallbackCause) {
|
|
421
|
+
console.warn(`${TAG} openSession failed`, cause, fallbackCause)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function post(payload) {
|
|
427
|
+
try {
|
|
428
|
+
fetch(ROUTE, {
|
|
429
|
+
method: 'POST',
|
|
430
|
+
headers: { 'content-type': 'application/json' },
|
|
431
|
+
body: JSON.stringify(payload),
|
|
432
|
+
}).catch(() => {})
|
|
433
|
+
} catch (cause) {
|
|
434
|
+
/* 诊断失败绝不影响面板 */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/* ------------------------------------------------------------------ *
|
|
439
|
+
* 格式化
|
|
440
|
+
* ------------------------------------------------------------------ */
|
|
441
|
+
|
|
442
|
+
function relativeTime(timestamp) {
|
|
443
|
+
if (!timestamp) return ''
|
|
444
|
+
const delta = Date.now() - timestamp
|
|
445
|
+
if (delta < 0) return '刚刚'
|
|
446
|
+
const seconds = Math.floor(delta / 1000)
|
|
447
|
+
if (seconds < 45) return '刚刚'
|
|
448
|
+
const minutes = Math.floor(seconds / 60)
|
|
449
|
+
if (minutes < 60) return `${Math.max(minutes, 1)} 分钟前`
|
|
450
|
+
const hours = Math.floor(minutes / 60)
|
|
451
|
+
if (hours < 24) return `${hours} 小时前`
|
|
452
|
+
const days = Math.floor(hours / 24)
|
|
453
|
+
if (days < 30) return `${days} 天前`
|
|
454
|
+
return `${Math.floor(days / 30)} 个月前`
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function formatTokens(tokens) {
|
|
458
|
+
if (!tokens) return ''
|
|
459
|
+
if (tokens < 1000) return `${tokens} tok`
|
|
460
|
+
if (tokens < 1000000) return `${(tokens / 1000).toFixed(1)}k tok`
|
|
461
|
+
return `${(tokens / 1000000).toFixed(2)}M tok`
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 排序权重:运行中 → 刚完成 → 其余。 */
|
|
465
|
+
function rankOf(entry) {
|
|
466
|
+
if (entry.running) return 0
|
|
467
|
+
if (entry.finishedAt) return 1
|
|
468
|
+
return 2
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* ------------------------------------------------------------------ *
|
|
472
|
+
* 样式:只用 DSH 主题 alias token,深浅色自动跟随
|
|
473
|
+
* ------------------------------------------------------------------ */
|
|
474
|
+
|
|
475
|
+
const RUNNING_COLOR = '#22a06b'
|
|
476
|
+
const FINISHED_COLOR = '#3b6cf6'
|
|
477
|
+
|
|
478
|
+
const S = {
|
|
479
|
+
root: {
|
|
480
|
+
display: 'flex',
|
|
481
|
+
flexDirection: 'column',
|
|
482
|
+
height: '100%',
|
|
483
|
+
minHeight: 0,
|
|
484
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
485
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
486
|
+
fontSize: 13,
|
|
487
|
+
},
|
|
488
|
+
header: {
|
|
489
|
+
display: 'flex',
|
|
490
|
+
alignItems: 'center',
|
|
491
|
+
gap: 10,
|
|
492
|
+
padding: '10px 14px',
|
|
493
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1)',
|
|
494
|
+
flex: '0 0 auto',
|
|
495
|
+
flexWrap: 'wrap',
|
|
496
|
+
},
|
|
497
|
+
headerTitle: { fontWeight: 600, fontSize: 14 },
|
|
498
|
+
counter: {
|
|
499
|
+
display: 'inline-flex',
|
|
500
|
+
alignItems: 'center',
|
|
501
|
+
gap: 5,
|
|
502
|
+
padding: '2px 8px',
|
|
503
|
+
borderRadius: 999,
|
|
504
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
505
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
506
|
+
fontSize: 12,
|
|
507
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
508
|
+
},
|
|
509
|
+
dot: { width: 7, height: 7, borderRadius: '50%', flex: '0 0 auto' },
|
|
510
|
+
spacer: { flex: '1 1 auto' },
|
|
511
|
+
button: {
|
|
512
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
513
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
514
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
515
|
+
borderRadius: 6,
|
|
516
|
+
padding: '3px 9px',
|
|
517
|
+
fontSize: 12,
|
|
518
|
+
cursor: 'pointer',
|
|
519
|
+
},
|
|
520
|
+
body: { flex: '1 1 auto', overflowY: 'auto', minHeight: 0, padding: '6px 0 20px' },
|
|
521
|
+
group: { padding: '8px 14px 2px' },
|
|
522
|
+
groupHeader: {
|
|
523
|
+
display: 'flex',
|
|
524
|
+
alignItems: 'center',
|
|
525
|
+
gap: 8,
|
|
526
|
+
marginBottom: 6,
|
|
527
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
528
|
+
fontSize: 12,
|
|
529
|
+
},
|
|
530
|
+
groupName: { fontWeight: 600, color: 'var(--dsw-alias-label-primary)' },
|
|
531
|
+
groupPath: {
|
|
532
|
+
fontSize: 11,
|
|
533
|
+
opacity: 0.65,
|
|
534
|
+
overflow: 'hidden',
|
|
535
|
+
textOverflow: 'ellipsis',
|
|
536
|
+
whiteSpace: 'nowrap',
|
|
537
|
+
maxWidth: 420,
|
|
538
|
+
},
|
|
539
|
+
card: {
|
|
540
|
+
display: 'flex',
|
|
541
|
+
gap: 9,
|
|
542
|
+
alignItems: 'flex-start',
|
|
543
|
+
padding: '8px 10px',
|
|
544
|
+
marginBottom: 6,
|
|
545
|
+
borderRadius: 8,
|
|
546
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
547
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
548
|
+
cursor: 'pointer',
|
|
549
|
+
transition: 'background 120ms ease, border-color 120ms ease',
|
|
550
|
+
},
|
|
551
|
+
cardRunning: { borderColor: 'rgba(34,160,107,0.45)' },
|
|
552
|
+
cardFinished: { borderColor: 'rgba(59,108,246,0.45)' },
|
|
553
|
+
cardLast: {
|
|
554
|
+
marginTop: 2,
|
|
555
|
+
fontSize: 11,
|
|
556
|
+
lineHeight: 1.45,
|
|
557
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
558
|
+
opacity: 0.9,
|
|
559
|
+
whiteSpace: 'nowrap',
|
|
560
|
+
overflow: 'hidden',
|
|
561
|
+
textOverflow: 'ellipsis',
|
|
562
|
+
},
|
|
563
|
+
cardBody: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0, flex: '1 1 auto' },
|
|
564
|
+
cardTitleRow: { display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 },
|
|
565
|
+
cardTitle: {
|
|
566
|
+
fontWeight: 500,
|
|
567
|
+
overflow: 'hidden',
|
|
568
|
+
textOverflow: 'ellipsis',
|
|
569
|
+
whiteSpace: 'nowrap',
|
|
570
|
+
minWidth: 0,
|
|
571
|
+
},
|
|
572
|
+
badge: {
|
|
573
|
+
flex: '0 0 auto',
|
|
574
|
+
fontSize: 10.5,
|
|
575
|
+
lineHeight: '16px',
|
|
576
|
+
padding: '0 6px',
|
|
577
|
+
borderRadius: 999,
|
|
578
|
+
border: '1px solid currentColor',
|
|
579
|
+
opacity: 0.9,
|
|
580
|
+
},
|
|
581
|
+
metaRow: {
|
|
582
|
+
display: 'flex',
|
|
583
|
+
flexWrap: 'wrap',
|
|
584
|
+
gap: 8,
|
|
585
|
+
fontSize: 11.5,
|
|
586
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
587
|
+
},
|
|
588
|
+
empty: {
|
|
589
|
+
padding: '40px 20px',
|
|
590
|
+
textAlign: 'center',
|
|
591
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
592
|
+
fontSize: 12.5,
|
|
593
|
+
lineHeight: 1.7,
|
|
594
|
+
},
|
|
595
|
+
/* 并排视图(Phase 2) */
|
|
596
|
+
split: {
|
|
597
|
+
display: 'flex',
|
|
598
|
+
flex: 1,
|
|
599
|
+
minHeight: 0,
|
|
600
|
+
gap: 10,
|
|
601
|
+
padding: '0 12px 12px',
|
|
602
|
+
},
|
|
603
|
+
listPane: {
|
|
604
|
+
display: 'flex',
|
|
605
|
+
flexDirection: 'column',
|
|
606
|
+
minHeight: 0,
|
|
607
|
+
maxHeight: 'min(56vh, 560px)',
|
|
608
|
+
overflow: 'auto',
|
|
609
|
+
scrollbarWidth: 'thin',
|
|
610
|
+
scrollbarColor: 'var(--dsw-alias-border-l1) transparent',
|
|
611
|
+
flex: '0 0 300px',
|
|
612
|
+
borderRight: '1px solid var(--dsw-alias-border-l1)',
|
|
613
|
+
paddingRight: 10,
|
|
614
|
+
},
|
|
615
|
+
listPaneWide: {
|
|
616
|
+
flex: 1,
|
|
617
|
+
borderRight: 'none',
|
|
618
|
+
paddingRight: 0,
|
|
619
|
+
},
|
|
620
|
+
board: {
|
|
621
|
+
display: 'flex',
|
|
622
|
+
flex: 1,
|
|
623
|
+
minWidth: 0,
|
|
624
|
+
minHeight: 0,
|
|
625
|
+
gap: 10,
|
|
626
|
+
overflowX: 'auto',
|
|
627
|
+
},
|
|
628
|
+
boardCol: {
|
|
629
|
+
display: 'flex',
|
|
630
|
+
flexDirection: 'column',
|
|
631
|
+
flex: '1 1 0',
|
|
632
|
+
minWidth: 330,
|
|
633
|
+
minHeight: 0,
|
|
634
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
635
|
+
borderRadius: 10,
|
|
636
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
637
|
+
overflow: 'hidden',
|
|
638
|
+
},
|
|
639
|
+
boardColHead: {
|
|
640
|
+
display: 'flex',
|
|
641
|
+
alignItems: 'center',
|
|
642
|
+
gap: 6,
|
|
643
|
+
padding: '7px 9px',
|
|
644
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1)',
|
|
645
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
646
|
+
flex: '0 0 auto',
|
|
647
|
+
},
|
|
648
|
+
boardColTitle: {
|
|
649
|
+
flex: 1,
|
|
650
|
+
minWidth: 0,
|
|
651
|
+
fontSize: 12,
|
|
652
|
+
fontWeight: 600,
|
|
653
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
654
|
+
overflow: 'hidden',
|
|
655
|
+
textOverflow: 'ellipsis',
|
|
656
|
+
whiteSpace: 'nowrap',
|
|
657
|
+
cursor: 'pointer',
|
|
658
|
+
},
|
|
659
|
+
boardColMeta: {
|
|
660
|
+
flex: '0 0 auto',
|
|
661
|
+
fontSize: 10.5,
|
|
662
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
663
|
+
},
|
|
664
|
+
boardScroll: {
|
|
665
|
+
flex: 1,
|
|
666
|
+
minHeight: 0,
|
|
667
|
+
// 父级高度一旦是 auto(插槽容器不给确定高度),flex:1 就约束不住,
|
|
668
|
+
// 整列会跟着消息条数一直长高、永远不出滚动条。这里用视口兜底封顶。
|
|
669
|
+
// 高度按视口百分比算(44vh,最多 420px):比用 100vh 减常数更可控,
|
|
670
|
+
// 小窗口不会算出负数,大屏也不会一列就顶满整屏。
|
|
671
|
+
maxHeight: 'min(44vh, 420px)',
|
|
672
|
+
minHeight: '120px',
|
|
673
|
+
overflowY: 'auto',
|
|
674
|
+
// 显式声明滚动条宽度/颜色:默认细滚动条在部分主题下几乎看不见。
|
|
675
|
+
scrollbarWidth: 'thin',
|
|
676
|
+
scrollbarColor: 'var(--dsw-alias-border-l1) transparent',
|
|
677
|
+
padding: '8px 9px',
|
|
678
|
+
display: 'flex',
|
|
679
|
+
flexDirection: 'column',
|
|
680
|
+
gap: 7,
|
|
681
|
+
},
|
|
682
|
+
digestItem: {
|
|
683
|
+
display: 'flex',
|
|
684
|
+
flexDirection: 'column',
|
|
685
|
+
gap: 2,
|
|
686
|
+
},
|
|
687
|
+
digestMsgUser: {
|
|
688
|
+
display: 'flex',
|
|
689
|
+
flexDirection: 'column',
|
|
690
|
+
gap: 3,
|
|
691
|
+
padding: '7px 9px',
|
|
692
|
+
borderRadius: 8,
|
|
693
|
+
borderLeft: '2px solid var(--dsw-alias-brand-primary)',
|
|
694
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
695
|
+
},
|
|
696
|
+
digestMsgAssistant: {
|
|
697
|
+
display: 'flex',
|
|
698
|
+
flexDirection: 'column',
|
|
699
|
+
gap: 3,
|
|
700
|
+
padding: '7px 9px',
|
|
701
|
+
borderRadius: 8,
|
|
702
|
+
borderLeft: `2px solid ${RUNNING_COLOR}`,
|
|
703
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
704
|
+
},
|
|
705
|
+
moreBtn: {
|
|
706
|
+
alignSelf: 'stretch',
|
|
707
|
+
marginBottom: 6,
|
|
708
|
+
padding: '4px 8px',
|
|
709
|
+
fontSize: 11,
|
|
710
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
711
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
712
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
713
|
+
borderRadius: 6,
|
|
714
|
+
cursor: 'pointer',
|
|
715
|
+
},
|
|
716
|
+
sendBox: {
|
|
717
|
+
borderTop: '1px solid var(--dsw-alias-border-l1)',
|
|
718
|
+
padding: '7px 9px',
|
|
719
|
+
display: 'flex',
|
|
720
|
+
flexDirection: 'column',
|
|
721
|
+
gap: 5,
|
|
722
|
+
flex: '0 0 auto',
|
|
723
|
+
},
|
|
724
|
+
sendInput: {
|
|
725
|
+
width: '100%',
|
|
726
|
+
boxSizing: 'border-box',
|
|
727
|
+
resize: 'none',
|
|
728
|
+
padding: '5px 7px',
|
|
729
|
+
fontSize: 12,
|
|
730
|
+
lineHeight: 1.45,
|
|
731
|
+
fontFamily: 'inherit',
|
|
732
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
733
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
734
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
735
|
+
borderRadius: 6,
|
|
736
|
+
outline: 'none',
|
|
737
|
+
},
|
|
738
|
+
sendRow: { display: 'flex', alignItems: 'center', gap: 6 },
|
|
739
|
+
sendBtn: {
|
|
740
|
+
padding: '3px 10px',
|
|
741
|
+
fontSize: 11,
|
|
742
|
+
color: 'var(--dsw-alias-label-primary-foreground)',
|
|
743
|
+
background: 'var(--dsw-alias-brand-primary)',
|
|
744
|
+
border: '1px solid transparent',
|
|
745
|
+
borderRadius: 6,
|
|
746
|
+
cursor: 'pointer',
|
|
747
|
+
},
|
|
748
|
+
sendStop: {
|
|
749
|
+
padding: '3px 10px',
|
|
750
|
+
fontSize: 11,
|
|
751
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
752
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
753
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
754
|
+
borderRadius: 6,
|
|
755
|
+
cursor: 'pointer',
|
|
756
|
+
},
|
|
757
|
+
sendNote: { fontSize: 10, color: 'var(--dsw-alias-label-secondary)', marginLeft: 'auto' },
|
|
758
|
+
digestRole: {
|
|
759
|
+
fontSize: 10,
|
|
760
|
+
fontWeight: 600,
|
|
761
|
+
letterSpacing: '0.02em',
|
|
762
|
+
textTransform: 'uppercase',
|
|
763
|
+
},
|
|
764
|
+
digestText: {
|
|
765
|
+
fontSize: 12,
|
|
766
|
+
lineHeight: 1.5,
|
|
767
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
768
|
+
whiteSpace: 'pre-wrap',
|
|
769
|
+
wordBreak: 'break-word',
|
|
770
|
+
},
|
|
771
|
+
pinBtn: {
|
|
772
|
+
flex: '0 0 auto',
|
|
773
|
+
border: 'none',
|
|
774
|
+
background: 'transparent',
|
|
775
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
776
|
+
fontSize: 12,
|
|
777
|
+
lineHeight: 1,
|
|
778
|
+
cursor: 'pointer',
|
|
779
|
+
padding: '0 2px',
|
|
780
|
+
borderRadius: 4,
|
|
781
|
+
},
|
|
782
|
+
pinBtnActive: {
|
|
783
|
+
color: 'var(--dsw-alias-brand-primary)',
|
|
784
|
+
},
|
|
785
|
+
boardEmpty: {
|
|
786
|
+
display: 'flex',
|
|
787
|
+
alignItems: 'center',
|
|
788
|
+
justifyContent: 'center',
|
|
789
|
+
flex: 1,
|
|
790
|
+
fontSize: 12,
|
|
791
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
792
|
+
padding: 20,
|
|
793
|
+
textAlign: 'center',
|
|
794
|
+
},
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/* ------------------------------------------------------------------ *
|
|
798
|
+
* 面板
|
|
799
|
+
* ------------------------------------------------------------------ */
|
|
800
|
+
|
|
801
|
+
function StatusDot(props) {
|
|
802
|
+
const color = props.running ? RUNNING_COLOR : props.finished ? FINISHED_COLOR : 'var(--dsw-alias-label-secondary)'
|
|
803
|
+
const style = { ...S.dot, background: color, marginTop: 5 }
|
|
804
|
+
if (props.running) {
|
|
805
|
+
style.boxShadow = `0 0 0 3px rgba(34,160,107,0.16)`
|
|
806
|
+
}
|
|
807
|
+
return h('span', { style })
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* 列表卡片上的一行「最后说了什么」。
|
|
812
|
+
* 只对运行中 / 刚完成的卡片挂载,且只拉一次(不轮询)——目的是不用钉住也能知道结果,
|
|
813
|
+
* 所以刻意用很小的 limit,别让列表本身变成 15 个并发重放。
|
|
814
|
+
*/
|
|
815
|
+
function LastLine(props) {
|
|
816
|
+
const entry = props.entry
|
|
817
|
+
// 第五个参数 false:卡片末行只拉一次,不参与慢轮询。
|
|
818
|
+
// 否则 15 张卡片会各自每 8 秒打一次 /digest,列表页瞬间变成负载源。
|
|
819
|
+
const digest = useDigest(entry.id, false, 3, 0, false)
|
|
820
|
+
if (digest.error !== '' || digest.items.length === 0) return null
|
|
821
|
+
let text = ''
|
|
822
|
+
for (let index = digest.items.length - 1; index >= 0; index -= 1) {
|
|
823
|
+
const item = digest.items[index]
|
|
824
|
+
if (item && item.kind === 'assistant' && item.text) {
|
|
825
|
+
text = String(item.text)
|
|
826
|
+
break
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (text === '') {
|
|
830
|
+
// 没有助手正文时退到最后一条(现在只可能是"我"的消息),
|
|
831
|
+
// 工具行在 Host 侧已经被丢掉,这里不会出现。
|
|
832
|
+
const last = digest.items[digest.items.length - 1]
|
|
833
|
+
if (last && last.text) text = `[我] ${last.text}`
|
|
834
|
+
}
|
|
835
|
+
if (text === '') return null
|
|
836
|
+
return h('div', { style: S.cardLast }, clip(text.replace(/\s+/g, ' ').trim(), 160))
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function SessionCard(props) {
|
|
840
|
+
const entry = props.entry
|
|
841
|
+
const meta = []
|
|
842
|
+
if (entry.running) meta.push('运行中')
|
|
843
|
+
else if (entry.finishedAt) meta.push(`完成于 ${relativeTime(entry.finishedAt)}`)
|
|
844
|
+
else if (entry.updatedAt) meta.push(relativeTime(entry.updatedAt))
|
|
845
|
+
if (entry.turns) meta.push(`${entry.turns} 轮`)
|
|
846
|
+
if (entry.subagents) meta.push(`${entry.subagents} 子代理`)
|
|
847
|
+
if (entry.tokens) meta.push(formatTokens(entry.tokens))
|
|
848
|
+
if (entry.model) meta.push(entry.model)
|
|
849
|
+
|
|
850
|
+
const cardStyle = { ...S.card }
|
|
851
|
+
if (entry.running) Object.assign(cardStyle, S.cardRunning)
|
|
852
|
+
else if (entry.finishedAt) Object.assign(cardStyle, S.cardFinished)
|
|
853
|
+
|
|
854
|
+
return h(
|
|
855
|
+
'div',
|
|
856
|
+
{
|
|
857
|
+
style: cardStyle,
|
|
858
|
+
onClick: () => props.onOpen(entry.id),
|
|
859
|
+
onMouseEnter: (event) => {
|
|
860
|
+
event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover)'
|
|
861
|
+
},
|
|
862
|
+
onMouseLeave: (event) => {
|
|
863
|
+
event.currentTarget.style.background = 'var(--dsw-alias-bg-layer-1)'
|
|
864
|
+
},
|
|
865
|
+
title: entry.cwd || entry.title,
|
|
866
|
+
},
|
|
867
|
+
h(StatusDot, { running: entry.running, finished: Boolean(entry.finishedAt) }),
|
|
868
|
+
h(
|
|
869
|
+
'div',
|
|
870
|
+
{ style: S.cardBody },
|
|
871
|
+
h(
|
|
872
|
+
'div',
|
|
873
|
+
{ style: S.cardTitleRow },
|
|
874
|
+
h('span', { style: S.cardTitle }, entry.title),
|
|
875
|
+
entry.running
|
|
876
|
+
? h('span', { style: { ...S.badge, color: RUNNING_COLOR } }, '运行中')
|
|
877
|
+
: null,
|
|
878
|
+
!entry.running && entry.finishedAt
|
|
879
|
+
? h('span', { style: { ...S.badge, color: FINISHED_COLOR } }, '待查看')
|
|
880
|
+
: null,
|
|
881
|
+
entry.parentId || entry.origin === 'subagent'
|
|
882
|
+
? h('span', { style: { ...S.badge, color: 'var(--dsw-alias-label-secondary)' } }, '子代理')
|
|
883
|
+
: null,
|
|
884
|
+
h(
|
|
885
|
+
'button',
|
|
886
|
+
{
|
|
887
|
+
type: 'button',
|
|
888
|
+
style: { ...S.pinBtn, ...(props.pinned ? S.pinBtnActive : {}) },
|
|
889
|
+
title: props.pinned ? '取消钉住' : '钉住,并排看它的对话',
|
|
890
|
+
onClick: (event) => {
|
|
891
|
+
event.stopPropagation()
|
|
892
|
+
props.onPin(entry.id)
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
props.pinned ? '⊟' : '⊞',
|
|
896
|
+
),
|
|
897
|
+
),
|
|
898
|
+
meta.length > 0 ? h('div', { style: S.metaRow }, meta.map((text, index) => h('span', { key: index }, text))) : null,
|
|
899
|
+
entry.running || entry.finishedAt ? h(LastLine, { entry }) : null,
|
|
900
|
+
),
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** 精简模式下最多列多少个"闲着"的会话(运行中/刚完成的永远全列)。 */
|
|
905
|
+
const IDLE_LIMIT = 15
|
|
906
|
+
|
|
907
|
+
/* ------------------------------------------------------------------ *
|
|
908
|
+
* 并排对话(Phase 2)
|
|
909
|
+
*
|
|
910
|
+
* 客户端事件窗(sessions.retain → eventSource)只对"正被会话视图打开"的会话
|
|
911
|
+
* 填充,实测对历史会话永远是 entries=0,所以对话正文只能走 Host 半边的
|
|
912
|
+
* /digest 路由(sessionQuery.readSession 读真实事件日志再折叠)。
|
|
913
|
+
* ------------------------------------------------------------------ */
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* 默认只拉 20 条:并排列的作用是"扫一眼这段在聊什么",不是复现整个会话。
|
|
917
|
+
* 20 条大约一屏,一打开就是最近的对话,不会一上来就是一堵墙。
|
|
918
|
+
* 要往前翻用「载入更早」,每次再加 20 条。
|
|
919
|
+
*/
|
|
920
|
+
const DIGEST_LIMIT = 20
|
|
921
|
+
/** 运行中会话的轮询间隔:1.2 秒,接近"看着它写"的体感。 */
|
|
922
|
+
const DIGEST_REFRESH_MS = 1200
|
|
923
|
+
/**
|
|
924
|
+
* 已停下会话的慢轮询:8 秒一次。
|
|
925
|
+
* 为什么停下来了还要轮?
|
|
926
|
+
* "跑完了"这件事必须能在原地看到结果,而运行状态翻转的那一次请求万一拿到
|
|
927
|
+
* 缓冲里的旧内容(或者快照翻转本身晚到一帧),面板就会永远停在"没有回复、
|
|
928
|
+
* 也没有显示完成",只有重开面板才恢复。慢轮询是兜底,成本很低
|
|
929
|
+
* (历史会话 observeSession 约 84ms,最多 3 列,8 秒一次)。
|
|
930
|
+
*/
|
|
931
|
+
const DIGEST_IDLE_REFRESH_MS = 8000
|
|
932
|
+
const MAX_PINNED = 3
|
|
933
|
+
|
|
934
|
+
async function fetchDigest(sessionId, limit, live, fresh) {
|
|
935
|
+
const liveFlag = live === true ? '&live=1' : ''
|
|
936
|
+
const freshFlag = fresh === true ? '&fresh=1' : ''
|
|
937
|
+
const response = await fetch(
|
|
938
|
+
`/plugins/dsh-multi-session/digest?sessionId=${encodeURIComponent(sessionId)}&limit=${limit}${liveFlag}${freshFlag}`,
|
|
939
|
+
{ cache: 'no-store' },
|
|
940
|
+
)
|
|
941
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
942
|
+
const body = await response.json()
|
|
943
|
+
if (!body || body.ok !== true) throw new Error('digest unavailable')
|
|
944
|
+
return body
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* 拉一个会话的对话摘要。
|
|
949
|
+
* 运行中的会话按 DIGEST_REFRESH_MS(1.2 秒)轮询;
|
|
950
|
+
* 停下之后不再停止轮询,而是降到 DIGEST_IDLE_REFRESH_MS(8 秒)继续守着 ——
|
|
951
|
+
* 这样"跑完了"的最后一句话一定会在原地补上,不用重开面板。
|
|
952
|
+
* limit 变化(用户点「载入更早」)会重新拉一次。
|
|
953
|
+
*/
|
|
954
|
+
function useDigest(sessionId, live, limit, nonce, poll) {
|
|
955
|
+
const take = Number(limit) > 0 ? Number(limit) : DIGEST_LIMIT
|
|
956
|
+
const bump = Number(nonce) || 0
|
|
957
|
+
/** 是否开启"停下之后继续慢轮询"。列表卡片的末行摘要传 false,只拉一次。 */
|
|
958
|
+
const keepPolling = poll !== false
|
|
959
|
+
const [state, setState] = React.useState({ items: [], turns: 0, endReason: null, error: '', loading: true })
|
|
960
|
+
/**
|
|
961
|
+
* 上一次是不是"运行中"。用来识别"刚跑完"这一帧:
|
|
962
|
+
* 这时候必须绕开缓存拿最新的,否则会显示成"没有回复也没有完成"。
|
|
963
|
+
*/
|
|
964
|
+
const wasLiveRef = React.useRef(false)
|
|
965
|
+
React.useEffect(() => {
|
|
966
|
+
let cancelled = false
|
|
967
|
+
// 从运行中变成停下:这一发强制绕过 Host 缓存。
|
|
968
|
+
const justStopped = wasLiveRef.current && !live
|
|
969
|
+
wasLiveRef.current = live
|
|
970
|
+
const load = async (fresh) => {
|
|
971
|
+
try {
|
|
972
|
+
const body = await fetchDigest(sessionId, take, live, fresh === true)
|
|
973
|
+
if (cancelled) return
|
|
974
|
+
setState({
|
|
975
|
+
items: Array.isArray(body.items) ? body.items : [],
|
|
976
|
+
turns: Number(body.turns) || 0,
|
|
977
|
+
endReason: body.endReason || null,
|
|
978
|
+
error: '',
|
|
979
|
+
loading: false,
|
|
980
|
+
})
|
|
981
|
+
} catch (cause) {
|
|
982
|
+
if (cancelled) return
|
|
983
|
+
setState((previous) => ({
|
|
984
|
+
...previous,
|
|
985
|
+
error: String(cause && cause.message ? cause.message : cause),
|
|
986
|
+
loading: false,
|
|
987
|
+
}))
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
setState((previous) => ({ ...previous, loading: true }))
|
|
991
|
+
load(justStopped)
|
|
992
|
+
if (!live && !keepPolling) return () => { cancelled = true }
|
|
993
|
+
const period = live ? DIGEST_REFRESH_MS : DIGEST_IDLE_REFRESH_MS
|
|
994
|
+
const handle = setInterval(() => load(false), period)
|
|
995
|
+
return () => {
|
|
996
|
+
cancelled = true
|
|
997
|
+
clearInterval(handle)
|
|
998
|
+
}
|
|
999
|
+
}, [sessionId, live, take, bump, keepPolling])
|
|
1000
|
+
return state
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function clip(text, max) {
|
|
1004
|
+
const value = typeof text === 'string' ? text : ''
|
|
1005
|
+
return value.length > max ? `${value.slice(0, max)}…` : value
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* 渲染一条对话。正文按原样保留换行、只做长度上限,不压成摘要。
|
|
1010
|
+
* 并排视图只保留"我 ↔ AI 的对话正文",
|
|
1011
|
+
* 工具调用/思考过程在 Host 侧折叠阶段就丢掉了,这里再兜一层:
|
|
1012
|
+
* 认不出的类型直接跳过,不画任何噪音行。
|
|
1013
|
+
*/
|
|
1014
|
+
function renderDigestItem(item, index) {
|
|
1015
|
+
const kind = item && item.kind
|
|
1016
|
+
if (kind !== 'user' && kind !== 'assistant') return null
|
|
1017
|
+
const isUser = kind === 'user'
|
|
1018
|
+
const roleText = isUser ? '我' : '助手'
|
|
1019
|
+
const color = isUser ? 'var(--dsw-alias-brand-primary)' : RUNNING_COLOR
|
|
1020
|
+
return h(
|
|
1021
|
+
'div',
|
|
1022
|
+
{ key: index, style: isUser ? S.digestMsgUser : S.digestMsgAssistant },
|
|
1023
|
+
h('span', { style: { ...S.digestRole, color } }, roleText),
|
|
1024
|
+
h('div', { style: S.digestText }, clip(item.text, 6000)),
|
|
1025
|
+
)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* 并排列底部的输入框:不用切到那个会话就能回一句,或把它打断。
|
|
1030
|
+
* 默认 queue(排进收件箱,不打断正在跑的回合);按住 Shift 发送 = steer(立刻插话)。
|
|
1031
|
+
*/
|
|
1032
|
+
function SendBox(props) {
|
|
1033
|
+
const entry = props.entry
|
|
1034
|
+
const [text, setText] = React.useState('')
|
|
1035
|
+
const [busy, setBusy] = React.useState(false)
|
|
1036
|
+
const [note, setNote] = React.useState('')
|
|
1037
|
+
|
|
1038
|
+
const post = async (payload) => {
|
|
1039
|
+
const response = await fetch('/plugins/dsh-multi-session/prompt', {
|
|
1040
|
+
method: 'POST',
|
|
1041
|
+
cache: 'no-store',
|
|
1042
|
+
headers: { 'content-type': 'application/json' },
|
|
1043
|
+
body: JSON.stringify(payload),
|
|
1044
|
+
})
|
|
1045
|
+
const body = await response.json().catch(() => null)
|
|
1046
|
+
if (!response.ok || !body || body.ok !== true) {
|
|
1047
|
+
throw new Error((body && body.error) || `HTTP ${response.status}`)
|
|
1048
|
+
}
|
|
1049
|
+
return body
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const send = async (steer) => {
|
|
1053
|
+
const value = text.trim()
|
|
1054
|
+
if (value === '' || busy) return
|
|
1055
|
+
setBusy(true)
|
|
1056
|
+
setNote('')
|
|
1057
|
+
try {
|
|
1058
|
+
await post({ sessionId: entry.id, text: value, mode: steer ? 'steer' : 'queue' })
|
|
1059
|
+
setText('')
|
|
1060
|
+
setNote(steer ? '已插话' : '已发送')
|
|
1061
|
+
if (typeof props.onSent === 'function') props.onSent()
|
|
1062
|
+
} catch (cause) {
|
|
1063
|
+
setNote(`发送失败:${cause && cause.message ? cause.message : cause}`)
|
|
1064
|
+
} finally {
|
|
1065
|
+
setBusy(false)
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const stop = async () => {
|
|
1070
|
+
if (busy) return
|
|
1071
|
+
setBusy(true)
|
|
1072
|
+
setNote('')
|
|
1073
|
+
try {
|
|
1074
|
+
await post({ sessionId: entry.id, action: 'cancel' })
|
|
1075
|
+
setNote('已请求停止')
|
|
1076
|
+
} catch (cause) {
|
|
1077
|
+
setNote(`停止失败:${cause && cause.message ? cause.message : cause}`)
|
|
1078
|
+
} finally {
|
|
1079
|
+
setBusy(false)
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return h(
|
|
1084
|
+
'div',
|
|
1085
|
+
{ style: S.sendBox },
|
|
1086
|
+
h('textarea', {
|
|
1087
|
+
style: S.sendInput,
|
|
1088
|
+
rows: 2,
|
|
1089
|
+
value: text,
|
|
1090
|
+
placeholder: entry.running ? '给它排一条消息(Shift+Enter 立即插话)' : '给这个会话发一条消息',
|
|
1091
|
+
onChange: (event) => setText(event.target.value),
|
|
1092
|
+
onKeyDown: (event) => {
|
|
1093
|
+
if (event.key !== 'Enter') return
|
|
1094
|
+
event.preventDefault()
|
|
1095
|
+
send(Boolean(event.shiftKey))
|
|
1096
|
+
},
|
|
1097
|
+
}),
|
|
1098
|
+
h(
|
|
1099
|
+
'div',
|
|
1100
|
+
{ style: S.sendRow },
|
|
1101
|
+
h(
|
|
1102
|
+
'button',
|
|
1103
|
+
{
|
|
1104
|
+
type: 'button',
|
|
1105
|
+
style: { ...S.sendBtn, opacity: busy || text.trim() === '' ? 0.5 : 1 },
|
|
1106
|
+
disabled: busy || text.trim() === '',
|
|
1107
|
+
onClick: () => send(false),
|
|
1108
|
+
},
|
|
1109
|
+
busy ? '…' : '发送',
|
|
1110
|
+
),
|
|
1111
|
+
entry.running
|
|
1112
|
+
? h(
|
|
1113
|
+
'button',
|
|
1114
|
+
{
|
|
1115
|
+
type: 'button',
|
|
1116
|
+
style: { ...S.sendStop, opacity: busy ? 0.5 : 1 },
|
|
1117
|
+
disabled: busy,
|
|
1118
|
+
title: '打断这个会话当前正在跑的回合',
|
|
1119
|
+
onClick: stop,
|
|
1120
|
+
},
|
|
1121
|
+
'停止',
|
|
1122
|
+
)
|
|
1123
|
+
: null,
|
|
1124
|
+
note !== '' ? h('span', { style: S.sendNote }, note) : null,
|
|
1125
|
+
),
|
|
1126
|
+
)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function PinnedColumn(props) {
|
|
1130
|
+
const entry = props.entry
|
|
1131
|
+
const [limit, setLimit] = React.useState(DIGEST_LIMIT)
|
|
1132
|
+
const [nonce, setNonce] = React.useState(0)
|
|
1133
|
+
const digest = useDigest(entry.id, Boolean(entry.running), limit, nonce)
|
|
1134
|
+
const scrollRef = React.useRef(null)
|
|
1135
|
+
const limitRef = React.useRef(limit)
|
|
1136
|
+
/** 首次加载直接贴底:看别的会话就是想看"它现在说到哪了",从最新一条往回看。 */
|
|
1137
|
+
const firstLoadRef = React.useRef(true)
|
|
1138
|
+
/** 用户是否停在底部附近(决定新消息来了要不要跟着滚)。默认算在底部。 */
|
|
1139
|
+
const stickRef = React.useRef(true)
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* 滚动策略:
|
|
1143
|
+
* - 首次加载 → 直接贴底(看的是"现在说到哪了");
|
|
1144
|
+
* - 用户停在底部时跟着新消息滚,往上翻历史时不打断;
|
|
1145
|
+
* - 点「载入更早」导致的条数变化不触发滚动,否则等于白点。
|
|
1146
|
+
*/
|
|
1147
|
+
React.useEffect(() => {
|
|
1148
|
+
const node = scrollRef.current
|
|
1149
|
+
if (!node) return
|
|
1150
|
+
if (digest.items.length === 0) return
|
|
1151
|
+
if (limitRef.current !== limit) {
|
|
1152
|
+
// 用户点了「载入更早」:保持视线不动,把新增的更早内容留在上面。
|
|
1153
|
+
limitRef.current = limit
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
if (firstLoadRef.current) {
|
|
1157
|
+
firstLoadRef.current = false
|
|
1158
|
+
node.scrollTop = node.scrollHeight
|
|
1159
|
+
return
|
|
1160
|
+
}
|
|
1161
|
+
if (stickRef.current) node.scrollTop = node.scrollHeight
|
|
1162
|
+
}, [digest.items, limit])
|
|
1163
|
+
|
|
1164
|
+
const hasMore = digest.items.length >= limit
|
|
1165
|
+
const meta = entry.running
|
|
1166
|
+
? '运行中'
|
|
1167
|
+
: digest.turns > 0
|
|
1168
|
+
? `${digest.turns} 轮`
|
|
1169
|
+
: entry.updatedAt
|
|
1170
|
+
? relativeTime(entry.updatedAt)
|
|
1171
|
+
: ''
|
|
1172
|
+
|
|
1173
|
+
let content
|
|
1174
|
+
if (digest.loading && digest.items.length === 0) {
|
|
1175
|
+
content = h('div', { style: S.boardEmpty }, '正在读取对话…')
|
|
1176
|
+
} else if (digest.error !== '') {
|
|
1177
|
+
content = h('div', { style: S.boardEmpty }, `读取失败:${digest.error}`)
|
|
1178
|
+
} else if (digest.items.length === 0) {
|
|
1179
|
+
content = h('div', { style: S.boardEmpty }, '这个会话还没有可显示的对话。')
|
|
1180
|
+
} else {
|
|
1181
|
+
content = digest.items.map((item, index) => renderDigestItem(item, index))
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const head = h(
|
|
1185
|
+
'div',
|
|
1186
|
+
{ style: S.boardColHead },
|
|
1187
|
+
h(StatusDot, { running: entry.running, finished: Boolean(entry.finishedAt) }),
|
|
1188
|
+
h(
|
|
1189
|
+
'span',
|
|
1190
|
+
{
|
|
1191
|
+
style: S.boardColTitle,
|
|
1192
|
+
title: `${entry.title}\n点击打开这个会话`,
|
|
1193
|
+
onClick: () => props.onOpen(entry.id),
|
|
1194
|
+
},
|
|
1195
|
+
entry.title,
|
|
1196
|
+
),
|
|
1197
|
+
h('span', { style: S.boardColMeta }, meta),
|
|
1198
|
+
h(
|
|
1199
|
+
'button',
|
|
1200
|
+
{
|
|
1201
|
+
type: 'button',
|
|
1202
|
+
style: S.pinBtn,
|
|
1203
|
+
title: '取消钉住',
|
|
1204
|
+
onClick: () => props.onUnpin(entry.id),
|
|
1205
|
+
},
|
|
1206
|
+
'×',
|
|
1207
|
+
),
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
const moreBar =
|
|
1211
|
+
hasMore && digest.error === ''
|
|
1212
|
+
? h(
|
|
1213
|
+
'button',
|
|
1214
|
+
{
|
|
1215
|
+
type: 'button',
|
|
1216
|
+
style: S.moreBtn,
|
|
1217
|
+
onClick: () => setLimit((value) => value + DIGEST_LIMIT),
|
|
1218
|
+
},
|
|
1219
|
+
`载入更早(当前 ${digest.items.length} 条)`,
|
|
1220
|
+
)
|
|
1221
|
+
: null
|
|
1222
|
+
|
|
1223
|
+
return h(
|
|
1224
|
+
'div',
|
|
1225
|
+
{ style: S.boardCol },
|
|
1226
|
+
head,
|
|
1227
|
+
h(
|
|
1228
|
+
'div',
|
|
1229
|
+
{
|
|
1230
|
+
style: S.boardScroll,
|
|
1231
|
+
ref: scrollRef,
|
|
1232
|
+
// 记住用户是否停在底部附近:只有本来就在底部,新消息才跟着滚。
|
|
1233
|
+
onScroll: (event) => {
|
|
1234
|
+
const node = event.currentTarget
|
|
1235
|
+
stickRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 40
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1238
|
+
moreBar,
|
|
1239
|
+
content,
|
|
1240
|
+
),
|
|
1241
|
+
h(SendBox, { entry, onSent: () => setNonce((value) => value + 1) }),
|
|
1242
|
+
)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function MultiSessionPanel(props) {
|
|
1246
|
+
const sessions = props.useSessions(identity)
|
|
1247
|
+
const workspaces = props.useWorkspaces(identity)
|
|
1248
|
+
const [showArchived, setShowArchived] = React.useState(false)
|
|
1249
|
+
const [showSubagents, setShowSubagents] = React.useState(false)
|
|
1250
|
+
const [showAll, setShowAll] = React.useState(false)
|
|
1251
|
+
/**
|
|
1252
|
+
* 钉住的会话必须活过刷新:否则一退出再进来,三个并排列全没了,
|
|
1253
|
+
* 等于每次都要重新挑一遍。写进 localStorage,键值都落盘。
|
|
1254
|
+
*/
|
|
1255
|
+
const [pinned, setPinned] = React.useState(() => loadPinned())
|
|
1256
|
+
const [tick, setTick] = React.useState(0)
|
|
1257
|
+
|
|
1258
|
+
const workspaceIndex = React.useMemo(() => readWorkspaces(workspaces), [workspaces])
|
|
1259
|
+
const entries = React.useMemo(
|
|
1260
|
+
() => readEntries(sessions, workspaceIndex),
|
|
1261
|
+
// tick 必须在依赖里:点「打开」时先删掉 finishedAt 再 setTick,
|
|
1262
|
+
// 如果这里只依赖 sessions,entries 不会重算,卡片上的"待查看"就永远不消失。
|
|
1263
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1264
|
+
[sessions, workspaceIndex, tick],
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
/** 完成检测必须在渲染前跑,发现新完成就重渲染一次(拿到 finishedAt)。 */
|
|
1268
|
+
React.useEffect(() => {
|
|
1269
|
+
if (detectFinished(entries)) setTick((value) => value + 1)
|
|
1270
|
+
}, [entries])
|
|
1271
|
+
|
|
1272
|
+
/** 让「3 分钟前」这类相对时间自己走。 */
|
|
1273
|
+
React.useEffect(() => {
|
|
1274
|
+
const handle = setInterval(() => setTick((value) => value + 1), 30000)
|
|
1275
|
+
return () => clearInterval(handle)
|
|
1276
|
+
}, [])
|
|
1277
|
+
|
|
1278
|
+
const visible = React.useMemo(() => {
|
|
1279
|
+
const filtered = entries.filter((entry) => {
|
|
1280
|
+
if (!showArchived && entry.archived) return false
|
|
1281
|
+
if (!showSubagents && (entry.parentId || entry.origin === 'subagent')) return false
|
|
1282
|
+
if (entry.blank) return false
|
|
1283
|
+
return true
|
|
1284
|
+
})
|
|
1285
|
+
if (showAll) return filtered
|
|
1286
|
+
// 精简:运行中和刚完成的必须留,其余按最近活跃取前 IDLE_LIMIT 个。
|
|
1287
|
+
const ranked = filtered
|
|
1288
|
+
.slice()
|
|
1289
|
+
.sort((left, right) => rankOf(left) - rankOf(right) || right.updatedAt - left.updatedAt)
|
|
1290
|
+
const kept = []
|
|
1291
|
+
let idle = 0
|
|
1292
|
+
for (const entry of ranked) {
|
|
1293
|
+
if (entry.running || entry.finishedAt) {
|
|
1294
|
+
kept.push(entry)
|
|
1295
|
+
continue
|
|
1296
|
+
}
|
|
1297
|
+
if (idle < IDLE_LIMIT) {
|
|
1298
|
+
kept.push(entry)
|
|
1299
|
+
idle++
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
return kept
|
|
1303
|
+
}, [entries, showArchived, showSubagents, showAll])
|
|
1304
|
+
|
|
1305
|
+
const runningCount = visible.filter((entry) => entry.running).length
|
|
1306
|
+
const finishedCount = visible.filter((entry) => !entry.running && entry.finishedAt).length
|
|
1307
|
+
|
|
1308
|
+
const grouped = React.useMemo(() => {
|
|
1309
|
+
const buckets = new Map()
|
|
1310
|
+
for (const entry of visible) {
|
|
1311
|
+
const key = entry.groupId || entry.cwd || '__other__'
|
|
1312
|
+
let bucket = buckets.get(key)
|
|
1313
|
+
if (!bucket) {
|
|
1314
|
+
bucket = {
|
|
1315
|
+
key,
|
|
1316
|
+
title: entry.groupTitle || basename(entry.cwd) || '其他会话',
|
|
1317
|
+
path: entry.groupPath || entry.cwd || '',
|
|
1318
|
+
entries: [],
|
|
1319
|
+
}
|
|
1320
|
+
buckets.set(key, bucket)
|
|
1321
|
+
}
|
|
1322
|
+
bucket.entries.push(entry)
|
|
1323
|
+
}
|
|
1324
|
+
const rank = rankOf
|
|
1325
|
+
const groups = Array.from(buckets.values())
|
|
1326
|
+
for (const group of groups) {
|
|
1327
|
+
group.entries.sort((left, right) => {
|
|
1328
|
+
const delta = rank(left) - rank(right)
|
|
1329
|
+
if (delta !== 0) return delta
|
|
1330
|
+
if (left.finishedAt !== right.finishedAt) return right.finishedAt - left.finishedAt
|
|
1331
|
+
return right.updatedAt - left.updatedAt
|
|
1332
|
+
})
|
|
1333
|
+
}
|
|
1334
|
+
groups.sort((left, right) => {
|
|
1335
|
+
const leftTop = rank(left.entries[0])
|
|
1336
|
+
const rightTop = rank(right.entries[0])
|
|
1337
|
+
if (leftTop !== rightTop) return leftTop - rightTop
|
|
1338
|
+
return right.entries[0].updatedAt - left.entries[0].updatedAt
|
|
1339
|
+
})
|
|
1340
|
+
return groups
|
|
1341
|
+
}, [visible])
|
|
1342
|
+
|
|
1343
|
+
/** 打开某个会话:清掉待查看标记,并且把"我看到了这个版本"刷进基线。 */
|
|
1344
|
+
const handleOpen = React.useCallback(
|
|
1345
|
+
(sessionId) => {
|
|
1346
|
+
finishedAt.delete(sessionId)
|
|
1347
|
+
saveFinished()
|
|
1348
|
+
// 关键:只删 finishedAt 不够。冷启动补标(catchUpFinished)比的是
|
|
1349
|
+
// "上次记住的 updatedAt vs 现在的 updatedAt",如果基线还停在这之前,
|
|
1350
|
+
// 页面一刷新(primed 归零 → 走冷启动分支)就会把它重新标成待查看,
|
|
1351
|
+
// 看起来就是"点了也白点"。
|
|
1352
|
+
// 基线写 Date.now() 而不是现在这个 updatedAt:语义是"此刻之前的一切我都看过了"。
|
|
1353
|
+
// 打开会话本身也会把 updatedAt 往前推一点,用旧值当基线会被自己顶掉。
|
|
1354
|
+
const entry = entries.find((item) => item.id === sessionId)
|
|
1355
|
+
const seenAt = Math.max(entry && entry.updatedAt > 0 ? entry.updatedAt : 0, Date.now())
|
|
1356
|
+
lastSeen.set(sessionId, seenAt)
|
|
1357
|
+
saveSeen(true)
|
|
1358
|
+
openSession(sessionId)
|
|
1359
|
+
setTick((value) => value + 1)
|
|
1360
|
+
},
|
|
1361
|
+
[entries],
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
/** 全部已读:待查看清空,所有会话的基线一起推进到当前版本。 */
|
|
1365
|
+
const clearFinished = React.useCallback(() => {
|
|
1366
|
+
finishedAt.clear()
|
|
1367
|
+
saveFinished()
|
|
1368
|
+
for (const entry of entries) {
|
|
1369
|
+
if (!entry.blank && entry.updatedAt > 0) lastSeen.set(entry.id, entry.updatedAt)
|
|
1370
|
+
}
|
|
1371
|
+
saveSeen(true)
|
|
1372
|
+
setTick((value) => value + 1)
|
|
1373
|
+
}, [entries])
|
|
1374
|
+
|
|
1375
|
+
/** 钉住/取消钉住;超过 MAX_PINNED 就挤掉最早钉的那个。每次都落盘。 */
|
|
1376
|
+
const togglePin = React.useCallback((sessionId) => {
|
|
1377
|
+
setPinned((current) => {
|
|
1378
|
+
let next
|
|
1379
|
+
if (current.indexOf(sessionId) >= 0) next = current.filter((id) => id !== sessionId)
|
|
1380
|
+
else if (current.length >= MAX_PINNED) next = current.slice(1).concat(sessionId)
|
|
1381
|
+
else next = current.concat(sessionId)
|
|
1382
|
+
savePinned(next)
|
|
1383
|
+
return next
|
|
1384
|
+
})
|
|
1385
|
+
}, [])
|
|
1386
|
+
|
|
1387
|
+
const unpin = React.useCallback((sessionId) => {
|
|
1388
|
+
setPinned((current) => {
|
|
1389
|
+
const next = current.filter((id) => id !== sessionId)
|
|
1390
|
+
savePinned(next)
|
|
1391
|
+
return next
|
|
1392
|
+
})
|
|
1393
|
+
}, [])
|
|
1394
|
+
|
|
1395
|
+
const pinnedEntries = React.useMemo(
|
|
1396
|
+
() =>
|
|
1397
|
+
pinned
|
|
1398
|
+
.map((sessionId) => entries.find((entry) => entry.id === sessionId))
|
|
1399
|
+
.filter((entry) => Boolean(entry)),
|
|
1400
|
+
[pinned, entries],
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
const splitMode = pinnedEntries.length > 0
|
|
1404
|
+
|
|
1405
|
+
const totalVisible = visible.length
|
|
1406
|
+
|
|
1407
|
+
const listContent =
|
|
1408
|
+
grouped.length === 0
|
|
1409
|
+
? sessions && sessions.phase !== 'ready'
|
|
1410
|
+
? h('div', { style: S.empty }, '正在加载会话列表…')
|
|
1411
|
+
: h(
|
|
1412
|
+
'div',
|
|
1413
|
+
{ style: S.empty },
|
|
1414
|
+
'还没有可显示的会话。',
|
|
1415
|
+
h('br'),
|
|
1416
|
+
'开一个新会话跑起来,这里就会实时出现。',
|
|
1417
|
+
)
|
|
1418
|
+
: grouped.map((group) =>
|
|
1419
|
+
h(
|
|
1420
|
+
'div',
|
|
1421
|
+
{ key: group.key, style: S.group },
|
|
1422
|
+
h(
|
|
1423
|
+
'div',
|
|
1424
|
+
{ style: S.groupHeader },
|
|
1425
|
+
h('span', { style: S.groupName }, group.title),
|
|
1426
|
+
group.path && group.path !== group.title
|
|
1427
|
+
? h('span', { style: S.groupPath }, group.path)
|
|
1428
|
+
: null,
|
|
1429
|
+
h('span', { style: { opacity: 0.6 } }, `${group.entries.length}`),
|
|
1430
|
+
),
|
|
1431
|
+
group.entries.map((entry) =>
|
|
1432
|
+
h(SessionCard, {
|
|
1433
|
+
key: entry.id,
|
|
1434
|
+
entry,
|
|
1435
|
+
onOpen: handleOpen,
|
|
1436
|
+
onPin: togglePin,
|
|
1437
|
+
pinned: pinned.indexOf(entry.id) >= 0,
|
|
1438
|
+
}),
|
|
1439
|
+
),
|
|
1440
|
+
),
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
const listTail =
|
|
1444
|
+
!showAll && entries.length > visible.length
|
|
1445
|
+
? h(
|
|
1446
|
+
'div',
|
|
1447
|
+
{ style: { ...S.empty, padding: '10px 20px' } },
|
|
1448
|
+
`另有 ${entries.length - visible.length} 个较久没动的会话,勾选「全部」展开。`,
|
|
1449
|
+
)
|
|
1450
|
+
: null
|
|
1451
|
+
|
|
1452
|
+
const listPane = h(
|
|
1453
|
+
'div',
|
|
1454
|
+
{ style: splitMode ? S.listPane : S.listPaneWide },
|
|
1455
|
+
listContent,
|
|
1456
|
+
listTail,
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
return h(
|
|
1460
|
+
'div',
|
|
1461
|
+
{ style: S.root },
|
|
1462
|
+
h(
|
|
1463
|
+
'div',
|
|
1464
|
+
{ style: S.header },
|
|
1465
|
+
h('span', { style: S.headerTitle }, '多会话'),
|
|
1466
|
+
runningCount > 0
|
|
1467
|
+
? h(
|
|
1468
|
+
'span',
|
|
1469
|
+
{ style: S.counter },
|
|
1470
|
+
h('span', { style: { ...S.dot, background: RUNNING_COLOR } }),
|
|
1471
|
+
`${runningCount} 运行中`,
|
|
1472
|
+
)
|
|
1473
|
+
: null,
|
|
1474
|
+
finishedCount > 0
|
|
1475
|
+
? h(
|
|
1476
|
+
'span',
|
|
1477
|
+
{ style: S.counter },
|
|
1478
|
+
h('span', { style: { ...S.dot, background: FINISHED_COLOR } }),
|
|
1479
|
+
`${finishedCount} 待查看`,
|
|
1480
|
+
)
|
|
1481
|
+
: null,
|
|
1482
|
+
h('span', { style: S.counter }, `${totalVisible} 个会话`),
|
|
1483
|
+
h('span', { style: S.spacer }),
|
|
1484
|
+
h(
|
|
1485
|
+
'label',
|
|
1486
|
+
{ style: { ...S.counter, cursor: 'pointer' } },
|
|
1487
|
+
h('input', {
|
|
1488
|
+
type: 'checkbox',
|
|
1489
|
+
checked: showAll,
|
|
1490
|
+
onChange: (event) => setShowAll(event.target.checked),
|
|
1491
|
+
style: { margin: 0 },
|
|
1492
|
+
}),
|
|
1493
|
+
'全部',
|
|
1494
|
+
),
|
|
1495
|
+
h(
|
|
1496
|
+
'label',
|
|
1497
|
+
{ style: { ...S.counter, cursor: 'pointer' } },
|
|
1498
|
+
h('input', {
|
|
1499
|
+
type: 'checkbox',
|
|
1500
|
+
checked: showSubagents,
|
|
1501
|
+
onChange: (event) => setShowSubagents(event.target.checked),
|
|
1502
|
+
style: { margin: 0 },
|
|
1503
|
+
}),
|
|
1504
|
+
'子代理',
|
|
1505
|
+
),
|
|
1506
|
+
h(
|
|
1507
|
+
'label',
|
|
1508
|
+
{ style: { ...S.counter, cursor: 'pointer' } },
|
|
1509
|
+
h('input', {
|
|
1510
|
+
type: 'checkbox',
|
|
1511
|
+
checked: showArchived,
|
|
1512
|
+
onChange: (event) => setShowArchived(event.target.checked),
|
|
1513
|
+
style: { margin: 0 },
|
|
1514
|
+
}),
|
|
1515
|
+
'归档',
|
|
1516
|
+
),
|
|
1517
|
+
finishedCount > 0
|
|
1518
|
+
? h(
|
|
1519
|
+
'button',
|
|
1520
|
+
{ style: S.button, onClick: clearFinished, type: 'button' },
|
|
1521
|
+
'清除标记',
|
|
1522
|
+
)
|
|
1523
|
+
: null,
|
|
1524
|
+
),
|
|
1525
|
+
splitMode
|
|
1526
|
+
? h(
|
|
1527
|
+
'div',
|
|
1528
|
+
{ style: S.split },
|
|
1529
|
+
listPane,
|
|
1530
|
+
h(
|
|
1531
|
+
'div',
|
|
1532
|
+
{ style: S.board },
|
|
1533
|
+
pinnedEntries.map((entry) =>
|
|
1534
|
+
h(PinnedColumn, {
|
|
1535
|
+
key: entry.id,
|
|
1536
|
+
entry,
|
|
1537
|
+
onOpen: handleOpen,
|
|
1538
|
+
onUnpin: unpin,
|
|
1539
|
+
}),
|
|
1540
|
+
),
|
|
1541
|
+
),
|
|
1542
|
+
)
|
|
1543
|
+
: h('div', { style: S.body }, listContent, listTail),
|
|
1544
|
+
)
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/* ------------------------------------------------------------------ *
|
|
1548
|
+
* 开发期诊断层
|
|
1549
|
+
*
|
|
1550
|
+
* conversation.view 只有用户切到那个页签才会挂载,所以「面板拿到数据了吗」
|
|
1551
|
+
* 没法靠面板自己回报。这里注册一个常驻 overlay(渲染 null),用同一套解析
|
|
1552
|
+
* 逻辑算一遍统计并回传 Host 落盘,页面一刷新就能离线核对。
|
|
1553
|
+
* ------------------------------------------------------------------ */
|
|
1554
|
+
|
|
1555
|
+
/** 每个 phase 只报一次,最多 4 次(pending → ready 这类过渡要抓全)。 */
|
|
1556
|
+
const diagPosted = new Set()
|
|
1557
|
+
|
|
1558
|
+
function DiagOverlay(props) {
|
|
1559
|
+
const sessions = typeof props.useSessions === 'function' ? props.useSessions(identity) : undefined
|
|
1560
|
+
const workspaces =
|
|
1561
|
+
typeof props.useWorkspaces === 'function' ? props.useWorkspaces(identity) : undefined
|
|
1562
|
+
// hook 必须无条件、按固定顺序调用,不能塞进 IIFE。
|
|
1563
|
+
const statusSnap =
|
|
1564
|
+
typeof props.useSessionStatus === 'function' ? props.useSessionStatus(identity) : undefined
|
|
1565
|
+
const phase = (sessions && sessions.phase) || 'none'
|
|
1566
|
+
|
|
1567
|
+
React.useEffect(() => {
|
|
1568
|
+
if (diagPosted.has(phase) || diagPosted.size >= 4) return
|
|
1569
|
+
diagPosted.add(phase)
|
|
1570
|
+
let error = null
|
|
1571
|
+
let payload
|
|
1572
|
+
try {
|
|
1573
|
+
const workspaceIndex = readWorkspaces(workspaces)
|
|
1574
|
+
const entries = readEntries(sessions, workspaceIndex)
|
|
1575
|
+
const visible = entries.filter((entry) => !entry.blank)
|
|
1576
|
+
payload = {
|
|
1577
|
+
phase: 'panel',
|
|
1578
|
+
build: BUILD,
|
|
1579
|
+
sessionsPhase: phase,
|
|
1580
|
+
at: new Date().toISOString(),
|
|
1581
|
+
hasSessions: Boolean(sessions),
|
|
1582
|
+
idsLength: sessions && Array.isArray(sessions.ids) ? sessions.ids.length : -1,
|
|
1583
|
+
total: entries.length,
|
|
1584
|
+
running: visible.filter((entry) => entry.running).length,
|
|
1585
|
+
withCwd: visible.filter((entry) => entry.cwd).length,
|
|
1586
|
+
withUpdatedAt: visible.filter((entry) => entry.updatedAt > 0).length,
|
|
1587
|
+
archived: visible.filter((entry) => entry.archived).length,
|
|
1588
|
+
withParent: visible.filter((entry) => entry.parentId).length,
|
|
1589
|
+
workspaces: workspaceIndex.groups.length,
|
|
1590
|
+
groups: workspaceIndex.groups.map((group) => ({
|
|
1591
|
+
title: group.title,
|
|
1592
|
+
path: group.path,
|
|
1593
|
+
count: group.sessionIds.length,
|
|
1594
|
+
})),
|
|
1595
|
+
sample: visible.slice(0, 5).map((entry) => ({
|
|
1596
|
+
id: entry.id,
|
|
1597
|
+
title: entry.title,
|
|
1598
|
+
running: entry.running,
|
|
1599
|
+
updatedAt: entry.updatedAt,
|
|
1600
|
+
cwd: entry.cwd,
|
|
1601
|
+
group: entry.groupTitle,
|
|
1602
|
+
turns: entry.turns,
|
|
1603
|
+
tokens: entry.tokens,
|
|
1604
|
+
model: entry.model,
|
|
1605
|
+
})),
|
|
1606
|
+
// 一次性探明 modelSelection 的真实形状(之前 model 一直为空)。
|
|
1607
|
+
modelRaw: (() => {
|
|
1608
|
+
try {
|
|
1609
|
+
const table =
|
|
1610
|
+
sessions && sessions.projectionsBySession && typeof sessions.projectionsBySession === 'object'
|
|
1611
|
+
? sessions.projectionsBySession
|
|
1612
|
+
: {}
|
|
1613
|
+
for (const key of Object.keys(table).slice(0, 12)) {
|
|
1614
|
+
const values = table[key] && table[key].values
|
|
1615
|
+
const selection = values && values.modelSelection
|
|
1616
|
+
if (selection !== undefined && selection !== null) {
|
|
1617
|
+
return JSON.stringify(selection).slice(0, 160)
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return 'none'
|
|
1621
|
+
} catch (cause) {
|
|
1622
|
+
return `threw: ${String(cause)}`
|
|
1623
|
+
}
|
|
1624
|
+
})(),
|
|
1625
|
+
// 一次性探明"当前会话"能不能被识别(决定浮标要不要排除当前会话)。
|
|
1626
|
+
locRaw: (() => {
|
|
1627
|
+
try {
|
|
1628
|
+
return JSON.stringify({
|
|
1629
|
+
path: String(location.pathname || '').slice(0, 120),
|
|
1630
|
+
hash: String(location.hash || '').slice(0, 120),
|
|
1631
|
+
search: String(location.search || '').slice(0, 60),
|
|
1632
|
+
})
|
|
1633
|
+
} catch (cause) {
|
|
1634
|
+
return `threw: ${String(cause)}`
|
|
1635
|
+
}
|
|
1636
|
+
})(),
|
|
1637
|
+
// 列表级 UI 状态里是否带"当前选中"标记。
|
|
1638
|
+
listMetaRaw: (() => {
|
|
1639
|
+
try {
|
|
1640
|
+
const table =
|
|
1641
|
+
sessions && sessions.projectionsBySession && typeof sessions.projectionsBySession === 'object'
|
|
1642
|
+
? sessions.projectionsBySession
|
|
1643
|
+
: {}
|
|
1644
|
+
for (const key of Object.keys(table).slice(0, 12)) {
|
|
1645
|
+
const values = table[key] && table[key].values
|
|
1646
|
+
const meta = values && values.sessionListMetadata
|
|
1647
|
+
if (meta !== undefined && meta !== null) {
|
|
1648
|
+
return JSON.stringify(meta).slice(0, 240)
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
return 'none'
|
|
1652
|
+
} catch (cause) {
|
|
1653
|
+
return `threw: ${String(cause)}`
|
|
1654
|
+
}
|
|
1655
|
+
})(),
|
|
1656
|
+
// useSessionStatus 的真实返回形状。
|
|
1657
|
+
statusRaw: (() => {
|
|
1658
|
+
try {
|
|
1659
|
+
return JSON.stringify(statusSnap).slice(0, 240)
|
|
1660
|
+
} catch (cause) {
|
|
1661
|
+
return `threw: ${String(cause)}`
|
|
1662
|
+
}
|
|
1663
|
+
})(),
|
|
1664
|
+
activeSessionId,
|
|
1665
|
+
}
|
|
1666
|
+
} catch (cause) {
|
|
1667
|
+
error = String(cause)
|
|
1668
|
+
}
|
|
1669
|
+
post({ phase: 'panel', build: BUILD, at: new Date().toISOString(), error, payload })
|
|
1670
|
+
|
|
1671
|
+
// 一次性探明 /prompt 的 content 形状:拿一个不存在的 sessionId 试发。
|
|
1672
|
+
// 期望「会话找不到」类错误——说明 content:[{type:'text',text}] 被接受了;
|
|
1673
|
+
// 如果报的是 content/校验类错误,就说明形状猜错了。
|
|
1674
|
+
if (!promptProbed) {
|
|
1675
|
+
promptProbed = true
|
|
1676
|
+
void (async () => {
|
|
1677
|
+
try {
|
|
1678
|
+
const response = await fetch('/plugins/dsh-multi-session/prompt', {
|
|
1679
|
+
method: 'POST',
|
|
1680
|
+
cache: 'no-store',
|
|
1681
|
+
headers: { 'content-type': 'application/json' },
|
|
1682
|
+
body: JSON.stringify({
|
|
1683
|
+
sessionId: 'session-00000000-0000-0000-0000-000000000000',
|
|
1684
|
+
text: 'shape-probe',
|
|
1685
|
+
}),
|
|
1686
|
+
})
|
|
1687
|
+
const body = await response.json().catch(() => null)
|
|
1688
|
+
post({
|
|
1689
|
+
phase: 'prompt-shape',
|
|
1690
|
+
build: BUILD,
|
|
1691
|
+
at: new Date().toISOString(),
|
|
1692
|
+
status: response.status,
|
|
1693
|
+
ok: Boolean(body && body.ok),
|
|
1694
|
+
error: String((body && body.error) || ''),
|
|
1695
|
+
})
|
|
1696
|
+
} catch (cause) {
|
|
1697
|
+
post({ phase: 'prompt-shape', build: BUILD, at: new Date().toISOString(), error: String(cause) })
|
|
1698
|
+
}
|
|
1699
|
+
})()
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// 紧跟 panel 探针做一次 /digest 自检(surface 模式 + 缓存命中),
|
|
1703
|
+
// 挂在这里而不是独立 effect,是因为实测独立 effect 有时不触发。
|
|
1704
|
+
if (digestProbed) return
|
|
1705
|
+
digestProbed = true
|
|
1706
|
+
const digestTarget =
|
|
1707
|
+
activeSessionId || (Array.isArray(payload.sample) && payload.sample[0] ? String(payload.sample[0].id) : '')
|
|
1708
|
+
if (digestTarget === '') return
|
|
1709
|
+
void (async () => {
|
|
1710
|
+
const lines = []
|
|
1711
|
+
// 面板真实路径:observe + DIGEST_LIMIT 条,测耗时与载荷大小。
|
|
1712
|
+
const realUrl = `sessionId=${encodeURIComponent(digestTarget)}&limit=${DIGEST_LIMIT}&fresh=1`
|
|
1713
|
+
const histUrl = `sessionId=session-9d2cd3d5-7770-4fac-9f23-0574c97d371d&limit=${DIGEST_LIMIT}&fresh=1`
|
|
1714
|
+
const probes = [
|
|
1715
|
+
{ label: 'panel-live', url: realUrl, bytes: true },
|
|
1716
|
+
{ label: 'panel-live-cached', url: realUrl, bytes: true },
|
|
1717
|
+
{ label: 'panel-history', url: histUrl, bytes: true },
|
|
1718
|
+
]
|
|
1719
|
+
for (const probe of probes) {
|
|
1720
|
+
try {
|
|
1721
|
+
const response = await fetch(`/plugins/dsh-multi-session/digest?${probe.url}`, { cache: 'no-store' })
|
|
1722
|
+
const raw = await response.text()
|
|
1723
|
+
const body = JSON.parse(raw)
|
|
1724
|
+
if (!body || body.ok !== true) {
|
|
1725
|
+
lines.push(`${probe.label} http=${response.status} body=${String(raw).slice(0, 120)}`)
|
|
1726
|
+
continue
|
|
1727
|
+
}
|
|
1728
|
+
const items = Array.isArray(body.items) ? body.items : []
|
|
1729
|
+
lines.push(
|
|
1730
|
+
`${probe.label} ms=${body.ms} cached=${body.cached} source=${body.source} events=${body.eventCount} turns=${body.turns} items=${items.length} bytes=${raw.length}`,
|
|
1731
|
+
)
|
|
1732
|
+
if (probe.bytes === true) {
|
|
1733
|
+
for (const item of items.slice(-2)) {
|
|
1734
|
+
lines.push(` [${item.kind}] ${String(item.text).slice(0, 70).replace(/\n/g, ' ')}`)
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
} catch (cause) {
|
|
1738
|
+
lines.push(`${probe.label} threw ${String(cause)}`)
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
post({ phase: 'digest', build: BUILD, at: new Date().toISOString(), target: digestTarget, lines })
|
|
1742
|
+
})()
|
|
1743
|
+
}, [phase])
|
|
1744
|
+
|
|
1745
|
+
// 一次性探针:Phase 2「并排对话流」的可行性前提——
|
|
1746
|
+
// 客户端能否 retain 别的会话并读到它的消息事件流。
|
|
1747
|
+
React.useEffect(() => {
|
|
1748
|
+
if (retainProbed) return
|
|
1749
|
+
retainProbed = true
|
|
1750
|
+
const sessionsService = getService('sessions')
|
|
1751
|
+
if (!sessionsService || typeof sessionsService.retain !== 'function') {
|
|
1752
|
+
post({ phase: 'retain', build: BUILD, at: new Date().toISOString(), error: 'no sessions service' })
|
|
1753
|
+
return
|
|
1754
|
+
}
|
|
1755
|
+
const describe = (label, value) => {
|
|
1756
|
+
try {
|
|
1757
|
+
if (value === null || value === undefined) return `${label}: ${String(value)}`
|
|
1758
|
+
if (typeof value !== 'object') return `${label}: ${typeof value} = ${String(value).slice(0, 80)}`
|
|
1759
|
+
const keys = Object.keys(value)
|
|
1760
|
+
return `${label}: object{${keys.slice(0, 12).join(',')}}`
|
|
1761
|
+
} catch (cause) {
|
|
1762
|
+
return `${label}: threw ${String(cause)}`
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
let reference
|
|
1766
|
+
const report = {}
|
|
1767
|
+
const lines = []
|
|
1768
|
+
const run = async () => {
|
|
1769
|
+
try {
|
|
1770
|
+
// 选一个"不是当前会话、且有内容"的历史会话,检验事件窗是否懒加载。
|
|
1771
|
+
let targetId = activeSessionId
|
|
1772
|
+
try {
|
|
1773
|
+
const table =
|
|
1774
|
+
sessions && sessions.projectionsBySession && typeof sessions.projectionsBySession === 'object'
|
|
1775
|
+
? sessions.projectionsBySession
|
|
1776
|
+
: {}
|
|
1777
|
+
for (const key of Object.keys(table)) {
|
|
1778
|
+
if (key === activeSessionId) continue
|
|
1779
|
+
const values = table[key] && table[key].values
|
|
1780
|
+
const outline = values && Array.isArray(values.turnOutline) ? values.turnOutline : []
|
|
1781
|
+
if (outline.length > 0) {
|
|
1782
|
+
targetId = key
|
|
1783
|
+
break
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
} catch (cause) {
|
|
1787
|
+
lines.push(`pickTarget threw ${String(cause)}`)
|
|
1788
|
+
}
|
|
1789
|
+
lines.push(`target=${targetId.slice(0, 44)} active=${targetId === activeSessionId}`)
|
|
1790
|
+
reference = sessionsService.retain({ sessionId: targetId }, {})
|
|
1791
|
+
lines.push(describe('reference', reference))
|
|
1792
|
+
// 关键:retain 是异步就绪的,readiness.promise 解析前 eventSource 是空的。
|
|
1793
|
+
const readiness = reference && reference.readiness
|
|
1794
|
+
if (readiness && readiness.promise) {
|
|
1795
|
+
const settled = await Promise.race([
|
|
1796
|
+
readiness.promise.then(() => 'resolved', () => 'rejected'),
|
|
1797
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), 4000)),
|
|
1798
|
+
])
|
|
1799
|
+
lines.push(`readiness=${settled}`)
|
|
1800
|
+
}
|
|
1801
|
+
const binding =
|
|
1802
|
+
(reference && reference.record && reference.record.binding) ||
|
|
1803
|
+
(typeof sessionsService.binding === 'function' ? sessionsService.binding(targetId) : null)
|
|
1804
|
+
lines.push(describe('binding', binding))
|
|
1805
|
+
const source = binding && binding.eventSource
|
|
1806
|
+
lines.push(describe('eventSource', source))
|
|
1807
|
+
// 实测 eventSource 是 {listeners, window, snapshot},不是带 get() 的对象。
|
|
1808
|
+
const window_ =
|
|
1809
|
+
source && source.snapshot && typeof source.snapshot === 'object'
|
|
1810
|
+
? source.snapshot
|
|
1811
|
+
: source && source.window && typeof source.window === 'object'
|
|
1812
|
+
? source.window
|
|
1813
|
+
: null
|
|
1814
|
+
lines.push(describe('window(from snapshot)', window_))
|
|
1815
|
+
// 把 window 自身 + 原型的成员全列出来,找分页/加载入口。
|
|
1816
|
+
try {
|
|
1817
|
+
const own = window_ ? Object.keys(window_) : []
|
|
1818
|
+
const proto = window_ ? Object.getOwnPropertyNames(Object.getPrototypeOf(window_)) : []
|
|
1819
|
+
lines.push(`window.own=[${own.join(',')}] proto=[${proto.join(',')}]`)
|
|
1820
|
+
for (const key of own.concat(proto)) {
|
|
1821
|
+
if (key === 'constructor') continue
|
|
1822
|
+
const type = typeof window_[key]
|
|
1823
|
+
const extra = type === 'function' ? ` arity=${window_[key].length}` : ''
|
|
1824
|
+
lines.push(` window.${key}: ${type}${extra}`)
|
|
1825
|
+
}
|
|
1826
|
+
} catch (cause) {
|
|
1827
|
+
lines.push(`window inspect threw ${String(cause)}`)
|
|
1828
|
+
}
|
|
1829
|
+
// eventSource 的订阅接口长什么样。
|
|
1830
|
+
try {
|
|
1831
|
+
const listeners = source && source.listeners
|
|
1832
|
+
const own = listeners ? Object.keys(listeners) : []
|
|
1833
|
+
const proto = listeners ? Object.getOwnPropertyNames(Object.getPrototypeOf(listeners)) : []
|
|
1834
|
+
lines.push(`listeners own=[${own.join(',')}] proto=[${proto.slice(0, 10).join(',')}]`)
|
|
1835
|
+
} catch (cause) {
|
|
1836
|
+
lines.push(`listeners inspect threw ${String(cause)}`)
|
|
1837
|
+
}
|
|
1838
|
+
const entries = window_ && Array.isArray(window_.entries) ? window_.entries : []
|
|
1839
|
+
lines.push(`entries=${entries.length} change=${window_ ? window_.change : 'n/a'}`)
|
|
1840
|
+
const sample = entries.slice(-3).map((entry) => {
|
|
1841
|
+
try {
|
|
1842
|
+
const event = entry && entry.event ? entry.event : entry
|
|
1843
|
+
const kind =
|
|
1844
|
+
event && typeof event === 'object'
|
|
1845
|
+
? event.type || event.kind || event.role || '?'
|
|
1846
|
+
: typeof event
|
|
1847
|
+
return `${String(kind).slice(0, 24)} :: ${JSON.stringify(event).slice(0, 180)}`
|
|
1848
|
+
} catch (cause) {
|
|
1849
|
+
return `threw ${String(cause)}`
|
|
1850
|
+
}
|
|
1851
|
+
})
|
|
1852
|
+
lines.push(...sample)
|
|
1853
|
+
const sessionFace = binding && binding.session
|
|
1854
|
+
const snapshot =
|
|
1855
|
+
sessionFace && typeof sessionFace.get === 'function'
|
|
1856
|
+
? sessionFace.get()
|
|
1857
|
+
: sessionFace && sessionFace.snapshot && typeof sessionFace.snapshot === 'object'
|
|
1858
|
+
? sessionFace.snapshot
|
|
1859
|
+
: null
|
|
1860
|
+
lines.push(describe('sessionSnapshot', snapshot))
|
|
1861
|
+
if (snapshot) {
|
|
1862
|
+
lines.push(`snapshot.running=${snapshot.running} pending=${snapshot.pendingSubmissions}`)
|
|
1863
|
+
}
|
|
1864
|
+
// 端到端验证 Host 侧 /digest:默认走 surface(最快内容路径),
|
|
1865
|
+
// 再打一次同样的请求确认 Host 缓存命中(第二次应该几毫秒返回)。
|
|
1866
|
+
for (const mode of ['surface', 'surface']) {
|
|
1867
|
+
try {
|
|
1868
|
+
const response = await fetch(
|
|
1869
|
+
`/plugins/dsh-multi-session/digest?mode=${mode}&sessionId=${encodeURIComponent(targetId)}&limit=8`,
|
|
1870
|
+
{ cache: 'no-store' },
|
|
1871
|
+
)
|
|
1872
|
+
const body = await response.json()
|
|
1873
|
+
if (!body || body.ok !== true) {
|
|
1874
|
+
lines.push(`digest[${mode}] http=${response.status} body=${JSON.stringify(body).slice(0, 120)}`)
|
|
1875
|
+
continue
|
|
1876
|
+
}
|
|
1877
|
+
lines.push(
|
|
1878
|
+
`digest[${mode}] ms=${body.ms} cached=${body.cached} age=${body.age} events=${body.eventCount} turns=${body.turns} items=${Array.isArray(body.items) ? body.items.length : 'n/a'}`,
|
|
1879
|
+
)
|
|
1880
|
+
if (Array.isArray(body.items)) {
|
|
1881
|
+
for (const item of body.items.slice(-3)) {
|
|
1882
|
+
lines.push(` [${item.kind}] ${String(item.text).slice(0, 90).replace(/\n/g, ' ')}`)
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
} catch (cause) {
|
|
1886
|
+
lines.push(`digest[${mode}] threw ${String(cause)}`)
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
} catch (cause) {
|
|
1890
|
+
report.error = String(cause)
|
|
1891
|
+
} finally {
|
|
1892
|
+
try {
|
|
1893
|
+
// 实测释放方法是 releaseReference(没有 release),必须配对调用,否则会话被永久持有。
|
|
1894
|
+
if (reference && typeof reference.releaseReference === 'function') reference.releaseReference()
|
|
1895
|
+
else if (reference && typeof reference.release === 'function') reference.release()
|
|
1896
|
+
} catch (cause) {
|
|
1897
|
+
report.releaseError = String(cause)
|
|
1898
|
+
}
|
|
1899
|
+
post({ phase: 'retain', build: BUILD, at: new Date().toISOString(), ...report, lines })
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
run()
|
|
1903
|
+
}, [])
|
|
1904
|
+
|
|
1905
|
+
return null
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* 零尺寸探针:挂在会话头部,把"用户当前正在看的会话"记到模块变量。
|
|
1910
|
+
*
|
|
1911
|
+
* 为什么需要它:shell.overlay 拿不到当前会话 id(URL 恒为 "/"、
|
|
1912
|
+
* usePanelInfo 会违反 hook 规则、sessionListMetadata 只有 {blank,lastPromptAt}),
|
|
1913
|
+
* 而 conversation.session.header.actions 的 props 里带 sessionId。
|
|
1914
|
+
* 浮标据此排除当前会话,避免"你正看着的这轮结束也弹提醒"的噪音。
|
|
1915
|
+
*/
|
|
1916
|
+
function ActiveSessionProbe(props) {
|
|
1917
|
+
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
|
|
1918
|
+
React.useEffect(() => {
|
|
1919
|
+
activeSessionId = sessionId
|
|
1920
|
+
}, [sessionId])
|
|
1921
|
+
return null
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
return {
|
|
1926
|
+
inject: ['slots'],
|
|
1927
|
+
// 仅供离线 harness 直接渲染内部组件;宿主只读 inject/apply,多出的键无副作用。
|
|
1928
|
+
__internals: DIAG
|
|
1929
|
+
? { PinnedColumn, renderDigestItem, fetchDigest, MultiSessionPanel, SessionCard, LastLine, SendBox, openSession }
|
|
1930
|
+
: undefined,
|
|
1931
|
+
apply(ctx) {
|
|
1932
|
+
rootCtx = ctx
|
|
1933
|
+
try {
|
|
1934
|
+
ctx.inject(['slots'], (native) => {
|
|
1935
|
+
native.slots.inject('conversation.view', () =>
|
|
1936
|
+
native.slots.register(
|
|
1937
|
+
{ name: 'conversation.view', id: VIEW_ID, order: 20, label: () => '多会话' },
|
|
1938
|
+
MultiSessionPanel,
|
|
1939
|
+
),
|
|
1940
|
+
)
|
|
1941
|
+
// 零尺寸探针:记录"当前正在看的会话",供浮标排除。
|
|
1942
|
+
native.slots.inject('conversation.session.header.actions', () =>
|
|
1943
|
+
native.slots.register(
|
|
1944
|
+
{
|
|
1945
|
+
name: 'conversation.session.header.actions',
|
|
1946
|
+
id: 'dsh-multi-session-active',
|
|
1947
|
+
order: 90,
|
|
1948
|
+
},
|
|
1949
|
+
ActiveSessionProbe,
|
|
1950
|
+
),
|
|
1951
|
+
)
|
|
1952
|
+
if (DIAG) {
|
|
1953
|
+
native.slots.inject('shell.overlay', () =>
|
|
1954
|
+
native.slots.register(
|
|
1955
|
+
{
|
|
1956
|
+
name: 'shell.overlay',
|
|
1957
|
+
id: 'dsh-multi-session-diag',
|
|
1958
|
+
order: 901,
|
|
1959
|
+
label: 'MultiSession Diag',
|
|
1960
|
+
},
|
|
1961
|
+
DiagOverlay,
|
|
1962
|
+
),
|
|
1963
|
+
)
|
|
1964
|
+
}
|
|
1965
|
+
})
|
|
1966
|
+
} catch (cause) {
|
|
1967
|
+
console.warn(`${TAG} failed to register the multi-session view`, cause)
|
|
1968
|
+
}
|
|
1969
|
+
},
|
|
1970
|
+
}
|
|
1971
|
+
},
|
|
1972
|
+
})
|