dsh-dock 0.9.8 → 0.10.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/README.md +56 -31
- package/client.js +1839 -1474
- package/features/animation/host.js +16 -557
- package/features/animation/view.jsx +57 -642
- package/features/modelconfig/host.js +4 -1
- package/features/notify/host.js +374 -0
- package/features/notify/view.jsx +751 -0
- package/features/runstate/host.js +64 -0
- package/features/runstate/view.jsx +269 -0
- package/index.js +25 -5
- package/package.json +7 -2
- package/src/client.jsx +17 -12
- package/src/host-core.js +74 -3
- package/src/shared.js +36 -11
- package/src/task-track.js +359 -0
package/src/shared.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
import react from "react";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* 功能视图错误边界:视图渲染抛错时降级为一行错误提示,不拖垮整块面板 UI。
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ 内置视图也必须包(v0.9.9 起):宿主把每个插槽条目包在 SlotErrorBoundary 里,
|
|
8
|
+
* 条目内任何渲染抛错都会被宿主换成空 div——表现为「点开功能坞界面整个没了」而侧栏
|
|
9
|
+
* 入口按钮仍是选中态。v0.9.3~0.9.8 的 animation 视图就是这样被一个 ReferenceError 打没的。
|
|
10
|
+
* label 用于提示里点明是哪个功能;调用处应带 key(切换功能时重建,否则错误态会跟着串页)。
|
|
7
11
|
*/
|
|
8
12
|
export class FeatureBoundary extends react.Component {
|
|
9
13
|
constructor(props) {
|
|
@@ -13,11 +17,15 @@ export class FeatureBoundary extends react.Component {
|
|
|
13
17
|
static getDerivedStateFromError(error) {
|
|
14
18
|
return { error: error };
|
|
15
19
|
}
|
|
20
|
+
componentDidCatch(error) {
|
|
21
|
+
console.error("[dsh-dock] 功能视图渲染出错:", error);
|
|
22
|
+
}
|
|
16
23
|
render() {
|
|
17
24
|
if (this.state.error) {
|
|
18
25
|
const msg = this.state.error && this.state.error.message ? this.state.error.message : String(this.state.error);
|
|
26
|
+
const who = this.props && this.props.label ? "「" + this.props.label + "」" : "功能视图";
|
|
19
27
|
return react.createElement("div", { className: "dockm-note dockm-err" },
|
|
20
|
-
"
|
|
28
|
+
who + "渲染出错:" + msg + "(已隔离,不影响面板其他功能;详情见浏览器控制台)");
|
|
21
29
|
}
|
|
22
30
|
return this.props.children;
|
|
23
31
|
}
|
|
@@ -55,7 +63,8 @@ export function initFeatureState(defs) {
|
|
|
55
63
|
featureState.set(f.id, { enabled, error: null });
|
|
56
64
|
}
|
|
57
65
|
// 宿主侧持久化开关(settings,跨浏览器/重启)优先:拉过来覆盖本地默认值。
|
|
58
|
-
// localStorage 里已有记录的 id
|
|
66
|
+
// localStorage 里已有记录的 id 视为用户在这台浏览器已表态,保持本地值;
|
|
67
|
+
// 若宿主从未收到过该功能的开关,则把本浏览器的选择补推一次(见 syncFeatureStateFromHost)。
|
|
59
68
|
syncFeatureStateFromHost(defs);
|
|
60
69
|
}
|
|
61
70
|
|
|
@@ -70,25 +79,41 @@ function syncFeatureStateFromHost(defs) {
|
|
|
70
79
|
if (!persisted || typeof persisted !== "object") return;
|
|
71
80
|
let changed = false;
|
|
72
81
|
for (const f of defs) {
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
if (
|
|
82
|
+
const local = featurePersist.map[f.id];
|
|
83
|
+
const host = persisted[f.id];
|
|
84
|
+
if (local === undefined) {
|
|
85
|
+
// 本浏览器没表态:跟随宿主持久化值
|
|
86
|
+
if (typeof host === "boolean") {
|
|
87
|
+
const st = stateOf(f.id);
|
|
88
|
+
if (st.enabled !== host) { st.enabled = host; changed = true; }
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
78
91
|
}
|
|
92
|
+
// 本浏览器已表态,但宿主从未收到过这个功能的开关(persisted 里根本没有它):
|
|
93
|
+
// 补推一次本浏览器的选择。否则会出现「面板显示已启用、宿主侧却从未 setup」——
|
|
94
|
+
// 该功能的路由整片 404,面板还会误报「宿主进程是旧版本」。宿主刚升级、
|
|
95
|
+
// 或新功能刚拆出来时最容易踩(「任务通知」拆分时就是这样:点开关时宿主
|
|
96
|
+
// 还是旧进程 → POST 404 → 开关只留在浏览器本地,重启后宿主永远不知道)。
|
|
97
|
+
// 注意只在「宿主没有记录」时补推:宿主已显式记过 false 说明是别处关掉的,
|
|
98
|
+
// 不能拿本浏览器的旧值去覆盖(多设备场景)。
|
|
99
|
+
if (typeof host !== "boolean") pushFeatureEnabledToHost(f.id, local);
|
|
79
100
|
}
|
|
80
101
|
if (changed) notifyState();
|
|
81
102
|
}).catch(() => { /* 宿主不可达时用本地默认 */ });
|
|
82
103
|
}
|
|
83
104
|
|
|
84
|
-
/** 把开关同步到宿主(settings 持久化 + 宿主半部即时 setup/dispose
|
|
85
|
-
* 宿主侧开关只影响路由注册等 Host 能力,本地 UI 状态仍然即时生效。
|
|
105
|
+
/** 把开关同步到宿主(settings 持久化 + 宿主半部即时 setup/dispose)。失败只 warn 不打断:
|
|
106
|
+
* 宿主侧开关只影响路由注册等 Host 能力,本地 UI 状态仍然即时生效。
|
|
107
|
+
* (注意 fetch 对 404 这类 HTTP 错误是正常 resolve,必须查 res.ok——曾因 id 不一致
|
|
108
|
+
* 开关永远 404 而这里毫无声息,排查了两个版本才发现。) */
|
|
86
109
|
function pushFeatureEnabledToHost(id, enabled) {
|
|
87
110
|
if (typeof fetch !== "function") return;
|
|
88
111
|
fetch("/dsh-dock/features", {
|
|
89
112
|
method: "POST",
|
|
90
113
|
headers: { "content-type": "application/json" },
|
|
91
114
|
body: JSON.stringify({ id, enabled }),
|
|
115
|
+
}).then((res) => {
|
|
116
|
+
if (!res.ok) console.warn(`[dsh-dock] 功能开关同步宿主失败:${id}=${enabled} → HTTP ${res.status}(宿主不认识该 id 或插件未就绪)`);
|
|
92
117
|
}).catch(() => { /* 宿主旧版本或不可达:本地开关仍生效(仅本浏览器) */ });
|
|
93
118
|
}
|
|
94
119
|
export function toggleFeature(id) {
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// dsh-dock · 宿主共享内核:会话级任务追踪(【任务动画】【任务通知】【运行状态】共用一份数据)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块:动画、通知与运行状态都需要「任务什么时候开始/结束、现在在干什么」,
|
|
4
|
+
// 但三者是各自独立开关的功能模块(可只开其一,也可各自提取成独立包发布)。
|
|
5
|
+
// 追踪逻辑放这里、用引用计数共享一份实例:三个功能都开也只有一份追踪(会话事件只订阅一次),
|
|
6
|
+
// 全都关则立刻退订,不留下常驻开销。
|
|
7
|
+
//
|
|
8
|
+
// 用法(features/<id>/host.js):
|
|
9
|
+
// const lease = acquireTaskTracker(ctx)
|
|
10
|
+
// disposers.push(() => lease.release())
|
|
11
|
+
// lease.tracker.snapshot(Date.now()) // → { now, active: [...], recent: [...] }
|
|
12
|
+
// const off = lease.tracker.onFinish((rec) => { ... }) // 任务结束回调(群机器人推送用)
|
|
13
|
+
//
|
|
14
|
+
// 数据形状(active/recent)由客户端各模块消费,字段改动要同时看:
|
|
15
|
+
// features/runstate/view.jsx、features/animation/view.jsx、features/notify/view.jsx
|
|
16
|
+
|
|
17
|
+
/** 最近完成记录保留条数(新→旧)。 */
|
|
18
|
+
const MAX_COMPLETED = 30
|
|
19
|
+
|
|
20
|
+
/** 当前共享实例(引用计数 > 0 时存在)。 */
|
|
21
|
+
let shared = null
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 取得共享追踪器租约。返回 { tracker, release }:
|
|
25
|
+
* 每个使用方在 setup 时 acquire、在 dispose 时 release(必须成对,否则追踪不会退订)。
|
|
26
|
+
*/
|
|
27
|
+
export function acquireTaskTracker(ctx) {
|
|
28
|
+
if (!shared) shared = { refs: 0, tracker: createTracker(ctx) }
|
|
29
|
+
const slot = shared
|
|
30
|
+
slot.refs += 1
|
|
31
|
+
let released = false
|
|
32
|
+
return {
|
|
33
|
+
tracker: slot.tracker,
|
|
34
|
+
release() {
|
|
35
|
+
if (released) return
|
|
36
|
+
released = true
|
|
37
|
+
slot.refs -= 1
|
|
38
|
+
if (slot.refs > 0) return
|
|
39
|
+
if (shared === slot) shared = null
|
|
40
|
+
try { slot.tracker.dispose() } catch { /* 退订失败不影响功能停用 */ }
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createTracker(ctx) {
|
|
46
|
+
// ===== 会话级任务追踪(agent/status 驱动开始/结束,session/event 补统计) =====
|
|
47
|
+
const activeSessions = new Map() // sessionId -> 追踪记录
|
|
48
|
+
const completedSessions = [] // 最近完成(新→旧)
|
|
49
|
+
const finishListeners = new Set()
|
|
50
|
+
const disposers = []
|
|
51
|
+
|
|
52
|
+
function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0 }
|
|
53
|
+
|
|
54
|
+
function extractText(content) {
|
|
55
|
+
if (!Array.isArray(content)) return ''
|
|
56
|
+
const parts = []
|
|
57
|
+
for (const block of content) {
|
|
58
|
+
if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
59
|
+
}
|
|
60
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncate(str, max = 160) {
|
|
64
|
+
if (!str) return ''
|
|
65
|
+
return String(str).length > max ? String(str).slice(0, max) + '…' : str
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 工具名 → 阶段:检索类工具 = search,其余 = code
|
|
69
|
+
function phaseOfToolName(name) {
|
|
70
|
+
const n = String(name || '').toLowerCase()
|
|
71
|
+
if (/web|search|fetch|grep|glob|find|read|ls$|^ls|tree|locate/.test(n)) return 'search'
|
|
72
|
+
return 'code'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 会话标题:官方标题服务,取不到回退首条用户输入(firstPrompt)
|
|
76
|
+
async function getSessionTitle(sessionId, sessionQuery) {
|
|
77
|
+
try {
|
|
78
|
+
if (!sessionQuery) return ''
|
|
79
|
+
if (typeof sessionQuery.readTitle === 'function') {
|
|
80
|
+
const snap = await sessionQuery.readTitle(sessionId)
|
|
81
|
+
if (snap && snap.title) return snap.title
|
|
82
|
+
}
|
|
83
|
+
if (typeof sessionQuery.readSession === 'function') {
|
|
84
|
+
const snap = await sessionQuery.readSession(sessionId)
|
|
85
|
+
if (snap && snap.header && snap.header.title) return snap.header.title
|
|
86
|
+
}
|
|
87
|
+
} catch { /* 标题取不到就用 firstPrompt 兜底 */ }
|
|
88
|
+
return ''
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function addActiveSession(sid, sessionQuery, extra = {}) {
|
|
92
|
+
if (activeSessions.has(sid)) return activeSessions.get(sid)
|
|
93
|
+
console.log('[dsh-dock] task-track: task started', sid.substring(0, 8))
|
|
94
|
+
const record = {
|
|
95
|
+
startTime: Date.now(),
|
|
96
|
+
title: '',
|
|
97
|
+
turns: 0,
|
|
98
|
+
steps: 0,
|
|
99
|
+
toolCalls: 0,
|
|
100
|
+
models: new Set(),
|
|
101
|
+
provider: '',
|
|
102
|
+
inputTokens: 0,
|
|
103
|
+
outputTokens: 0,
|
|
104
|
+
totalTokens: 0,
|
|
105
|
+
reasoningTokens: 0,
|
|
106
|
+
cacheReadTokens: 0,
|
|
107
|
+
endReason: '',
|
|
108
|
+
errorMessage: '',
|
|
109
|
+
lastText: '',
|
|
110
|
+
firstPrompt: '',
|
|
111
|
+
// 工具等待用户确认(dsh 会话流 approval/asked):浏览器侧据此弹"需确认"通知
|
|
112
|
+
approvals: new Map(), // approvalId -> { toolName, reason, askedAt }
|
|
113
|
+
phase: 'think',
|
|
114
|
+
phaseAt: Date.now(),
|
|
115
|
+
lastActivityAt: Date.now(),
|
|
116
|
+
// assistant/chunk 是实时流;用轻量活动计数给浏览器侧映射动作速度,
|
|
117
|
+
// 不等最终 usage 落盘,避免任务正在输出时机器人仍慢速。
|
|
118
|
+
motionTicks: 0,
|
|
119
|
+
lastMotionAt: Date.now(),
|
|
120
|
+
...extra,
|
|
121
|
+
}
|
|
122
|
+
activeSessions.set(sid, record)
|
|
123
|
+
getSessionTitle(sid, sessionQuery).then((title) => {
|
|
124
|
+
const s = activeSessions.get(sid)
|
|
125
|
+
if (s) s.title = title
|
|
126
|
+
})
|
|
127
|
+
return record
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 任务结束:归档 + 通知订阅者(浏览器侧通知/群机器人推送都由各自消费方决定怎么用)
|
|
131
|
+
function finishSession(sid) {
|
|
132
|
+
const session = activeSessions.get(sid)
|
|
133
|
+
if (!session) return
|
|
134
|
+
activeSessions.delete(sid)
|
|
135
|
+
const endTime = Date.now()
|
|
136
|
+
const duration = endTime - session.startTime
|
|
137
|
+
console.log('[dsh-dock] task-track: task finished', {
|
|
138
|
+
sid: sid.substring(0, 8),
|
|
139
|
+
duration,
|
|
140
|
+
endReason: session.endReason || 'completed',
|
|
141
|
+
})
|
|
142
|
+
const record = {
|
|
143
|
+
sessionId: sid,
|
|
144
|
+
title: session.title || truncate(session.firstPrompt, 60) || '(无标题)',
|
|
145
|
+
duration,
|
|
146
|
+
turns: session.turns,
|
|
147
|
+
steps: session.steps,
|
|
148
|
+
toolCalls: session.toolCalls,
|
|
149
|
+
models: [...session.models],
|
|
150
|
+
provider: session.provider,
|
|
151
|
+
inputTokens: session.inputTokens,
|
|
152
|
+
outputTokens: session.outputTokens,
|
|
153
|
+
totalTokens: session.totalTokens,
|
|
154
|
+
reasoningTokens: session.reasoningTokens,
|
|
155
|
+
cacheReadTokens: session.cacheReadTokens,
|
|
156
|
+
endReason: session.endReason,
|
|
157
|
+
errorMessage: session.errorMessage,
|
|
158
|
+
lastText: session.lastText,
|
|
159
|
+
startTime: session.startTime,
|
|
160
|
+
endTime,
|
|
161
|
+
}
|
|
162
|
+
completedSessions.unshift(record)
|
|
163
|
+
if (completedSessions.length > MAX_COMPLETED) completedSessions.pop()
|
|
164
|
+
for (const fn of finishListeners) {
|
|
165
|
+
try { fn(record) } catch (e) {
|
|
166
|
+
console.error('[dsh-dock] task-track finish listener error:', e && e.message)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function handleSessionEvent(sessionQuery, session, event) {
|
|
172
|
+
const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
|
|
173
|
+
if (!sid || !event || !event.type) return
|
|
174
|
+
|
|
175
|
+
if (event.type === 'step/start') {
|
|
176
|
+
const s = addActiveSession(sid, sessionQuery, { startTime: event.time || Date.now() })
|
|
177
|
+
s.steps++
|
|
178
|
+
s.phase = 'think' // 新步骤:模型正在推理下一步
|
|
179
|
+
s.phaseAt = event.time || Date.now()
|
|
180
|
+
s.lastActivityAt = s.phaseAt
|
|
181
|
+
s.motionTicks += 2
|
|
182
|
+
s.lastMotionAt = s.phaseAt
|
|
183
|
+
if (event.data && event.data.turn !== undefined) {
|
|
184
|
+
s.turns = Math.max(s.turns, num(event.data.turn) + 1)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
const s = activeSessions.get(sid)
|
|
189
|
+
if (!s) return
|
|
190
|
+
const now = event.time || Date.now()
|
|
191
|
+
// 流级阶段:chunk 高频到达(每 token 一条),只做最廉价的阶段切换
|
|
192
|
+
if (event.type === 'assistant/chunk') {
|
|
193
|
+
const chunk = event.data && event.data.chunk
|
|
194
|
+
const ctype = chunk && chunk.type
|
|
195
|
+
if (ctype === 'reasoning-delta') {
|
|
196
|
+
s.phase = 'think'
|
|
197
|
+
s.phaseAt = now
|
|
198
|
+
} else if (ctype === 'text-delta') {
|
|
199
|
+
s.phase = 'write'
|
|
200
|
+
s.phaseAt = now
|
|
201
|
+
}
|
|
202
|
+
// 不同适配器的 delta 字段名并不完全一致。按可见文本量估算,
|
|
203
|
+
// 无文本时每个 chunk 也记一个脉冲,保证推理流同样有动作反馈。
|
|
204
|
+
if (ctype === 'reasoning-delta' || ctype === 'text-delta') {
|
|
205
|
+
const delta = chunk && (chunk.text || chunk.delta || chunk.content)
|
|
206
|
+
const units = typeof delta === 'string' ? Math.max(1, Math.ceil(delta.length / 24)) : 1
|
|
207
|
+
s.motionTicks += units
|
|
208
|
+
s.lastMotionAt = now
|
|
209
|
+
}
|
|
210
|
+
s.lastActivityAt = now
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
if (event.type === 'turn/end' && event.data && event.data.reason) {
|
|
214
|
+
const reason = event.data.reason
|
|
215
|
+
s.endReason = (reason.kind || 'completed')
|
|
216
|
+
if (reason.kind === 'error' && reason.error) {
|
|
217
|
+
const msg = (reason.error && (reason.error.message || reason.error.code)) || ''
|
|
218
|
+
s.errorMessage = typeof msg === 'string' ? msg : String(msg)
|
|
219
|
+
}
|
|
220
|
+
} else if (event.type === 'approval/asked' && event.data) {
|
|
221
|
+
// 工具等待用户确认/批准:挂在会话记录上,/status 下发给浏览器弹"需确认"通知
|
|
222
|
+
const id = typeof event.data.id === 'string' ? event.data.id : ''
|
|
223
|
+
if (id) {
|
|
224
|
+
s.approvals.set(id, {
|
|
225
|
+
toolName: typeof event.data.toolName === 'string' ? event.data.toolName : '',
|
|
226
|
+
reason: typeof event.data.reason === 'string' ? truncate(event.data.reason, 200) : '',
|
|
227
|
+
askedAt: event.time || Date.now(),
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
s.lastActivityAt = now
|
|
231
|
+
} else if (event.type === 'approval/decided' && event.data) {
|
|
232
|
+
s.approvals.delete(typeof event.data.id === 'string' ? event.data.id : '')
|
|
233
|
+
} else if (event.type === 'tool/call') {
|
|
234
|
+
s.toolCalls++
|
|
235
|
+
s.phase = phaseOfToolName(event.data && event.data.name)
|
|
236
|
+
s.phaseAt = now
|
|
237
|
+
// 工具启动是稀疏事件,给一个更明显的短促动作,检索/写文件也不会显得停住。
|
|
238
|
+
s.motionTicks += s.phase === 'search' ? 7 : 9
|
|
239
|
+
s.lastMotionAt = now
|
|
240
|
+
} else if (event.type === 'user/message') {
|
|
241
|
+
if (!s.firstPrompt && event.data && event.data.content) {
|
|
242
|
+
const text = extractText(event.data.content)
|
|
243
|
+
if (text) s.firstPrompt = text
|
|
244
|
+
}
|
|
245
|
+
} else if (event.type === 'assistant/message' && event.data) {
|
|
246
|
+
const usage = event.data.usage
|
|
247
|
+
if (usage) {
|
|
248
|
+
s.inputTokens += num(usage.inputTokens)
|
|
249
|
+
s.outputTokens += num(usage.outputTokens)
|
|
250
|
+
s.totalTokens += num(usage.inputTokens) + num(usage.outputTokens)
|
|
251
|
+
s.reasoningTokens += num(usage.reasoningTokens)
|
|
252
|
+
s.cacheReadTokens += num(usage.cacheReadTokens)
|
|
253
|
+
}
|
|
254
|
+
if (event.data.message && event.data.message.content) {
|
|
255
|
+
const text = extractText(event.data.message.content)
|
|
256
|
+
if (text) s.lastText = text
|
|
257
|
+
}
|
|
258
|
+
// 完整消息落盘后不改阶段:下一步要么 tool/call(code/search)要么收尾,
|
|
259
|
+
// 维持 chunk 推导出的最后状态,避免"输完还显示思考中"的回跳
|
|
260
|
+
} else if (event.type === 'request/context' && event.data) {
|
|
261
|
+
if (event.data.provider) s.provider = event.data.provider
|
|
262
|
+
if (event.data.model) s.models.add(event.data.model)
|
|
263
|
+
} else if (event.type === 'request/header' && event.data && event.data.header && event.data.header.config) {
|
|
264
|
+
const c = event.data.header.config
|
|
265
|
+
if (c.provider) s.provider = c.provider
|
|
266
|
+
if (c.model) s.models.add(c.model)
|
|
267
|
+
}
|
|
268
|
+
if (event.type !== 'request/header' && event.type !== 'request/context') s.lastActivityAt = now
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function handleAgentStatus(sessionQuery, agent, status) {
|
|
272
|
+
const sid = agent && agent.id ? String(agent.id) : ''
|
|
273
|
+
if (!sid) return
|
|
274
|
+
if (status === 'running') {
|
|
275
|
+
const record = addActiveSession(sid, sessionQuery)
|
|
276
|
+
if (agent.options) {
|
|
277
|
+
if (agent.options.model) record.models.add(agent.options.model)
|
|
278
|
+
if (agent.options.provider) record.provider = agent.options.provider
|
|
279
|
+
}
|
|
280
|
+
} else if (status === 'idle') {
|
|
281
|
+
finishSession(sid)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 不能在 setup 时软获取 sessionQuery(可能早于服务激活,见 tokenlog 模块同款注释),用 inject 等就绪
|
|
286
|
+
disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
|
|
287
|
+
const sessionQuery = sqCtx.sessionQuery
|
|
288
|
+
disposers.push(sqCtx.on('session/event', (session, event) => {
|
|
289
|
+
try {
|
|
290
|
+
handleSessionEvent(sessionQuery, session, event)
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error('[dsh-dock] task-track handle event error', err)
|
|
293
|
+
}
|
|
294
|
+
}))
|
|
295
|
+
disposers.push(sqCtx.on('agent/status', (payload) => {
|
|
296
|
+
try {
|
|
297
|
+
handleAgentStatus(sessionQuery, payload && payload.agent, payload && payload.status)
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error('[dsh-dock] task-track handle agent/status error', err)
|
|
300
|
+
}
|
|
301
|
+
}))
|
|
302
|
+
// agent 销毁兜底归档(会话直接关闭时也产出完成记录)
|
|
303
|
+
disposers.push(sqCtx.on('agent/disposed', (payload) => {
|
|
304
|
+
try {
|
|
305
|
+
const sid = payload && payload.agent && payload.agent.id ? String(payload.agent.id) : ''
|
|
306
|
+
if (sid) finishSession(sid)
|
|
307
|
+
} catch (err) {
|
|
308
|
+
console.error('[dsh-dock] task-track handle agent/disposed error', err)
|
|
309
|
+
}
|
|
310
|
+
}))
|
|
311
|
+
}))
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
/** 当前快照(活跃任务 + 最近完成),供各功能模块的 /status 路由下发。 */
|
|
315
|
+
snapshot(now) {
|
|
316
|
+
const at = typeof now === 'number' ? now : Date.now()
|
|
317
|
+
const active = []
|
|
318
|
+
for (const [sid, s] of activeSessions) {
|
|
319
|
+
active.push({
|
|
320
|
+
sessionId: sid,
|
|
321
|
+
title: s.title || truncate(s.firstPrompt, 60) || '(无标题)',
|
|
322
|
+
startTime: s.startTime,
|
|
323
|
+
elapsed: at - s.startTime,
|
|
324
|
+
turns: s.turns,
|
|
325
|
+
steps: s.steps,
|
|
326
|
+
toolCalls: s.toolCalls,
|
|
327
|
+
models: [...s.models],
|
|
328
|
+
provider: s.provider,
|
|
329
|
+
inputTokens: s.inputTokens,
|
|
330
|
+
outputTokens: s.outputTokens,
|
|
331
|
+
totalTokens: s.totalTokens,
|
|
332
|
+
phase: s.phase,
|
|
333
|
+
phaseAt: s.phaseAt,
|
|
334
|
+
lastActivityAt: s.lastActivityAt,
|
|
335
|
+
motionTicks: s.motionTicks,
|
|
336
|
+
lastMotionAt: s.lastMotionAt,
|
|
337
|
+
approvals: [...s.approvals.entries()].map(([id, v]) => ({ id, ...v })),
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
return { now: at, active, recent: completedSessions.slice(0, 20) }
|
|
341
|
+
},
|
|
342
|
+
/** 任务结束订阅(返回退订函数);异常不会打断其他订阅者。 */
|
|
343
|
+
onFinish(fn) {
|
|
344
|
+
if (typeof fn !== 'function') return () => {}
|
|
345
|
+
finishListeners.add(fn)
|
|
346
|
+
return () => { finishListeners.delete(fn) }
|
|
347
|
+
},
|
|
348
|
+
/** 退订会话事件(引用计数归零时由 acquireTaskTracker 调用)。 */
|
|
349
|
+
dispose() {
|
|
350
|
+
finishListeners.clear()
|
|
351
|
+
activeSessions.clear()
|
|
352
|
+
completedSessions.length = 0
|
|
353
|
+
while (disposers.length > 0) {
|
|
354
|
+
const fn = disposers.pop()
|
|
355
|
+
try { if (typeof fn === 'function') fn() } catch { /* 退订失败不阻断 */ }
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
}
|