dsh-activity-pane 0.1.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/.dsh-plugin/client.js +5305 -0
- package/.dsh-plugin/index.mjs +5 -0
- package/LICENSE +38 -0
- package/README.md +41 -0
- package/cordis.patch.yml +7 -0
- package/package.json +68 -0
- package/scripts/acceptance.mjs +203 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-client.mjs +46 -0
- package/scripts/check-staged-client.mjs +47 -0
- package/scripts/check.mjs +3536 -0
- package/scripts/watch.mjs +43 -0
- package/src/client.mjs +3269 -0
- package/src/core.mjs +1933 -0
- package/src/host.mjs +207 -0
- package/src/navigation.mjs +91 -0
package/src/host.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// dsh-activity-pane 宿主侧:完成确认与错误提醒状态(R-01-002/AC-03、AC-05、AC-10~AC-13、R-01-010/AC-06,C-030、C-043)。
|
|
2
|
+
//
|
|
3
|
+
// 职责:
|
|
4
|
+
// 1. 订阅 `session/event` 的 `turn/end`,把事件顶层 `time` 登记为会话的 `lastTurnEnd`,
|
|
5
|
+
// 并登记回合结束原因 `lastTurnEndKind`(取 `data.reason.kind`,缺失/非法归一
|
|
6
|
+
// `'unknown'`)与 error 回合的错误信息 `lastTurnEndError`(截断至 ERROR_NOTE_MAX);
|
|
7
|
+
// 2. `POST /dsh-activity-pane/api/ack` 写回 `ackedAt`;
|
|
8
|
+
// 3. `GET /dsh-activity-pane/api/acks` 全量快照;`GET /dsh-activity-pane/api/acks/stream` SSE 推送
|
|
9
|
+
// (连接即发全量、变更即广播)。
|
|
10
|
+
//
|
|
11
|
+
// 持久化:storageDomain 声明式 domain 表 `acks`(sessionId → { lastTurnEnd, lastTurnEndKind,
|
|
12
|
+
// lastTurnEndError, ackedAt })。
|
|
13
|
+
// 设计约束(C-030):不写会话日志、不依赖 sessionProjections、不引入客户端轮询;
|
|
14
|
+
// 完成提醒成立 = 主会话 && 非 running && 无阻塞等待 && 非委托周期 && lastTurnEnd > ackedAt,
|
|
15
|
+
// 错误提醒成立 = 主会话 && 非 running && 无阻塞等待 && 非委托周期 && lastTurnEndKind === 'error'
|
|
16
|
+
// (不消费 ack 游标,C-043),判定在客户端纯函数完成,本侧只维护持久事实与广播。
|
|
17
|
+
|
|
18
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
19
|
+
import { z } from 'zod'
|
|
20
|
+
import { truncateErrorNote } from './core.mjs'
|
|
21
|
+
|
|
22
|
+
export const name = 'dsh-activity-pane'
|
|
23
|
+
export const inject = ['storageDomain', 'webServer']
|
|
24
|
+
|
|
25
|
+
const API_PATH = '/dsh-activity-pane/api'
|
|
26
|
+
|
|
27
|
+
/** 每会话完成/错误登记:lastTurnEnd 最后回合结束时刻(事件 time,毫秒)、
|
|
28
|
+
* lastTurnEndKind 回合结束原因(completed/blocked/max-tokens/aborted/error/unknown)、
|
|
29
|
+
* lastTurnEndError error 回合的错误信息(截断,非 error 回合为 null)、ackedAt 确认时刻。
|
|
30
|
+
* 字段全部可选/可空:容纳升级前仅含 { lastTurnEnd, ackedAt } 的旧记录——
|
|
31
|
+
* dsh-storage-domain 打开时对每条记录做 valueSchema.parse,缺失必填键会以 invalid-record
|
|
32
|
+
* 使整个 domain 打开失败、登记与确认写回永久挂起(C-043 Spec 轴审核发现);写入侧恒写全字段。 */
|
|
33
|
+
const ackRecord = z.object({
|
|
34
|
+
lastTurnEnd: z.number().nullable().optional(),
|
|
35
|
+
lastTurnEndKind: z.string().nullable().optional(),
|
|
36
|
+
lastTurnEndError: z.string().nullable().optional(),
|
|
37
|
+
ackedAt: z.number().nullable().optional(),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const domainSpec = defineDomain({
|
|
41
|
+
name: 'dsh_activity_pane',
|
|
42
|
+
version: 1,
|
|
43
|
+
global: { schema: z.object({}), initial: {} },
|
|
44
|
+
tables: {
|
|
45
|
+
acks: domainTable(ackRecord),
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/** 从请求流读取 JSON body;空 body 返回 null。 */
|
|
50
|
+
function readJsonBody(req) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const chunks = []
|
|
53
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
54
|
+
req.on('end', () => {
|
|
55
|
+
if (chunks.length === 0) return resolve(null)
|
|
56
|
+
try {
|
|
57
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
58
|
+
} catch {
|
|
59
|
+
resolve(null)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
req.on('error', () => resolve(null))
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function apply(ctx) {
|
|
67
|
+
// domain 就绪 Promise:路由与事件登记都先等它(storageDomain.open 是异步的)。
|
|
68
|
+
let resolveDomain = null
|
|
69
|
+
const domainReady = new Promise((resolve) => {
|
|
70
|
+
resolveDomain = resolve
|
|
71
|
+
})
|
|
72
|
+
let acks = null // KvTable: sessionId -> { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt }
|
|
73
|
+
/** SSE 连接集合:广播时逐个写 `event: state`。 */
|
|
74
|
+
const streamClients = new Set()
|
|
75
|
+
|
|
76
|
+
ctx.inject(['storageDomain'], async (domainCtx) => {
|
|
77
|
+
const domain = await domainCtx.storageDomain.open(domainSpec)
|
|
78
|
+
ctx.effect(() => () => domain.close(), 'dsh-activity-pane: domainClose')
|
|
79
|
+
acks = domain.table('acks')
|
|
80
|
+
resolveDomain()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/** 全量快照:{ [sessionId]: { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt } }。
|
|
84
|
+
* acks 未就绪时返回空对象。旧记录缺新字段时按 null/undefined 下发(客户端判定恒安全)。 */
|
|
85
|
+
function snapshot() {
|
|
86
|
+
const out = {}
|
|
87
|
+
if (acks === null) return out
|
|
88
|
+
for (const [id, record] of acks.entries()) out[id] = record
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 向全部 SSE 连接广播当前全量状态;写失败(连接已断)的连接直接移除。 */
|
|
93
|
+
function broadcast() {
|
|
94
|
+
const data = `event: state\ndata: ${JSON.stringify(snapshot())}\n\n`
|
|
95
|
+
for (const res of streamClients) {
|
|
96
|
+
try {
|
|
97
|
+
res.write(data)
|
|
98
|
+
} catch {
|
|
99
|
+
streamClients.delete(res)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// turn/end 登记(R-01-002/AC-03、AC-11~AC-13、C-043):事件按会话序提交,get+put 无竞态;
|
|
105
|
+
// 保留既有 ackedAt(回合更替不清确认游标),lastTurnEnd 前移即让旧提醒失效、新提醒成立;
|
|
106
|
+
// 同回合登记结束原因与错误信息(错误提醒的持久事实来源)。
|
|
107
|
+
ctx.on('session/event', async (session, event) => {
|
|
108
|
+
if (event?.type !== 'turn/end') return
|
|
109
|
+
const time = Number(event.time)
|
|
110
|
+
if (!Number.isFinite(time)) return
|
|
111
|
+
const id = String(session?.id ?? '')
|
|
112
|
+
if (id === '') return
|
|
113
|
+
try {
|
|
114
|
+
await domainReady
|
|
115
|
+
const reason = event.data && typeof event.data === 'object' && event.data.reason && typeof event.data.reason === 'object'
|
|
116
|
+
? event.data.reason
|
|
117
|
+
: null
|
|
118
|
+
const kind = typeof reason?.kind === 'string' ? reason.kind : 'unknown'
|
|
119
|
+
// 错误信息契约仅为字符串(agent-loop 的 LlmError failure.message);非字符串不展示,
|
|
120
|
+
// 避免界面出现 "[object Object]"(C-043 复审收紧)。
|
|
121
|
+
const errorMessage = kind === 'error' && typeof reason?.error?.message === 'string' ? reason.error.message : ''
|
|
122
|
+
const current = acks.get(id)
|
|
123
|
+
await acks.put(id, {
|
|
124
|
+
lastTurnEnd: time,
|
|
125
|
+
lastTurnEndKind: kind,
|
|
126
|
+
lastTurnEndError: kind === 'error' && errorMessage !== '' ? truncateErrorNote(errorMessage) : null,
|
|
127
|
+
ackedAt: current?.ackedAt ?? null,
|
|
128
|
+
})
|
|
129
|
+
broadcast()
|
|
130
|
+
} catch (error) {
|
|
131
|
+
ctx.logger?.warn?.(`dsh-activity-pane: turn/end 登记失败(${id}): ${String(error)}`)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// HTTP API(前缀挂载,handler 内按子路径分发)。
|
|
136
|
+
ctx.webServer.register({
|
|
137
|
+
path: API_PATH,
|
|
138
|
+
handler(req, res) {
|
|
139
|
+
const url = new URL(req.url || '/', 'http://dsh-activity-pane')
|
|
140
|
+
const route = url.pathname.slice(API_PATH.length)
|
|
141
|
+
const method = req.method || 'GET'
|
|
142
|
+
if (route === '/acks' && method === 'GET') {
|
|
143
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
144
|
+
res.end(JSON.stringify(snapshot()))
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (route === '/acks/stream' && method === 'GET') {
|
|
148
|
+
// SSE:连接即发全量快照,此后每次变更广播;浏览器 EventSource 自动重连,
|
|
149
|
+
// 重连时再收一次全量,状态必然收敛(R-01-002/AC-12)。
|
|
150
|
+
res.writeHead(200, {
|
|
151
|
+
'Content-Type': 'text/event-stream',
|
|
152
|
+
'Cache-Control': 'no-cache',
|
|
153
|
+
Connection: 'keep-alive',
|
|
154
|
+
})
|
|
155
|
+
const hello = `event: state\ndata: ${JSON.stringify(snapshot())}\n\n`
|
|
156
|
+
res.write(hello)
|
|
157
|
+
streamClients.add(res)
|
|
158
|
+
const remove = () => streamClients.delete(res)
|
|
159
|
+
req.on('close', remove)
|
|
160
|
+
res.on('close', remove)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (route === '/ack' && method === 'POST') {
|
|
164
|
+
readJsonBody(req).then(async (body) => {
|
|
165
|
+
const sessionId = body !== null && typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
166
|
+
if (sessionId === '') {
|
|
167
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
168
|
+
res.end(JSON.stringify({ ok: false, error: 'sessionId 缺失或非法' }))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await domainReady
|
|
173
|
+
const current = acks.get(sessionId)
|
|
174
|
+
// 确认写回只动 ackedAt:保留回合结束时刻、结束原因与错误信息(C-043)。
|
|
175
|
+
await acks.put(sessionId, {
|
|
176
|
+
lastTurnEnd: current?.lastTurnEnd ?? null,
|
|
177
|
+
lastTurnEndKind: current?.lastTurnEndKind ?? null,
|
|
178
|
+
lastTurnEndError: current?.lastTurnEndError ?? null,
|
|
179
|
+
ackedAt: Date.now(),
|
|
180
|
+
})
|
|
181
|
+
broadcast()
|
|
182
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
183
|
+
res.end(JSON.stringify({ ok: true }))
|
|
184
|
+
} catch (error) {
|
|
185
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
186
|
+
res.end(JSON.stringify({ ok: false, error: String(error) }))
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
192
|
+
res.end(JSON.stringify({ ok: false, error: 'not found' }))
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// 卸载:关闭全部 SSE 连接;domain 由 ctx.effect 关闭。
|
|
197
|
+
return () => {
|
|
198
|
+
for (const res of streamClients) {
|
|
199
|
+
try {
|
|
200
|
+
res.end()
|
|
201
|
+
} catch {
|
|
202
|
+
/* 连接已断 */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
streamClients.clear()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 为单张 card 绑定点击/键盘激活,并返回卸载函数。
|
|
3
|
+
* 事件处理读取 card 当前的 data-session-id,兼容同一 DOM 在渲染中复用。
|
|
4
|
+
*/
|
|
5
|
+
export function bindCardActivation(card, open) {
|
|
6
|
+
if (typeof card?.addEventListener !== "function" || typeof open !== "function")
|
|
7
|
+
return () => {};
|
|
8
|
+
const activate = (event) => {
|
|
9
|
+
if (event.type === "keydown" && event.key !== "Enter" && event.key !== " ") return;
|
|
10
|
+
if (event.type !== "click" && event.type !== "keydown") return;
|
|
11
|
+
const currentCard = event.currentTarget ?? card;
|
|
12
|
+
const sessionId = currentCard?.dataset?.sessionId;
|
|
13
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
14
|
+
event.preventDefault?.();
|
|
15
|
+
event.stopPropagation?.();
|
|
16
|
+
open(sessionId);
|
|
17
|
+
};
|
|
18
|
+
card.addEventListener("click", activate);
|
|
19
|
+
card.addEventListener("keydown", activate);
|
|
20
|
+
return () => {
|
|
21
|
+
card.removeEventListener?.("click", activate);
|
|
22
|
+
card.removeEventListener?.("keydown", activate);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 判定卡片激活是否转为收起移动端抽屉(R-01-008/AC-06):移动断点内抽屉打开、
|
|
28
|
+
* 且激活目标已是当前会话时,激活不再发起切换(避免无意义 open 与重试链),
|
|
29
|
+
* 改为收起抽屉直达会话。纯函数,无 DOM 假设。
|
|
30
|
+
*/
|
|
31
|
+
export function shouldDismissDrawerOnActivation({ targetId, currentId, mobile, drawerOpen } = {}) {
|
|
32
|
+
if (mobile !== true || drawerOpen !== true) return false;
|
|
33
|
+
return typeof targetId === "string" && targetId !== "" && targetId === currentId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 调用 DSH 原生会话导航;由调用方决定失败后的 refresh/retry 策略。
|
|
38
|
+
* 不读取 sessions.list,避免用另一份可能已过期的快照拦截跳转。
|
|
39
|
+
*/
|
|
40
|
+
export function openSession(sessions, sessionId) {
|
|
41
|
+
if (typeof sessions?.open !== "function") return false;
|
|
42
|
+
try {
|
|
43
|
+
sessions.open(sessionId);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 原生会话输入框(dsh-client-ui-conversation composer 的 textarea)。 */
|
|
51
|
+
export const COMPOSER_SELECTOR = "textarea[data-phase]";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 抑制切换会话后原生 composer 的自动聚焦(R-01-005/AC-01 移动端回归)。
|
|
55
|
+
* dsh-client-ui-conversation 的 composer 在 sessionId 变化的 effect 里
|
|
56
|
+
* focus 输入框(桌面便于立即输入);移动视口下该聚焦会弹出软键盘遮挡会话。
|
|
57
|
+
* open 成功后立即 blur 一次(覆盖 composer 已持焦的情形),并在短暂窗口内
|
|
58
|
+
* 以捕获阶段 focusin 拦截随后的自动聚焦;窗口外用户主动聚焦不受影响。
|
|
59
|
+
*/
|
|
60
|
+
export function suppressComposerAutofocus(doc, scheduleTimeout = setTimeout, windowMs = 1200) {
|
|
61
|
+
if (typeof doc?.addEventListener !== "function") return;
|
|
62
|
+
const blurComposer = (el) => {
|
|
63
|
+
if (typeof el?.matches === "function" && el.matches(COMPOSER_SELECTOR)) el.blur?.();
|
|
64
|
+
};
|
|
65
|
+
blurComposer(doc.activeElement);
|
|
66
|
+
const onFocusIn = (event) => blurComposer(event?.target);
|
|
67
|
+
doc.addEventListener("focusin", onFocusIn, true);
|
|
68
|
+
scheduleTimeout(() => doc.removeEventListener?.("focusin", onFocusIn, true), windowMs);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 为移动端抽屉的透明遮罩绑定点击收起,并返回卸载函数。
|
|
73
|
+
* 抽屉与浮动开关位于遮罩之上,能到达遮罩的点击必然来自抽屉外部,
|
|
74
|
+
* 无需 contains 判定。触摸轻点经浏览器 tap→click 合成事件覆盖
|
|
75
|
+
* (与浮动开关、× 与卡片的既有交互一致,故仅绑 click),
|
|
76
|
+
* 不额外绑 touch 事件以避免双触发与滑动误收起(R-01-008/AC-03)。
|
|
77
|
+
*/
|
|
78
|
+
export function bindBackdropDismiss(backdrop, dismiss) {
|
|
79
|
+
if (typeof backdrop?.addEventListener !== "function" || typeof dismiss !== "function")
|
|
80
|
+
return () => {};
|
|
81
|
+
const onBackdropClick = (event) => {
|
|
82
|
+
if (event.type !== "click") return;
|
|
83
|
+
event.preventDefault?.();
|
|
84
|
+
event.stopPropagation?.();
|
|
85
|
+
dismiss();
|
|
86
|
+
};
|
|
87
|
+
backdrop.addEventListener("click", onBackdropClick);
|
|
88
|
+
return () => {
|
|
89
|
+
backdrop.removeEventListener?.("click", onBackdropClick);
|
|
90
|
+
};
|
|
91
|
+
}
|