@chenkai114/dsh-team-admin 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenkai114/dsh-team-admin",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "DSH 插件:GUI 管理任意会话的智能体团队 —— 查看成员、删除角色并清理其持久会话(进回收站,可恢复)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -55,4 +55,4 @@
55
55
  "test:real": "node tests/e2e/real-data.mjs",
56
56
  "test:pack": "npm pack --dry-run"
57
57
  }
58
- }
58
+ }
package/src/client.js CHANGED
@@ -172,6 +172,7 @@
172
172
  const DISMISS_URL = API_BASE + '/dismiss'
173
173
  const DISMISSED_URL = API_BASE + '/dismissed'
174
174
  const LIST_FILTER_FLAG = '__dshTeamAdminSessionListFilter__'
175
+ const RESPONSE_FILTER_FLAG = '__dshTeamAdminResponseFilter__'
175
176
 
176
177
  function dismissPayload(sessionId, name) {
177
178
  return { sessionId: sessionId, name: name, deleteSession: true }
@@ -215,6 +216,36 @@
215
216
  return dismissedIds
216
217
  }
217
218
 
219
+ // 宿主可能在插件加载前就把全局 fetch 抓进闭包,包装 window.fetch 会被绕过;
220
+ // 再补一层 Response.prototype 过滤,任何解析 JSON 的消费方都会拿到过滤后的数据。
221
+ function installResponseFilter(scope) {
222
+ const ResponseCtor = scope === null || scope === undefined ? undefined : scope.Response
223
+ if (typeof ResponseCtor !== 'function') return false
224
+ const proto = ResponseCtor.prototype
225
+ if (proto === undefined || proto === null || proto[RESPONSE_FILTER_FLAG] === true) return false
226
+ proto[RESPONSE_FILTER_FLAG] = true
227
+ const originalJson = proto.json
228
+ const originalText = proto.text
229
+ if (typeof originalJson === 'function') {
230
+ proto.json = async function teamAdminAwareJson() {
231
+ const value = await originalJson.call(this)
232
+ try { return stripDismissedCatalogs(value, dismissedIds) } catch (error) { return value }
233
+ }
234
+ }
235
+ if (typeof originalText === 'function') {
236
+ proto.text = async function teamAdminAwareText() {
237
+ const text = await originalText.call(this)
238
+ if (typeof text !== 'string' || (text.indexOf('subagentCatalog') < 0 && text.indexOf('agentTeam') < 0)) return text
239
+ try {
240
+ const value = JSON.parse(text)
241
+ stripDismissedCatalogs(value, dismissedIds)
242
+ return JSON.stringify(value)
243
+ } catch (error) { return text }
244
+ }
245
+ }
246
+ return true
247
+ }
248
+
218
249
  function installSessionListFilter(win) {
219
250
  const scope = win !== undefined ? win : (typeof window === 'undefined' ? null : window)
220
251
  if (scope === null || scope === undefined) return false
@@ -225,10 +256,12 @@
225
256
  const original = scope.fetch
226
257
  if (typeof original !== 'function') return false
227
258
  scope[LIST_FILTER_FLAG] = true
259
+ installResponseFilter(scope)
228
260
  dismissedReady = refreshDismissedIds()
229
261
  scope.fetch = async function teamAdminAwareFetch(input, init) {
230
262
  const url = typeof input === 'string' ? input : (isRecord(input) && typeof input.url === 'string' ? input.url : '')
231
- if (url.indexOf('/api/session/list') < 0) return await original(input, init)
263
+ // 宿主用相对路径(api/session/list,无前导 /),按子串匹配兼容两种形态。
264
+ if (url.indexOf('api/session/list') < 0) return await original(input, init)
232
265
  if (dismissedReady !== null) await dismissedReady
233
266
  const response = await original(input, init)
234
267
  if (dismissedIds.size === 0 || response.ok !== true) return response
@@ -1053,6 +1086,7 @@
1053
1086
  DISMISSED_URL: DISMISSED_URL,
1054
1087
  stripDismissedCatalogs: stripDismissedCatalogs,
1055
1088
  installSessionListFilter: installSessionListFilter,
1089
+ installResponseFilter: installResponseFilter,
1056
1090
  refreshDismissedIds: refreshDismissedIds,
1057
1091
  DEBOUNCE_MS: DEBOUNCE_MS,
1058
1092
  CARD_TITLE: CARD_TITLE,
package/src/filter.mjs ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 从会话列表投影里摘掉已删除成员:顶部「子智能体」选择器读 `subagentCatalog`,
3
+ * 内置「智能体团队」面板读 `agentTeam.members`;两者都在 `/api/session/list` 的响应里。
4
+ * 宿主 catalog 与 roster 都是 append-only,删会话不会撤销事件,所以在服务端出口过滤一次。
5
+ *
6
+ * @module filter
7
+ */
8
+
9
+ /**
10
+ * 判断条目是否属于已删除成员。
11
+ * @param {unknown} entry - 投影里的一个条目。
12
+ * @param {Set<string>} dismissed - 已删除的会话/成员 id 集合。
13
+ * @returns {boolean} 命中墓碑时返回 true(应被过滤)。
14
+ */
15
+ function isDismissed(entry, dismissed) {
16
+ return entry !== null && typeof entry === 'object' && typeof entry.id === 'string' && dismissed.has(entry.id)
17
+ }
18
+
19
+ /**
20
+ * 递归过滤一份会话列表载荷(原地修改)。
21
+ * @param {unknown} payload - `/api/session/list` 的返回体(或其中的单个会话行)。
22
+ * @param {Set<string>} dismissed - 已删除的会话/成员 id 集合。
23
+ * @param {number} [maxDepth] - 递归深度上限,防御异常结构。
24
+ * @returns {unknown} 同一个 payload 引用。
25
+ */
26
+ export function stripDismissedMembers(payload, dismissed, maxDepth = 6) {
27
+ if (payload === null || typeof payload !== 'object' || !(dismissed instanceof Set) || dismissed.size === 0) return payload
28
+ const visit = (node, depth) => {
29
+ if (node === null || typeof node !== 'object' || depth > maxDepth) return
30
+ if (Array.isArray(node.subagentCatalog)) node.subagentCatalog = node.subagentCatalog.filter((entry) => !isDismissed(entry, dismissed))
31
+ if (node.agentTeam !== null && typeof node.agentTeam === 'object' && Array.isArray(node.agentTeam.members)) {
32
+ node.agentTeam.members = node.agentTeam.members.filter((entry) => !isDismissed(entry, dismissed))
33
+ }
34
+ // 投影帧形如 { key: 'agentTeam', value: { members } } / { key: 'subagentCatalog', value: [...] }
35
+ if (node.key === 'agentTeam' && node.value !== null && typeof node.value === 'object' && Array.isArray(node.value.members)) {
36
+ node.value.members = node.value.members.filter((entry) => !isDismissed(entry, dismissed))
37
+ }
38
+ if (node.key === 'subagentCatalog' && Array.isArray(node.value)) {
39
+ node.value = node.value.filter((entry) => !isDismissed(entry, dismissed))
40
+ }
41
+ for (const key of Object.keys(node)) {
42
+ if (key === 'subagentCatalog' || key === 'agentTeam') continue
43
+ visit(node[key], depth + 1)
44
+ }
45
+ }
46
+ visit(payload, 0)
47
+ return payload
48
+ }
49
+
50
+ /**
51
+ * 从持久化状态里收集已删除成员 id。
52
+ * @param {object|null} state - store.readAll() 的返回值。
53
+ * @returns {Set<string>} 墓碑成员 id 集合。
54
+ */
55
+ export function collectDismissedIds(state) {
56
+ const ids = new Set()
57
+ const buckets = state !== null && typeof state === 'object' && state.dismissed !== null && typeof state.dismissed === 'object'
58
+ ? state.dismissed
59
+ : {}
60
+ for (const bucket of Object.values(buckets)) {
61
+ if (bucket === null || typeof bucket !== 'object') continue
62
+ for (const memberId of Object.keys(bucket)) ids.add(memberId)
63
+ }
64
+ return ids
65
+ }
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ import { join } from 'node:path'
6
6
 
7
7
  import { createApi } from './api.mjs'
8
8
  import { dismissMember } from './dismiss.mjs'
9
+ import { collectDismissedIds, stripDismissedMembers } from './filter.mjs'
9
10
  import { openStore } from './store.mjs'
10
11
  import { listTeamSessions, readTeam } from './team-read.mjs'
11
12
 
@@ -16,8 +17,16 @@ export const inject = ['webServer']
16
17
  const DATA_DIR_ENV = 'DSH_TEAM_ADMIN_DATA_DIR'
17
18
  /** 路由前缀:webServer 只支持 exact/prefix 注册。 */
18
19
  const ROUTE_PREFIX = '/api/team-admin'
20
+ /** 墓碑 id 缓存时长:过滤发生在每次投影响应/帧上,短缓存足够。 */
21
+ const DISMISSED_TTL_MS = 2000
22
+ /** 包装标记:apply 与延迟注入可能都执行一次,避免重复包装。 */
23
+ const FILTER_MARK = Symbol.for('dsh.team-admin.projection-filter')
24
+ /** 单值出口:一次请求/一次流的快照。 */
25
+ const UNARY_METHODS = ['list', 'projections']
26
+ /** 流出口:control 基线携带全部投影,follow 携带单个会话的开场快照。 */
27
+ const STREAM_METHODS = ['control', 'follow']
19
28
  /** 与 package.json 版本保持一致(发布前由测试校验)。 */
20
- const PLUGIN_VERSION = '0.1.7'
29
+ const PLUGIN_VERSION = '0.1.9'
21
30
 
22
31
  /**
23
32
  * 解析插件数据目录。
@@ -32,7 +41,7 @@ export function resolveDataDir(env = process.env, home = homedir()) {
32
41
  }
33
42
 
34
43
  /**
35
- * 装配插件:惰性建 store,注册一条 prefix 路由。
44
+ * 装配插件:惰性建 store,注册一条 prefix 路由,并给投影出口加过滤。
36
45
  * @param {object} ctx - Cordis 插件上下文。
37
46
  */
38
47
  export function apply(ctx) {
@@ -61,4 +70,81 @@ export function apply(ctx) {
61
70
  () => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }),
62
71
  'team-admin routes',
63
72
  )
64
- }
73
+ if (!installSessionListFilter(ctx, { getStore, logSystem, silent: true }) && typeof ctx.inject === 'function') {
74
+ // 插件可能早于 sessionController 注册;等服务可用后再包一次。
75
+ ctx.inject(['sessionController'], (sub) => installSessionListFilter(sub, { getStore, logSystem }))
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 建一个带短缓存的墓碑 id 读取器。
81
+ * @param {() => Promise<object>} getStore - 惰性打开的状态存储。
82
+ * @param {(line: string) => void} logSystem - 系统日志。
83
+ * @returns {() => Promise<Set<string>>} 读取已删除成员 id。
84
+ */
85
+ function createDismissedIds(getStore, logSystem) {
86
+ let cached = { at: 0, ids: new Set() }
87
+ return async () => {
88
+ if (Date.now() - cached.at < DISMISSED_TTL_MS) return cached.ids
89
+ let ids = new Set()
90
+ try {
91
+ const store = await getStore()
92
+ ids = collectDismissedIds(await store.readAll())
93
+ } catch (error) {
94
+ logSystem(`team-admin:读取墓碑失败,本次不过滤投影(${error instanceof Error ? error.message : String(error)})`)
95
+ }
96
+ cached = { at: Date.now(), ids }
97
+ return ids
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 给宿主的 sessionController 出口包一层:把已删除成员从 subagentCatalog / agentTeam.members 里摘掉。
103
+ * 顶部「子智能体」选择器读 `list` 与 `control` 基线,内置「智能体团队」面板读同一批投影,
104
+ * 因此在宿主出口过滤一次即可覆盖所有客户端,不需要改宿主源码,也不用重编译。
105
+ * @param {object} ctx - Cordis 插件上下文。
106
+ * @param {{ getStore: () => Promise<object>, logSystem: (line: string) => void, silent?: boolean }} deps - 存储与日志。
107
+ * @returns {boolean} 至少包上一个方法时返回 true。
108
+ */
109
+ export function installSessionListFilter(ctx, { getStore, logSystem, silent = false }) {
110
+ const get = ctx !== null && typeof ctx?.get === 'function' ? ctx.get.bind(ctx) : null
111
+ const service = get === null ? undefined : get('sessionController')
112
+ if (service === null || service === undefined) {
113
+ if (silent !== true) logSystem('team-admin:未找到 sessionController,跳过投影过滤')
114
+ return false
115
+ }
116
+ if (service[FILTER_MARK] === true) return false
117
+ const dismissedIds = createDismissedIds(getStore, logSystem)
118
+ const strip = async (value) => {
119
+ try {
120
+ const ids = await dismissedIds()
121
+ return ids.size === 0 ? value : stripDismissedMembers(value, ids, 10)
122
+ } catch (error) {
123
+ logSystem(`team-admin:过滤投影失败(${error instanceof Error ? error.message : String(error)})`)
124
+ return value
125
+ }
126
+ }
127
+ let wrapped = 0
128
+ for (const method of UNARY_METHODS) {
129
+ const original = service[method]
130
+ if (typeof original !== 'function') continue
131
+ service[method] = async function teamAdminFilteredUnary(...args) {
132
+ return await strip(await original.apply(this, args))
133
+ }
134
+ wrapped += 1
135
+ }
136
+ for (const method of STREAM_METHODS) {
137
+ const original = service[method]
138
+ if (typeof original !== 'function') continue
139
+ service[method] = async function* teamAdminFilteredStream(...args) {
140
+ for await (const frame of original.apply(this, args)) yield await strip(frame)
141
+ }
142
+ wrapped += 1
143
+ }
144
+ if (wrapped === 0) {
145
+ if (silent !== true) logSystem('team-admin:sessionController 上没有可过滤的方法,跳过投影过滤')
146
+ return false
147
+ }
148
+ service[FILTER_MARK] = true
149
+ return true
150
+ }